processor.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549
  1. # -*- coding: utf-8 -*-
  2. """
  3. Crossfade Audio-Prozessor.
  4. Mischt das Ende eines Tracks mit dem Anfang des nächsten Tracks
  5. für einen nahtlosen Übergang.
  6. """
  7. import asyncio
  8. import struct
  9. import threading
  10. from collections import deque
  11. from dataclasses import dataclass, field
  12. from pathlib import Path
  13. from typing import TYPE_CHECKING, Any
  14. from trixy_core.audio.processing import (
  15. AudioProcessor,
  16. AudioProcessingContext,
  17. ProcessorPriority,
  18. AudioType,
  19. )
  20. from trixy_core.audio.processing.processor import mix_chunks, apply_volume
  21. from trixy_core.audio.config import get_audio_settings
  22. if TYPE_CHECKING:
  23. from trixy_core.music.formats import DecoderRegistry
  24. from trixy_core.music.track import Track
  25. @dataclass
  26. class CrossfadeConfig:
  27. """Konfiguration für Crossfade."""
  28. # Crossfade-Dauer in Millisekunden
  29. duration_ms: int = 5000
  30. # Fade-Kurve: "linear", "exponential", "cosine"
  31. fade_curve: str = "linear"
  32. # Minimale Track-Dauer für Crossfade (kürzere Tracks werden nicht gefaded)
  33. min_track_duration_ms: int = 30000
  34. # Prebuffer-Dauer (wie viel vom nächsten Track vorgeladen wird)
  35. prebuffer_duration_ms: int = 6000
  36. # Aktiviert/Deaktiviert
  37. enabled: bool = True
  38. class CrossfadeProcessor(AudioProcessor):
  39. """
  40. Audio-Prozessor für Crossfade zwischen Tracks.
  41. Funktionsweise:
  42. 1. Wenn Track sich dem Ende nähert und ein nächster Track vorhanden ist,
  43. wird der Anfang des nächsten Tracks vorgeladen.
  44. 2. Während der Crossfade-Region wird der aktuelle Track ausgeblendet
  45. und der nächste Track eingeblendet.
  46. 3. Die Chunks werden gemischt und zurückgegeben.
  47. WICHTIG: Der nächste Track muss vorab dekodiert und gepuffert werden,
  48. da process() synchron läuft.
  49. """
  50. def __init__(
  51. self,
  52. processor_id: str = "crossfade",
  53. name: str = "Crossfade",
  54. priority: int = ProcessorPriority.CROSSFADE,
  55. enabled: bool = True,
  56. config: CrossfadeConfig | None = None,
  57. decoder_registry: "DecoderRegistry | None" = None,
  58. ) -> None:
  59. """
  60. Initialisiert den Crossfade-Prozessor.
  61. Args:
  62. processor_id: Eindeutige ID
  63. name: Anzeigename
  64. priority: Priorität in der Pipeline
  65. enabled: Aktiviert
  66. config: Crossfade-Konfiguration
  67. decoder_registry: Decoder-Registry für Audio-Dekodierung
  68. """
  69. # Nur MUSIC verarbeiten - kein Crossfade für TTS oder Assets
  70. super().__init__(processor_id, name, priority, enabled, audio_types=[AudioType.MUSIC])
  71. self.config = config or CrossfadeConfig()
  72. self._decoder_registry = decoder_registry
  73. # Puffer für nächsten Track
  74. self._next_track_buffer: deque[bytes] = deque()
  75. self._next_track_loaded = False
  76. self._next_track_position = 0 # Position im Puffer (in Bytes)
  77. # Aktueller Track
  78. self._current_track: "Track | None" = None
  79. self._next_track: "Track | None" = None
  80. # Crossfade-Status
  81. self._is_crossfading = False
  82. self._crossfade_started_at_ms = 0
  83. # Synchronisation
  84. self._buffer_lock = threading.Lock()
  85. self._loading_task: asyncio.Task | None = None
  86. # Audio-Format aus globalen Einstellungen holen (wird bei Track-Start überschrieben)
  87. audio_settings = get_audio_settings()
  88. self._sample_rate = audio_settings.music_sample_rate
  89. self._channels = audio_settings.music_channels
  90. self._bytes_per_ms = 0 # Wird berechnet
  91. # Statistiken
  92. self._crossfades_performed = 0
  93. self._chunks_mixed = 0
  94. def set_decoder_registry(self, registry: "DecoderRegistry") -> None:
  95. """Setzt die Decoder-Registry."""
  96. self._decoder_registry = registry
  97. def update_config(self, **kwargs) -> None:
  98. """Aktualisiert Konfiguration."""
  99. for key, value in kwargs.items():
  100. if hasattr(self.config, key):
  101. setattr(self.config, key, value)
  102. def process(self, chunk: bytes, context: AudioProcessingContext) -> bytes:
  103. """
  104. Verarbeitet einen Audio-Chunk.
  105. Wenn wir in der Crossfade-Region sind und der nächste Track
  106. gepuffert ist, wird gemischt.
  107. """
  108. if not self.enabled or not self.config.enabled:
  109. return chunk
  110. # Audio-Format aktualisieren
  111. self._sample_rate = context.sample_rate
  112. self._channels = context.channels
  113. self._bytes_per_ms = (context.sample_rate * context.channels * 2) // 1000 # 16-bit
  114. # Prüfen ob Crossfade möglich
  115. if not self._should_crossfade(context):
  116. return chunk
  117. # Crossfade starten wenn noch nicht aktiv
  118. if not self._is_crossfading:
  119. self._start_crossfade(context)
  120. # Wenn keine gepufferten Daten, nur Fade-Out
  121. with self._buffer_lock:
  122. if not self._next_track_loaded or not self._next_track_buffer:
  123. return self._apply_fade_out(chunk, context)
  124. # Nächsten Chunk aus Puffer holen
  125. next_chunk = self._get_next_chunk(len(chunk))
  126. if not next_chunk or len(next_chunk) != len(chunk):
  127. # Nicht genug Daten, nur Fade-Out
  128. return self._apply_fade_out(chunk, context)
  129. # Mix-Faktor berechnen (0.0 = nur aktueller Track, 1.0 = nur nächster Track)
  130. mix_factor = self._calculate_mix_factor(context)
  131. # Chunks mischen
  132. mixed = mix_chunks(chunk, next_chunk, mix_factor)
  133. self._chunks_mixed += 1
  134. return mixed
  135. def on_track_start(self, context: AudioProcessingContext) -> None:
  136. """Wird bei Track-Start aufgerufen."""
  137. self._current_track = context.track
  138. self._next_track = context.next_track
  139. self._is_crossfading = False
  140. self._crossfade_started_at_ms = 0
  141. # Audio-Format speichern
  142. self._sample_rate = context.sample_rate
  143. self._channels = context.channels
  144. self._bytes_per_ms = (context.sample_rate * context.channels * 2) // 1000
  145. # Prüfen ob Crossfade überhaupt möglich
  146. if not self._can_crossfade(context):
  147. return
  148. # Nächsten Track vorladen
  149. if context.next_track and context.next_track.path:
  150. self._start_preloading(context.next_track)
  151. def on_track_end(self, context: AudioProcessingContext) -> None:
  152. """Wird bei Track-Ende aufgerufen."""
  153. if self._is_crossfading:
  154. self._crossfades_performed += 1
  155. self._is_crossfading = False
  156. # Puffer NICHT leeren - wird für den nächsten Track-Start benötigt
  157. # Der nächste Track verwendet diesen Puffer als seinen Anfang
  158. def on_state_change(self, state_name: str, value: bool) -> None:
  159. """Wird bei Zustandsänderungen aufgerufen."""
  160. # Bei Wakeword/Conversation/TTS: Crossfade abbrechen
  161. if value and state_name in ("wakeword_active", "conversation_active", "tts_playing"):
  162. self._abort_crossfade()
  163. def reset(self) -> None:
  164. """Setzt den Prozessor zurück."""
  165. self._abort_crossfade()
  166. with self._buffer_lock:
  167. self._next_track_buffer.clear()
  168. self._next_track_loaded = False
  169. self._next_track_position = 0
  170. self._current_track = None
  171. self._next_track = None
  172. def get_config(self) -> dict[str, Any]:
  173. """Liefert Konfiguration."""
  174. base = super().get_config()
  175. base.update({
  176. "crossfade_duration_ms": self.config.duration_ms,
  177. "fade_curve": self.config.fade_curve,
  178. "min_track_duration_ms": self.config.min_track_duration_ms,
  179. "crossfades_performed": self._crossfades_performed,
  180. "chunks_mixed": self._chunks_mixed,
  181. "is_crossfading": self._is_crossfading,
  182. "next_track_loaded": self._next_track_loaded,
  183. })
  184. return base
  185. # ==========================================================================
  186. # Interne Methoden
  187. # ==========================================================================
  188. def _can_crossfade(self, context: AudioProcessingContext) -> bool:
  189. """Prüft ob Crossfade für diesen Track möglich ist."""
  190. if not self.enabled or not self.config.enabled:
  191. return False
  192. # Kein nächster Track
  193. if not context.has_next_track:
  194. return False
  195. # Track zu kurz
  196. if context.duration_ms < self.config.min_track_duration_ms:
  197. return False
  198. # Crossfade länger als halber Track
  199. if self.config.duration_ms > context.duration_ms // 2:
  200. return False
  201. return True
  202. def _should_crossfade(self, context: AudioProcessingContext) -> bool:
  203. """Prüft ob wir gerade crossfaden sollten."""
  204. if not self._can_crossfade(context):
  205. return False
  206. # Noch nicht nahe genug am Ende?
  207. remaining_ms = context.remaining_ms
  208. if remaining_ms > self.config.duration_ms:
  209. return False
  210. return True
  211. def _start_crossfade(self, context: AudioProcessingContext) -> None:
  212. """Startet Crossfade."""
  213. self._is_crossfading = True
  214. self._crossfade_started_at_ms = context.position_ms
  215. def _abort_crossfade(self) -> None:
  216. """Bricht Crossfade ab."""
  217. if self._loading_task and not self._loading_task.done():
  218. self._loading_task.cancel()
  219. self._is_crossfading = False
  220. def _calculate_mix_factor(self, context: AudioProcessingContext) -> float:
  221. """
  222. Berechnet Mix-Faktor basierend auf Position.
  223. 0.0 = 100% aktueller Track
  224. 1.0 = 100% nächster Track
  225. """
  226. remaining_ms = context.remaining_ms
  227. duration_ms = self.config.duration_ms
  228. if remaining_ms >= duration_ms:
  229. return 0.0
  230. if remaining_ms <= 0:
  231. return 1.0
  232. # Fortschritt im Crossfade (0.0 = Start, 1.0 = Ende)
  233. progress = 1.0 - (remaining_ms / duration_ms)
  234. # Kurve anwenden
  235. if self.config.fade_curve == "exponential":
  236. # Exponentiell: schneller Anstieg am Anfang
  237. return progress ** 0.5
  238. elif self.config.fade_curve == "cosine":
  239. # Kosinus: S-Kurve
  240. import math
  241. return (1.0 - math.cos(progress * math.pi)) / 2.0
  242. else:
  243. # Linear (Standard)
  244. return progress
  245. def _apply_fade_out(self, chunk: bytes, context: AudioProcessingContext) -> bytes:
  246. """Wendet nur Fade-Out auf aktuellen Track an."""
  247. remaining_ms = context.remaining_ms
  248. duration_ms = self.config.duration_ms
  249. if remaining_ms >= duration_ms:
  250. return chunk
  251. # Lautstärke basierend auf verbleibender Zeit
  252. volume = max(0.0, remaining_ms / duration_ms)
  253. return apply_volume(chunk, volume)
  254. def _get_next_chunk(self, size: int) -> bytes | None:
  255. """Holt Chunk aus Puffer für nächsten Track."""
  256. if not self._next_track_buffer:
  257. return None
  258. result = bytearray()
  259. remaining = size
  260. while remaining > 0 and self._next_track_buffer:
  261. chunk = self._next_track_buffer[0]
  262. if len(chunk) <= remaining:
  263. # Ganzen Chunk verwenden
  264. result.extend(chunk)
  265. remaining -= len(chunk)
  266. self._next_track_buffer.popleft()
  267. else:
  268. # Teil des Chunks verwenden
  269. result.extend(chunk[:remaining])
  270. self._next_track_buffer[0] = chunk[remaining:]
  271. remaining = 0
  272. self._next_track_position += len(result)
  273. return bytes(result) if result else None
  274. def _start_preloading(self, track: "Track") -> None:
  275. """Startet Vorladen des nächsten Tracks im Hintergrund."""
  276. if not self._decoder_registry:
  277. return
  278. # Alten Puffer leeren
  279. with self._buffer_lock:
  280. self._next_track_buffer.clear()
  281. self._next_track_loaded = False
  282. self._next_track_position = 0
  283. # Vorladen in separatem Thread (async in sync integrieren)
  284. try:
  285. loop = asyncio.get_running_loop()
  286. self._loading_task = loop.create_task(self._preload_track(track))
  287. except RuntimeError:
  288. # Kein Event-Loop - synchron laden nicht möglich
  289. pass
  290. async def _preload_track(self, track: "Track") -> None:
  291. """Lädt Anfang des nächsten Tracks."""
  292. if not track.path or not self._decoder_registry:
  293. return
  294. try:
  295. decoder = self._decoder_registry.get_best_decoder(
  296. path=track.path,
  297. extension=track.metadata.format if track.metadata else None,
  298. )
  299. if not decoder:
  300. return
  301. # Chunk-Größe: 100ms
  302. chunk_duration_ms = 100
  303. target_duration_ms = self.config.prebuffer_duration_ms
  304. loaded_ms = 0
  305. async for chunk in decoder.decode_stream(track.path, chunk_duration_ms):
  306. if loaded_ms >= target_duration_ms:
  307. break
  308. with self._buffer_lock:
  309. self._next_track_buffer.append(chunk.pcm_data)
  310. loaded_ms += chunk.duration_ms
  311. with self._buffer_lock:
  312. self._next_track_loaded = True
  313. except Exception:
  314. # Fehler beim Laden - Crossfade wird ohne nächsten Track durchgeführt
  315. pass
  316. # ==========================================================================
  317. # Synchrone Hilfsfunktionen für Tests
  318. # ==========================================================================
  319. def preload_from_pcm(self, pcm_data: bytes, chunk_size: int = 19200) -> None:
  320. """
  321. Lädt PCM-Daten direkt in den Puffer (für Tests).
  322. Args:
  323. pcm_data: Rohe PCM-Daten (16-bit stereo)
  324. chunk_size: Größe der Chunks
  325. """
  326. with self._buffer_lock:
  327. self._next_track_buffer.clear()
  328. # In Chunks aufteilen
  329. for i in range(0, len(pcm_data), chunk_size):
  330. chunk = pcm_data[i:i + chunk_size]
  331. self._next_track_buffer.append(chunk)
  332. self._next_track_loaded = True
  333. self._next_track_position = 0
  334. def crossfade_audio(
  335. audio1: bytes,
  336. audio2: bytes,
  337. crossfade_ms: int,
  338. sample_rate: int = 44100,
  339. channels: int = 2,
  340. fade_curve: str = "linear",
  341. ) -> bytes:
  342. """
  343. Erstellt einen Crossfade zwischen zwei Audio-Daten.
  344. Diese Standalone-Funktion ist nützlich für Tests und
  345. Batch-Verarbeitung.
  346. Args:
  347. audio1: PCM-Daten des ersten Tracks (16-bit)
  348. audio2: PCM-Daten des zweiten Tracks (16-bit)
  349. crossfade_ms: Crossfade-Dauer in Millisekunden
  350. sample_rate: Sample-Rate
  351. channels: Anzahl Kanäle
  352. fade_curve: "linear", "exponential", oder "cosine"
  353. Returns:
  354. Gemischte PCM-Daten
  355. """
  356. import math
  357. bytes_per_ms = (sample_rate * channels * 2) // 1000 # 16-bit
  358. crossfade_bytes = crossfade_ms * bytes_per_ms
  359. # Sicherstellen dass genug Daten vorhanden
  360. if len(audio1) < crossfade_bytes or len(audio2) < crossfade_bytes:
  361. # Nicht genug Daten für Crossfade
  362. return audio1 + audio2
  363. # Audio1 bis auf Crossfade-Region
  364. result = bytearray(audio1[:-crossfade_bytes])
  365. # Crossfade-Region
  366. fade_out_region = audio1[-crossfade_bytes:]
  367. fade_in_region = audio2[:crossfade_bytes]
  368. # Samples extrahieren
  369. sample_count = crossfade_bytes // 2 # 16-bit = 2 bytes pro sample
  370. fade_out_samples = struct.unpack(f"<{sample_count}h", fade_out_region)
  371. fade_in_samples = struct.unpack(f"<{sample_count}h", fade_in_region)
  372. # Mischen
  373. mixed_samples = []
  374. for i in range(sample_count):
  375. progress = i / sample_count
  376. # Kurve anwenden
  377. if fade_curve == "exponential":
  378. mix = progress ** 0.5
  379. elif fade_curve == "cosine":
  380. mix = (1.0 - math.cos(progress * math.pi)) / 2.0
  381. else:
  382. mix = progress
  383. # Mischen
  384. sample1 = fade_out_samples[i] * (1.0 - mix)
  385. sample2 = fade_in_samples[i] * mix
  386. mixed = int(sample1 + sample2)
  387. # Clipping vermeiden
  388. mixed = max(-32768, min(32767, mixed))
  389. mixed_samples.append(mixed)
  390. # Gemischte Region hinzufügen
  391. result.extend(struct.pack(f"<{sample_count}h", *mixed_samples))
  392. # Rest von Audio2 (nach Crossfade-Region)
  393. result.extend(audio2[crossfade_bytes:])
  394. return bytes(result)
  395. def save_wav(
  396. pcm_data: bytes,
  397. path: Path | str,
  398. sample_rate: int = 44100,
  399. channels: int = 2,
  400. bit_depth: int = 16,
  401. ) -> None:
  402. """
  403. Speichert PCM-Daten als WAV-Datei.
  404. Args:
  405. pcm_data: Rohe PCM-Daten
  406. path: Ziel-Pfad
  407. sample_rate: Sample-Rate in Hz
  408. channels: Anzahl Kanäle
  409. bit_depth: Bits pro Sample
  410. """
  411. import wave
  412. path = Path(path)
  413. path.parent.mkdir(parents=True, exist_ok=True)
  414. with wave.open(str(path), "wb") as wav:
  415. wav.setnchannels(channels)
  416. wav.setsampwidth(bit_depth // 8)
  417. wav.setframerate(sample_rate)
  418. wav.writeframes(pcm_data)
  419. def load_wav(path: Path | str) -> tuple[bytes, int, int]:
  420. """
  421. Lädt WAV-Datei.
  422. Args:
  423. path: Pfad zur WAV-Datei
  424. Returns:
  425. Tuple (pcm_data, sample_rate, channels)
  426. """
  427. import wave
  428. with wave.open(str(path), "rb") as wav:
  429. return (
  430. wav.readframes(wav.getnframes()),
  431. wav.getframerate(),
  432. wav.getnchannels(),
  433. )