import queue
|
import threading
|
import logging
|
import time
|
from typing import Any, Callable
|
|
import numpy as np
|
|
from ..config import RuntimeConfig
|
from .detector_assets import DetectorAssetsBuilder
|
|
logger = logging.getLogger(__name__)
|
|
|
class WakewordDetector:
|
def __init__(self, config: RuntimeConfig) -> None:
|
self.config = config
|
self.enabled = config.wakeword_enabled
|
self.assets_builder = DetectorAssetsBuilder(config)
|
self.audio_source = None
|
self.keyword_spotter: Any | None = None
|
self.stream: Any | None = None
|
self.is_running_flag = False
|
self.paused = False
|
self.on_detected_callback: Callable[[str, str], None] | None = None
|
self.on_error: Callable[[Exception], None] | None = None
|
self._audio_queue: queue.Queue[np.ndarray] = queue.Queue(maxsize=100)
|
self._worker_thread: threading.Thread | None = None
|
self.last_detection_time = 0.0
|
self.detection_cooldown = self.config.detector.cooldown_seconds
|
|
def initialize(self) -> None:
|
if not self.enabled:
|
raise RuntimeError("wakeword detector is disabled")
|
|
try:
|
import sherpa_onnx
|
except ImportError as exc:
|
raise RuntimeError(
|
"Missing dependency: sherpa-onnx. Install runtime dependencies before initializing detector."
|
) from exc
|
|
assets = self.assets_builder.prepare()
|
detector_cfg = self.config.detector
|
|
self.keyword_spotter = sherpa_onnx.KeywordSpotter(
|
tokens=str(assets.tokens_file),
|
encoder=str(assets.encoder_file),
|
decoder=str(assets.decoder_file),
|
joiner=str(assets.joiner_file),
|
keywords_file=str(assets.keywords_file),
|
num_threads=detector_cfg.num_threads,
|
sample_rate=self.config.audio.sample_rate,
|
feature_dim=80,
|
max_active_paths=detector_cfg.max_active_paths,
|
keywords_score=detector_cfg.keywords_score,
|
keywords_threshold=detector_cfg.keywords_threshold,
|
num_trailing_blanks=detector_cfg.num_trailing_blanks,
|
provider=detector_cfg.provider,
|
)
|
self.stream = self.keyword_spotter.create_stream()
|
logger.info("detector initialized")
|
logger.info("detector model root: %s", assets.model_root)
|
logger.info("detector keywords file: %s", assets.keywords_file)
|
|
def on_detected(self, callback: Callable[[str, str], None]) -> None:
|
self.on_detected_callback = callback
|
|
def on_audio_data(self, audio_data: np.ndarray) -> None:
|
if not self.enabled or not self.is_running_flag or self.paused:
|
return
|
|
try:
|
self._audio_queue.put_nowait(audio_data.copy())
|
except queue.Full:
|
try:
|
self._audio_queue.get_nowait()
|
self._audio_queue.put_nowait(audio_data.copy())
|
except queue.Empty:
|
pass
|
except Exception as exc:
|
logger.debug("audio data enqueue failed: %s", exc)
|
|
def start(self, audio_source) -> None:
|
if not self.enabled:
|
logger.info("wakeword detector disabled")
|
return
|
|
if self.keyword_spotter is None or self.stream is None:
|
self.initialize()
|
|
if self.is_running_flag:
|
return
|
|
self.audio_source = audio_source
|
self.audio_source.add_audio_listener(self)
|
self.is_running_flag = True
|
self.paused = False
|
self._worker_thread = threading.Thread(
|
target=self._detection_loop,
|
name="wakeword-detector",
|
daemon=True,
|
)
|
self._worker_thread.start()
|
logger.info("wakeword detector started")
|
|
def stop(self) -> None:
|
if not self.is_running_flag and self.audio_source is None and self._worker_thread is None:
|
return
|
|
self.is_running_flag = False
|
|
if self.audio_source is not None:
|
self.audio_source.remove_audio_listener(self)
|
self.audio_source = None
|
|
if self._worker_thread is not None:
|
self._worker_thread.join(timeout=1.0)
|
self._worker_thread = None
|
|
while not self._audio_queue.empty():
|
try:
|
self._audio_queue.get_nowait()
|
except queue.Empty:
|
break
|
|
logger.info("wakeword detector stopped")
|
|
def _detection_loop(self) -> None:
|
error_count = 0
|
max_errors = 5
|
|
while self.is_running_flag:
|
try:
|
if self.paused:
|
time.sleep(0.1)
|
continue
|
|
audio_data = self._audio_queue.get(timeout=0.1)
|
result = self.process_audio_chunk(audio_data)
|
if result and self.on_detected_callback is not None:
|
self.on_detected_callback(result, result)
|
error_count = 0
|
except queue.Empty:
|
continue
|
except Exception as exc:
|
error_count += 1
|
logger.error("wakeword detection loop error(%s/%s): %s", error_count, max_errors, exc)
|
if self.on_error is not None:
|
try:
|
self.on_error(exc)
|
except Exception:
|
logger.exception("wakeword error callback failed")
|
if error_count >= max_errors:
|
logger.critical("too many wakeword detection errors, stopping detector")
|
break
|
time.sleep(1)
|
|
self.is_running_flag = False
|
|
def process_audio_chunk(self, audio_data: np.ndarray) -> str | None:
|
if self.keyword_spotter is None or self.stream is None:
|
raise RuntimeError("detector is not initialized")
|
|
if audio_data is None or len(audio_data) == 0:
|
return None
|
|
if audio_data.dtype == np.int16:
|
samples = audio_data.astype(np.float32) / 32768.0
|
else:
|
samples = audio_data.astype(np.float32)
|
|
sample_rate = self.config.audio.sample_rate
|
self.stream.accept_waveform(sample_rate=sample_rate, waveform=samples)
|
|
if not self.keyword_spotter.is_ready(self.stream):
|
return None
|
|
self.keyword_spotter.decode_stream(self.stream)
|
result = self.keyword_spotter.get_result(self.stream)
|
if not result:
|
return None
|
|
self.keyword_spotter.reset_stream(self.stream)
|
|
current_time = time.time()
|
if current_time - self.last_detection_time < self.detection_cooldown:
|
return None
|
|
self.last_detection_time = current_time
|
return str(result)
|