detector.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. # -*- coding: utf-8 -*-
  2. """
  3. Wakeword Detector - Wrapper fuer OpenWakeWord.
  4. Laedt native OWW-Modelle (.onnx) — sowohl vortrainierte als auch
  5. custom-trainierte Modelle aus models/wakeword/{name}/{name}.onnx.
  6. """
  7. from dataclasses import dataclass, field
  8. from datetime import datetime
  9. from enum import Enum
  10. from pathlib import Path
  11. from typing import Callable
  12. import numpy as np
  13. try:
  14. from openwakeword.model import Model as OWWModel
  15. OPENWAKEWORD_AVAILABLE = True
  16. except ImportError:
  17. OPENWAKEWORD_AVAILABLE = False
  18. OWWModel = None
  19. class WakewordType(Enum):
  20. """Typ des erkannten Wakewords."""
  21. CUSTOM = "custom"
  22. SYSTEM_COMMAND = "system_command"
  23. UNKNOWN = "unknown"
  24. @dataclass
  25. class DetectorConfig:
  26. """Konfiguration fuer den Wakeword-Detector."""
  27. # Modell-Konfiguration
  28. model_directory: str = "models/wakeword"
  29. models: list[str] = field(default_factory=lambda: ["custom", "system_command"])
  30. use_onnx: bool = True
  31. # Erkennungs-Schwellenwerte
  32. threshold: float = 0.5
  33. threshold_per_model: dict[str, float] = field(default_factory=dict)
  34. # Audio-Konfiguration
  35. sample_rate: int = 16000
  36. frame_length_ms: int = 80 # OpenWakeWord empfiehlt 80ms Frames
  37. # Verhalten
  38. refractory_period_ms: int = 500 # Mindestzeit zwischen Erkennungen
  39. @property
  40. def frame_length_samples(self) -> int:
  41. """Anzahl Samples pro Frame."""
  42. return int(self.sample_rate * self.frame_length_ms / 1000)
  43. def get_threshold(self, model_name: str) -> float:
  44. """Gibt Threshold fuer spezifisches Modell zurueck."""
  45. return self.threshold_per_model.get(model_name, self.threshold)
  46. @dataclass
  47. class WakewordDetection:
  48. """Ergebnis einer Wakeword-Erkennung."""
  49. wakeword_type: WakewordType
  50. model_name: str
  51. confidence: float
  52. timestamp: datetime
  53. audio_level: float # RMS-Level fuer Arbitration
  54. audio_chunks: list[bytes] = field(default_factory=list)
  55. def to_dict(self) -> dict:
  56. """Konvertiert zu Dictionary."""
  57. return {
  58. "wakeword_type": self.wakeword_type.value,
  59. "model_name": self.model_name,
  60. "confidence": self.confidence,
  61. "timestamp": self.timestamp.isoformat(),
  62. "audio_level": self.audio_level,
  63. "audio_chunks_count": len(self.audio_chunks),
  64. }
  65. class WakewordDetector:
  66. """
  67. Wrapper fuer OpenWakeWord.
  68. Verarbeitet Audio-Frames und erkennt Wakewords.
  69. Laedt ONNX-Modelle aus dem konfigurierten Verzeichnis.
  70. """
  71. def __init__(self, config: DetectorConfig | None = None):
  72. self._config = config or DetectorConfig()
  73. self._model: OWWModel | None = None
  74. self._loaded_models: dict[str, str] = {} # model_name -> file_path
  75. self._last_detection_time: datetime | None = None
  76. # Audio-Ring-Buffer fuer Wakeword-Chunks
  77. self._chunk_buffer: list[bytes] = []
  78. self._chunk_buffer_max = 10 # Letzte 10 Chunks (~800ms bei 80ms Frames)
  79. # Callbacks
  80. self._on_detection_callbacks: list[Callable[[WakewordDetection], None]] = []
  81. # Statistiken
  82. self._total_frames_processed = 0
  83. self._total_detections = 0
  84. @property
  85. def config(self) -> DetectorConfig:
  86. """Gibt Konfiguration zurueck."""
  87. return self._config
  88. @property
  89. def is_loaded(self) -> bool:
  90. """Prueft ob Modelle geladen sind."""
  91. return self._model is not None
  92. @property
  93. def loaded_models(self) -> list[str]:
  94. """Liste geladener Modelle."""
  95. return list(self._loaded_models.keys())
  96. @property
  97. def stats(self) -> dict:
  98. """Statistiken."""
  99. return {
  100. "frames_processed": self._total_frames_processed,
  101. "detections": self._total_detections,
  102. "is_loaded": self.is_loaded,
  103. "loaded_models": self.loaded_models,
  104. }
  105. def load_models(self) -> bool:
  106. """
  107. Laedt die konfigurierten Wakeword-Modelle.
  108. Sucht nach ONNX-Dateien in models/wakeword/{name}/.
  109. """
  110. if not OPENWAKEWORD_AVAILABLE:
  111. raise RuntimeError(
  112. "OpenWakeWord nicht installiert. "
  113. "Installiere mit: pip install openwakeword"
  114. )
  115. model_dir = Path(self._config.model_directory)
  116. model_paths = []
  117. for model_name in self._config.models:
  118. model_path = self._find_model_file(model_dir / model_name)
  119. if model_path:
  120. model_paths.append(str(model_path))
  121. self._loaded_models[model_name] = str(model_path)
  122. else:
  123. # Pre-trained OWW-Modell (wird von OWW selbst geladen)
  124. self._loaded_models[model_name] = model_name
  125. try:
  126. if model_paths:
  127. self._model = OWWModel(wakeword_model_paths=model_paths)
  128. else:
  129. self._model = OWWModel()
  130. return True
  131. except Exception as e:
  132. raise RuntimeError(f"Fehler beim Laden der Modelle: {e}")
  133. def _find_model_file(self, model_dir: Path) -> Path | None:
  134. """Sucht nach Modell-Datei (.onnx/.tflite/.pt) im Verzeichnis."""
  135. if not model_dir.exists():
  136. return None
  137. extensions = [".onnx", ".tflite", ".pt"]
  138. for ext in extensions:
  139. model_file = model_dir / f"model{ext}"
  140. if model_file.exists():
  141. return model_file
  142. files = list(model_dir.glob(f"*{ext}"))
  143. if files:
  144. return files[0]
  145. return None
  146. def unload_models(self) -> None:
  147. """Entlaedt alle Modelle."""
  148. self._model = None
  149. self._loaded_models.clear()
  150. def process_frame(self, audio_frame: bytes | np.ndarray) -> WakewordDetection | None:
  151. """
  152. Verarbeitet einen Audio-Frame.
  153. Args:
  154. audio_frame: Audio-Daten (16-bit PCM oder numpy array)
  155. Returns:
  156. WakewordDetection wenn erkannt, sonst None
  157. """
  158. if not self.is_loaded:
  159. raise RuntimeError("Modelle nicht geladen. Rufe load_models() auf.")
  160. # Konvertiere zu numpy wenn noetig
  161. if isinstance(audio_frame, bytes):
  162. audio_array = np.frombuffer(audio_frame, dtype=np.int16)
  163. self._add_to_chunk_buffer(audio_frame)
  164. else:
  165. audio_array = audio_frame
  166. self._add_to_chunk_buffer(audio_array.astype(np.int16).tobytes())
  167. # Audio-Level berechnen (RMS)
  168. audio_level = self._calculate_rms(audio_array)
  169. # OpenWakeWord Prediction
  170. predictions = self._model.predict(audio_array)
  171. self._total_frames_processed += 1
  172. # Pruefe auf Erkennungen
  173. detection = self._check_predictions(predictions, audio_level)
  174. if detection:
  175. self._total_detections += 1
  176. self._last_detection_time = detection.timestamp
  177. for callback in self._on_detection_callbacks:
  178. try:
  179. callback(detection)
  180. except Exception:
  181. pass
  182. return detection
  183. def _add_to_chunk_buffer(self, audio_bytes: bytes) -> None:
  184. """Fuegt Audio-Chunk zum Ring-Buffer hinzu."""
  185. self._chunk_buffer.append(audio_bytes)
  186. while len(self._chunk_buffer) > self._chunk_buffer_max:
  187. self._chunk_buffer.pop(0)
  188. def _get_wakeword_chunks(self) -> list[bytes]:
  189. """Gibt Audio-Chunks zurueck, die das Wakeword enthalten."""
  190. return list(self._chunk_buffer)
  191. def _calculate_rms(self, audio: np.ndarray) -> float:
  192. """Berechnet RMS-Level."""
  193. if len(audio) == 0:
  194. return 0.0
  195. audio_float = audio.astype(np.float32) / 32768.0
  196. return float(np.sqrt(np.mean(audio_float ** 2)))
  197. def _check_predictions(
  198. self,
  199. predictions: dict,
  200. audio_level: float,
  201. ) -> WakewordDetection | None:
  202. """Prueft Predictions auf Erkennungen."""
  203. now = datetime.now()
  204. # Refractory Period pruefen
  205. if self._last_detection_time:
  206. elapsed_ms = (now - self._last_detection_time).total_seconds() * 1000
  207. if elapsed_ms < self._config.refractory_period_ms:
  208. return None
  209. # Hoechste Confidence ueber Threshold suchen
  210. best_model = None
  211. best_confidence = 0.0
  212. for model_name, confidence in predictions.items():
  213. threshold = self._config.get_threshold(model_name)
  214. if confidence >= threshold and confidence > best_confidence:
  215. best_model = model_name
  216. best_confidence = confidence
  217. if best_model is None:
  218. return None
  219. wakeword_type = self._determine_wakeword_type(best_model)
  220. return WakewordDetection(
  221. wakeword_type=wakeword_type,
  222. model_name=best_model,
  223. confidence=best_confidence,
  224. timestamp=now,
  225. audio_level=audio_level,
  226. audio_chunks=self._get_wakeword_chunks(),
  227. )
  228. def _determine_wakeword_type(self, model_name: str) -> WakewordType:
  229. """Bestimmt Wakeword-Typ basierend auf Modellname."""
  230. model_lower = model_name.lower()
  231. if "custom" in model_lower or model_lower in ["hey_jarvis", "alexa", "hey_mycroft"]:
  232. return WakewordType.CUSTOM
  233. elif "system" in model_lower or "command" in model_lower:
  234. return WakewordType.SYSTEM_COMMAND
  235. else:
  236. return WakewordType.CUSTOM
  237. def on_detection(self, callback: Callable[[WakewordDetection], None]) -> None:
  238. """Registriert Callback fuer Wakeword-Erkennungen."""
  239. self._on_detection_callbacks.append(callback)
  240. def remove_callback(self, callback: Callable[[WakewordDetection], None]) -> bool:
  241. """Entfernt Callback."""
  242. try:
  243. self._on_detection_callbacks.remove(callback)
  244. return True
  245. except ValueError:
  246. return False
  247. def reset(self) -> None:
  248. """Setzt Detector-State zurueck."""
  249. self._chunk_buffer.clear()
  250. self._last_detection_time = None
  251. if self._model and hasattr(self._model, "reset"):
  252. self._model.reset()
  253. def reset_stats(self) -> None:
  254. """Setzt Statistiken zurueck."""
  255. self._total_frames_processed = 0
  256. self._total_detections = 0