test_wakeword.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests für Wakeword Detection Module.
  4. """
  5. import pytest
  6. from datetime import datetime, timedelta
  7. from unittest.mock import MagicMock, AsyncMock, patch
  8. try:
  9. import numpy as np
  10. HAS_NUMPY = True
  11. except ImportError:
  12. HAS_NUMPY = False
  13. np = None
  14. from trixy_core.wakeword.detector import (
  15. WakewordDetector,
  16. WakewordDetection,
  17. DetectorConfig,
  18. WakewordType,
  19. )
  20. from trixy_core.wakeword.audio_buffer import (
  21. AudioBuffer,
  22. AudioChunk,
  23. BufferConfig,
  24. AudioBufferPool,
  25. )
  26. from trixy_core.wakeword.vad import (
  27. VoiceActivityDetector,
  28. VADConfig,
  29. VADState,
  30. SpeechSegment,
  31. )
  32. class TestDetectorConfig:
  33. """Tests für DetectorConfig."""
  34. def test_default_config(self):
  35. """Testet Standard-Konfiguration."""
  36. config = DetectorConfig()
  37. assert config.threshold == 0.5
  38. assert config.sample_rate == 16000
  39. assert config.frame_length_ms == 80
  40. assert config.use_onnx is True
  41. def test_frame_length_samples(self):
  42. """Testet Frame-Länge in Samples."""
  43. config = DetectorConfig(sample_rate=16000, frame_length_ms=80)
  44. assert config.frame_length_samples == 1280 # 16000 * 80 / 1000
  45. def test_threshold_per_model(self):
  46. """Testet model-spezifische Thresholds."""
  47. config = DetectorConfig(
  48. threshold=0.5,
  49. threshold_per_model={"custom": 0.7},
  50. )
  51. assert config.get_threshold("custom") == 0.7
  52. assert config.get_threshold("other") == 0.5
  53. class TestWakewordDetection:
  54. """Tests für WakewordDetection."""
  55. def test_detection_creation(self):
  56. """Testet Detection-Erstellung."""
  57. detection = WakewordDetection(
  58. wakeword_type=WakewordType.CUSTOM,
  59. model_name="hey_jarvis",
  60. confidence=0.95,
  61. timestamp=datetime.now(),
  62. audio_level=0.5,
  63. )
  64. assert detection.model_name == "hey_jarvis"
  65. assert detection.confidence == 0.95
  66. assert detection.wakeword_type == WakewordType.CUSTOM
  67. def test_to_dict(self):
  68. """Testet Dictionary-Konvertierung."""
  69. detection = WakewordDetection(
  70. wakeword_type=WakewordType.SYSTEM_COMMAND,
  71. model_name="test",
  72. confidence=0.8,
  73. timestamp=datetime.now(),
  74. audio_level=0.3,
  75. )
  76. d = detection.to_dict()
  77. assert d["wakeword_type"] == "system_command"
  78. assert d["confidence"] == 0.8
  79. class TestWakewordType:
  80. """Tests für WakewordType."""
  81. def test_types(self):
  82. """Testet Wakeword-Typen."""
  83. assert WakewordType.CUSTOM.value == "custom"
  84. assert WakewordType.SYSTEM_COMMAND.value == "system_command"
  85. assert WakewordType.UNKNOWN.value == "unknown"
  86. class TestWakewordDetector:
  87. """Tests für WakewordDetector (Mock-basiert)."""
  88. @pytest.fixture
  89. def config(self):
  90. """Erstellt Test-Konfiguration."""
  91. return DetectorConfig(
  92. model_directory="models/wakeword",
  93. models=["custom"],
  94. threshold=0.5,
  95. )
  96. @pytest.fixture
  97. def detector(self, config):
  98. """Erstellt Detector."""
  99. return WakewordDetector(config)
  100. def test_detector_creation(self, detector):
  101. """Testet Detector-Erstellung."""
  102. assert detector.config is not None
  103. assert not detector.is_loaded
  104. assert detector.loaded_models == []
  105. def test_stats(self, detector):
  106. """Testet Statistiken."""
  107. stats = detector.stats
  108. assert stats["frames_processed"] == 0
  109. assert stats["detections"] == 0
  110. assert stats["is_loaded"] is False
  111. def test_callback_registration(self, detector):
  112. """Testet Callback-Registrierung."""
  113. callback = MagicMock()
  114. detector.on_detection(callback)
  115. assert callback in detector._on_detection_callbacks
  116. assert detector.remove_callback(callback)
  117. assert callback not in detector._on_detection_callbacks
  118. def test_reset(self, detector):
  119. """Testet Reset."""
  120. detector._chunk_buffer = [b"test"]
  121. detector._last_detection_time = datetime.now()
  122. detector.reset()
  123. assert detector._chunk_buffer == []
  124. assert detector._last_detection_time is None
  125. class TestBufferConfig:
  126. """Tests für BufferConfig."""
  127. def test_default_config(self):
  128. """Testet Standard-Konfiguration."""
  129. config = BufferConfig()
  130. assert config.max_duration_seconds == 60.0
  131. assert config.sample_rate == 16000
  132. assert config.bit_depth == 16
  133. def test_bytes_per_second(self):
  134. """Testet Bytes pro Sekunde."""
  135. config = BufferConfig(sample_rate=16000, channels=1, bit_depth=16)
  136. assert config.bytes_per_second == 32000 # 16000 * 1 * 2
  137. def test_max_buffer_size(self):
  138. """Testet maximale Buffer-Größe."""
  139. config = BufferConfig(max_duration_seconds=60.0, sample_rate=16000)
  140. assert config.max_buffer_size == 60 * 32000
  141. class TestAudioBuffer:
  142. """Tests für AudioBuffer."""
  143. @pytest.fixture
  144. def config(self):
  145. """Erstellt Test-Konfiguration."""
  146. return BufferConfig(
  147. max_duration_seconds=10.0,
  148. pre_buffer_seconds=0.5,
  149. )
  150. @pytest.fixture
  151. def buffer(self, config):
  152. """Erstellt AudioBuffer."""
  153. return AudioBuffer(config)
  154. def test_initial_state(self, buffer):
  155. """Testet initialen Zustand."""
  156. assert not buffer.is_recording
  157. assert buffer.size_bytes == 0
  158. assert buffer.chunk_count == 0
  159. def test_pre_buffer(self, buffer):
  160. """Testet Pre-Buffer."""
  161. audio = b"\x00" * 1000
  162. buffer.add_to_pre_buffer(audio)
  163. stats = buffer.get_stats()
  164. assert stats["pre_buffer_bytes"] == 1000
  165. def test_start_recording(self, buffer):
  166. """Testet Aufnahme-Start."""
  167. buffer.add_to_pre_buffer(b"\x00" * 500)
  168. buffer.start_recording(include_pre_buffer=True)
  169. assert buffer.is_recording
  170. assert buffer.size_bytes == 500
  171. def test_add_chunk(self, buffer):
  172. """Testet Chunk-Hinzufügen."""
  173. buffer.start_recording()
  174. audio = b"\x00" * 1000
  175. assert buffer.add_chunk(audio)
  176. assert buffer.size_bytes == 1000
  177. assert buffer.chunk_count == 1
  178. def test_stop_recording(self, buffer):
  179. """Testet Aufnahme-Stop."""
  180. buffer.start_recording()
  181. buffer.add_chunk(b"\x00" * 1000)
  182. buffer.add_chunk(b"\x01" * 500)
  183. data = buffer.stop_recording()
  184. assert len(data) == 1500
  185. assert not buffer.is_recording
  186. def test_max_size_limit(self, buffer):
  187. """Testet Größen-Limit."""
  188. buffer.start_recording()
  189. # Fülle Buffer bis zum Limit
  190. chunk_size = buffer.config.max_buffer_size + 1
  191. result = buffer.add_chunk(b"\x00" * chunk_size)
  192. assert not result # Sollte fehlschlagen
  193. def test_clear(self, buffer):
  194. """Testet Löschen."""
  195. buffer.start_recording()
  196. buffer.add_chunk(b"\x00" * 1000)
  197. buffer.clear()
  198. assert buffer.size_bytes == 0
  199. assert not buffer.is_recording
  200. class TestAudioBufferPool:
  201. """Tests für AudioBufferPool."""
  202. @pytest.fixture
  203. def pool(self):
  204. """Erstellt Buffer-Pool."""
  205. return AudioBufferPool(pool_size=3)
  206. def test_acquire_release(self, pool):
  207. """Testet Acquire/Release."""
  208. buffer = pool.acquire("test-1")
  209. assert buffer is not None
  210. assert pool.active_count == 1
  211. data = pool.release("test-1")
  212. assert pool.active_count == 0
  213. assert pool.available_count >= 3
  214. def test_multiple_acquire(self, pool):
  215. """Testet mehrfaches Acquire."""
  216. b1 = pool.acquire("test-1")
  217. b2 = pool.acquire("test-2")
  218. assert b1 is not b2
  219. assert pool.active_count == 2
  220. def test_get_same_buffer(self, pool):
  221. """Testet Wiederverwendung bei gleicher ID."""
  222. b1 = pool.acquire("test-1")
  223. b2 = pool.acquire("test-1")
  224. assert b1 is b2
  225. class TestVADConfig:
  226. """Tests für VADConfig."""
  227. def test_default_config(self):
  228. """Testet Standard-Konfiguration."""
  229. config = VADConfig()
  230. assert config.speech_threshold == 0.02
  231. assert config.silence_duration_ms == 3000
  232. assert config.max_duration_ms == 60000
  233. class TestVADState:
  234. """Tests für VADState."""
  235. def test_states(self):
  236. """Testet VAD-Zustände."""
  237. assert VADState.WAITING.value == "waiting"
  238. assert VADState.SPEECH.value == "speech"
  239. assert VADState.SILENCE.value == "silence"
  240. assert VADState.TIMEOUT.value == "timeout"
  241. @pytest.mark.skipif(not HAS_NUMPY, reason="numpy nicht installiert")
  242. class TestVoiceActivityDetector:
  243. """Tests für VoiceActivityDetector."""
  244. @pytest.fixture
  245. def config(self):
  246. """Erstellt Test-Konfiguration."""
  247. return VADConfig(
  248. speech_threshold=0.02,
  249. silence_threshold=0.01,
  250. silence_duration_ms=500, # Kurz für Tests
  251. max_duration_ms=5000,
  252. min_speech_ms=100,
  253. )
  254. @pytest.fixture
  255. def vad(self, config):
  256. """Erstellt VAD."""
  257. return VoiceActivityDetector(config)
  258. def test_initial_state(self, vad):
  259. """Testet initialen Zustand."""
  260. assert vad.state == VADState.WAITING
  261. assert not vad.is_active
  262. assert not vad.has_speech
  263. def test_start(self, vad):
  264. """Testet Start."""
  265. vad.start()
  266. assert vad.is_active
  267. assert vad.state == VADState.WAITING
  268. def test_stop(self, vad):
  269. """Testet Stop."""
  270. vad.start()
  271. vad.stop()
  272. assert not vad.is_active
  273. def test_speech_detection(self, vad):
  274. """Testet Sprach-Erkennung."""
  275. vad.start()
  276. # Simuliere lautes Audio (Sprache)
  277. loud_audio = np.array([10000] * 1280, dtype=np.int16)
  278. state = vad.process_frame(loud_audio.tobytes())
  279. assert state == VADState.SPEECH
  280. def test_silence_detection(self, vad):
  281. """Testet Stille-Erkennung."""
  282. vad.start()
  283. # Erst Sprache (mehrere Frames für min_speech_ms)
  284. loud = np.array([10000] * 1280, dtype=np.int16)
  285. for _ in range(5): # 400ms Sprache
  286. vad.process_frame(loud.tobytes())
  287. # Dann Stille - muss unter silence_threshold sein
  288. quiet = np.array([1] * 1280, dtype=np.int16) # Sehr leise
  289. state = vad.process_frame(quiet.tobytes())
  290. # Nach nur einem Frame Stille sind wir noch in SPEECH→SILENCE Übergang
  291. assert state in (VADState.SPEECH, VADState.SILENCE)
  292. def test_callbacks(self, vad):
  293. """Testet Callbacks."""
  294. speech_started = []
  295. vad.on_speech_start(lambda: speech_started.append(True))
  296. vad.start()
  297. # Sprache simulieren - lauter als speech_threshold (0.02)
  298. # RMS von 15000/32768 = 0.46, deutlich über 0.02
  299. loud = np.array([15000] * 1280, dtype=np.int16)
  300. # Genug Frames für min_speech_ms (100ms = 2 Frames bei 80ms)
  301. for _ in range(10): # 800ms sollte reichen
  302. state = vad.process_frame(loud.tobytes())
  303. # Sollte im SPEECH-Zustand sein
  304. assert state == VADState.SPEECH
  305. # _speech_start_time sollte gesetzt sein
  306. assert vad._speech_start_time is not None
  307. def test_stats(self, vad):
  308. """Testet Statistiken."""
  309. vad.start()
  310. audio = np.array([5000] * 1280, dtype=np.int16)
  311. vad.process_frame(audio.tobytes())
  312. stats = vad.get_stats()
  313. assert stats["is_active"] is True
  314. assert stats["frame_count"] == 1
  315. def test_reset(self, vad):
  316. """Testet Reset."""
  317. vad.start()
  318. vad._speech_detected = True
  319. vad.reset()
  320. assert vad.state == VADState.WAITING
  321. assert not vad.has_speech
  322. class TestSpeechSegment:
  323. """Tests für SpeechSegment."""
  324. def test_segment_creation(self):
  325. """Testet Segment-Erstellung."""
  326. segment = SpeechSegment(
  327. start_time=datetime.now(),
  328. peak_level=0.5,
  329. avg_level=0.3,
  330. )
  331. assert segment.peak_level == 0.5
  332. assert not segment.is_complete
  333. def test_duration(self):
  334. """Testet Dauer-Berechnung."""
  335. start = datetime.now() - timedelta(seconds=2)
  336. segment = SpeechSegment(start_time=start)
  337. assert segment.duration_ms >= 2000
  338. def test_complete_segment(self):
  339. """Testet abgeschlossenes Segment."""
  340. segment = SpeechSegment(
  341. start_time=datetime.now() - timedelta(seconds=1),
  342. end_time=datetime.now(),
  343. )
  344. assert segment.is_complete