test_audio_processing.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests für Audio Processing Pipeline.
  4. Testet:
  5. - AudioProcessingContext
  6. - AudioProcessor Interface
  7. - AudioProcessingPipeline
  8. - Hilfsfunktionen (apply_volume, mix_chunks, fade_chunk)
  9. """
  10. import pytest
  11. from unittest.mock import MagicMock
  12. from trixy_core.audio.processing import (
  13. AudioProcessingContext,
  14. AudioProcessor,
  15. ProcessorPriority,
  16. AudioProcessingPipeline,
  17. )
  18. from trixy_core.audio.processing.processor import (
  19. apply_volume,
  20. mix_chunks,
  21. fade_chunk,
  22. )
  23. # =============================================================================
  24. # Test Processor Implementation
  25. # =============================================================================
  26. class MockProcessor(AudioProcessor):
  27. """Mock-Prozessor für Unit-Tests."""
  28. def __init__(
  29. self,
  30. processor_id: str = "test",
  31. name: str = "Test",
  32. priority: int = ProcessorPriority.DEFAULT,
  33. enabled: bool = True,
  34. modify: bool = False,
  35. ):
  36. super().__init__(processor_id, name, priority, enabled)
  37. self.modify = modify
  38. self.process_count = 0
  39. self.last_context = None
  40. self.track_start_called = False
  41. self.track_end_called = False
  42. self.state_changes = []
  43. def process(self, chunk: bytes, context: AudioProcessingContext) -> bytes:
  44. self.process_count += 1
  45. self.last_context = context
  46. if self.modify:
  47. # Invertiere alle Bytes als Modifikation
  48. return bytes([255 - b for b in chunk])
  49. return chunk
  50. def on_track_start(self, context: AudioProcessingContext) -> None:
  51. self.track_start_called = True
  52. def on_track_end(self, context: AudioProcessingContext) -> None:
  53. self.track_end_called = True
  54. def on_state_change(self, state_name: str, value: bool) -> None:
  55. self.state_changes.append((state_name, value))
  56. def reset(self) -> None:
  57. self.process_count = 0
  58. self.last_context = None
  59. self.track_start_called = False
  60. self.track_end_called = False
  61. self.state_changes.clear()
  62. # =============================================================================
  63. # Tests für AudioProcessingContext
  64. # =============================================================================
  65. class TestAudioProcessingContext:
  66. """Tests für AudioProcessingContext."""
  67. def test_default_values(self):
  68. """Testet Standard-Werte."""
  69. context = AudioProcessingContext()
  70. assert context.position_ms == 0
  71. assert context.duration_ms == 0
  72. assert context.sample_rate == 44100
  73. assert context.channels == 2
  74. assert context.is_wakeword_active is False
  75. assert context.is_conversation_active is False
  76. assert context.volume == 1.0
  77. def test_remaining_ms(self):
  78. """Testet remaining_ms Berechnung."""
  79. context = AudioProcessingContext(
  80. position_ms=30000,
  81. duration_ms=180000, # 3 Minuten
  82. )
  83. assert context.remaining_ms == 150000
  84. def test_remaining_ms_past_end(self):
  85. """Testet remaining_ms wenn Position > Duration."""
  86. context = AudioProcessingContext(
  87. position_ms=200000,
  88. duration_ms=180000,
  89. )
  90. assert context.remaining_ms == 0
  91. def test_progress(self):
  92. """Testet progress Berechnung."""
  93. context = AudioProcessingContext(
  94. position_ms=90000,
  95. duration_ms=180000,
  96. )
  97. assert context.progress == 0.5
  98. def test_progress_zero_duration(self):
  99. """Testet progress bei Duration 0."""
  100. context = AudioProcessingContext(duration_ms=0)
  101. assert context.progress == 0.0
  102. def test_is_near_end(self):
  103. """Testet is_near_end."""
  104. context = AudioProcessingContext(
  105. position_ms=176000,
  106. duration_ms=180000,
  107. )
  108. assert context.is_near_end is True
  109. context.position_ms = 170000
  110. assert context.is_near_end is False
  111. def test_should_duck(self):
  112. """Testet should_duck bei verschiedenen Zuständen."""
  113. context = AudioProcessingContext()
  114. assert context.should_duck is False
  115. context.is_wakeword_active = True
  116. assert context.should_duck is True
  117. context.is_wakeword_active = False
  118. context.is_conversation_active = True
  119. assert context.should_duck is True
  120. context.is_conversation_active = False
  121. context.is_tts_playing = True
  122. assert context.should_duck is True
  123. def test_copy(self):
  124. """Testet copy mit Updates."""
  125. original = AudioProcessingContext(
  126. position_ms=1000,
  127. duration_ms=5000,
  128. )
  129. copied = original.copy(position_ms=2000)
  130. assert copied.position_ms == 2000
  131. assert copied.duration_ms == 5000
  132. assert original.position_ms == 1000
  133. # =============================================================================
  134. # Tests für AudioProcessor
  135. # =============================================================================
  136. class TestAudioProcessor:
  137. """Tests für AudioProcessor Interface."""
  138. def test_processor_creation(self):
  139. """Testet Prozessor-Erstellung."""
  140. processor = MockProcessor(
  141. processor_id="test_proc",
  142. name="Test Processor",
  143. priority=ProcessorPriority.DUCKING,
  144. )
  145. assert processor.id == "test_proc"
  146. assert processor.name == "Test Processor"
  147. assert processor.priority == ProcessorPriority.DUCKING
  148. assert processor.enabled is True
  149. def test_processor_enable_disable(self):
  150. """Testet Aktivieren/Deaktivieren."""
  151. processor = MockProcessor()
  152. assert processor.enabled is True
  153. processor.enabled = False
  154. assert processor.enabled is False
  155. processor.enabled = True
  156. assert processor.enabled is True
  157. def test_process_called(self):
  158. """Testet, dass process aufgerufen wird."""
  159. processor = MockProcessor()
  160. chunk = b'\x00\x01\x02\x03'
  161. context = AudioProcessingContext()
  162. result = processor.process(chunk, context)
  163. assert processor.process_count == 1
  164. assert processor.last_context is context
  165. assert result == chunk
  166. def test_process_modifies_chunk(self):
  167. """Testet Chunk-Modifikation."""
  168. processor = MockProcessor(modify=True)
  169. chunk = b'\x00\x01\x02\x03'
  170. context = AudioProcessingContext()
  171. result = processor.process(chunk, context)
  172. assert result != chunk
  173. assert result == bytes([255, 254, 253, 252])
  174. def test_get_config(self):
  175. """Testet get_config."""
  176. processor = MockProcessor(
  177. processor_id="config_test",
  178. name="Config Test",
  179. priority=100,
  180. )
  181. config = processor.get_config()
  182. assert config["id"] == "config_test"
  183. assert config["name"] == "Config Test"
  184. assert config["priority"] == 100
  185. assert config["enabled"] is True
  186. # =============================================================================
  187. # Tests für ProcessorPriority
  188. # =============================================================================
  189. class TestProcessorPriority:
  190. """Tests für ProcessorPriority Enum."""
  191. def test_priority_order(self):
  192. """Testet, dass Prioritäten korrekt sortiert sind."""
  193. assert ProcessorPriority.FIRST < ProcessorPriority.ANALYSIS
  194. assert ProcessorPriority.ANALYSIS < ProcessorPriority.DUCKING
  195. assert ProcessorPriority.DUCKING < ProcessorPriority.CROSSFADE
  196. assert ProcessorPriority.CROSSFADE < ProcessorPriority.VOLUME
  197. assert ProcessorPriority.VOLUME < ProcessorPriority.LAST
  198. # =============================================================================
  199. # Tests für AudioProcessingPipeline
  200. # =============================================================================
  201. class TestAudioProcessingPipeline:
  202. """Tests für AudioProcessingPipeline."""
  203. @pytest.fixture
  204. def pipeline(self):
  205. """Erstellt eine leere Pipeline."""
  206. return AudioProcessingPipeline()
  207. def test_empty_pipeline(self, pipeline):
  208. """Testet leere Pipeline."""
  209. assert pipeline.count == 0
  210. assert len(pipeline.processors) == 0
  211. def test_add_processor(self, pipeline):
  212. """Testet Hinzufügen eines Prozessors."""
  213. processor = MockProcessor()
  214. pipeline.add(processor)
  215. assert pipeline.count == 1
  216. assert processor in pipeline.processors
  217. def test_add_replaces_existing(self, pipeline):
  218. """Testet, dass Prozessor mit gleicher ID ersetzt wird."""
  219. processor1 = MockProcessor(processor_id="test", name="First")
  220. processor2 = MockProcessor(processor_id="test", name="Second")
  221. pipeline.add(processor1)
  222. pipeline.add(processor2)
  223. assert pipeline.count == 1
  224. assert pipeline.get("test").name == "Second"
  225. def test_processors_sorted_by_priority(self, pipeline):
  226. """Testet Sortierung nach Priorität."""
  227. p_high = MockProcessor("high", priority=100)
  228. p_low = MockProcessor("low", priority=900)
  229. p_mid = MockProcessor("mid", priority=500)
  230. pipeline.add(p_low)
  231. pipeline.add(p_high)
  232. pipeline.add(p_mid)
  233. processors = pipeline.processors
  234. assert processors[0].id == "high"
  235. assert processors[1].id == "mid"
  236. assert processors[2].id == "low"
  237. def test_remove_processor(self, pipeline):
  238. """Testet Entfernen eines Prozessors."""
  239. processor = MockProcessor()
  240. pipeline.add(processor)
  241. result = pipeline.remove("test")
  242. assert result is True
  243. assert pipeline.count == 0
  244. def test_remove_nonexistent(self, pipeline):
  245. """Testet Entfernen nicht existierender ID."""
  246. result = pipeline.remove("nonexistent")
  247. assert result is False
  248. def test_get_processor(self, pipeline):
  249. """Testet Abrufen eines Prozessors."""
  250. processor = MockProcessor(processor_id="find_me")
  251. pipeline.add(processor)
  252. found = pipeline.get("find_me")
  253. assert found is processor
  254. not_found = pipeline.get("unknown")
  255. assert not_found is None
  256. def test_enable_disable(self, pipeline):
  257. """Testet enable/disable."""
  258. processor = MockProcessor(enabled=True)
  259. pipeline.add(processor)
  260. pipeline.disable("test")
  261. assert processor.enabled is False
  262. pipeline.enable("test")
  263. assert processor.enabled is True
  264. def test_clear(self, pipeline):
  265. """Testet clear."""
  266. pipeline.add(MockProcessor("a"))
  267. pipeline.add(MockProcessor("b"))
  268. pipeline.add(MockProcessor("c"))
  269. assert pipeline.count == 3
  270. pipeline.clear()
  271. assert pipeline.count == 0
  272. def test_contains(self, pipeline):
  273. """Testet __contains__."""
  274. processor = MockProcessor(processor_id="contained")
  275. pipeline.add(processor)
  276. assert "contained" in pipeline
  277. assert "not_contained" not in pipeline
  278. def test_process_empty_pipeline(self, pipeline):
  279. """Testet process mit leerer Pipeline."""
  280. chunk = b'\x00\x01\x02\x03'
  281. result = pipeline.process(chunk)
  282. assert result == chunk
  283. def test_process_single_processor(self, pipeline):
  284. """Testet process mit einem Prozessor."""
  285. processor = MockProcessor(modify=True)
  286. pipeline.add(processor)
  287. chunk = b'\x00\x01\x02\x03'
  288. result = pipeline.process(chunk)
  289. assert processor.process_count == 1
  290. assert result != chunk
  291. def test_process_chain(self, pipeline):
  292. """Testet Verarbeitungskette."""
  293. p1 = MockProcessor("p1", priority=100)
  294. p2 = MockProcessor("p2", priority=200)
  295. p3 = MockProcessor("p3", priority=300)
  296. pipeline.add(p2)
  297. pipeline.add(p3)
  298. pipeline.add(p1)
  299. chunk = b'\x00\x01\x02\x03'
  300. pipeline.process(chunk)
  301. assert p1.process_count == 1
  302. assert p2.process_count == 1
  303. assert p3.process_count == 1
  304. def test_process_skips_disabled(self, pipeline):
  305. """Testet, dass deaktivierte Prozessoren übersprungen werden."""
  306. enabled = MockProcessor("enabled", enabled=True)
  307. disabled = MockProcessor("disabled", enabled=False)
  308. pipeline.add(enabled)
  309. pipeline.add(disabled)
  310. chunk = b'\x00\x01\x02\x03'
  311. pipeline.process(chunk)
  312. assert enabled.process_count == 1
  313. assert disabled.process_count == 0
  314. def test_process_creates_context_if_none(self, pipeline):
  315. """Testet automatische Kontext-Erstellung."""
  316. processor = MockProcessor()
  317. pipeline.add(processor)
  318. pipeline.process(b'\x00')
  319. assert processor.last_context is not None
  320. def test_process_uses_provided_context(self, pipeline):
  321. """Testet Verwendung des übergebenen Kontexts."""
  322. processor = MockProcessor()
  323. pipeline.add(processor)
  324. context = AudioProcessingContext(position_ms=5000)
  325. pipeline.process(b'\x00', context)
  326. assert processor.last_context.position_ms == 5000
  327. def test_state_management(self, pipeline):
  328. """Testet Zustandsverwaltung."""
  329. processor = MockProcessor()
  330. pipeline.add(processor)
  331. pipeline.set_wakeword_active(True)
  332. pipeline.set_conversation_active(True)
  333. pipeline.set_tts_playing(True)
  334. assert ("wakeword_active", True) in processor.state_changes
  335. assert ("conversation_active", True) in processor.state_changes
  336. assert ("tts_playing", True) in processor.state_changes
  337. def test_track_notifications(self, pipeline):
  338. """Testet Track-Start/End-Benachrichtigungen."""
  339. processor = MockProcessor()
  340. pipeline.add(processor)
  341. context = AudioProcessingContext()
  342. pipeline.notify_track_start(context)
  343. assert processor.track_start_called is True
  344. pipeline.notify_track_end(context)
  345. assert processor.track_end_called is True
  346. def test_reset_all(self, pipeline):
  347. """Testet reset_all."""
  348. processor = MockProcessor()
  349. processor.process_count = 10
  350. pipeline.add(processor)
  351. pipeline.set_wakeword_active(True)
  352. pipeline.reset_all()
  353. assert processor.process_count == 0
  354. # Pipeline-Zustand auch zurückgesetzt
  355. context = pipeline._create_default_context()
  356. assert context.is_wakeword_active is False
  357. def test_get_status(self, pipeline):
  358. """Testet get_status."""
  359. pipeline.add(MockProcessor("a", priority=100, enabled=True))
  360. pipeline.add(MockProcessor("b", priority=200, enabled=False))
  361. pipeline.set_wakeword_active(True)
  362. status = pipeline.get_status()
  363. assert status["processor_count"] == 2
  364. assert status["active_count"] == 1
  365. assert status["is_wakeword_active"] is True
  366. assert len(status["processors"]) == 2
  367. # =============================================================================
  368. # Tests für Hilfsfunktionen
  369. # =============================================================================
  370. class TestAudioHelpers:
  371. """Tests für Audio-Hilfsfunktionen."""
  372. def test_apply_volume_no_change(self):
  373. """Testet apply_volume bei Volume 1.0."""
  374. chunk = b'\x00\x10\x00\x20'
  375. result = apply_volume(chunk, 1.0)
  376. assert result == chunk
  377. def test_apply_volume_mute(self):
  378. """Testet apply_volume bei Volume 0.0."""
  379. chunk = b'\x00\x10\x00\x20'
  380. result = apply_volume(chunk, 0.0)
  381. assert result == b'\x00\x00\x00\x00'
  382. def test_apply_volume_half(self):
  383. """Testet apply_volume bei Volume 0.5."""
  384. # 16-bit Sample: 0x1000 = 4096
  385. import struct
  386. chunk = struct.pack("<h", 4096)
  387. result = apply_volume(chunk, 0.5)
  388. value = struct.unpack("<h", result)[0]
  389. assert value == 2048
  390. def test_mix_chunks_equal(self):
  391. """Testet mix_chunks bei 50/50."""
  392. import struct
  393. chunk1 = struct.pack("<h", 1000)
  394. chunk2 = struct.pack("<h", 3000)
  395. result = mix_chunks(chunk1, chunk2, 0.5)
  396. value = struct.unpack("<h", result)[0]
  397. # 0.5 * 1000 + 0.5 * 3000 = 2000
  398. assert value == 2000
  399. def test_mix_chunks_only_first(self):
  400. """Testet mix_chunks mit mix=0.0."""
  401. import struct
  402. chunk1 = struct.pack("<h", 1000)
  403. chunk2 = struct.pack("<h", 3000)
  404. result = mix_chunks(chunk1, chunk2, 0.0)
  405. value = struct.unpack("<h", result)[0]
  406. assert value == 1000
  407. def test_mix_chunks_only_second(self):
  408. """Testet mix_chunks mit mix=1.0."""
  409. import struct
  410. chunk1 = struct.pack("<h", 1000)
  411. chunk2 = struct.pack("<h", 3000)
  412. result = mix_chunks(chunk1, chunk2, 1.0)
  413. value = struct.unpack("<h", result)[0]
  414. assert value == 3000
  415. def test_fade_chunk_in(self):
  416. """Testet fade_chunk fade_in."""
  417. import struct
  418. # 4 Samples
  419. chunk = struct.pack("<4h", 1000, 1000, 1000, 1000)
  420. result = fade_chunk(chunk, fade_in=True)
  421. values = struct.unpack("<4h", result)
  422. # Erstes Sample sollte 0 sein, letztes 1000
  423. assert values[0] == 0
  424. assert values[-1] == 1000
  425. def test_fade_chunk_out(self):
  426. """Testet fade_chunk fade_out."""
  427. import struct
  428. chunk = struct.pack("<4h", 1000, 1000, 1000, 1000)
  429. result = fade_chunk(chunk, fade_out=True)
  430. values = struct.unpack("<4h", result)
  431. # Erstes Sample sollte 1000 sein, letztes 0
  432. assert values[0] == 1000
  433. assert values[-1] == 0
  434. def test_fade_chunk_no_fade(self):
  435. """Testet fade_chunk ohne Fade."""
  436. chunk = b'\x00\x10\x00\x20'
  437. result = fade_chunk(chunk)
  438. assert result == chunk