processor.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. # -*- coding: utf-8 -*-
  2. """
  3. Audio Processor Interface.
  4. Basis-Interface für alle Audio-Prozessoren.
  5. """
  6. from abc import ABC, abstractmethod
  7. from enum import IntEnum
  8. from typing import TYPE_CHECKING
  9. if TYPE_CHECKING:
  10. from trixy_core.audio.processing.context import AudioProcessingContext, AudioType
  11. class ProcessorPriority(IntEnum):
  12. """
  13. Prioritäten für Audio-Prozessoren.
  14. Niedrigere Werte werden zuerst ausgeführt.
  15. """
  16. # Früheste Verarbeitung
  17. FIRST = 0
  18. # Analyse (nur lesen, nicht modifizieren)
  19. ANALYSIS = 100
  20. # Effekte die früh angewendet werden
  21. PRE_EFFECTS = 200
  22. # Ducking (Lautstärke bei Wakeword/Conversation)
  23. DUCKING = 300
  24. # Crossfade und Übergänge
  25. CROSSFADE = 400
  26. # Equalizer und Frequenz-Anpassungen
  27. EQUALIZER = 500
  28. # Späte Effekte
  29. POST_EFFECTS = 600
  30. # Lautstärke-Anpassung
  31. VOLUME = 700
  32. # Limiter/Compressor (Clipping verhindern)
  33. LIMITER = 800
  34. # Letzte Verarbeitung
  35. LAST = 900
  36. # Standard für Plugins ohne spezifische Priorität
  37. DEFAULT = 500
  38. class AudioProcessor(ABC):
  39. """
  40. Basis-Klasse für Audio-Prozessoren.
  41. Audio-Prozessoren verarbeiten PCM-Audio-Chunks in einer Pipeline.
  42. Jeder Prozessor erhält den Chunk und einen Kontext mit Informationen
  43. über den aktuellen Zustand (Position, Wakeword, etc.).
  44. Beispiel:
  45. class DuckingProcessor(AudioProcessor):
  46. def __init__(self):
  47. super().__init__(
  48. processor_id="ducking",
  49. name="Ducking",
  50. priority=ProcessorPriority.DUCKING,
  51. )
  52. self.duck_factor = 0.3
  53. def process(self, chunk: bytes, context: AudioProcessingContext) -> bytes:
  54. if context.should_duck:
  55. return self._apply_volume(chunk, self.duck_factor)
  56. return chunk
  57. """
  58. def __init__(
  59. self,
  60. processor_id: str,
  61. name: str,
  62. priority: int = ProcessorPriority.DEFAULT,
  63. enabled: bool = True,
  64. audio_types: list["AudioType"] | None = None,
  65. ) -> None:
  66. """
  67. Initialisiert den Prozessor.
  68. Args:
  69. processor_id: Eindeutige ID
  70. name: Anzeigename
  71. priority: Reihenfolge in der Pipeline (niedriger = früher)
  72. enabled: Ist der Prozessor aktiv?
  73. audio_types: Liste der unterstützten Audio-Typen (None = alle)
  74. """
  75. self._id = processor_id
  76. self._name = name
  77. self._priority = priority
  78. self._enabled = enabled
  79. self._audio_types = audio_types # None = alle Typen
  80. @property
  81. def id(self) -> str:
  82. """Eindeutige ID des Prozessors."""
  83. return self._id
  84. @property
  85. def name(self) -> str:
  86. """Anzeigename."""
  87. return self._name
  88. @property
  89. def priority(self) -> int:
  90. """Priorität in der Pipeline (niedriger = früher)."""
  91. return self._priority
  92. @property
  93. def enabled(self) -> bool:
  94. """Ist der Prozessor aktiv?"""
  95. return self._enabled
  96. @enabled.setter
  97. def enabled(self, value: bool) -> None:
  98. """Aktiviert/Deaktiviert den Prozessor."""
  99. self._enabled = value
  100. @property
  101. def audio_types(self) -> list["AudioType"] | None:
  102. """
  103. Unterstützte Audio-Typen.
  104. Returns:
  105. Liste der Typen oder None für alle Typen.
  106. """
  107. return self._audio_types
  108. def supports_audio_type(self, audio_type: "AudioType") -> bool:
  109. """
  110. Prüft ob dieser Prozessor einen Audio-Typ unterstützt.
  111. Args:
  112. audio_type: Der zu prüfende Audio-Typ
  113. Returns:
  114. True wenn unterstützt (oder wenn alle Typen unterstützt werden)
  115. """
  116. if self._audio_types is None:
  117. return True # Alle Typen unterstützt
  118. return audio_type in self._audio_types
  119. def should_process(self, context: "AudioProcessingContext") -> bool:
  120. """
  121. Prüft ob dieser Prozessor für den Kontext aktiv sein sollte.
  122. Berücksichtigt: enabled, audio_type
  123. Args:
  124. context: Der Verarbeitungskontext
  125. Returns:
  126. True wenn der Prozessor aktiv sein sollte
  127. """
  128. if not self._enabled:
  129. return False
  130. return self.supports_audio_type(context.audio_type)
  131. @abstractmethod
  132. def process(
  133. self,
  134. chunk: bytes,
  135. context: "AudioProcessingContext",
  136. ) -> bytes:
  137. """
  138. Verarbeitet einen Audio-Chunk.
  139. Args:
  140. chunk: PCM-Audio-Daten (16-bit, little-endian)
  141. context: Verarbeitungskontext mit Zustandsinformationen
  142. Returns:
  143. Verarbeiteter Audio-Chunk (gleiche Länge wie Eingabe)
  144. Note:
  145. - Der zurückgegebene Chunk MUSS die gleiche Länge haben
  146. - Für Crossfade: Zweiten Track separat laden und mixen
  147. - Bei Fehlern: Unveränderten Chunk zurückgeben
  148. """
  149. pass
  150. def on_track_start(self, context: "AudioProcessingContext") -> None:
  151. """
  152. Callback wenn ein neuer Track startet.
  153. Args:
  154. context: Verarbeitungskontext
  155. """
  156. pass
  157. def on_track_end(self, context: "AudioProcessingContext") -> None:
  158. """
  159. Callback wenn ein Track endet.
  160. Args:
  161. context: Verarbeitungskontext
  162. """
  163. pass
  164. def on_state_change(self, state_name: str, value: bool) -> None:
  165. """
  166. Callback bei Zustandsänderungen.
  167. Args:
  168. state_name: Name des Zustands (z.B. "wakeword_active")
  169. value: Neuer Wert
  170. """
  171. pass
  172. def reset(self) -> None:
  173. """
  174. Setzt den Prozessor zurück.
  175. Wird aufgerufen wenn die Wiedergabe gestoppt wird.
  176. """
  177. pass
  178. def get_config(self) -> dict:
  179. """
  180. Liefert die aktuelle Konfiguration.
  181. Returns:
  182. Konfigurations-Dictionary
  183. """
  184. return {
  185. "id": self._id,
  186. "name": self._name,
  187. "priority": self._priority,
  188. "enabled": self._enabled,
  189. }
  190. def __repr__(self) -> str:
  191. return (
  192. f"{self.__class__.__name__}("
  193. f"id={self._id!r}, "
  194. f"priority={self._priority}, "
  195. f"enabled={self._enabled})"
  196. )
  197. # =============================================================================
  198. # Hilfsfunktionen für Audio-Verarbeitung
  199. # =============================================================================
  200. def apply_volume(chunk: bytes, volume: float) -> bytes:
  201. """
  202. Wendet Lautstärke auf PCM-Daten an.
  203. Args:
  204. chunk: PCM-Audio-Daten (16-bit, little-endian)
  205. volume: Lautstärke-Faktor (0.0 - 1.0+)
  206. Returns:
  207. Modifizierter Chunk
  208. """
  209. import struct
  210. if volume == 1.0:
  211. return chunk
  212. if volume == 0.0:
  213. return b'\x00' * len(chunk)
  214. # 16-bit Samples entpacken
  215. num_samples = len(chunk) // 2
  216. samples = struct.unpack(f"<{num_samples}h", chunk)
  217. # Lautstärke anwenden mit Clipping
  218. adjusted = [
  219. max(-32768, min(32767, int(s * volume)))
  220. for s in samples
  221. ]
  222. return struct.pack(f"<{num_samples}h", *adjusted)
  223. def mix_chunks(chunk1: bytes, chunk2: bytes, mix: float = 0.5) -> bytes:
  224. """
  225. Mischt zwei Audio-Chunks.
  226. Args:
  227. chunk1: Erster Chunk
  228. chunk2: Zweiter Chunk
  229. mix: Mix-Verhältnis (0.0 = nur chunk1, 1.0 = nur chunk2)
  230. Returns:
  231. Gemischter Chunk
  232. """
  233. import struct
  234. if len(chunk1) != len(chunk2):
  235. # Längen angleichen (kürzeren mit Stille auffüllen)
  236. max_len = max(len(chunk1), len(chunk2))
  237. chunk1 = chunk1.ljust(max_len, b'\x00')
  238. chunk2 = chunk2.ljust(max_len, b'\x00')
  239. num_samples = len(chunk1) // 2
  240. samples1 = struct.unpack(f"<{num_samples}h", chunk1)
  241. samples2 = struct.unpack(f"<{num_samples}h", chunk2)
  242. # Mixen
  243. factor1 = 1.0 - mix
  244. factor2 = mix
  245. mixed = [
  246. max(-32768, min(32767, int(s1 * factor1 + s2 * factor2)))
  247. for s1, s2 in zip(samples1, samples2)
  248. ]
  249. return struct.pack(f"<{num_samples}h", *mixed)
  250. def fade_chunk(
  251. chunk: bytes,
  252. fade_in: bool = False,
  253. fade_out: bool = False,
  254. ) -> bytes:
  255. """
  256. Wendet Fade-In oder Fade-Out auf einen Chunk an.
  257. Args:
  258. chunk: PCM-Audio-Daten
  259. fade_in: Fade von 0 auf 1
  260. fade_out: Fade von 1 auf 0
  261. Returns:
  262. Chunk mit Fade
  263. """
  264. import struct
  265. if not fade_in and not fade_out:
  266. return chunk
  267. num_samples = len(chunk) // 2
  268. samples = struct.unpack(f"<{num_samples}h", chunk)
  269. result = []
  270. for i, sample in enumerate(samples):
  271. progress = i / max(1, num_samples - 1)
  272. if fade_in:
  273. factor = progress
  274. elif fade_out:
  275. factor = 1.0 - progress
  276. else:
  277. factor = 1.0
  278. result.append(max(-32768, min(32767, int(sample * factor))))
  279. return struct.pack(f"<{num_samples}h", *result)