violawake_sdk
ViolaWake SDK — Open-source wake word detection + voice pipeline.
Public API surface:
WakeDetector — detect a wake word in an audio stream AsyncWakeDetector — async wrapper for asyncio-based applications DetectorConfig — advanced configuration (ensemble, adaptive, speaker, power) VADEngine — voice activity detection (WebRTC, Silero, RMS) TTSEngine — on-device text-to-speech (Kokoro-82M) STTEngine — speech-to-text (faster-whisper) VoicePipeline — bundled Wake→VAD→STT→TTS orchestration NoiseProfiler — noise-adaptive threshold adjustment PowerManager — battery-aware power management FusionStrategy — multi-model ensemble scoring list_models() — discover available wake word models list_voices() — discover available TTS voices
Quick start::
from violawake_sdk import WakeDetector
with WakeDetector(threshold=0.80) as detector:
for chunk in detector.stream_mic():
if detector.detect(chunk):
print("Wake word detected!")
break
See README.md or https://github.com/GeeIHadAGoodTime/ViolaWake for full documentation.
1""" 2ViolaWake SDK — Open-source wake word detection + voice pipeline. 3 4Public API surface: 5 WakeDetector — detect a wake word in an audio stream 6 AsyncWakeDetector — async wrapper for asyncio-based applications 7 DetectorConfig — advanced configuration (ensemble, adaptive, speaker, power) 8 VADEngine — voice activity detection (WebRTC, Silero, RMS) 9 TTSEngine — on-device text-to-speech (Kokoro-82M) 10 STTEngine — speech-to-text (faster-whisper) 11 VoicePipeline — bundled Wake→VAD→STT→TTS orchestration 12 NoiseProfiler — noise-adaptive threshold adjustment 13 PowerManager — battery-aware power management 14 FusionStrategy — multi-model ensemble scoring 15 list_models() — discover available wake word models 16 list_voices() — discover available TTS voices 17 18Quick start:: 19 20 from violawake_sdk import WakeDetector 21 22 with WakeDetector(threshold=0.80) as detector: 23 for chunk in detector.stream_mic(): 24 if detector.detect(chunk): 25 print("Wake word detected!") 26 break 27 28See README.md or https://github.com/GeeIHadAGoodTime/ViolaWake for full documentation. 29""" 30 31from __future__ import annotations 32 33# Read the installed distribution version so __version__ never drifts from 34# pyproject.toml/PyPI. The fallback covers running from a source checkout 35# without an editable install (rare in user code, common in CI before 36# `pip install -e .`). 37try: 38 from importlib.metadata import PackageNotFoundError 39 from importlib.metadata import version as _pkg_version 40 41 try: 42 __version__ = _pkg_version("violawake") 43 except PackageNotFoundError: 44 __version__ = "0.0.0+source" 45except ImportError: # pragma: no cover - Python < 3.8 (unsupported) 46 __version__ = "0.0.0+source" 47 48__author__ = "ViolaWake Contributors" 49__license__ = "Apache-2.0" 50 51from violawake_sdk._exceptions import ( 52 AudioCaptureError, 53 ModelLoadError, 54 ModelNotFoundError, 55 PipelineError, 56 VADBackendError, 57 ViolaWakeError, 58) 59from violawake_sdk.async_detector import AsyncWakeDetector 60from violawake_sdk.confidence import ConfidenceLevel, ConfidenceResult 61from violawake_sdk.ensemble import FusionStrategy 62from violawake_sdk.noise_profiler import NoiseProfiler 63from violawake_sdk.pipeline import AsyncVoicePipeline, VoicePipeline 64from violawake_sdk.power_manager import PowerManager 65from violawake_sdk.vad import VADEngine 66from violawake_sdk.wake_detector import ( 67 DetectorConfig, 68 WakeDecisionPolicy, 69 WakeDetector, 70 WakewordDetector, # noqa: F401 — backward compat 71 validate_audio_chunk, 72) 73 74# Conditional imports for optional extras — provide helpful error on use if missing 75 76 77def _make_missing_extra_class(name: str, extra: str): 78 """Return a placeholder class that raises ImportError on instantiation.""" 79 80 class _Missing: 81 def __init__(self, *args, **kwargs): 82 raise ImportError( 83 f"{name} requires extra dependencies. " 84 f'Install them with: pip install "violawake[{extra}]"' 85 ) 86 87 _Missing.__name__ = _Missing.__qualname__ = name 88 return _Missing 89 90 91try: 92 from violawake_sdk.tts import TTSEngine 93except ImportError: 94 TTSEngine = _make_missing_extra_class("TTSEngine", "tts") # type: ignore[assignment,misc] 95 96try: 97 from violawake_sdk.stt import StreamingSTTEngine, STTEngine 98except ImportError: 99 STTEngine = _make_missing_extra_class("STTEngine", "stt") # type: ignore[assignment,misc] 100 StreamingSTTEngine = _make_missing_extra_class("StreamingSTTEngine", "stt") # type: ignore[assignment,misc] 101 102 103def list_models() -> list[dict[str, str]]: 104 """Return available wake word models with their descriptions. 105 106 Each entry is a dict with keys: ``name``, ``description``, ``version``. 107 108 Example:: 109 110 >>> from violawake_sdk import list_models 111 >>> for m in list_models(): 112 ... print(f"{m['name']:20s} {m['description']}") 113 """ 114 from violawake_sdk.models import MODEL_REGISTRY 115 116 seen: set[str] = set() 117 result: list[dict[str, str]] = [] 118 for name, spec in MODEL_REGISTRY.items(): 119 # Deduplicate aliases (e.g. "viola" -> "temporal_cnn") 120 if spec.name in seen: 121 continue 122 # Hide deprecated, package-managed, and non-wake-word models 123 if "DEPRECATED" in spec.description: 124 continue 125 if spec.name in ("oww_backbone", "kokoro_v1_0", "kokoro_voices_v1_0"): 126 continue 127 seen.add(spec.name) 128 result.append( 129 { 130 "name": name, 131 "description": spec.description, 132 "version": spec.version, 133 } 134 ) 135 return result 136 137 138def list_voices() -> list[str]: 139 """Return available TTS voice names for use with ``TTSEngine``. 140 141 Requires the ``[tts]`` extra to be installed for actual synthesis, 142 but this function always works for discovery. 143 144 Example:: 145 146 >>> from violawake_sdk import list_voices 147 >>> list_voices() 148 ['af_heart', 'af_bella', 'af_sarah', ...] 149 """ 150 from violawake_sdk.tts import AVAILABLE_VOICES 151 152 return list(AVAILABLE_VOICES) 153 154 155__all__ = [ 156 # Core detection 157 "DetectorConfig", 158 "WakeDetector", 159 "WakewordDetector", 160 "AsyncWakeDetector", 161 "WakeDecisionPolicy", 162 "validate_audio_chunk", 163 # Confidence & scoring 164 "ConfidenceResult", 165 "ConfidenceLevel", 166 "FusionStrategy", 167 # Advanced features 168 "NoiseProfiler", 169 "PowerManager", 170 # Pipeline components 171 "VADEngine", 172 "TTSEngine", 173 "STTEngine", 174 "StreamingSTTEngine", 175 "VoicePipeline", 176 "AsyncVoicePipeline", 177 # Exceptions 178 "ViolaWakeError", 179 "ModelNotFoundError", 180 "AudioCaptureError", 181 "ModelLoadError", 182 "PipelineError", 183 "VADBackendError", 184 # Discovery 185 "list_models", 186 "list_voices", 187 "__version__", 188]
71@dataclass 72class DetectorConfig: 73 """Advanced configuration for WakeDetector. 74 75 Basic usage needs no config -- just use ``WakeDetector(threshold=0.80)``. 76 Use ``DetectorConfig`` to opt-in to advanced features without cluttering 77 the constructor. 78 79 Example:: 80 81 # Simple (80% of users): 82 det = WakeDetector(model="temporal_cnn", threshold=0.80) 83 84 # Advanced (multi-model ensemble + adaptive threshold): 85 det = WakeDetector( 86 model="temporal_cnn", 87 config=DetectorConfig( 88 adaptive_threshold=True, 89 confirm_count=3, 90 ), 91 ) 92 93 Attributes: 94 models: Additional model paths for multi-model ensemble (K3). 95 fusion_strategy: Score fusion strategy for ensemble (K3). 96 fusion_weights: Per-model weights for weighted_average fusion (K3). 97 adaptive_threshold: Enable dynamic threshold based on noise (K4). 98 noise_profiler: Custom NoiseProfiler instance (K4). 99 speaker_verify_fn: Post-detection speaker verification callback (K5). 100 power_manager: Power management controller for duty cycling (K7). 101 confirm_count: Consecutive above-threshold scores required (K2). 102 score_history_size: Number of recent scores to retain (K2). 103 """ 104 105 # K3: Multi-model ensemble 106 models: list[str] | None = None 107 fusion_strategy: FusionStrategy | str = FusionStrategy.AVERAGE 108 fusion_weights: list[float] | None = None 109 110 # K4: Adaptive threshold 111 adaptive_threshold: bool = False 112 noise_profiler: NoiseProfiler | None = None 113 114 # K5: Speaker verification 115 speaker_verify_fn: Callable[..., bool] | None = None 116 117 # K7: Power management 118 power_manager: PowerManager | None = None 119 120 # K2: Confidence tracking 121 confirm_count: int = 1 122 score_history_size: int = 50 123 124 def build(self, model: str = "temporal_cnn", **kwargs: Any) -> WakeDetector: 125 """Build a WakeDetector from this config. 126 127 Convenience method that passes ``self`` as the ``config=`` argument. 128 129 Args: 130 model: Model name or path (default: ``"temporal_cnn"``). 131 **kwargs: Additional WakeDetector constructor arguments 132 (threshold, cooldown_s, etc.). 133 134 Returns: 135 Configured WakeDetector instance. 136 """ 137 return WakeDetector(model=model, config=self, **kwargs)
Advanced configuration for WakeDetector.
Basic usage needs no config -- just use WakeDetector(threshold=0.80).
Use DetectorConfig to opt-in to advanced features without cluttering
the constructor.
Example::
# Simple (80% of users):
det = WakeDetector(model="temporal_cnn", threshold=0.80)
# Advanced (multi-model ensemble + adaptive threshold):
det = WakeDetector(
model="temporal_cnn",
config=DetectorConfig(
adaptive_threshold=True,
confirm_count=3,
),
)
Attributes:
- models: Additional model paths for multi-model ensemble (K3).
- fusion_strategy: Score fusion strategy for ensemble (K3).
- fusion_weights: Per-model weights for weighted_average fusion (K3).
- adaptive_threshold: Enable dynamic threshold based on noise (K4).
- noise_profiler: Custom NoiseProfiler instance (K4).
- speaker_verify_fn: Post-detection speaker verification callback (K5).
- power_manager: Power management controller for duty cycling (K7).
- confirm_count: Consecutive above-threshold scores required (K2).
- score_history_size: Number of recent scores to retain (K2).
124 def build(self, model: str = "temporal_cnn", **kwargs: Any) -> WakeDetector: 125 """Build a WakeDetector from this config. 126 127 Convenience method that passes ``self`` as the ``config=`` argument. 128 129 Args: 130 model: Model name or path (default: ``"temporal_cnn"``). 131 **kwargs: Additional WakeDetector constructor arguments 132 (threshold, cooldown_s, etc.). 133 134 Returns: 135 Configured WakeDetector instance. 136 """ 137 return WakeDetector(model=model, config=self, **kwargs)
Build a WakeDetector from this config.
Convenience method that passes self as the config= argument.
Arguments:
- model: Model name or path (default:
"temporal_cnn"). - **kwargs: Additional WakeDetector constructor arguments (threshold, cooldown_s, etc.).
Returns:
Configured WakeDetector instance.
275class WakeDetector: 276 """Wake word detector using ViolaWake MLP on OpenWakeWord embeddings. 277 278 Supports pluggable inference backends (ONNX Runtime, TFLite) via the 279 ``backend`` parameter. The default ``"auto"`` mode tries ONNX Runtime 280 first, then falls back to TFLite, so users on edge devices can run 281 without installing ``onnxruntime``. 282 283 Also supports optional competitive features (all opt-in, backward compatible): 284 285 - **K2 Confidence API**: ``get_confidence()`` and ``last_scores`` property. 286 - **K3 Multi-model ensemble**: ``models`` parameter with fusion strategies. 287 - **K4 Adaptive threshold**: ``adaptive_threshold`` parameter with noise profiling. 288 - **K5 Speaker verification**: ``speaker_verify_fn`` callback for post-detection. 289 - **K6 Audio source abstraction**: ``from_source()`` class method factory. 290 - **K7 Power management**: ``power_manager`` parameter for duty cycling. 291 292 **Threshold tuning guide:** 293 294 - 0.70 = sensitive (more detections, more false positives) 295 - 0.80 = balanced (default, recommended starting point) 296 - 0.85 = conservative (fewer false positives, may miss some) 297 - 0.90+ = very conservative (for noisy environments) 298 299 Start at 0.80 and adjust based on your false accept rate. 300 301 Args: 302 model: Model name from the registry, or a path to a model file. 303 threshold: Detection confidence threshold in [0.0, 1.0]. 304 cooldown_s: Minimum seconds between consecutive detections. 305 providers: ONNX Runtime execution providers (ignored for TFLite). 306 backend: Inference backend selector (``"onnx"``, ``"tflite"``, ``"auto"``). 307 config: A ``DetectorConfig`` instance bundling all advanced options. 308 Mutually exclusive with the individual advanced kwargs below. 309 models: Additional model paths for ensemble scoring (K3). 310 fusion_strategy: Score fusion strategy for ensemble (K3). 311 fusion_weights: Per-model weights for weighted_average fusion (K3). 312 adaptive_threshold: Enable dynamic threshold based on noise (K4). 313 noise_profiler: Custom NoiseProfiler instance (K4). 314 speaker_verify_fn: Post-detection speaker verification callback (K5). 315 power_manager: Power management controller for duty cycling (K7). 316 confirm_count: Consecutive above-threshold scores required for detection (K2). 317 score_history_size: Number of recent scores to retain (K2). 318 """ 319 320 _VALID_BACKENDS = ("onnx", "tflite", "auto") 321 322 def __init__( 323 self, 324 model: str = "temporal_cnn", 325 threshold: float = DEFAULT_THRESHOLD, 326 cooldown_s: float = DEFAULT_COOLDOWN_S, 327 providers: list[str] | None = None, 328 backend: str = "auto", 329 *, 330 config: DetectorConfig | None = None, 331 # K3: Multi-model ensemble (individual kwargs, backwards compat) 332 models: list[str] | None = _UNSET, 333 fusion_strategy: FusionStrategy | str = _UNSET, 334 fusion_weights: list[float] | None = _UNSET, 335 # K4: Adaptive threshold 336 adaptive_threshold: bool = _UNSET, 337 noise_profiler: NoiseProfiler | None = _UNSET, 338 # K5: Speaker verification 339 speaker_verify_fn: Callable[[np.ndarray], bool] | None = _UNSET, 340 # K7: Power management 341 power_manager: PowerManager | None = _UNSET, 342 # K2: Confidence tracking 343 confirm_count: int = _UNSET, 344 score_history_size: int = _UNSET, 345 ) -> None: 346 # --- Resolve config vs individual kwargs ------------------------- 347 # Detect if any advanced kwarg was explicitly passed (not _UNSET) 348 _locals = { 349 "models": models, 350 "fusion_strategy": fusion_strategy, 351 "fusion_weights": fusion_weights, 352 "adaptive_threshold": adaptive_threshold, 353 "noise_profiler": noise_profiler, 354 "speaker_verify_fn": speaker_verify_fn, 355 "power_manager": power_manager, 356 "confirm_count": confirm_count, 357 "score_history_size": score_history_size, 358 } 359 explicit_kwargs = {name for name, val in _locals.items() if val is not _UNSET} 360 if config is not None and explicit_kwargs: 361 raise ValueError( 362 f"Cannot specify both config= and individual advanced kwargs. " 363 f"Conflicting kwargs: {sorted(explicit_kwargs)}. " 364 f"Either use config=DetectorConfig(...) or pass kwargs directly, not both." 365 ) 366 367 if config is not None: 368 # Unpack from DetectorConfig 369 models = config.models 370 fusion_strategy = config.fusion_strategy 371 fusion_weights = config.fusion_weights 372 adaptive_threshold = config.adaptive_threshold 373 noise_profiler = config.noise_profiler 374 speaker_verify_fn = config.speaker_verify_fn 375 power_manager = config.power_manager 376 confirm_count = config.confirm_count 377 score_history_size = config.score_history_size 378 else: 379 # Apply defaults for any _UNSET values (backwards compat path) 380 if models is _UNSET: 381 models = None 382 if fusion_strategy is _UNSET: 383 fusion_strategy = FusionStrategy.AVERAGE 384 if fusion_weights is _UNSET: 385 fusion_weights = None 386 if adaptive_threshold is _UNSET: 387 adaptive_threshold = False 388 if noise_profiler is _UNSET: 389 noise_profiler = None 390 if speaker_verify_fn is _UNSET: 391 speaker_verify_fn = None 392 if power_manager is _UNSET: 393 power_manager = None 394 if confirm_count is _UNSET: 395 confirm_count = 1 396 if score_history_size is _UNSET: 397 score_history_size = 50 398 399 # G1: Input validation for public constructor parameters 400 if not isinstance(threshold, (int, float)): 401 raise TypeError(f"threshold must be a number, got {type(threshold).__name__}") 402 if not 0.0 <= threshold <= 1.0: 403 raise ValueError(f"threshold must be in [0.0, 1.0], got {threshold!r}") 404 if not isinstance(cooldown_s, (int, float)): 405 raise TypeError(f"cooldown_s must be a number, got {type(cooldown_s).__name__}") 406 if cooldown_s < 0: 407 raise ValueError(f"cooldown_s must be >= 0, got {cooldown_s!r}") 408 if backend not in self._VALID_BACKENDS: 409 raise ValueError(f"backend must be one of {self._VALID_BACKENDS}, got {backend!r}") 410 if confirm_count < 1: 411 raise ValueError(f"confirm_count must be >= 1, got {confirm_count}") 412 413 self.threshold = threshold 414 self._lock = threading.Lock() 415 self._backbone_lock = threading.Lock() 416 self._policy = WakeDecisionPolicy(threshold=threshold, cooldown_s=cooldown_s) 417 self._providers = providers or ["CPUExecutionProvider"] 418 self._backend: InferenceBackend = get_backend(backend, providers=self._providers) 419 420 # K2: Confidence tracking 421 self._score_tracker = ScoreTracker( 422 threshold=threshold, 423 history_size=score_history_size, 424 ) 425 self._confirm_required = confirm_count 426 self._confirm_counter = 0 427 428 # K3: Ensemble support 429 self._ensemble: EnsembleScorer | None = None 430 if models and len(models) > 0: 431 if isinstance(fusion_strategy, str): 432 fusion_strategy = FusionStrategy(fusion_strategy) 433 self._ensemble = EnsembleScorer( 434 strategy=fusion_strategy, 435 weights=fusion_weights, 436 ) 437 438 # K4: Noise profiler / adaptive threshold 439 self._adaptive_threshold = adaptive_threshold 440 if noise_profiler is not None: 441 self._noise_profiler: NoiseProfiler | None = noise_profiler 442 elif adaptive_threshold: 443 self._noise_profiler = NoiseProfiler(base_threshold=threshold) 444 else: 445 self._noise_profiler = None 446 447 # K5: Speaker verification 448 self._speaker_verify_fn = speaker_verify_fn 449 450 # K7: Power manager 451 self._power_manager = power_manager 452 453 # Warn on deprecated models 454 if model in MODEL_REGISTRY and "DEPRECATED" in MODEL_REGISTRY[model].description: 455 import warnings 456 457 warnings.warn( 458 f"Model '{model}' is deprecated: {MODEL_REGISTRY[model].description}. " 459 f"Use model='temporal_cnn' instead.", 460 DeprecationWarning, 461 stacklevel=2, 462 ) 463 464 # Load models 465 self._oww_backbone = self._create_oww_backbone() 466 self._mlp_session = self._load_session(model) 467 self._mlp_input_name = self._mlp_session.get_inputs()[0].name 468 self._last_score = 0.0 469 470 # Detect temporal vs MLP model from input shape 471 mlp_input_shape = self._mlp_session.get_inputs()[0].shape 472 if len(mlp_input_shape) == 3: 473 # Temporal model: input is (batch, seq_len, embedding_dim) 474 self._is_temporal = True 475 self._temporal_seq_len = ( 476 mlp_input_shape[1] 477 if isinstance(mlp_input_shape[1], int) 478 else _TEMPORAL_SEQ_LEN_DEFAULT 479 ) 480 self._embedding_buffer: collections.deque[np.ndarray] = collections.deque( 481 maxlen=self._temporal_seq_len, 482 ) 483 logger.info( 484 "Temporal model detected: seq_len=%d", 485 self._temporal_seq_len, 486 ) 487 else: 488 self._is_temporal = False 489 self._temporal_seq_len = 0 490 491 # K3: Load additional ensemble models 492 if models and self._ensemble is not None: 493 # Add primary model to ensemble 494 self._ensemble.add_session(self._mlp_session, self._mlp_input_name) 495 for extra_model in models: 496 extra_session = self._load_session(extra_model) 497 extra_input_name = extra_session.get_inputs()[0].name 498 self._ensemble.add_session(extra_session, extra_input_name) 499 500 logger.info( 501 "WakeDetector initialized: model=%s, threshold=%.2f, backend=%s", 502 model, 503 threshold, 504 self._backend.name, 505 ) 506 self._warn_on_oww_backbone_change(self._resolve_model_path(model)) 507 508 # ------------------------------------------------------------------ 509 # Context manager support 510 # ------------------------------------------------------------------ 511 512 def __enter__(self) -> WakeDetector: 513 """Enter sync context manager. Returns self.""" 514 return self 515 516 def __exit__( 517 self, 518 exc_type: type[BaseException] | None, 519 exc_val: BaseException | None, 520 exc_tb: object, 521 ) -> None: 522 """Exit sync context manager. Releases sessions and resets state.""" 523 self.close() 524 525 def close(self) -> None: 526 """Release inference sessions and reset internal state. 527 528 After calling close(), the detector should not be used for inference. 529 This is called automatically when using WakeDetector as a context 530 manager. 531 """ 532 self.reset() 533 # Release inference session references so the underlying runtime 534 # (ONNX / TFLite) can free memory immediately rather than waiting 535 # for garbage collection. 536 self._mlp_session = None # type: ignore[assignment] 537 if self._ensemble is not None: 538 self._ensemble.clear() 539 self._oww_backbone = None # type: ignore[assignment] 540 541 def _create_oww_backbone(self) -> OpenWakeWordBackbone: 542 """Create the shared OpenWakeWord backbone.""" 543 return OpenWakeWordBackbone(self._backend) 544 545 def _warn_on_oww_backbone_change(self, model_path: Path) -> None: 546 """Warn when the installed OWW backbone differs from the training config.""" 547 config_path = model_path.with_suffix(".config.json") 548 if not config_path.exists(): 549 return 550 551 try: 552 with config_path.open(encoding="utf-8") as f: 553 config = json.load(f) 554 except (OSError, json.JSONDecodeError): 555 return 556 557 expected_mel = config.get("oww_mel_sha256") 558 expected_emb = config.get("oww_emb_sha256") 559 if not isinstance(expected_mel, str) or not isinstance(expected_emb, str): 560 return 561 562 try: 563 current_hashes = get_openwakeword_backbone_hashes("onnx") 564 except Exception: 565 return 566 567 if ( 568 current_hashes["oww_mel_sha256"] != expected_mel 569 or current_hashes["oww_emb_sha256"] != expected_emb 570 ): 571 logger.warning( 572 "OWW backbone version changed since training. Model may produce degraded results." 573 ) 574 575 def _load_session(self, model: str) -> BackendSession: 576 """Load a model file via the configured backend. 577 578 Resolves *model* to a file path (direct path, .onnx/.tflite suffix, 579 or registry lookup), then delegates to ``self._backend.load()``. 580 581 For TFLite backends, if only a ``.onnx`` file exists in the cache 582 the method looks for a sibling ``.tflite`` file with the same stem. 583 """ 584 model_path = self._resolve_model_path(model) 585 586 # When using the TFLite backend, prefer a .tflite sibling if the 587 # resolved path is an .onnx file. 588 if self._backend.name == "tflite" and model_path.suffix == ".onnx": 589 tflite_sibling = model_path.with_suffix(".tflite") 590 if tflite_sibling.exists(): 591 model_path = tflite_sibling 592 logger.debug("TFLite backend: using .tflite sibling %s", model_path) 593 else: 594 logger.warning( 595 "TFLite backend selected but only .onnx file found at %s. " 596 "Convert with: python -c " 597 '"from violawake_sdk.backends.tflite_backend import ' 598 "convert_onnx_to_tflite; convert_onnx_to_tflite('%s')\"", 599 model_path, 600 model_path, 601 ) 602 603 try: 604 session = self._backend.load(model_path) 605 except Exception as e: 606 raise ModelLoadError(f"Failed to load model {model_path}: {e}") from e 607 logger.debug("Loaded model via %s backend: %s", self._backend.name, model_path) 608 return session 609 610 @staticmethod 611 def _resolve_model_path(model: str) -> Path: 612 """Resolve a model name or path string to a concrete file path. 613 614 Resolution order: 615 1. If *model* is an existing file path, use it directly. 616 2. If *model* ends with ``.onnx`` or ``.tflite``, treat as a path 617 (raise if not found). 618 3. Otherwise, look up *model* in the model registry / cache. 619 """ 620 if Path(model).is_file(): 621 return Path(model) 622 623 if model.endswith((".onnx", ".tflite")): 624 path = Path(model) 625 if not path.exists(): 626 raise ModelNotFoundError( 627 f"Model file not found: {model}. " 628 f"If this is a named model, omit the file extension." 629 ) 630 return path 631 632 try: 633 return get_model_path(model) 634 except FileNotFoundError as e: 635 raise ModelNotFoundError( 636 f"Model '{model}' not found in cache and auto-download failed or is disabled. " 637 f"Run: violawake-download --model {model}" 638 ) from e 639 640 def _get_embedding(self, audio_frame: bytes | np.ndarray) -> np.ndarray: 641 """Extract the OWW embedding from an audio frame. 642 643 Returns the raw embedding vector before MLP scoring. 644 Used internally for speaker verification (K5). 645 """ 646 with self._backbone_lock: 647 embedding = self._oww_backbone.last_embedding 648 if embedding is None: 649 _, embedding = self._oww_backbone.push_audio(audio_frame) 650 if embedding is None: 651 return np.zeros(EMBEDDING_DIM, dtype=np.float32) 652 return embedding 653 654 @staticmethod 655 def _needs_int16_normalization(audio_frame: bytes | np.ndarray) -> bool: 656 """Check whether audio_frame requires int16-to-float normalization.""" 657 return isinstance(audio_frame, bytes) or ( 658 isinstance(audio_frame, np.ndarray) and audio_frame.dtype == np.int16 659 ) 660 661 @staticmethod 662 def _prepare_model_audio(audio_frame: bytes | np.ndarray) -> np.ndarray: 663 """Validate an audio frame and normalize it for model inference.""" 664 pcm = validate_audio_chunk(audio_frame) 665 if WakeDetector._needs_int16_normalization(audio_frame): 666 return pcm / 32768.0 667 return pcm 668 669 def process(self, audio_frame: bytes | np.ndarray) -> float: 670 """Process a 20ms audio frame and return the wake word detection score. 671 672 If ensemble mode is active (K3), returns the fused score. 673 The score is recorded for confidence tracking (K2) and reported 674 to the power manager (K7) if configured. 675 676 Thread-safe: protects internal state mutation with a lock. 677 678 Raises: 679 TypeError: If audio_frame is not bytes or ndarray. 680 ValueError: If audio_frame is empty, malformed, or drastically 681 larger than the supported streaming frame size. 682 """ 683 return self._process_core(self._prepare_model_audio(audio_frame), audio_frame) 684 685 def _process_core(self, pcm: np.ndarray, raw_audio_frame: bytes | np.ndarray) -> float: 686 """Internal scoring engine operating on pre-validated, normalized PCM. 687 688 Args: 689 pcm: Float32 array, already validated and normalized by _prepare_model_audio. 690 raw_audio_frame: Original audio frame passed through to OWW backbone. 691 """ 692 if pcm.shape[0] != FRAME_SAMPLES: 693 # Reject pathologically large or empty frames first 694 if pcm.shape[0] == 0 or pcm.shape[0] > _MAX_PROCESS_FRAME_SAMPLES: 695 raise ValueError( 696 "Audio frame length is too far from the expected streaming size: " 697 f"expected {FRAME_SAMPLES} samples, got {pcm.shape[0]} " 698 f"(maximum non-pathological size is {_MAX_PROCESS_FRAME_SAMPLES})" 699 ) 700 # Non-multiples of 320 indicate wrong sample rate — return 0.0 701 if pcm.shape[0] % FRAME_SAMPLES != 0: 702 logger.warning( 703 "Audio frame has %d samples (not a multiple of %d). " 704 "Expected 16kHz, 20ms frames. Returning score 0.0.", 705 pcm.shape[0], 706 FRAME_SAMPLES, 707 ) 708 return 0.0 709 with self._backbone_lock: 710 produced_embedding, embedding = self._oww_backbone.push_audio(raw_audio_frame) 711 if embedding is None: 712 score = self._last_score 713 elif self._ensemble is not None and self._ensemble.model_count > 0: 714 score = ( 715 self._ensemble.score(embedding.flatten()) 716 if produced_embedding 717 else self._last_score 718 ) 719 elif self._is_temporal: 720 if produced_embedding: 721 self._embedding_buffer.append(embedding.flatten()) 722 if len(self._embedding_buffer) >= self._temporal_seq_len: 723 temporal_input = np.stack(list(self._embedding_buffer)) 724 temporal_input = temporal_input.reshape( 725 1, 726 self._temporal_seq_len, 727 EMBEDDING_DIM, 728 ).astype(np.float32) 729 score = float( 730 self._mlp_session.run(None, {self._mlp_input_name: temporal_input})[ 731 0 732 ].flatten()[0] 733 ) 734 else: 735 score = 0.0 736 else: 737 score = self._last_score 738 else: 739 if produced_embedding: 740 mlp_input = embedding.reshape(1, EMBEDDING_DIM).astype(np.float32) 741 score_output = self._mlp_session.run(None, {self._mlp_input_name: mlp_input})[0] 742 score = float(np.asarray(score_output).reshape(-1)[0]) 743 else: 744 score = self._last_score 745 746 with self._lock: 747 self._last_score = score 748 # K2: Record score for confidence tracking 749 self._score_tracker.record(score) 750 751 # K7: Report score to power manager for activity detection 752 if self._power_manager is not None: 753 self._power_manager.report_score(score) 754 755 return score 756 757 def detect(self, audio_frame: bytes | np.ndarray, is_playing: bool = False) -> bool: 758 """Process a frame and apply the full decision policy. 759 760 Integrates adaptive threshold (K4), multi-window confirmation (K2), 761 speaker verification (K5), and power management (K7) when configured. 762 763 Thread-safe: protects internal state mutation with a lock. 764 765 Raises: 766 TypeError: If audio_frame is not bytes or ndarray. 767 ValueError: If audio_frame is empty or has invalid format. 768 """ 769 # G1: Input validation (single pass — process_core skips re-validation) 770 pcm = validate_audio_chunk(audio_frame) 771 772 # Compute RMS on int16-scale PCM for the rms_floor comparison. 773 # rms_floor=1.0 is calibrated for int16 scale (speech ≈ 500–5000, 774 # silence ≈ 0–5). Float32 input in [-1, 1] is scaled up so the 775 # same rms_floor works regardless of input format. 776 rms = float(np.sqrt(np.mean(pcm**2))) 777 if not self._needs_int16_normalization(audio_frame): 778 # Float32/float64 input: RMS is in [0, ~0.7] — scale to int16 range 779 rms *= 32768.0 780 781 # Normalize for model inference 782 model_pcm = pcm / 32768.0 if self._needs_int16_normalization(audio_frame) else pcm 783 784 # K7: Power management -- skip frame if power manager says so 785 if self._power_manager is not None and not self._power_manager.should_process(pcm): 786 return False 787 788 # K4: Update noise profiler and get adaptive threshold 789 if self._noise_profiler is not None and self._adaptive_threshold: 790 adapted = self._noise_profiler.update(pcm) 791 self._policy.threshold = adapted 792 793 score = self._process_core(model_pcm, audio_frame) 794 795 # K5: Pre-fetch embedding outside _lock to avoid ABBA deadlock 796 # (_process_core acquires _backbone_lock -> _lock; we must not 797 # acquire _backbone_lock while holding _lock). 798 # 799 # Trade-off: Under concurrent access, _get_embedding reads the 800 # backbone's last_embedding which may have been overwritten by 801 # another thread's _process_core call since our score was computed. 802 # This means the embedding used for speaker verification may not 803 # correspond to the score just returned by _process_core. This is 804 # accepted for performance — taking _backbone_lock across both 805 # _process_core and _get_embedding would serialize all detection, 806 # and the mismatch is benign (embeddings from adjacent frames are 807 # nearly identical in practice). 808 speaker_embedding: np.ndarray | None = None 809 if self._speaker_verify_fn is not None: 810 speaker_embedding = self._get_embedding(audio_frame) 811 812 with self._lock: 813 # K2: Multi-window confirmation 814 if score >= self._policy.threshold: 815 self._confirm_counter += 1 816 else: 817 self._confirm_counter = 0 818 819 effective_detected = self._confirm_counter >= self._confirm_required 820 821 if effective_detected: 822 detected = self._policy.evaluate(score=score, rms=rms, is_playing=is_playing) 823 else: 824 detected = False 825 826 if detected: 827 # K5: Speaker verification post-detection 828 if self._speaker_verify_fn is not None and speaker_embedding is not None: # noqa: SIM102 829 if not self._speaker_verify_fn(speaker_embedding.flatten()): 830 logger.debug("Speaker verification rejected detection") 831 return False 832 833 self._confirm_counter = 0 834 return True 835 836 return False 837 838 def reset_cooldown(self) -> None: 839 """Reset the cooldown window without clearing confirmation state or buffers.""" 840 with self._lock: 841 self._policy.reset_cooldown() 842 843 def reset(self) -> None: 844 """Reset cooldown, confirmation state, score history, and temporal buffers. 845 846 Lock ordering: _backbone_lock then _lock, matching _process_core 847 to prevent ABBA deadlock. 848 """ 849 with self._backbone_lock, self._lock: 850 self._policy.reset_cooldown() 851 self._confirm_counter = 0 852 self._last_score = 0.0 853 self._score_tracker.reset() 854 self._oww_backbone.reset() 855 if self._is_temporal: 856 self._embedding_buffer.clear() 857 858 # ------------------------------------------------------------------ 859 # K2: Confidence API 860 # ------------------------------------------------------------------ 861 862 def get_confidence(self) -> ConfidenceResult: 863 """Return a confidence assessment of the current detection state. 864 865 Includes the raw MLP score, multi-window confirmation count, 866 and a classified confidence level (LOW/MEDIUM/HIGH/CERTAIN). 867 """ 868 return self._score_tracker.classify( 869 confirm_count=self._confirm_counter, 870 confirm_required=self._confirm_required, 871 ) 872 873 @property 874 def last_scores(self) -> tuple[float, ...]: 875 """Return the recent score history (most recent last).""" 876 return self._score_tracker.last_scores 877 878 # ------------------------------------------------------------------ 879 # K5: Speaker verification helpers 880 # ------------------------------------------------------------------ 881 882 def enroll_speaker(self, speaker_id: str, audio_frames: list[bytes | np.ndarray]) -> int: 883 """Enroll a speaker by extracting embeddings from audio frames. 884 885 Requires a ``SpeakerVerificationHook`` as the ``speaker_verify_fn``. 886 887 Args: 888 speaker_id: Unique identifier for the speaker. 889 audio_frames: Audio frames to extract embeddings from. 890 891 Returns: 892 Total enrollment count for this speaker. 893 894 Raises: 895 RuntimeError: If no SpeakerVerificationHook is configured. 896 """ 897 from violawake_sdk.speaker import SpeakerVerificationHook 898 899 hook = self._speaker_verify_fn 900 if not isinstance(hook, SpeakerVerificationHook): 901 raise RuntimeError( 902 "enroll_speaker requires a SpeakerVerificationHook as speaker_verify_fn" 903 ) 904 905 embeddings = [] 906 for frame in audio_frames: 907 emb = self._get_embedding(frame) 908 embeddings.append(emb.flatten()) 909 910 return hook.enroll_speaker(speaker_id, embeddings) 911 912 def verify_speaker(self, audio_frame: bytes | np.ndarray) -> SpeakerVerifyResult: 913 """Verify the speaker in an audio frame against enrolled profiles. 914 915 Args: 916 audio_frame: Audio frame to verify. 917 918 Returns: 919 SpeakerVerifyResult with match details. 920 921 Raises: 922 RuntimeError: If no SpeakerVerificationHook is configured. 923 """ 924 from violawake_sdk.speaker import SpeakerVerificationHook 925 926 hook = self._speaker_verify_fn 927 if not isinstance(hook, SpeakerVerificationHook): 928 raise RuntimeError( 929 "verify_speaker requires a SpeakerVerificationHook as speaker_verify_fn" 930 ) 931 932 embedding = self._get_embedding(audio_frame) 933 return hook.verify_speaker(embedding.flatten()) 934 935 # ------------------------------------------------------------------ 936 # K6: Audio source factory 937 # ------------------------------------------------------------------ 938 939 @classmethod 940 def from_source( 941 cls, 942 source: AudioSource, 943 model: str = "temporal_cnn", 944 threshold: float = DEFAULT_THRESHOLD, 945 cooldown_s: float = DEFAULT_COOLDOWN_S, 946 **kwargs: Any, 947 ) -> _SourceDetector: 948 """Create a WakeDetector bound to an AudioSource. 949 950 The returned object wraps a WakeDetector and provides a ``run()`` 951 method that reads frames from the source and runs detection. 952 953 Args: 954 source: Any object implementing the AudioSource protocol. 955 model: Model name or path. 956 threshold: Detection threshold. 957 cooldown_s: Cooldown between detections. 958 **kwargs: Additional WakeDetector keyword arguments. 959 960 Returns: 961 A _SourceDetector wrapping both the source and detector. 962 """ 963 detector = cls( 964 model=model, 965 threshold=threshold, 966 cooldown_s=cooldown_s, 967 **kwargs, 968 ) 969 return _SourceDetector(detector=detector, source=source) 970 971 # ------------------------------------------------------------------ 972 # Original methods 973 # ------------------------------------------------------------------ 974 975 def stream_mic(self, device_index: int | None = None) -> Generator[bytes, None, None]: 976 """Generator that yields 20ms audio frames from the default microphone.""" 977 try: 978 import pyaudio 979 except ImportError: 980 raise ImportError( 981 "pyaudio is required for microphone features. " 982 "Install with: pip install violawake[audio]" 983 ) from None 984 pa = pyaudio.PyAudio() 985 try: 986 stream = pa.open( 987 format=pyaudio.paInt16, 988 channels=1, 989 rate=SAMPLE_RATE, 990 input=True, 991 frames_per_buffer=FRAME_SAMPLES, 992 input_device_index=device_index, 993 ) 994 except Exception as e: 995 pa.terminate() 996 raise AudioCaptureError( 997 f"Failed to open microphone: {e}. " 998 f"Check that a microphone is connected and not in use by another application." 999 ) from e 1000 logger.info("Microphone capture started (16kHz, mono, 20ms frames)") 1001 _MAX_CONSECUTIVE_ERRORS = 10 1002 consecutive_errors = 0 1003 try: 1004 while True: 1005 try: 1006 yield stream.read(FRAME_SAMPLES, exception_on_overflow=False) 1007 consecutive_errors = 0 1008 except Exception as e: 1009 consecutive_errors += 1 1010 logger.warning( 1011 "Mic read error (%d/%d): %s", consecutive_errors, _MAX_CONSECUTIVE_ERRORS, e 1012 ) 1013 if consecutive_errors >= _MAX_CONSECUTIVE_ERRORS: 1014 raise AudioCaptureError( 1015 f"Microphone read failed {_MAX_CONSECUTIVE_ERRORS} consecutive times. " 1016 f"Last error: {e}" 1017 ) from e 1018 continue 1019 finally: 1020 stream.stop_stream() 1021 stream.close() 1022 pa.terminate() 1023 logger.info("Microphone capture stopped")
Wake word detector using ViolaWake MLP on OpenWakeWord embeddings.
Supports pluggable inference backends (ONNX Runtime, TFLite) via the
backend parameter. The default "auto" mode tries ONNX Runtime
first, then falls back to TFLite, so users on edge devices can run
without installing onnxruntime.
Also supports optional competitive features (all opt-in, backward compatible):
- K2 Confidence API:
get_confidence()andlast_scoresproperty. - K3 Multi-model ensemble:
modelsparameter with fusion strategies. - K4 Adaptive threshold:
adaptive_thresholdparameter with noise profiling. - K5 Speaker verification:
speaker_verify_fncallback for post-detection. - K6 Audio source abstraction:
from_source()class method factory. - K7 Power management:
power_managerparameter for duty cycling.
Threshold tuning guide:
- 0.70 = sensitive (more detections, more false positives)
- 0.80 = balanced (default, recommended starting point)
- 0.85 = conservative (fewer false positives, may miss some)
- 0.90+ = very conservative (for noisy environments)
Start at 0.80 and adjust based on your false accept rate.
Arguments:
- model: Model name from the registry, or a path to a model file.
- threshold: Detection confidence threshold in [0.0, 1.0].
- cooldown_s: Minimum seconds between consecutive detections.
- providers: ONNX Runtime execution providers (ignored for TFLite).
- backend: Inference backend selector (
"onnx","tflite","auto"). - config: A
DetectorConfiginstance bundling all advanced options. Mutually exclusive with the individual advanced kwargs below. - models: Additional model paths for ensemble scoring (K3).
- fusion_strategy: Score fusion strategy for ensemble (K3).
- fusion_weights: Per-model weights for weighted_average fusion (K3).
- adaptive_threshold: Enable dynamic threshold based on noise (K4).
- noise_profiler: Custom NoiseProfiler instance (K4).
- speaker_verify_fn: Post-detection speaker verification callback (K5).
- power_manager: Power management controller for duty cycling (K7).
- confirm_count: Consecutive above-threshold scores required for detection (K2).
- score_history_size: Number of recent scores to retain (K2).
322 def __init__( 323 self, 324 model: str = "temporal_cnn", 325 threshold: float = DEFAULT_THRESHOLD, 326 cooldown_s: float = DEFAULT_COOLDOWN_S, 327 providers: list[str] | None = None, 328 backend: str = "auto", 329 *, 330 config: DetectorConfig | None = None, 331 # K3: Multi-model ensemble (individual kwargs, backwards compat) 332 models: list[str] | None = _UNSET, 333 fusion_strategy: FusionStrategy | str = _UNSET, 334 fusion_weights: list[float] | None = _UNSET, 335 # K4: Adaptive threshold 336 adaptive_threshold: bool = _UNSET, 337 noise_profiler: NoiseProfiler | None = _UNSET, 338 # K5: Speaker verification 339 speaker_verify_fn: Callable[[np.ndarray], bool] | None = _UNSET, 340 # K7: Power management 341 power_manager: PowerManager | None = _UNSET, 342 # K2: Confidence tracking 343 confirm_count: int = _UNSET, 344 score_history_size: int = _UNSET, 345 ) -> None: 346 # --- Resolve config vs individual kwargs ------------------------- 347 # Detect if any advanced kwarg was explicitly passed (not _UNSET) 348 _locals = { 349 "models": models, 350 "fusion_strategy": fusion_strategy, 351 "fusion_weights": fusion_weights, 352 "adaptive_threshold": adaptive_threshold, 353 "noise_profiler": noise_profiler, 354 "speaker_verify_fn": speaker_verify_fn, 355 "power_manager": power_manager, 356 "confirm_count": confirm_count, 357 "score_history_size": score_history_size, 358 } 359 explicit_kwargs = {name for name, val in _locals.items() if val is not _UNSET} 360 if config is not None and explicit_kwargs: 361 raise ValueError( 362 f"Cannot specify both config= and individual advanced kwargs. " 363 f"Conflicting kwargs: {sorted(explicit_kwargs)}. " 364 f"Either use config=DetectorConfig(...) or pass kwargs directly, not both." 365 ) 366 367 if config is not None: 368 # Unpack from DetectorConfig 369 models = config.models 370 fusion_strategy = config.fusion_strategy 371 fusion_weights = config.fusion_weights 372 adaptive_threshold = config.adaptive_threshold 373 noise_profiler = config.noise_profiler 374 speaker_verify_fn = config.speaker_verify_fn 375 power_manager = config.power_manager 376 confirm_count = config.confirm_count 377 score_history_size = config.score_history_size 378 else: 379 # Apply defaults for any _UNSET values (backwards compat path) 380 if models is _UNSET: 381 models = None 382 if fusion_strategy is _UNSET: 383 fusion_strategy = FusionStrategy.AVERAGE 384 if fusion_weights is _UNSET: 385 fusion_weights = None 386 if adaptive_threshold is _UNSET: 387 adaptive_threshold = False 388 if noise_profiler is _UNSET: 389 noise_profiler = None 390 if speaker_verify_fn is _UNSET: 391 speaker_verify_fn = None 392 if power_manager is _UNSET: 393 power_manager = None 394 if confirm_count is _UNSET: 395 confirm_count = 1 396 if score_history_size is _UNSET: 397 score_history_size = 50 398 399 # G1: Input validation for public constructor parameters 400 if not isinstance(threshold, (int, float)): 401 raise TypeError(f"threshold must be a number, got {type(threshold).__name__}") 402 if not 0.0 <= threshold <= 1.0: 403 raise ValueError(f"threshold must be in [0.0, 1.0], got {threshold!r}") 404 if not isinstance(cooldown_s, (int, float)): 405 raise TypeError(f"cooldown_s must be a number, got {type(cooldown_s).__name__}") 406 if cooldown_s < 0: 407 raise ValueError(f"cooldown_s must be >= 0, got {cooldown_s!r}") 408 if backend not in self._VALID_BACKENDS: 409 raise ValueError(f"backend must be one of {self._VALID_BACKENDS}, got {backend!r}") 410 if confirm_count < 1: 411 raise ValueError(f"confirm_count must be >= 1, got {confirm_count}") 412 413 self.threshold = threshold 414 self._lock = threading.Lock() 415 self._backbone_lock = threading.Lock() 416 self._policy = WakeDecisionPolicy(threshold=threshold, cooldown_s=cooldown_s) 417 self._providers = providers or ["CPUExecutionProvider"] 418 self._backend: InferenceBackend = get_backend(backend, providers=self._providers) 419 420 # K2: Confidence tracking 421 self._score_tracker = ScoreTracker( 422 threshold=threshold, 423 history_size=score_history_size, 424 ) 425 self._confirm_required = confirm_count 426 self._confirm_counter = 0 427 428 # K3: Ensemble support 429 self._ensemble: EnsembleScorer | None = None 430 if models and len(models) > 0: 431 if isinstance(fusion_strategy, str): 432 fusion_strategy = FusionStrategy(fusion_strategy) 433 self._ensemble = EnsembleScorer( 434 strategy=fusion_strategy, 435 weights=fusion_weights, 436 ) 437 438 # K4: Noise profiler / adaptive threshold 439 self._adaptive_threshold = adaptive_threshold 440 if noise_profiler is not None: 441 self._noise_profiler: NoiseProfiler | None = noise_profiler 442 elif adaptive_threshold: 443 self._noise_profiler = NoiseProfiler(base_threshold=threshold) 444 else: 445 self._noise_profiler = None 446 447 # K5: Speaker verification 448 self._speaker_verify_fn = speaker_verify_fn 449 450 # K7: Power manager 451 self._power_manager = power_manager 452 453 # Warn on deprecated models 454 if model in MODEL_REGISTRY and "DEPRECATED" in MODEL_REGISTRY[model].description: 455 import warnings 456 457 warnings.warn( 458 f"Model '{model}' is deprecated: {MODEL_REGISTRY[model].description}. " 459 f"Use model='temporal_cnn' instead.", 460 DeprecationWarning, 461 stacklevel=2, 462 ) 463 464 # Load models 465 self._oww_backbone = self._create_oww_backbone() 466 self._mlp_session = self._load_session(model) 467 self._mlp_input_name = self._mlp_session.get_inputs()[0].name 468 self._last_score = 0.0 469 470 # Detect temporal vs MLP model from input shape 471 mlp_input_shape = self._mlp_session.get_inputs()[0].shape 472 if len(mlp_input_shape) == 3: 473 # Temporal model: input is (batch, seq_len, embedding_dim) 474 self._is_temporal = True 475 self._temporal_seq_len = ( 476 mlp_input_shape[1] 477 if isinstance(mlp_input_shape[1], int) 478 else _TEMPORAL_SEQ_LEN_DEFAULT 479 ) 480 self._embedding_buffer: collections.deque[np.ndarray] = collections.deque( 481 maxlen=self._temporal_seq_len, 482 ) 483 logger.info( 484 "Temporal model detected: seq_len=%d", 485 self._temporal_seq_len, 486 ) 487 else: 488 self._is_temporal = False 489 self._temporal_seq_len = 0 490 491 # K3: Load additional ensemble models 492 if models and self._ensemble is not None: 493 # Add primary model to ensemble 494 self._ensemble.add_session(self._mlp_session, self._mlp_input_name) 495 for extra_model in models: 496 extra_session = self._load_session(extra_model) 497 extra_input_name = extra_session.get_inputs()[0].name 498 self._ensemble.add_session(extra_session, extra_input_name) 499 500 logger.info( 501 "WakeDetector initialized: model=%s, threshold=%.2f, backend=%s", 502 model, 503 threshold, 504 self._backend.name, 505 ) 506 self._warn_on_oww_backbone_change(self._resolve_model_path(model))
525 def close(self) -> None: 526 """Release inference sessions and reset internal state. 527 528 After calling close(), the detector should not be used for inference. 529 This is called automatically when using WakeDetector as a context 530 manager. 531 """ 532 self.reset() 533 # Release inference session references so the underlying runtime 534 # (ONNX / TFLite) can free memory immediately rather than waiting 535 # for garbage collection. 536 self._mlp_session = None # type: ignore[assignment] 537 if self._ensemble is not None: 538 self._ensemble.clear() 539 self._oww_backbone = None # type: ignore[assignment]
Release inference sessions and reset internal state.
After calling close(), the detector should not be used for inference. This is called automatically when using WakeDetector as a context manager.
669 def process(self, audio_frame: bytes | np.ndarray) -> float: 670 """Process a 20ms audio frame and return the wake word detection score. 671 672 If ensemble mode is active (K3), returns the fused score. 673 The score is recorded for confidence tracking (K2) and reported 674 to the power manager (K7) if configured. 675 676 Thread-safe: protects internal state mutation with a lock. 677 678 Raises: 679 TypeError: If audio_frame is not bytes or ndarray. 680 ValueError: If audio_frame is empty, malformed, or drastically 681 larger than the supported streaming frame size. 682 """ 683 return self._process_core(self._prepare_model_audio(audio_frame), audio_frame)
Process a 20ms audio frame and return the wake word detection score.
If ensemble mode is active (K3), returns the fused score. The score is recorded for confidence tracking (K2) and reported to the power manager (K7) if configured.
Thread-safe: protects internal state mutation with a lock.
Raises:
- TypeError: If audio_frame is not bytes or ndarray.
- ValueError: If audio_frame is empty, malformed, or drastically larger than the supported streaming frame size.
757 def detect(self, audio_frame: bytes | np.ndarray, is_playing: bool = False) -> bool: 758 """Process a frame and apply the full decision policy. 759 760 Integrates adaptive threshold (K4), multi-window confirmation (K2), 761 speaker verification (K5), and power management (K7) when configured. 762 763 Thread-safe: protects internal state mutation with a lock. 764 765 Raises: 766 TypeError: If audio_frame is not bytes or ndarray. 767 ValueError: If audio_frame is empty or has invalid format. 768 """ 769 # G1: Input validation (single pass — process_core skips re-validation) 770 pcm = validate_audio_chunk(audio_frame) 771 772 # Compute RMS on int16-scale PCM for the rms_floor comparison. 773 # rms_floor=1.0 is calibrated for int16 scale (speech ≈ 500–5000, 774 # silence ≈ 0–5). Float32 input in [-1, 1] is scaled up so the 775 # same rms_floor works regardless of input format. 776 rms = float(np.sqrt(np.mean(pcm**2))) 777 if not self._needs_int16_normalization(audio_frame): 778 # Float32/float64 input: RMS is in [0, ~0.7] — scale to int16 range 779 rms *= 32768.0 780 781 # Normalize for model inference 782 model_pcm = pcm / 32768.0 if self._needs_int16_normalization(audio_frame) else pcm 783 784 # K7: Power management -- skip frame if power manager says so 785 if self._power_manager is not None and not self._power_manager.should_process(pcm): 786 return False 787 788 # K4: Update noise profiler and get adaptive threshold 789 if self._noise_profiler is not None and self._adaptive_threshold: 790 adapted = self._noise_profiler.update(pcm) 791 self._policy.threshold = adapted 792 793 score = self._process_core(model_pcm, audio_frame) 794 795 # K5: Pre-fetch embedding outside _lock to avoid ABBA deadlock 796 # (_process_core acquires _backbone_lock -> _lock; we must not 797 # acquire _backbone_lock while holding _lock). 798 # 799 # Trade-off: Under concurrent access, _get_embedding reads the 800 # backbone's last_embedding which may have been overwritten by 801 # another thread's _process_core call since our score was computed. 802 # This means the embedding used for speaker verification may not 803 # correspond to the score just returned by _process_core. This is 804 # accepted for performance — taking _backbone_lock across both 805 # _process_core and _get_embedding would serialize all detection, 806 # and the mismatch is benign (embeddings from adjacent frames are 807 # nearly identical in practice). 808 speaker_embedding: np.ndarray | None = None 809 if self._speaker_verify_fn is not None: 810 speaker_embedding = self._get_embedding(audio_frame) 811 812 with self._lock: 813 # K2: Multi-window confirmation 814 if score >= self._policy.threshold: 815 self._confirm_counter += 1 816 else: 817 self._confirm_counter = 0 818 819 effective_detected = self._confirm_counter >= self._confirm_required 820 821 if effective_detected: 822 detected = self._policy.evaluate(score=score, rms=rms, is_playing=is_playing) 823 else: 824 detected = False 825 826 if detected: 827 # K5: Speaker verification post-detection 828 if self._speaker_verify_fn is not None and speaker_embedding is not None: # noqa: SIM102 829 if not self._speaker_verify_fn(speaker_embedding.flatten()): 830 logger.debug("Speaker verification rejected detection") 831 return False 832 833 self._confirm_counter = 0 834 return True 835 836 return False
Process a frame and apply the full decision policy.
Integrates adaptive threshold (K4), multi-window confirmation (K2), speaker verification (K5), and power management (K7) when configured.
Thread-safe: protects internal state mutation with a lock.
Raises:
- TypeError: If audio_frame is not bytes or ndarray.
- ValueError: If audio_frame is empty or has invalid format.
838 def reset_cooldown(self) -> None: 839 """Reset the cooldown window without clearing confirmation state or buffers.""" 840 with self._lock: 841 self._policy.reset_cooldown()
Reset the cooldown window without clearing confirmation state or buffers.
843 def reset(self) -> None: 844 """Reset cooldown, confirmation state, score history, and temporal buffers. 845 846 Lock ordering: _backbone_lock then _lock, matching _process_core 847 to prevent ABBA deadlock. 848 """ 849 with self._backbone_lock, self._lock: 850 self._policy.reset_cooldown() 851 self._confirm_counter = 0 852 self._last_score = 0.0 853 self._score_tracker.reset() 854 self._oww_backbone.reset() 855 if self._is_temporal: 856 self._embedding_buffer.clear()
Reset cooldown, confirmation state, score history, and temporal buffers.
Lock ordering: _backbone_lock then _lock, matching _process_core to prevent ABBA deadlock.
862 def get_confidence(self) -> ConfidenceResult: 863 """Return a confidence assessment of the current detection state. 864 865 Includes the raw MLP score, multi-window confirmation count, 866 and a classified confidence level (LOW/MEDIUM/HIGH/CERTAIN). 867 """ 868 return self._score_tracker.classify( 869 confirm_count=self._confirm_counter, 870 confirm_required=self._confirm_required, 871 )
Return a confidence assessment of the current detection state.
Includes the raw MLP score, multi-window confirmation count, and a classified confidence level (LOW/MEDIUM/HIGH/CERTAIN).
873 @property 874 def last_scores(self) -> tuple[float, ...]: 875 """Return the recent score history (most recent last).""" 876 return self._score_tracker.last_scores
Return the recent score history (most recent last).
882 def enroll_speaker(self, speaker_id: str, audio_frames: list[bytes | np.ndarray]) -> int: 883 """Enroll a speaker by extracting embeddings from audio frames. 884 885 Requires a ``SpeakerVerificationHook`` as the ``speaker_verify_fn``. 886 887 Args: 888 speaker_id: Unique identifier for the speaker. 889 audio_frames: Audio frames to extract embeddings from. 890 891 Returns: 892 Total enrollment count for this speaker. 893 894 Raises: 895 RuntimeError: If no SpeakerVerificationHook is configured. 896 """ 897 from violawake_sdk.speaker import SpeakerVerificationHook 898 899 hook = self._speaker_verify_fn 900 if not isinstance(hook, SpeakerVerificationHook): 901 raise RuntimeError( 902 "enroll_speaker requires a SpeakerVerificationHook as speaker_verify_fn" 903 ) 904 905 embeddings = [] 906 for frame in audio_frames: 907 emb = self._get_embedding(frame) 908 embeddings.append(emb.flatten()) 909 910 return hook.enroll_speaker(speaker_id, embeddings)
Enroll a speaker by extracting embeddings from audio frames.
Requires a SpeakerVerificationHook as the speaker_verify_fn.
Arguments:
- speaker_id: Unique identifier for the speaker.
- audio_frames: Audio frames to extract embeddings from.
Returns:
Total enrollment count for this speaker.
Raises:
- RuntimeError: If no SpeakerVerificationHook is configured.
912 def verify_speaker(self, audio_frame: bytes | np.ndarray) -> SpeakerVerifyResult: 913 """Verify the speaker in an audio frame against enrolled profiles. 914 915 Args: 916 audio_frame: Audio frame to verify. 917 918 Returns: 919 SpeakerVerifyResult with match details. 920 921 Raises: 922 RuntimeError: If no SpeakerVerificationHook is configured. 923 """ 924 from violawake_sdk.speaker import SpeakerVerificationHook 925 926 hook = self._speaker_verify_fn 927 if not isinstance(hook, SpeakerVerificationHook): 928 raise RuntimeError( 929 "verify_speaker requires a SpeakerVerificationHook as speaker_verify_fn" 930 ) 931 932 embedding = self._get_embedding(audio_frame) 933 return hook.verify_speaker(embedding.flatten())
Verify the speaker in an audio frame against enrolled profiles.
Arguments:
- audio_frame: Audio frame to verify.
Returns:
SpeakerVerifyResult with match details.
Raises:
- RuntimeError: If no SpeakerVerificationHook is configured.
939 @classmethod 940 def from_source( 941 cls, 942 source: AudioSource, 943 model: str = "temporal_cnn", 944 threshold: float = DEFAULT_THRESHOLD, 945 cooldown_s: float = DEFAULT_COOLDOWN_S, 946 **kwargs: Any, 947 ) -> _SourceDetector: 948 """Create a WakeDetector bound to an AudioSource. 949 950 The returned object wraps a WakeDetector and provides a ``run()`` 951 method that reads frames from the source and runs detection. 952 953 Args: 954 source: Any object implementing the AudioSource protocol. 955 model: Model name or path. 956 threshold: Detection threshold. 957 cooldown_s: Cooldown between detections. 958 **kwargs: Additional WakeDetector keyword arguments. 959 960 Returns: 961 A _SourceDetector wrapping both the source and detector. 962 """ 963 detector = cls( 964 model=model, 965 threshold=threshold, 966 cooldown_s=cooldown_s, 967 **kwargs, 968 ) 969 return _SourceDetector(detector=detector, source=source)
Create a WakeDetector bound to an AudioSource.
The returned object wraps a WakeDetector and provides a run()
method that reads frames from the source and runs detection.
Arguments:
- source: Any object implementing the AudioSource protocol.
- model: Model name or path.
- threshold: Detection threshold.
- cooldown_s: Cooldown between detections.
- **kwargs: Additional WakeDetector keyword arguments.
Returns:
A _SourceDetector wrapping both the source and detector.
975 def stream_mic(self, device_index: int | None = None) -> Generator[bytes, None, None]: 976 """Generator that yields 20ms audio frames from the default microphone.""" 977 try: 978 import pyaudio 979 except ImportError: 980 raise ImportError( 981 "pyaudio is required for microphone features. " 982 "Install with: pip install violawake[audio]" 983 ) from None 984 pa = pyaudio.PyAudio() 985 try: 986 stream = pa.open( 987 format=pyaudio.paInt16, 988 channels=1, 989 rate=SAMPLE_RATE, 990 input=True, 991 frames_per_buffer=FRAME_SAMPLES, 992 input_device_index=device_index, 993 ) 994 except Exception as e: 995 pa.terminate() 996 raise AudioCaptureError( 997 f"Failed to open microphone: {e}. " 998 f"Check that a microphone is connected and not in use by another application." 999 ) from e 1000 logger.info("Microphone capture started (16kHz, mono, 20ms frames)") 1001 _MAX_CONSECUTIVE_ERRORS = 10 1002 consecutive_errors = 0 1003 try: 1004 while True: 1005 try: 1006 yield stream.read(FRAME_SAMPLES, exception_on_overflow=False) 1007 consecutive_errors = 0 1008 except Exception as e: 1009 consecutive_errors += 1 1010 logger.warning( 1011 "Mic read error (%d/%d): %s", consecutive_errors, _MAX_CONSECUTIVE_ERRORS, e 1012 ) 1013 if consecutive_errors >= _MAX_CONSECUTIVE_ERRORS: 1014 raise AudioCaptureError( 1015 f"Microphone read failed {_MAX_CONSECUTIVE_ERRORS} consecutive times. " 1016 f"Last error: {e}" 1017 ) from e 1018 continue 1019 finally: 1020 stream.stop_stream() 1021 stream.close() 1022 pa.terminate() 1023 logger.info("Microphone capture stopped")
Generator that yields 20ms audio frames from the default microphone.
1081class WakewordDetector: 1082 """Deprecated compatibility wrapper — use ``WakeDetector`` instead. 1083 1084 .. deprecated:: 0.1.0 1085 Use :class:`WakeDetector` directly. ``WakewordDetector`` will be 1086 removed in v1.0. 1087 """ 1088 1089 def __init__( 1090 self, 1091 wake_word: str = "viola", 1092 threshold: float = DEFAULT_THRESHOLD, 1093 cooldown_s: float = DEFAULT_COOLDOWN_S, 1094 providers: list[str] | None = None, 1095 backend: str = "auto", 1096 ) -> None: 1097 import warnings 1098 1099 warnings.warn( 1100 "WakewordDetector is deprecated. Use WakeDetector instead.", 1101 DeprecationWarning, 1102 stacklevel=2, 1103 ) 1104 self.wake_word = wake_word 1105 self.threshold = threshold 1106 self.cooldown_s = cooldown_s 1107 self.providers = providers 1108 self.backend = backend 1109 self._detector: WakeDetector | None = None 1110 self._init_lock = threading.Lock() 1111 self._model_name = self._resolve_model_name(wake_word) 1112 1113 @staticmethod 1114 def _resolve_model_name(wake_word: str) -> str: 1115 if wake_word in WAKE_WORD_ALIASES: 1116 return WAKE_WORD_ALIASES[wake_word] 1117 if wake_word in MODEL_REGISTRY: 1118 return wake_word 1119 available = ", ".join(sorted({*WAKE_WORD_ALIASES, *MODEL_REGISTRY})) 1120 raise KeyError(f"Unknown wakeword '{wake_word}'. Available: {available}") 1121 1122 def _get_detector(self) -> WakeDetector: 1123 # Double-checked locking: fast path avoids lock acquisition 1124 if self._detector is not None: 1125 return self._detector 1126 with self._init_lock: 1127 if self._detector is None: 1128 self._detector = WakeDetector( 1129 model=self._model_name, 1130 threshold=self.threshold, 1131 cooldown_s=self.cooldown_s, 1132 providers=self.providers, 1133 backend=self.backend, 1134 ) 1135 return self._detector 1136 1137 def process_audio(self, audio_frame: bytes | np.ndarray, is_playing: bool = False) -> bool: 1138 return self._get_detector().detect(audio_frame, is_playing=is_playing) 1139 1140 def process(self, audio_frame: bytes | np.ndarray) -> float: 1141 return self._get_detector().process(audio_frame) 1142 1143 def detect(self, audio_frame: bytes | np.ndarray, is_playing: bool = False) -> bool: 1144 return self._get_detector().detect(audio_frame, is_playing=is_playing) 1145 1146 def stream_mic(self, device_index: int | None = None) -> Generator[bytes, None, None]: 1147 yield from self._get_detector().stream_mic(device_index=device_index) 1148 1149 def reset(self) -> None: 1150 self._get_detector().reset() 1151 1152 def reset_cooldown(self) -> None: 1153 self._get_detector().reset_cooldown() 1154 1155 def get_confidence(self) -> ConfidenceResult: 1156 return self._get_detector().get_confidence() 1157 1158 @property 1159 def last_scores(self) -> tuple[float, ...]: 1160 return self._get_detector().last_scores
Deprecated compatibility wrapper — use WakeDetector instead.
Deprecated since version 0.1.0:
Use WakeDetector directly. WakewordDetector will be
removed in v1.0.
1089 def __init__( 1090 self, 1091 wake_word: str = "viola", 1092 threshold: float = DEFAULT_THRESHOLD, 1093 cooldown_s: float = DEFAULT_COOLDOWN_S, 1094 providers: list[str] | None = None, 1095 backend: str = "auto", 1096 ) -> None: 1097 import warnings 1098 1099 warnings.warn( 1100 "WakewordDetector is deprecated. Use WakeDetector instead.", 1101 DeprecationWarning, 1102 stacklevel=2, 1103 ) 1104 self.wake_word = wake_word 1105 self.threshold = threshold 1106 self.cooldown_s = cooldown_s 1107 self.providers = providers 1108 self.backend = backend 1109 self._detector: WakeDetector | None = None 1110 self._init_lock = threading.Lock() 1111 self._model_name = self._resolve_model_name(wake_word)
38class AsyncWakeDetector: 39 """Async wrapper around ``WakeDetector`` for asyncio-based applications. 40 41 All CPU-bound inference is dispatched to a background thread via 42 ``loop.run_in_executor``. The wrapper is fully transparent -- it 43 accepts the same constructor arguments and exposes the same methods 44 as ``WakeDetector``, but with ``async`` signatures. 45 46 Args: 47 **kwargs: Forwarded to ``WakeDetector.__init__``. 48 """ 49 50 def __init__(self, **kwargs: Any) -> None: 51 self._detector = WakeDetector(**kwargs) 52 self._executor: ThreadPoolExecutor | None = None 53 54 def _get_executor(self) -> ThreadPoolExecutor: 55 if self._executor is None: 56 self._executor = ThreadPoolExecutor(max_workers=1) 57 return self._executor 58 59 async def __aenter__(self) -> AsyncWakeDetector: 60 """Enter async context manager.""" 61 return self 62 63 async def __aexit__( 64 self, exc_type: type | None, exc_val: BaseException | None, exc_tb: object 65 ) -> None: 66 """Exit async context manager, shutting down the executor.""" 67 self.close() 68 69 async def detect( 70 self, 71 audio_frame: bytes | np.ndarray, 72 is_playing: bool = False, 73 ) -> bool: 74 """Async version of ``WakeDetector.detect``.""" 75 loop = asyncio.get_running_loop() 76 return await loop.run_in_executor( 77 self._get_executor(), 78 lambda: self._detector.detect(audio_frame, is_playing), 79 ) 80 81 async def process( 82 self, 83 audio_frame: bytes | np.ndarray, 84 ) -> float: 85 """Async version of ``WakeDetector.process``.""" 86 loop = asyncio.get_running_loop() 87 return await loop.run_in_executor( 88 self._get_executor(), 89 lambda: self._detector.process(audio_frame), 90 ) 91 92 async def stream( 93 self, 94 source: AsyncIterator[bytes | np.ndarray], 95 ) -> AsyncIterator[bool]: 96 """Async generator that yields detection results from an async audio source. 97 98 Usage:: 99 100 async for detected in detector.stream(audio_source): 101 if detected: 102 print("Wake word!") 103 104 Args: 105 source: An async iterator yielding audio frames. 106 107 Yields: 108 Boolean detection result for each frame. 109 """ 110 async for frame in source: 111 yield await self.detect(frame) 112 113 def reset_cooldown(self) -> None: 114 """Reset the cooldown window (delegates to WakeDetector public API).""" 115 self._detector.reset_cooldown() 116 117 @property 118 def threshold(self) -> float: 119 """Current detection threshold.""" 120 return self._detector.threshold 121 122 def get_confidence(self) -> ConfidenceResult: 123 """Return confidence assessment of the current detection state (K2).""" 124 return self._detector.get_confidence() 125 126 @property 127 def last_scores(self) -> tuple[float, ...]: 128 """Return the recent score history (most recent last).""" 129 return self._detector.last_scores 130 131 async def aclose(self) -> None: 132 """Async shutdown — shuts down executor in a thread to avoid blocking.""" 133 loop = asyncio.get_running_loop() 134 if self._executor is not None: 135 executor = self._executor 136 self._executor = None 137 await loop.run_in_executor(None, lambda: executor.shutdown(wait=True)) 138 self._detector.close() 139 140 def close(self) -> None: 141 """Shut down the background executor and release detector resources. 142 143 Safe to call multiple times. For async contexts prefer ``aclose()``. 144 """ 145 if self._executor is not None: 146 self._executor.shutdown(wait=True) 147 self._executor = None 148 self._detector.close() 149 150 def __del__(self) -> None: 151 # Use wait=False in __del__ to avoid blocking the GC/finalizer thread. 152 # The explicit close() method still uses wait=True for graceful shutdown. 153 if self._executor is not None: 154 self._executor.shutdown(wait=False) 155 self._executor = None
Async wrapper around WakeDetector for asyncio-based applications.
All CPU-bound inference is dispatched to a background thread via
loop.run_in_executor. The wrapper is fully transparent -- it
accepts the same constructor arguments and exposes the same methods
as WakeDetector, but with async signatures.
Arguments:
- **kwargs: Forwarded to
WakeDetector.__init__.
69 async def detect( 70 self, 71 audio_frame: bytes | np.ndarray, 72 is_playing: bool = False, 73 ) -> bool: 74 """Async version of ``WakeDetector.detect``.""" 75 loop = asyncio.get_running_loop() 76 return await loop.run_in_executor( 77 self._get_executor(), 78 lambda: self._detector.detect(audio_frame, is_playing), 79 )
Async version of WakeDetector.detect.
81 async def process( 82 self, 83 audio_frame: bytes | np.ndarray, 84 ) -> float: 85 """Async version of ``WakeDetector.process``.""" 86 loop = asyncio.get_running_loop() 87 return await loop.run_in_executor( 88 self._get_executor(), 89 lambda: self._detector.process(audio_frame), 90 )
Async version of WakeDetector.process.
92 async def stream( 93 self, 94 source: AsyncIterator[bytes | np.ndarray], 95 ) -> AsyncIterator[bool]: 96 """Async generator that yields detection results from an async audio source. 97 98 Usage:: 99 100 async for detected in detector.stream(audio_source): 101 if detected: 102 print("Wake word!") 103 104 Args: 105 source: An async iterator yielding audio frames. 106 107 Yields: 108 Boolean detection result for each frame. 109 """ 110 async for frame in source: 111 yield await self.detect(frame)
Async generator that yields detection results from an async audio source.
Usage::
async for detected in detector.stream(audio_source):
if detected:
print("Wake word!")
Arguments:
- source: An async iterator yielding audio frames.
Yields:
Boolean detection result for each frame.
113 def reset_cooldown(self) -> None: 114 """Reset the cooldown window (delegates to WakeDetector public API).""" 115 self._detector.reset_cooldown()
Reset the cooldown window (delegates to WakeDetector public API).
117 @property 118 def threshold(self) -> float: 119 """Current detection threshold.""" 120 return self._detector.threshold
Current detection threshold.
122 def get_confidence(self) -> ConfidenceResult: 123 """Return confidence assessment of the current detection state (K2).""" 124 return self._detector.get_confidence()
Return confidence assessment of the current detection state (K2).
126 @property 127 def last_scores(self) -> tuple[float, ...]: 128 """Return the recent score history (most recent last).""" 129 return self._detector.last_scores
Return the recent score history (most recent last).
131 async def aclose(self) -> None: 132 """Async shutdown — shuts down executor in a thread to avoid blocking.""" 133 loop = asyncio.get_running_loop() 134 if self._executor is not None: 135 executor = self._executor 136 self._executor = None 137 await loop.run_in_executor(None, lambda: executor.shutdown(wait=True)) 138 self._detector.close()
Async shutdown — shuts down executor in a thread to avoid blocking.
140 def close(self) -> None: 141 """Shut down the background executor and release detector resources. 142 143 Safe to call multiple times. For async contexts prefer ``aclose()``. 144 """ 145 if self._executor is not None: 146 self._executor.shutdown(wait=True) 147 self._executor = None 148 self._detector.close()
Shut down the background executor and release detector resources.
Safe to call multiple times. For async contexts prefer aclose().
218class WakeDecisionPolicy: 219 """4-gate core decision pipeline (RMS floor, threshold, cooldown, playback suppression). 220 221 Extended by WakeDetector with optional confirmation (K2), adaptive 222 threshold (K4), and speaker verification (K5). 223 224 Gate 1: Zero-input guard -- skip if RMS < 1.0 (silence / DC offset artifact) 225 Gate 2: Score threshold -- skip if model score < threshold 226 Gate 3: Cooldown -- ignore events within cooldown_s of last detection 227 Gate 4: Listening gate -- suppress during active playback (optional) 228 """ 229 230 def __init__( 231 self, 232 threshold: float = DEFAULT_THRESHOLD, 233 cooldown_s: float = DEFAULT_COOLDOWN_S, 234 rms_floor: float = 1.0, 235 ) -> None: 236 if not 0.0 <= threshold <= 1.0: 237 raise ValueError(f"threshold must be in [0.0, 1.0], got {threshold!r}") 238 239 self.threshold = threshold 240 self.cooldown_s = cooldown_s 241 self.rms_floor = rms_floor 242 self._last_detection: float = 0.0 243 244 def evaluate( 245 self, 246 score: float, 247 rms: float = 100.0, 248 is_playing: bool = False, 249 ) -> bool: 250 """Evaluate whether a wake word event should be triggered.""" 251 if rms < self.rms_floor: 252 logger.debug("Gate 1 reject: RMS %.1f below floor %.1f", rms, self.rms_floor) 253 return False 254 if score < self.threshold: 255 return False 256 now = time.monotonic() 257 if now - self._last_detection < self.cooldown_s: 258 logger.debug( 259 "Gate 3 reject: cooldown active (%.1fs remaining)", 260 self.cooldown_s - (now - self._last_detection), 261 ) 262 return False 263 if is_playing: 264 logger.debug("Gate 4 reject: playback active") 265 return False 266 self._last_detection = now 267 logger.info("Wake word detected! score=%.3f", score) 268 return True 269 270 def reset_cooldown(self) -> None: 271 """Reset the cooldown window (useful for testing).""" 272 self._last_detection = 0.0
4-gate core decision pipeline (RMS floor, threshold, cooldown, playback suppression).
Extended by WakeDetector with optional confirmation (K2), adaptive threshold (K4), and speaker verification (K5).
Gate 1: Zero-input guard -- skip if RMS < 1.0 (silence / DC offset artifact) Gate 2: Score threshold -- skip if model score < threshold Gate 3: Cooldown -- ignore events within cooldown_s of last detection Gate 4: Listening gate -- suppress during active playback (optional)
230 def __init__( 231 self, 232 threshold: float = DEFAULT_THRESHOLD, 233 cooldown_s: float = DEFAULT_COOLDOWN_S, 234 rms_floor: float = 1.0, 235 ) -> None: 236 if not 0.0 <= threshold <= 1.0: 237 raise ValueError(f"threshold must be in [0.0, 1.0], got {threshold!r}") 238 239 self.threshold = threshold 240 self.cooldown_s = cooldown_s 241 self.rms_floor = rms_floor 242 self._last_detection: float = 0.0
244 def evaluate( 245 self, 246 score: float, 247 rms: float = 100.0, 248 is_playing: bool = False, 249 ) -> bool: 250 """Evaluate whether a wake word event should be triggered.""" 251 if rms < self.rms_floor: 252 logger.debug("Gate 1 reject: RMS %.1f below floor %.1f", rms, self.rms_floor) 253 return False 254 if score < self.threshold: 255 return False 256 now = time.monotonic() 257 if now - self._last_detection < self.cooldown_s: 258 logger.debug( 259 "Gate 3 reject: cooldown active (%.1fs remaining)", 260 self.cooldown_s - (now - self._last_detection), 261 ) 262 return False 263 if is_playing: 264 logger.debug("Gate 4 reject: playback active") 265 return False 266 self._last_detection = now 267 logger.info("Wake word detected! score=%.3f", score) 268 return True
Evaluate whether a wake word event should be triggered.
162def validate_audio_chunk(data: bytes | np.ndarray) -> np.ndarray: 163 """Validate and normalize an audio chunk for use with WakeDetector. 164 165 Accepts bytes (int16 PCM) or numpy arrays (int16, float32, float64). 166 Returns a float32 numpy array suitable for processing. 167 168 Args: 169 data: Audio chunk as bytes (int16 little-endian PCM) or numpy array. 170 171 Returns: 172 Validated float32 numpy array. 173 174 Raises: 175 TypeError: If data is not bytes or ndarray. 176 ValueError: If data is empty, has invalid dtype, contains only 177 non-finite values, or exceeds the maximum chunk size. 178 """ 179 if isinstance(data, bytes): 180 if len(data) == 0: 181 raise ValueError("Audio chunk is empty (0 bytes)") 182 if len(data) % 2 != 0: 183 raise ValueError( 184 f"Audio bytes length must be even (int16 = 2 bytes/sample), got {len(data)}" 185 ) 186 pcm = np.frombuffer(data, dtype=np.int16).astype(np.float32) 187 elif isinstance(data, np.ndarray): 188 if data.size == 0: 189 raise ValueError("Audio chunk is empty (0 samples)") 190 if data.ndim != 1: 191 raise ValueError( 192 f"Audio chunk must be 1-D, got {data.ndim}-D array with shape {data.shape}" 193 ) 194 original_dtype = data.dtype 195 if original_dtype not in _ALLOWED_AUDIO_DTYPES: 196 raise ValueError( 197 f"Audio chunk dtype must be one of {[str(d) for d in _ALLOWED_AUDIO_DTYPES]}, " 198 f"got {original_dtype}" 199 ) 200 pcm = data.astype(np.float32) 201 # Check the pre-cast dtype here; after astype(float32) every ndarray looks floating, 202 # which made the old post-cast dtype branch in the process() path effectively dead. 203 if original_dtype != np.int16 and not np.all(np.isfinite(pcm)): 204 pcm = np.where(np.isfinite(pcm), pcm, 0.0).astype(np.float32, copy=False) 205 logger.warning("Audio chunk contained non-finite values (NaN/inf); replaced with 0") 206 else: 207 raise TypeError(f"Audio chunk must be bytes or numpy ndarray, got {type(data).__name__}") 208 209 if len(pcm) > _MAX_CHUNK_SAMPLES: 210 raise ValueError( 211 f"Audio chunk too large: {len(pcm)} samples " 212 f"(max {_MAX_CHUNK_SAMPLES} = {_MAX_CHUNK_SAMPLES // SAMPLE_RATE}s at {SAMPLE_RATE}Hz)" 213 ) 214 215 return pcm
Validate and normalize an audio chunk for use with WakeDetector.
Accepts bytes (int16 PCM) or numpy arrays (int16, float32, float64). Returns a float32 numpy array suitable for processing.
Arguments:
- data: Audio chunk as bytes (int16 little-endian PCM) or numpy array.
Returns:
Validated float32 numpy array.
Raises:
- TypeError: If data is not bytes or ndarray.
- ValueError: If data is empty, has invalid dtype, contains only non-finite values, or exceeds the maximum chunk size.
28@dataclass(frozen=True) 29class ConfidenceResult: 30 """Result from get_confidence() with full detection context. 31 32 Attributes: 33 raw_score: The most recent MLP/CNN output score in [0.0, 1.0]. 34 confirm_count: Number of consecutive above-threshold scores in the 35 current multi-window confirmation sequence. 36 confirm_required: Total consecutive scores required for detection. 37 confidence: Classified confidence level. 38 score_history: Recent score history (most recent last). 39 """ 40 41 raw_score: float 42 confirm_count: int 43 confirm_required: int 44 confidence: ConfidenceLevel 45 score_history: tuple[float, ...]
Result from get_confidence() with full detection context.
Attributes:
- raw_score: The most recent MLP/CNN output score in [0.0, 1.0].
- confirm_count: Number of consecutive above-threshold scores in the current multi-window confirmation sequence.
- confirm_required: Total consecutive scores required for detection.
- confidence: Classified confidence level.
- score_history: Recent score history (most recent last).
19class ConfidenceLevel(str, Enum): 20 """Confidence classification for wake word detection.""" 21 22 LOW = "LOW" 23 MEDIUM = "MEDIUM" 24 HIGH = "HIGH" 25 CERTAIN = "CERTAIN"
Confidence classification for wake word detection.
27class FusionStrategy(str, Enum): 28 """Score fusion strategy for multi-model ensemble.""" 29 30 AVERAGE = "average" 31 MAX = "max" 32 VOTING = "voting" 33 WEIGHTED_AVERAGE = "weighted_average"
Score fusion strategy for multi-model ensemble.
47class NoiseProfiler: 48 """Estimates ambient noise and adjusts detection threshold. 49 50 The profiler maintains a rolling window of RMS energy measurements. 51 The noise floor is estimated as the 10th percentile of recent RMS values 52 (capturing the quietest frames, which are likely ambient noise). 53 54 Threshold adjustment logic: 55 - High SNR (signal clearly above noise): lower threshold slightly to 56 improve sensitivity. 57 - Low SNR (signal barely above noise): raise threshold to reduce 58 false alarms from noise bursts. 59 - The adjusted threshold is always clamped to 60 ``[min_threshold, max_threshold]``. 61 62 Adaptive threshold bounds: 63 - ``min_threshold`` defaults to ``0.60``. In very quiet environments, or 64 when the current frame is far above the estimated noise floor, the 65 threshold may be lowered to improve sensitivity, but it will never be 66 reduced below this floor. 67 - ``max_threshold`` defaults to ``0.95``. In very noisy environments, or 68 when the current frame is close to the estimated noise floor, the 69 threshold may be raised to suppress false accepts, but it will never be 70 increased above this ceiling. 71 - Before enough history is collected (fewer than 10 RMS frames), the 72 profiler returns ``base_threshold`` with no adaptation. 73 74 Args: 75 base_threshold: The default detection threshold (e.g. 0.80). 76 noise_window_s: Seconds of audio history for noise estimation. 77 min_threshold: Floor for adaptive threshold. Default 0.60. 78 max_threshold: Ceiling for adaptive threshold. Default 0.95. 79 snr_boost_db: SNR above this value enables threshold lowering. 80 snr_penalty_db: SNR below this value enables threshold raising. 81 frames_per_second: Expected audio frames per second (default 50 for 20ms). 82 """ 83 84 def __init__( 85 self, 86 base_threshold: float = 0.80, 87 noise_window_s: float = DEFAULT_NOISE_WINDOW_S, 88 min_threshold: float = DEFAULT_MIN_THRESHOLD, 89 max_threshold: float = DEFAULT_MAX_THRESHOLD, 90 snr_boost_db: float = DEFAULT_SNR_BOOST_DB, 91 snr_penalty_db: float = DEFAULT_SNR_PENALTY_DB, 92 frames_per_second: float = 50.0, 93 ) -> None: 94 self._base_threshold = base_threshold 95 self._min_threshold = min_threshold 96 self._max_threshold = max_threshold 97 self._snr_boost_db = snr_boost_db 98 self._snr_penalty_db = snr_penalty_db 99 100 window_frames = max(1, int(noise_window_s * frames_per_second)) 101 self._rms_history: deque[float] = deque(maxlen=window_frames) 102 self._current_rms: float = 0.0 103 self._noise_floor_rms: float = 0.0 104 105 @property 106 def base_threshold(self) -> float: 107 """The unadjusted detection threshold.""" 108 return self._base_threshold 109 110 @property 111 def noise_floor(self) -> float: 112 """Current estimated noise floor RMS.""" 113 return self._noise_floor_rms 114 115 def update(self, audio_frame: np.ndarray) -> float: 116 """Update noise estimate with a new audio frame and return adjusted threshold. 117 118 The audio should be float32 values. Both normalized [-1,1] and int16-range 119 float32 are accepted; the profiler works on relative ratios so absolute 120 scale doesn't matter. 121 122 Args: 123 audio_frame: 1-D float32 audio samples (any length). 124 125 Returns: 126 The adaptively adjusted detection threshold. 127 """ 128 rms = float(np.sqrt(np.mean(audio_frame.astype(np.float64) ** 2))) 129 self._current_rms = rms 130 self._rms_history.append(rms) 131 132 # Estimate noise floor as 10th percentile of recent RMS values 133 if len(self._rms_history) >= 10: 134 sorted_rms = sorted(self._rms_history) 135 idx = max(0, int(len(sorted_rms) * 0.10)) 136 self._noise_floor_rms = sorted_rms[idx] 137 elif self._rms_history: 138 self._noise_floor_rms = min(self._rms_history) 139 else: 140 self._noise_floor_rms = 0.0 141 142 return self._compute_adjusted_threshold() 143 144 def _compute_adjusted_threshold(self) -> float: 145 """Compute threshold adjustment based on current SNR estimate.""" 146 snr_db = self._estimate_snr_db() 147 148 # No adjustment if we don't have enough data 149 if len(self._rms_history) < 10: 150 return self._base_threshold 151 152 if snr_db > self._snr_boost_db: 153 # High SNR: lower threshold proportionally (max 0.10 reduction) 154 excess = snr_db - self._snr_boost_db 155 reduction = min(0.10, excess * 0.01) 156 adjusted = self._base_threshold - reduction 157 elif snr_db < self._snr_penalty_db: 158 # Low SNR: raise threshold proportionally (max 0.10 increase) 159 deficit = self._snr_penalty_db - snr_db 160 increase = min(0.10, deficit * 0.02) 161 adjusted = self._base_threshold + increase 162 else: 163 adjusted = self._base_threshold 164 165 return max(self._min_threshold, min(self._max_threshold, adjusted)) 166 167 def _estimate_snr_db(self) -> float: 168 """Estimate signal-to-noise ratio in decibels. 169 170 Uses the current frame RMS as signal and the noise floor as noise. 171 Returns 0.0 if noise floor is effectively zero. 172 """ 173 if self._noise_floor_rms < 1e-10: 174 # No noise estimate yet — return a neutral value 175 return self._snr_boost_db # neutral, no adjustment 176 if self._current_rms < 1e-10: 177 return 0.0 178 179 ratio = self._current_rms / self._noise_floor_rms 180 return 20.0 * math.log10(max(ratio, 1e-10)) 181 182 def get_profile(self) -> NoiseProfile: 183 """Return a snapshot of the current noise state. 184 185 Returns: 186 NoiseProfile with noise floor, signal RMS, SNR, and adjusted threshold. 187 """ 188 snr_db = self._estimate_snr_db() 189 adjusted = self._compute_adjusted_threshold() 190 191 return NoiseProfile( 192 noise_rms=self._noise_floor_rms, 193 signal_rms=self._current_rms, 194 snr_db=snr_db, 195 adjusted_threshold=adjusted, 196 base_threshold=self._base_threshold, 197 ) 198 199 def reset(self) -> None: 200 """Clear noise history and reset estimates.""" 201 self._rms_history.clear() 202 self._current_rms = 0.0 203 self._noise_floor_rms = 0.0
Estimates ambient noise and adjusts detection threshold.
The profiler maintains a rolling window of RMS energy measurements. The noise floor is estimated as the 10th percentile of recent RMS values (capturing the quietest frames, which are likely ambient noise).
Threshold adjustment logic:
- High SNR (signal clearly above noise): lower threshold slightly to improve sensitivity.
- Low SNR (signal barely above noise): raise threshold to reduce false alarms from noise bursts.
- The adjusted threshold is always clamped to
[min_threshold, max_threshold].
Adaptive threshold bounds:
min_thresholddefaults to0.60. In very quiet environments, or when the current frame is far above the estimated noise floor, the threshold may be lowered to improve sensitivity, but it will never be reduced below this floor.max_thresholddefaults to0.95. In very noisy environments, or when the current frame is close to the estimated noise floor, the threshold may be raised to suppress false accepts, but it will never be increased above this ceiling.- Before enough history is collected (fewer than 10 RMS frames), the
profiler returns
base_thresholdwith no adaptation.
Arguments:
- base_threshold: The default detection threshold (e.g. 0.80).
- noise_window_s: Seconds of audio history for noise estimation.
- min_threshold: Floor for adaptive threshold. Default 0.60.
- max_threshold: Ceiling for adaptive threshold. Default 0.95.
- snr_boost_db: SNR above this value enables threshold lowering.
- snr_penalty_db: SNR below this value enables threshold raising.
- frames_per_second: Expected audio frames per second (default 50 for 20ms).
84 def __init__( 85 self, 86 base_threshold: float = 0.80, 87 noise_window_s: float = DEFAULT_NOISE_WINDOW_S, 88 min_threshold: float = DEFAULT_MIN_THRESHOLD, 89 max_threshold: float = DEFAULT_MAX_THRESHOLD, 90 snr_boost_db: float = DEFAULT_SNR_BOOST_DB, 91 snr_penalty_db: float = DEFAULT_SNR_PENALTY_DB, 92 frames_per_second: float = 50.0, 93 ) -> None: 94 self._base_threshold = base_threshold 95 self._min_threshold = min_threshold 96 self._max_threshold = max_threshold 97 self._snr_boost_db = snr_boost_db 98 self._snr_penalty_db = snr_penalty_db 99 100 window_frames = max(1, int(noise_window_s * frames_per_second)) 101 self._rms_history: deque[float] = deque(maxlen=window_frames) 102 self._current_rms: float = 0.0 103 self._noise_floor_rms: float = 0.0
105 @property 106 def base_threshold(self) -> float: 107 """The unadjusted detection threshold.""" 108 return self._base_threshold
The unadjusted detection threshold.
110 @property 111 def noise_floor(self) -> float: 112 """Current estimated noise floor RMS.""" 113 return self._noise_floor_rms
Current estimated noise floor RMS.
115 def update(self, audio_frame: np.ndarray) -> float: 116 """Update noise estimate with a new audio frame and return adjusted threshold. 117 118 The audio should be float32 values. Both normalized [-1,1] and int16-range 119 float32 are accepted; the profiler works on relative ratios so absolute 120 scale doesn't matter. 121 122 Args: 123 audio_frame: 1-D float32 audio samples (any length). 124 125 Returns: 126 The adaptively adjusted detection threshold. 127 """ 128 rms = float(np.sqrt(np.mean(audio_frame.astype(np.float64) ** 2))) 129 self._current_rms = rms 130 self._rms_history.append(rms) 131 132 # Estimate noise floor as 10th percentile of recent RMS values 133 if len(self._rms_history) >= 10: 134 sorted_rms = sorted(self._rms_history) 135 idx = max(0, int(len(sorted_rms) * 0.10)) 136 self._noise_floor_rms = sorted_rms[idx] 137 elif self._rms_history: 138 self._noise_floor_rms = min(self._rms_history) 139 else: 140 self._noise_floor_rms = 0.0 141 142 return self._compute_adjusted_threshold()
Update noise estimate with a new audio frame and return adjusted threshold.
The audio should be float32 values. Both normalized [-1,1] and int16-range float32 are accepted; the profiler works on relative ratios so absolute scale doesn't matter.
Arguments:
- audio_frame: 1-D float32 audio samples (any length).
Returns:
The adaptively adjusted detection threshold.
182 def get_profile(self) -> NoiseProfile: 183 """Return a snapshot of the current noise state. 184 185 Returns: 186 NoiseProfile with noise floor, signal RMS, SNR, and adjusted threshold. 187 """ 188 snr_db = self._estimate_snr_db() 189 adjusted = self._compute_adjusted_threshold() 190 191 return NoiseProfile( 192 noise_rms=self._noise_floor_rms, 193 signal_rms=self._current_rms, 194 snr_db=snr_db, 195 adjusted_threshold=adjusted, 196 base_threshold=self._base_threshold, 197 )
Return a snapshot of the current noise state.
Returns:
NoiseProfile with noise floor, signal RMS, SNR, and adjusted threshold.
99class PowerManager: 100 """Energy-aware inference controller. 101 102 Reduces inference frequency based on battery level, silence detection, 103 and explicit duty cycling configuration. 104 105 Modes of power saving: 106 1. **Duty cycling**: Process every Nth frame when idle (no recent detections). 107 When a score above ``activity_threshold`` is detected, switches to 108 full-rate processing for ``active_window_s`` seconds. 109 2. **Silence skipping**: Skip inference when audio RMS is below 110 ``silence_rms`` (no speech possible). 111 3. **Battery-aware**: When on battery and below ``battery_low_pct``, 112 increase the duty cycle factor by ``battery_multiplier``. 113 114 Args: 115 duty_cycle_n: Base duty cycle (process every Nth frame). Default 1 (no skipping). 116 silence_rms: RMS threshold in int16 scale (typical range 0-32768) below which 117 frames are skipped. Default 10.0 filters near-silence. 118 activity_threshold: Score above which the system enters "active" mode. Default 0.3. 119 active_window_s: Seconds to stay in full-rate mode after activity. Default 3.0. 120 battery_low_pct: Battery percent below which power saving kicks in. Default 20. 121 battery_multiplier: Multiply duty_cycle_n by this when on low battery. Default 3. 122 check_battery_interval_s: How often to re-check battery. Default 60. 123 """ 124 125 def __init__( 126 self, 127 duty_cycle_n: int = 1, 128 silence_rms: float = 10.0, 129 activity_threshold: float = 0.3, 130 active_window_s: float = 3.0, 131 battery_low_pct: int = 20, 132 battery_multiplier: int = 3, 133 check_battery_interval_s: float = 60.0, 134 ) -> None: 135 if duty_cycle_n < 1: 136 raise ValueError(f"duty_cycle_n must be >= 1, got {duty_cycle_n}") 137 138 self._base_duty = duty_cycle_n 139 self._silence_rms = silence_rms 140 self._activity_threshold = activity_threshold 141 self._active_window_s = active_window_s 142 self._battery_low_pct = battery_low_pct 143 self._battery_multiplier = battery_multiplier 144 self._check_interval = check_battery_interval_s 145 146 # Lock protects all mutable state below 147 self._lock = threading.Lock() 148 149 # State 150 self._frame_counter = 0 151 self._frames_processed = 0 152 self._frames_skipped = 0 153 self._silence_skipped = 0 154 self._last_activity_time = 0.0 155 self._is_active = False 156 157 # Battery state (cached) 158 self._battery_pct = -1 159 self._is_on_battery = False 160 self._last_battery_check = 0.0 161 162 @property 163 def effective_duty_cycle(self) -> int: 164 """Current effective duty cycle considering battery and activity state.""" 165 with self._lock: 166 return self._effective_duty_cycle_unlocked() 167 168 def _effective_duty_cycle_unlocked(self) -> int: 169 """Compute duty cycle without acquiring the lock (caller must hold it).""" 170 if self._is_active: 171 return 1 # Full rate when active 172 173 base = self._base_duty 174 175 # Battery scaling 176 if self._is_on_battery and 0 <= self._battery_pct < self._battery_low_pct: 177 base = base * self._battery_multiplier 178 179 return max(1, base) 180 181 def should_process(self, audio_frame: np.ndarray) -> bool: 182 """Decide whether this frame should be processed or skipped. 183 184 Call this before running inference. If it returns False, skip the 185 frame to save CPU/power. 186 187 Args: 188 audio_frame: 1-D audio samples (int16-range float32 or actual int16). 189 190 Returns: 191 True if inference should run on this frame. 192 """ 193 # Compute RMS outside the lock (pure computation on immutable input) 194 rms = float(np.sqrt(np.mean(audio_frame.astype(np.float32) ** 2))) 195 196 with self._lock: 197 self._frame_counter += 1 198 199 # Periodically check battery 200 now = time.monotonic() 201 if now - self._last_battery_check > self._check_interval: 202 self._battery_pct, self._is_on_battery = _get_battery_info() 203 self._last_battery_check = now 204 205 # Check if active window has expired 206 if self._is_active and (now - self._last_activity_time > self._active_window_s): 207 self._is_active = False 208 209 # Silence gate: skip if audio is very quiet 210 if rms < self._silence_rms: 211 self._frames_skipped += 1 212 self._silence_skipped += 1 213 return False 214 215 # Duty cycling: process every Nth frame 216 duty = self._effective_duty_cycle_unlocked() 217 if duty > 1 and (self._frame_counter % duty) != 0: 218 self._frames_skipped += 1 219 return False 220 221 self._frames_processed += 1 222 return True 223 224 def report_score(self, score: float) -> None: 225 """Report a detection score to the power manager. 226 227 If the score is above the activity threshold, the manager switches 228 to full-rate processing mode for ``active_window_s`` seconds. 229 230 Args: 231 score: Detection score from the model. 232 """ 233 if score >= self._activity_threshold: 234 with self._lock: 235 self._is_active = True 236 self._last_activity_time = time.monotonic() 237 238 def get_state(self) -> PowerState: 239 """Return current power management state snapshot.""" 240 with self._lock: 241 total = self._frames_processed + self._frames_skipped 242 # Returns 0.0 if no frames received (no data yet to measure). 243 rate = self._frames_processed / total if total > 0 else 0.0 244 245 return PowerState( 246 battery_percent=self._battery_pct, 247 is_on_battery=self._is_on_battery, 248 duty_cycle_n=self._effective_duty_cycle_unlocked(), 249 frames_processed=self._frames_processed, 250 frames_skipped=self._frames_skipped, 251 silence_skipped=self._silence_skipped, 252 effective_rate=rate, 253 ) 254 255 def reset(self) -> None: 256 """Reset all counters and state.""" 257 with self._lock: 258 self._frame_counter = 0 259 self._frames_processed = 0 260 self._frames_skipped = 0 261 self._silence_skipped = 0 262 self._last_activity_time = 0.0 263 self._is_active = False
Energy-aware inference controller.
Reduces inference frequency based on battery level, silence detection, and explicit duty cycling configuration.
Modes of power saving:
- Duty cycling: Process every Nth frame when idle (no recent detections).
When a score above
activity_thresholdis detected, switches to full-rate processing foractive_window_sseconds. - Silence skipping: Skip inference when audio RMS is below
silence_rms(no speech possible). - Battery-aware: When on battery and below
battery_low_pct, increase the duty cycle factor bybattery_multiplier.
Arguments:
- duty_cycle_n: Base duty cycle (process every Nth frame). Default 1 (no skipping).
- silence_rms: RMS threshold in int16 scale (typical range 0-32768) below which frames are skipped. Default 10.0 filters near-silence.
- activity_threshold: Score above which the system enters "active" mode. Default 0.3.
- active_window_s: Seconds to stay in full-rate mode after activity. Default 3.0.
- battery_low_pct: Battery percent below which power saving kicks in. Default 20.
- battery_multiplier: Multiply duty_cycle_n by this when on low battery. Default 3.
- check_battery_interval_s: How often to re-check battery. Default 60.
125 def __init__( 126 self, 127 duty_cycle_n: int = 1, 128 silence_rms: float = 10.0, 129 activity_threshold: float = 0.3, 130 active_window_s: float = 3.0, 131 battery_low_pct: int = 20, 132 battery_multiplier: int = 3, 133 check_battery_interval_s: float = 60.0, 134 ) -> None: 135 if duty_cycle_n < 1: 136 raise ValueError(f"duty_cycle_n must be >= 1, got {duty_cycle_n}") 137 138 self._base_duty = duty_cycle_n 139 self._silence_rms = silence_rms 140 self._activity_threshold = activity_threshold 141 self._active_window_s = active_window_s 142 self._battery_low_pct = battery_low_pct 143 self._battery_multiplier = battery_multiplier 144 self._check_interval = check_battery_interval_s 145 146 # Lock protects all mutable state below 147 self._lock = threading.Lock() 148 149 # State 150 self._frame_counter = 0 151 self._frames_processed = 0 152 self._frames_skipped = 0 153 self._silence_skipped = 0 154 self._last_activity_time = 0.0 155 self._is_active = False 156 157 # Battery state (cached) 158 self._battery_pct = -1 159 self._is_on_battery = False 160 self._last_battery_check = 0.0
162 @property 163 def effective_duty_cycle(self) -> int: 164 """Current effective duty cycle considering battery and activity state.""" 165 with self._lock: 166 return self._effective_duty_cycle_unlocked()
Current effective duty cycle considering battery and activity state.
181 def should_process(self, audio_frame: np.ndarray) -> bool: 182 """Decide whether this frame should be processed or skipped. 183 184 Call this before running inference. If it returns False, skip the 185 frame to save CPU/power. 186 187 Args: 188 audio_frame: 1-D audio samples (int16-range float32 or actual int16). 189 190 Returns: 191 True if inference should run on this frame. 192 """ 193 # Compute RMS outside the lock (pure computation on immutable input) 194 rms = float(np.sqrt(np.mean(audio_frame.astype(np.float32) ** 2))) 195 196 with self._lock: 197 self._frame_counter += 1 198 199 # Periodically check battery 200 now = time.monotonic() 201 if now - self._last_battery_check > self._check_interval: 202 self._battery_pct, self._is_on_battery = _get_battery_info() 203 self._last_battery_check = now 204 205 # Check if active window has expired 206 if self._is_active and (now - self._last_activity_time > self._active_window_s): 207 self._is_active = False 208 209 # Silence gate: skip if audio is very quiet 210 if rms < self._silence_rms: 211 self._frames_skipped += 1 212 self._silence_skipped += 1 213 return False 214 215 # Duty cycling: process every Nth frame 216 duty = self._effective_duty_cycle_unlocked() 217 if duty > 1 and (self._frame_counter % duty) != 0: 218 self._frames_skipped += 1 219 return False 220 221 self._frames_processed += 1 222 return True
Decide whether this frame should be processed or skipped.
Call this before running inference. If it returns False, skip the frame to save CPU/power.
Arguments:
- audio_frame: 1-D audio samples (int16-range float32 or actual int16).
Returns:
True if inference should run on this frame.
224 def report_score(self, score: float) -> None: 225 """Report a detection score to the power manager. 226 227 If the score is above the activity threshold, the manager switches 228 to full-rate processing mode for ``active_window_s`` seconds. 229 230 Args: 231 score: Detection score from the model. 232 """ 233 if score >= self._activity_threshold: 234 with self._lock: 235 self._is_active = True 236 self._last_activity_time = time.monotonic()
Report a detection score to the power manager.
If the score is above the activity threshold, the manager switches
to full-rate processing mode for active_window_s seconds.
Arguments:
- score: Detection score from the model.
238 def get_state(self) -> PowerState: 239 """Return current power management state snapshot.""" 240 with self._lock: 241 total = self._frames_processed + self._frames_skipped 242 # Returns 0.0 if no frames received (no data yet to measure). 243 rate = self._frames_processed / total if total > 0 else 0.0 244 245 return PowerState( 246 battery_percent=self._battery_pct, 247 is_on_battery=self._is_on_battery, 248 duty_cycle_n=self._effective_duty_cycle_unlocked(), 249 frames_processed=self._frames_processed, 250 frames_skipped=self._frames_skipped, 251 silence_skipped=self._silence_skipped, 252 effective_rate=rate, 253 )
Return current power management state snapshot.
255 def reset(self) -> None: 256 """Reset all counters and state.""" 257 with self._lock: 258 self._frame_counter = 0 259 self._frames_processed = 0 260 self._frames_skipped = 0 261 self._silence_skipped = 0 262 self._last_activity_time = 0.0 263 self._is_active = False
Reset all counters and state.
301class VADEngine: 302 """Voice Activity Detection engine. 303 304 Auto-selects the best available backend unless explicitly specified. 305 306 Example:: 307 308 vad = VADEngine(backend="silero") # or "webrtc", "rms", "auto" 309 prob = vad.process_frame(audio_20ms_bytes) 310 is_speech = prob > 0.5 311 """ 312 313 def __init__( 314 self, 315 backend: str | VADBackend = VADBackend.AUTO, 316 **backend_kwargs: object, 317 ) -> None: 318 """Initialize the VAD engine. 319 320 Args: 321 backend: One of "auto", "webrtc", "silero", "rms". 322 "auto" selects the best available backend. 323 **backend_kwargs: Backend-specific arguments. 324 For "silero": no backend-specific args 325 For "webrtc": aggressiveness (0–3, default 2) 326 For "rms": speech_threshold, silence_threshold 327 """ 328 if isinstance(backend, str): 329 backend = VADBackend(backend) 330 331 self._backend_name, self._backend = _create_backend(backend, **backend_kwargs) 332 333 @property 334 def backend_name(self) -> str: 335 """Name of the active backend.""" 336 return self._backend_name.value 337 338 def process_frame(self, audio: bytes | np.ndarray) -> float: 339 """Process a 16kHz mono audio frame. 340 341 Args: 342 audio: Accepted formats: 343 - bytes/bytearray: int16 PCM 344 - np.ndarray float32/float64: assumed normalized to [-1.0, 1.0], 345 scaled by 32768 to int16. Use int16 dtype for int16-range data. 346 - np.ndarray int16: converted to bytes directly 347 348 WebRTC accepts 10/20/30ms frames. Silero runs on 512-sample 349 16kHz windows internally and pads/chunks frames as needed. 350 351 Returns: 352 Speech probability in [0.0, 1.0]. 353 1.0 = definitely speech, 0.0 = definitely silence. 354 """ 355 audio_bytes = _coerce_to_bytes(audio) 356 return self._backend.process_frame(audio_bytes) 357 358 def is_speech(self, audio: bytes | np.ndarray, threshold: float = 0.5) -> bool: 359 """Convenience method: returns True if speech probability exceeds threshold.""" 360 return self.process_frame(audio) >= threshold 361 362 def reset(self) -> None: 363 """Reset internal state (useful between utterances).""" 364 self._backend.reset() 365 366 def close(self) -> None: 367 """Release backend resources.""" 368 self._backend = None # type: ignore[assignment] 369 370 def __enter__(self) -> VADEngine: 371 """Enter sync context manager. Returns self.""" 372 return self 373 374 def __exit__( 375 self, 376 exc_type: type[BaseException] | None, 377 exc_val: BaseException | None, 378 exc_tb: object, 379 ) -> None: 380 """Exit sync context manager. Releases backend resources.""" 381 self.close()
Voice Activity Detection engine.
Auto-selects the best available backend unless explicitly specified.
Example::
vad = VADEngine(backend="silero") # or "webrtc", "rms", "auto"
prob = vad.process_frame(audio_20ms_bytes)
is_speech = prob > 0.5
313 def __init__( 314 self, 315 backend: str | VADBackend = VADBackend.AUTO, 316 **backend_kwargs: object, 317 ) -> None: 318 """Initialize the VAD engine. 319 320 Args: 321 backend: One of "auto", "webrtc", "silero", "rms". 322 "auto" selects the best available backend. 323 **backend_kwargs: Backend-specific arguments. 324 For "silero": no backend-specific args 325 For "webrtc": aggressiveness (0–3, default 2) 326 For "rms": speech_threshold, silence_threshold 327 """ 328 if isinstance(backend, str): 329 backend = VADBackend(backend) 330 331 self._backend_name, self._backend = _create_backend(backend, **backend_kwargs)
Initialize the VAD engine.
Arguments:
- backend: One of "auto", "webrtc", "silero", "rms". "auto" selects the best available backend.
- **backend_kwargs: Backend-specific arguments. For "silero": no backend-specific args For "webrtc": aggressiveness (0–3, default 2) For "rms": speech_threshold, silence_threshold
333 @property 334 def backend_name(self) -> str: 335 """Name of the active backend.""" 336 return self._backend_name.value
Name of the active backend.
338 def process_frame(self, audio: bytes | np.ndarray) -> float: 339 """Process a 16kHz mono audio frame. 340 341 Args: 342 audio: Accepted formats: 343 - bytes/bytearray: int16 PCM 344 - np.ndarray float32/float64: assumed normalized to [-1.0, 1.0], 345 scaled by 32768 to int16. Use int16 dtype for int16-range data. 346 - np.ndarray int16: converted to bytes directly 347 348 WebRTC accepts 10/20/30ms frames. Silero runs on 512-sample 349 16kHz windows internally and pads/chunks frames as needed. 350 351 Returns: 352 Speech probability in [0.0, 1.0]. 353 1.0 = definitely speech, 0.0 = definitely silence. 354 """ 355 audio_bytes = _coerce_to_bytes(audio) 356 return self._backend.process_frame(audio_bytes)
Process a 16kHz mono audio frame.
Arguments:
- audio: Accepted formats:
- bytes/bytearray: int16 PCM
- np.ndarray float32/float64: assumed normalized to [-1.0, 1.0], scaled by 32768 to int16. Use int16 dtype for int16-range data.
- np.ndarray int16: converted to bytes directly
- WebRTC accepts 10/20/30ms frames. Silero runs on 512-sample
- 16kHz windows internally and pads/chunks frames as needed.
Returns:
Speech probability in [0.0, 1.0]. 1.0 = definitely speech, 0.0 = definitely silence.
358 def is_speech(self, audio: bytes | np.ndarray, threshold: float = 0.5) -> bool: 359 """Convenience method: returns True if speech probability exceeds threshold.""" 360 return self.process_frame(audio) >= threshold
Convenience method: returns True if speech probability exceeds threshold.
61class TTSEngine: 62 """On-device TTS using Kokoro-82M (Apache 2.0 model). 63 64 Thread-safe: multiple threads can call ``synthesize()`` concurrently. 65 Calls are serialized via ``_synthesis_lock`` since kokoro-onnx is not 66 guaranteed to be thread-safe. Model initialization is separately guarded 67 by ``_lock`` (lazy load on first use). 68 69 Model files required (auto-downloaded on first use): 70 - ``kokoro_v1_0.onnx`` — Kokoro-82M model (~326MB) 71 - ``kokoro_voices_v1_0.bin`` — Voice embeddings (~28MB) 72 73 Example:: 74 75 tts = TTSEngine(voice="af_heart") 76 audio = tts.synthesize("Hello, world!") # returns np.ndarray 77 tts.play(audio) # blocking by default 78 tts.play_async(audio) # optional non-blocking playback 79 """ 80 81 def __init__( 82 self, 83 voice: str = DEFAULT_VOICE, 84 speed: float = 1.0, 85 sample_rate: int = TARGET_SAMPLE_RATE, 86 ) -> None: 87 """Initialize the TTS engine. 88 89 Args: 90 voice: Kokoro voice name. Default "af_heart". 91 See ``AVAILABLE_VOICES`` for full list. 92 speed: Speech speed multiplier. 1.0 = normal, 1.2 = 20% faster. 93 sample_rate: Output sample rate. Default 16kHz (pipeline standard). 94 Kokoro outputs 24kHz; resampled if different. 95 """ 96 if voice not in AVAILABLE_VOICES: 97 raise ValueError(f"Unknown voice '{voice}'. Available: {', '.join(AVAILABLE_VOICES)}") 98 99 if not (0.1 <= speed <= 3.0): 100 raise ValueError(f"Speed must be between 0.1 and 3.0, got {speed}") 101 102 self.voice = voice 103 self.speed = speed 104 self.sample_rate = sample_rate 105 self._lock = threading.Lock() 106 self._synthesis_lock = threading.Lock() 107 self._kokoro: object | None = None 108 109 # Lazy initialization — load model on first use 110 logger.info("TTSEngine created: voice=%s, speed=%.1f", voice, speed) 111 112 def _get_kokoro(self) -> object: 113 """Lazy-load the Kokoro model (thread-safe).""" 114 with self._lock: 115 if self._kokoro is None: 116 self._kokoro = self._load_kokoro() 117 return self._kokoro 118 119 def _load_kokoro(self) -> object: 120 """Load the Kokoro ONNX model.""" 121 try: 122 import kokoro_onnx 123 except ImportError as e: 124 raise ImportError( 125 "kokoro-onnx is not installed. Install with: pip install 'violawake[tts]'" 126 ) from e 127 128 try: 129 model_path = get_model_path("kokoro_v1_0") 130 voices_path = get_model_path("kokoro_voices_v1_0") 131 except FileNotFoundError as e: 132 raise ModelNotFoundError( 133 "Kokoro models not found. Run:\n" 134 " violawake-download --model kokoro_v1_0\n" 135 " violawake-download --model kokoro_voices_v1_0" 136 ) from e 137 138 try: 139 kokoro = kokoro_onnx.Kokoro(str(model_path), str(voices_path)) 140 except Exception as e: 141 raise ModelLoadError(f"Failed to load Kokoro model: {e}") from e 142 143 logger.info("Kokoro-82M loaded: %s", model_path) 144 return kokoro 145 146 def synthesize(self, text: str) -> np.ndarray: 147 """Synthesize text to audio. 148 149 Args: 150 text: Text to synthesize. May be multi-sentence. 151 Long text is processed as a single batch call. 152 153 Returns: 154 Audio samples as float32 numpy array at ``self.sample_rate``. 155 """ 156 if not text.strip(): 157 return np.zeros(0, dtype=np.float32) 158 159 kokoro = self._get_kokoro() 160 161 # Hold synthesis lock to serialize access to the kokoro model, 162 # which is not guaranteed to be thread-safe by kokoro-onnx. 163 with self._synthesis_lock: 164 try: 165 # kokoro-onnx API: returns (samples, sample_rate) 166 audio, sr = kokoro.create( # type: ignore[attr-defined] 167 text, 168 voice=self.voice, 169 speed=self.speed, 170 lang="en-us", 171 ) 172 except Exception as e: 173 logger.exception("TTS synthesis failed for text: %.50s...", text) 174 raise RuntimeError(f"TTS synthesis failed: {e}") from e 175 176 audio = np.asarray(audio, dtype=np.float32) 177 178 # Resample if needed 179 if sr != self.sample_rate: 180 audio = self._resample(audio, sr, self.sample_rate) 181 182 return audio 183 184 def synthesize_chunked(self, text: str) -> Generator[np.ndarray, None, None]: 185 """Synthesize text sentence-by-sentence for lower latency. 186 187 Splits text at sentence boundaries and yields audio for each sentence 188 as soon as it's synthesized. This allows playback to begin before 189 the full text is processed — matching the pattern from production Viola. 190 191 Args: 192 text: Text to synthesize. May be multi-sentence. 193 194 Yields: 195 Audio chunks (one per sentence) as float32 numpy arrays. 196 """ 197 sentences = self._split_sentences(text) 198 for sentence in sentences: 199 if sentence.strip(): 200 audio = self.synthesize(sentence) 201 if audio.size > 0: 202 yield audio 203 204 def play(self, audio: np.ndarray, *, blocking: bool = True) -> None: 205 """Play audio through the default output device. 206 207 Args: 208 audio: Float32 numpy array of audio samples. 209 blocking: If True, wait for playback to finish. If False, return 210 immediately after starting playback. 211 """ 212 try: 213 import sounddevice as sd 214 except ImportError as sd_err: 215 logger.debug("sounddevice not available (%s), falling back to pyaudio", sd_err) 216 try: 217 self._play_pyaudio(audio, blocking=blocking) 218 except ImportError as e: 219 raise ImportError( 220 "No audio playback backend is installed. " 221 "Install sounddevice with: pip install sounddevice " 222 "or install violawake[audio] for PyAudio playback." 223 ) from e 224 return 225 226 # Copy to prevent mutation of caller's array during async playback 227 sd.play(audio.copy(), samplerate=self.sample_rate, blocking=blocking) 228 229 def play_async(self, audio: np.ndarray) -> None: 230 """Play audio without blocking the calling thread.""" 231 self.play(audio, blocking=False) 232 233 def _play_pyaudio(self, audio: np.ndarray, *, blocking: bool = True) -> None: 234 """Play audio using pyaudio as fallback.""" 235 try: 236 import pyaudio 237 except ImportError: 238 raise ImportError( 239 "pyaudio is required for audio playback. Install with: pip install violawake[audio]" 240 ) from None 241 242 if not blocking: 243 thread = threading.Thread( 244 target=self._play_pyaudio, 245 args=(audio.copy(),), 246 kwargs={"blocking": True}, 247 daemon=True, 248 ) 249 thread.start() 250 return 251 252 clipped = np.clip(audio, -1.0, 1.0) 253 pcm = (clipped * 32767).astype(np.int16) 254 pa = pyaudio.PyAudio() 255 stream = pa.open( 256 format=pyaudio.paInt16, 257 channels=1, 258 rate=self.sample_rate, 259 output=True, 260 ) 261 try: 262 stream.write(pcm.tobytes()) 263 finally: 264 stream.stop_stream() 265 stream.close() 266 pa.terminate() 267 268 @staticmethod 269 def _resample(audio: np.ndarray, src_rate: int, dst_rate: int) -> np.ndarray: 270 """Resample audio using scipy.""" 271 import math 272 273 try: 274 from scipy.signal import resample_poly 275 except ImportError as e: 276 raise ImportError( 277 "scipy is required for audio resampling. Install with: pip install scipy" 278 ) from e 279 gcd = math.gcd(src_rate, dst_rate) 280 return resample_poly(audio, dst_rate // gcd, src_rate // gcd).astype(np.float32) 281 282 @staticmethod 283 def _split_sentences(text: str) -> list[str]: 284 """Split text at sentence boundaries for chunked synthesis. 285 286 Uses ``pysbd`` when available for robust sentence boundary 287 disambiguation, with a regex fallback if the dependency is missing. 288 """ 289 if not text: 290 return [] 291 292 if pysbd is not None: 293 segmenter = pysbd.Segmenter(language="en", clean=False) 294 return [s.strip() for s in segmenter.segment(text) if s and s.strip()] 295 296 return TTSEngine._split_sentences_fallback(text) 297 298 @staticmethod 299 def _split_sentences_fallback(text: str) -> list[str]: 300 """Fallback regex sentence splitter used when ``pysbd`` is unavailable.""" 301 import re 302 303 pattern = r"(?<=[.!?])\s+(?=[A-Z])|(?<=[.!?])\s*$" 304 parts = re.split(pattern, text) 305 return [s.strip() for s in parts if s and s.strip()] 306 307 def close(self) -> None: 308 """Release model resources.""" 309 self._kokoro = None 310 311 def __enter__(self) -> TTSEngine: 312 """Enter sync context manager. Returns self.""" 313 return self 314 315 def __exit__( 316 self, 317 exc_type: type[BaseException] | None, 318 exc_val: BaseException | None, 319 exc_tb: object, 320 ) -> None: 321 """Exit sync context manager. Releases model resources.""" 322 self.close()
On-device TTS using Kokoro-82M (Apache 2.0 model).
Thread-safe: multiple threads can call synthesize() concurrently.
Calls are serialized via _synthesis_lock since kokoro-onnx is not
guaranteed to be thread-safe. Model initialization is separately guarded
by _lock (lazy load on first use).
Model files required (auto-downloaded on first use):
- kokoro_v1_0.onnx — Kokoro-82M model (~326MB)
- kokoro_voices_v1_0.bin — Voice embeddings (~28MB)
Example::
tts = TTSEngine(voice="af_heart")
audio = tts.synthesize("Hello, world!") # returns np.ndarray
tts.play(audio) # blocking by default
tts.play_async(audio) # optional non-blocking playback
81 def __init__( 82 self, 83 voice: str = DEFAULT_VOICE, 84 speed: float = 1.0, 85 sample_rate: int = TARGET_SAMPLE_RATE, 86 ) -> None: 87 """Initialize the TTS engine. 88 89 Args: 90 voice: Kokoro voice name. Default "af_heart". 91 See ``AVAILABLE_VOICES`` for full list. 92 speed: Speech speed multiplier. 1.0 = normal, 1.2 = 20% faster. 93 sample_rate: Output sample rate. Default 16kHz (pipeline standard). 94 Kokoro outputs 24kHz; resampled if different. 95 """ 96 if voice not in AVAILABLE_VOICES: 97 raise ValueError(f"Unknown voice '{voice}'. Available: {', '.join(AVAILABLE_VOICES)}") 98 99 if not (0.1 <= speed <= 3.0): 100 raise ValueError(f"Speed must be between 0.1 and 3.0, got {speed}") 101 102 self.voice = voice 103 self.speed = speed 104 self.sample_rate = sample_rate 105 self._lock = threading.Lock() 106 self._synthesis_lock = threading.Lock() 107 self._kokoro: object | None = None 108 109 # Lazy initialization — load model on first use 110 logger.info("TTSEngine created: voice=%s, speed=%.1f", voice, speed)
Initialize the TTS engine.
Arguments:
- voice: Kokoro voice name. Default "af_heart".
See
AVAILABLE_VOICESfor full list. - speed: Speech speed multiplier. 1.0 = normal, 1.2 = 20% faster.
- sample_rate: Output sample rate. Default 16kHz (pipeline standard). Kokoro outputs 24kHz; resampled if different.
146 def synthesize(self, text: str) -> np.ndarray: 147 """Synthesize text to audio. 148 149 Args: 150 text: Text to synthesize. May be multi-sentence. 151 Long text is processed as a single batch call. 152 153 Returns: 154 Audio samples as float32 numpy array at ``self.sample_rate``. 155 """ 156 if not text.strip(): 157 return np.zeros(0, dtype=np.float32) 158 159 kokoro = self._get_kokoro() 160 161 # Hold synthesis lock to serialize access to the kokoro model, 162 # which is not guaranteed to be thread-safe by kokoro-onnx. 163 with self._synthesis_lock: 164 try: 165 # kokoro-onnx API: returns (samples, sample_rate) 166 audio, sr = kokoro.create( # type: ignore[attr-defined] 167 text, 168 voice=self.voice, 169 speed=self.speed, 170 lang="en-us", 171 ) 172 except Exception as e: 173 logger.exception("TTS synthesis failed for text: %.50s...", text) 174 raise RuntimeError(f"TTS synthesis failed: {e}") from e 175 176 audio = np.asarray(audio, dtype=np.float32) 177 178 # Resample if needed 179 if sr != self.sample_rate: 180 audio = self._resample(audio, sr, self.sample_rate) 181 182 return audio
Synthesize text to audio.
Arguments:
- text: Text to synthesize. May be multi-sentence. Long text is processed as a single batch call.
Returns:
Audio samples as float32 numpy array at
self.sample_rate.
184 def synthesize_chunked(self, text: str) -> Generator[np.ndarray, None, None]: 185 """Synthesize text sentence-by-sentence for lower latency. 186 187 Splits text at sentence boundaries and yields audio for each sentence 188 as soon as it's synthesized. This allows playback to begin before 189 the full text is processed — matching the pattern from production Viola. 190 191 Args: 192 text: Text to synthesize. May be multi-sentence. 193 194 Yields: 195 Audio chunks (one per sentence) as float32 numpy arrays. 196 """ 197 sentences = self._split_sentences(text) 198 for sentence in sentences: 199 if sentence.strip(): 200 audio = self.synthesize(sentence) 201 if audio.size > 0: 202 yield audio
Synthesize text sentence-by-sentence for lower latency.
Splits text at sentence boundaries and yields audio for each sentence as soon as it's synthesized. This allows playback to begin before the full text is processed — matching the pattern from production Viola.
Arguments:
- text: Text to synthesize. May be multi-sentence.
Yields:
Audio chunks (one per sentence) as float32 numpy arrays.
204 def play(self, audio: np.ndarray, *, blocking: bool = True) -> None: 205 """Play audio through the default output device. 206 207 Args: 208 audio: Float32 numpy array of audio samples. 209 blocking: If True, wait for playback to finish. If False, return 210 immediately after starting playback. 211 """ 212 try: 213 import sounddevice as sd 214 except ImportError as sd_err: 215 logger.debug("sounddevice not available (%s), falling back to pyaudio", sd_err) 216 try: 217 self._play_pyaudio(audio, blocking=blocking) 218 except ImportError as e: 219 raise ImportError( 220 "No audio playback backend is installed. " 221 "Install sounddevice with: pip install sounddevice " 222 "or install violawake[audio] for PyAudio playback." 223 ) from e 224 return 225 226 # Copy to prevent mutation of caller's array during async playback 227 sd.play(audio.copy(), samplerate=self.sample_rate, blocking=blocking)
Play audio through the default output device.
Arguments:
- audio: Float32 numpy array of audio samples.
- blocking: If True, wait for playback to finish. If False, return immediately after starting playback.
85class STTEngine: 86 """Speech-to-text transcription via faster-whisper. 87 88 Thread-safe: ``WhisperModel`` is thread-safe for concurrent ``transcribe()`` calls. 89 90 Model is loaded once and reused. First call includes model load time 91 (~1-3s). Subsequent calls are ~380ms (base model, CPU, 3s audio). 92 93 Example:: 94 95 stt = STTEngine(model="base") 96 text = stt.transcribe(audio_np_float32) 97 print(text) # "what's the weather today" 98 """ 99 100 def __init__( 101 self, 102 model: str = DEFAULT_MODEL, 103 device: str = "cpu", 104 compute_type: str = "int8", 105 language: str | None = None, 106 language_cache_ttl_s: float = 60.0, 107 ) -> None: 108 """Initialize the STT engine. 109 110 Args: 111 model: Whisper model size. One of: tiny, base, small, medium, large-v3. 112 Default "base" — good accuracy/speed balance (WER ~9%). 113 device: "cpu" or "cuda". Default "cpu". 114 compute_type: CTranslate2 compute type. "int8" (default), "float16", "float32". 115 "int8" is fastest on CPU with minimal accuracy loss. 116 language: Force a specific language (e.g., "en"). None = auto-detect. 117 language_cache_ttl_s: Cache detected language for N seconds to avoid 118 per-call language detection overhead. 119 """ 120 if model not in MODEL_PROFILES: 121 available = ", ".join(MODEL_PROFILES.keys()) 122 raise ValueError(f"Unknown model '{model}'. Available: {available}") 123 124 self.model_name = model 125 self.device = device 126 self.compute_type = compute_type 127 self.forced_language = language 128 self._language_cache: tuple[str, float] | None = None # (lang, cached_at) 129 self._language_cache_ttl = language_cache_ttl_s 130 self._model: WhisperModel | None = None 131 self._model_lock = threading.Lock() 132 133 profile = MODEL_PROFILES[model] 134 logger.info( 135 "STTEngine created: model=%s, device=%s (WER~%.0f%%, %dMB)", 136 model, 137 device, 138 profile["wer"], 139 profile["vram_mb"], 140 ) 141 142 def _get_model(self) -> WhisperModel: 143 """Lazy-load the Whisper model on first use (thread-safe).""" 144 if self._model is not None: 145 return self._model 146 147 with self._model_lock: 148 # Double-checked locking: another thread may have loaded 149 # the model while we waited for the lock. 150 if self._model is not None: 151 return self._model 152 153 try: 154 from faster_whisper import WhisperModel # type: ignore[import] 155 except ModuleNotFoundError as e: 156 if e.name == "faster_whisper": 157 raise ImportError( 158 "faster-whisper is not installed. " 159 "Install with: pip install 'violawake[stt]'" 160 ) from e 161 raise ImportError( 162 f"faster-whisper is installed but failed to import dependency '{e.name}': {e}" 163 ) from e 164 except ImportError as e: 165 raise ImportError(f"faster-whisper is installed but failed to import: {e}") from e 166 167 logger.info("Loading Whisper model '%s'...", self.model_name) 168 t0 = time.perf_counter() 169 self._model = WhisperModel( 170 self.model_name, 171 device=self.device, 172 compute_type=self.compute_type, 173 ) 174 elapsed_ms = (time.perf_counter() - t0) * 1000 175 logger.info("Whisper model loaded in %.0f ms", elapsed_ms) 176 177 return self._model 178 179 def transcribe(self, audio: np.ndarray) -> str: 180 """Transcribe audio to text. 181 182 Note: 183 This engine uses a progressive temperature fallback of 184 ``(0.0, 0.2, 0.4)`` during decoding, which can trigger up to 185 3 decoding passes and increase latency. For 186 low-latency use cases, prefer a single-pass configuration such as 187 ``temperature_fallback=[0.0]``. 188 189 Args: 190 audio: Float32 numpy array at 16kHz mono. Values should be in [-1.0, 1.0]. 191 192 Returns: 193 Transcribed text as string. Empty string if no speech detected. 194 """ 195 result = self.transcribe_full(audio) 196 return result.text 197 198 def transcribe_streaming( 199 self, 200 audio: np.ndarray, 201 channels_first: bool | None = None, 202 beam_size: int = 5, 203 best_of: int = 5, 204 temperature: list[float] | None = None, 205 ) -> Iterator[TranscriptSegment]: 206 """Stream transcription segments as they become available. 207 208 Uses faster-whisper's generator mode: ``model.transcribe()`` returns a 209 ``(segments_iterator, info)`` tuple. This method yields each 210 ``TranscriptSegment`` one at a time as faster-whisper decodes it, 211 instead of collecting all segments first. 212 213 This is useful when: 214 - You want to display partial results before full transcription completes. 215 - You need to pipe segments to a downstream consumer (TTS, logging, etc.) 216 without waiting for the full buffer to finish. 217 218 Note: 219 Segments with ``no_speech_prob`` above ``NO_SPEECH_THRESHOLD`` are 220 silently skipped (not yielded). 221 222 Args: 223 audio: Float32 numpy array at 16kHz mono, or 2-D stereo. 224 channels_first: Layout hint for 2-D stereo audio (same semantics as 225 ``transcribe_full``). 226 beam_size: Beam search width. Default 5. 227 best_of: Number of candidates when sampling. Default 5. 228 temperature: Temperature schedule. Default ``(0.0, 0.2, 0.4)``. 229 230 Yields: 231 TranscriptSegment — one per decoded segment, in time order. 232 233 Example:: 234 235 stt = STTEngine(model="base") 236 for seg in stt.transcribe_streaming(audio_np): 237 print(f"[{seg.start:.1f}s] {seg.text}") 238 """ 239 if temperature is None: 240 # Keep 0.0 inside a fallback schedule so faster-whisper can retry if needed 241 # instead of pairing greedy decoding with beam_size > 1 in a single pass. 242 temperature = list(_DEFAULT_TEMPERATURE_FALLBACK) 243 244 audio = np.asarray(audio, dtype=np.float32) 245 if audio.ndim > 1: 246 if channels_first is True: 247 audio = audio.mean(axis=0) 248 elif channels_first is False: 249 audio = audio.mean(axis=1) 250 else: 251 if audio.shape[0] < audio.shape[1]: 252 audio = audio.mean(axis=0) 253 else: 254 audio = audio.mean(axis=1) 255 256 language = self._get_language() 257 model = self._get_model() 258 259 logger.debug("transcribe_streaming: starting generator on %d samples", len(audio)) 260 261 segments_gen, info = model.transcribe( 262 audio, 263 language=language, 264 vad_filter=True, 265 vad_parameters={"min_silence_duration_ms": 500}, 266 word_timestamps=False, 267 beam_size=beam_size, 268 best_of=best_of, 269 temperature=temperature, 270 ) 271 272 # Update language cache after model.transcribe() returns info — same 273 # logic as transcribe_full, but we must do it before consuming the 274 # generator so the cache is primed for subsequent calls. 275 if language is None and info.language_probability > 0.5: 276 with self._model_lock: 277 self._language_cache = (info.language, time.monotonic()) 278 279 for seg in segments_gen: 280 if seg.no_speech_prob > NO_SPEECH_THRESHOLD: 281 logger.debug( 282 "Skipping silent segment [%.1f-%.1f] no_speech_prob=%.2f", 283 seg.start, 284 seg.end, 285 seg.no_speech_prob, 286 ) 287 continue 288 289 text = seg.text.strip() 290 logger.debug("Streaming segment [%.1f-%.1f]: '%s'", seg.start, seg.end, text) 291 yield TranscriptSegment( 292 text=text, 293 start=seg.start, 294 end=seg.end, 295 no_speech_prob=seg.no_speech_prob, 296 ) 297 298 def transcribe_full( 299 self, 300 audio: np.ndarray, 301 channels_first: bool | None = None, 302 ) -> TranscriptResult: 303 """Transcribe audio and return full result with segments, timing, and metadata. 304 305 Args: 306 audio: Float32 numpy array at 16kHz mono, or 2-D stereo. 307 channels_first: Layout hint for 2-D stereo audio. 308 ``True`` = (channels, samples) e.g. shape (2, 48000). 309 ``False`` = (samples, channels) e.g. shape (48000, 2) — the 310 standard layout. 311 ``None`` (default) = fall back to a shape heuristic (smaller 312 dimension is assumed to be channels). Prefer passing an 313 explicit value to avoid ambiguity with short audio clips. 314 315 Returns: 316 TranscriptResult with text, segments, language, and no_speech_prob. 317 """ 318 audio = np.asarray(audio, dtype=np.float32) 319 if audio.ndim > 1: 320 if channels_first is True: 321 # Explicit: (channels, samples) — e.g. shape (2, 48000) 322 audio = audio.mean(axis=0) 323 elif channels_first is False: 324 # Explicit: (samples, channels) — e.g. shape (48000, 2) 325 audio = audio.mean(axis=1) 326 else: 327 # Legacy heuristic: channels axis is the smaller dimension. 328 if audio.shape[0] < audio.shape[1]: 329 audio = audio.mean(axis=0) 330 else: 331 audio = audio.mean(axis=1) 332 333 # Determine language (use cache if available) 334 language = self._get_language() 335 336 model = self._get_model() 337 t0 = time.perf_counter() 338 339 segments_gen, info = model.transcribe( 340 audio, 341 language=language, 342 vad_filter=True, # Use Silero VAD for silence removal 343 vad_parameters={"min_silence_duration_ms": 500}, 344 word_timestamps=False, 345 beam_size=5, 346 best_of=5, 347 # Keep 0.0 in a short fallback schedule so the initial pass is greedy-safe 348 # without combining a lone temperature=0.0 decode with beam_size > 1. 349 temperature=_DEFAULT_TEMPERATURE_FALLBACK, 350 ) 351 352 # Consume the generator (transcription happens here) 353 segments = list(segments_gen) 354 elapsed_ms = (time.perf_counter() - t0) * 1000 355 356 # Update language cache (protected by _model_lock for thread safety) 357 if language is None and info.language_probability > 0.5: 358 with self._model_lock: 359 self._language_cache = (info.language, time.monotonic()) 360 361 transcript_segments = [ 362 TranscriptSegment( 363 text=s.text.strip(), 364 start=s.start, 365 end=s.end, 366 no_speech_prob=s.no_speech_prob, 367 ) 368 for s in segments 369 ] 370 371 full_text = " ".join(s.text for s in transcript_segments).strip() 372 overall_no_speech = max((s.no_speech_prob for s in transcript_segments), default=0.0) 373 374 if overall_no_speech > NO_SPEECH_THRESHOLD: 375 logger.debug( 376 "No speech detected (no_speech_prob=%.2f) — returning empty", 377 overall_no_speech, 378 ) 379 full_text = "" 380 381 logger.debug( 382 "Transcribed in %.0f ms: '%s'", 383 elapsed_ms, 384 full_text[:60] + "..." if len(full_text) > 60 else full_text, 385 ) 386 387 return TranscriptResult( 388 text=full_text, 389 segments=transcript_segments, 390 language=info.language, 391 language_prob=info.language_probability, 392 duration_s=info.duration, 393 no_speech_prob=overall_no_speech, 394 ) 395 396 def _get_language(self) -> str | None: 397 """Return cached language or None for auto-detection. 398 399 Thread-safe: reads ``_language_cache`` under ``_model_lock``. 400 """ 401 if self.forced_language: 402 return self.forced_language 403 404 with self._model_lock: 405 if self._language_cache is not None: 406 lang, cached_at = self._language_cache 407 if time.monotonic() - cached_at < self._language_cache_ttl: 408 return lang 409 410 return None # auto-detect 411 412 def prewarm(self) -> None: 413 """Load the model eagerly (avoids cold-start latency on first transcription).""" 414 self._get_model() 415 logger.info("STTEngine prewarmed: model '%s' loaded", self.model_name) 416 417 def close(self) -> None: 418 """Release model resources.""" 419 with self._model_lock: 420 self._model = None 421 422 def __enter__(self) -> STTEngine: 423 """Enter sync context manager. Returns self.""" 424 return self 425 426 def __exit__( 427 self, 428 exc_type: type[BaseException] | None, 429 exc_val: BaseException | None, 430 exc_tb: object, 431 ) -> None: 432 """Exit sync context manager. Releases model resources.""" 433 self.close()
Speech-to-text transcription via faster-whisper.
Thread-safe: WhisperModel is thread-safe for concurrent transcribe() calls.
Model is loaded once and reused. First call includes model load time (~1-3s). Subsequent calls are ~380ms (base model, CPU, 3s audio).
Example::
stt = STTEngine(model="base")
text = stt.transcribe(audio_np_float32)
print(text) # "what's the weather today"
100 def __init__( 101 self, 102 model: str = DEFAULT_MODEL, 103 device: str = "cpu", 104 compute_type: str = "int8", 105 language: str | None = None, 106 language_cache_ttl_s: float = 60.0, 107 ) -> None: 108 """Initialize the STT engine. 109 110 Args: 111 model: Whisper model size. One of: tiny, base, small, medium, large-v3. 112 Default "base" — good accuracy/speed balance (WER ~9%). 113 device: "cpu" or "cuda". Default "cpu". 114 compute_type: CTranslate2 compute type. "int8" (default), "float16", "float32". 115 "int8" is fastest on CPU with minimal accuracy loss. 116 language: Force a specific language (e.g., "en"). None = auto-detect. 117 language_cache_ttl_s: Cache detected language for N seconds to avoid 118 per-call language detection overhead. 119 """ 120 if model not in MODEL_PROFILES: 121 available = ", ".join(MODEL_PROFILES.keys()) 122 raise ValueError(f"Unknown model '{model}'. Available: {available}") 123 124 self.model_name = model 125 self.device = device 126 self.compute_type = compute_type 127 self.forced_language = language 128 self._language_cache: tuple[str, float] | None = None # (lang, cached_at) 129 self._language_cache_ttl = language_cache_ttl_s 130 self._model: WhisperModel | None = None 131 self._model_lock = threading.Lock() 132 133 profile = MODEL_PROFILES[model] 134 logger.info( 135 "STTEngine created: model=%s, device=%s (WER~%.0f%%, %dMB)", 136 model, 137 device, 138 profile["wer"], 139 profile["vram_mb"], 140 )
Initialize the STT engine.
Arguments:
- model: Whisper model size. One of: tiny, base, small, medium, large-v3. Default "base" — good accuracy/speed balance (WER ~9%).
- device: "cpu" or "cuda". Default "cpu".
- compute_type: CTranslate2 compute type. "int8" (default), "float16", "float32". "int8" is fastest on CPU with minimal accuracy loss.
- language: Force a specific language (e.g., "en"). None = auto-detect.
- language_cache_ttl_s: Cache detected language for N seconds to avoid per-call language detection overhead.
179 def transcribe(self, audio: np.ndarray) -> str: 180 """Transcribe audio to text. 181 182 Note: 183 This engine uses a progressive temperature fallback of 184 ``(0.0, 0.2, 0.4)`` during decoding, which can trigger up to 185 3 decoding passes and increase latency. For 186 low-latency use cases, prefer a single-pass configuration such as 187 ``temperature_fallback=[0.0]``. 188 189 Args: 190 audio: Float32 numpy array at 16kHz mono. Values should be in [-1.0, 1.0]. 191 192 Returns: 193 Transcribed text as string. Empty string if no speech detected. 194 """ 195 result = self.transcribe_full(audio) 196 return result.text
Transcribe audio to text.
Note:
This engine uses a progressive temperature fallback of
(0.0, 0.2, 0.4)during decoding, which can trigger up to 3 decoding passes and increase latency. For low-latency use cases, prefer a single-pass configuration such astemperature_fallback=[0.0].
Arguments:
- audio: Float32 numpy array at 16kHz mono. Values should be in [-1.0, 1.0].
Returns:
Transcribed text as string. Empty string if no speech detected.
198 def transcribe_streaming( 199 self, 200 audio: np.ndarray, 201 channels_first: bool | None = None, 202 beam_size: int = 5, 203 best_of: int = 5, 204 temperature: list[float] | None = None, 205 ) -> Iterator[TranscriptSegment]: 206 """Stream transcription segments as they become available. 207 208 Uses faster-whisper's generator mode: ``model.transcribe()`` returns a 209 ``(segments_iterator, info)`` tuple. This method yields each 210 ``TranscriptSegment`` one at a time as faster-whisper decodes it, 211 instead of collecting all segments first. 212 213 This is useful when: 214 - You want to display partial results before full transcription completes. 215 - You need to pipe segments to a downstream consumer (TTS, logging, etc.) 216 without waiting for the full buffer to finish. 217 218 Note: 219 Segments with ``no_speech_prob`` above ``NO_SPEECH_THRESHOLD`` are 220 silently skipped (not yielded). 221 222 Args: 223 audio: Float32 numpy array at 16kHz mono, or 2-D stereo. 224 channels_first: Layout hint for 2-D stereo audio (same semantics as 225 ``transcribe_full``). 226 beam_size: Beam search width. Default 5. 227 best_of: Number of candidates when sampling. Default 5. 228 temperature: Temperature schedule. Default ``(0.0, 0.2, 0.4)``. 229 230 Yields: 231 TranscriptSegment — one per decoded segment, in time order. 232 233 Example:: 234 235 stt = STTEngine(model="base") 236 for seg in stt.transcribe_streaming(audio_np): 237 print(f"[{seg.start:.1f}s] {seg.text}") 238 """ 239 if temperature is None: 240 # Keep 0.0 inside a fallback schedule so faster-whisper can retry if needed 241 # instead of pairing greedy decoding with beam_size > 1 in a single pass. 242 temperature = list(_DEFAULT_TEMPERATURE_FALLBACK) 243 244 audio = np.asarray(audio, dtype=np.float32) 245 if audio.ndim > 1: 246 if channels_first is True: 247 audio = audio.mean(axis=0) 248 elif channels_first is False: 249 audio = audio.mean(axis=1) 250 else: 251 if audio.shape[0] < audio.shape[1]: 252 audio = audio.mean(axis=0) 253 else: 254 audio = audio.mean(axis=1) 255 256 language = self._get_language() 257 model = self._get_model() 258 259 logger.debug("transcribe_streaming: starting generator on %d samples", len(audio)) 260 261 segments_gen, info = model.transcribe( 262 audio, 263 language=language, 264 vad_filter=True, 265 vad_parameters={"min_silence_duration_ms": 500}, 266 word_timestamps=False, 267 beam_size=beam_size, 268 best_of=best_of, 269 temperature=temperature, 270 ) 271 272 # Update language cache after model.transcribe() returns info — same 273 # logic as transcribe_full, but we must do it before consuming the 274 # generator so the cache is primed for subsequent calls. 275 if language is None and info.language_probability > 0.5: 276 with self._model_lock: 277 self._language_cache = (info.language, time.monotonic()) 278 279 for seg in segments_gen: 280 if seg.no_speech_prob > NO_SPEECH_THRESHOLD: 281 logger.debug( 282 "Skipping silent segment [%.1f-%.1f] no_speech_prob=%.2f", 283 seg.start, 284 seg.end, 285 seg.no_speech_prob, 286 ) 287 continue 288 289 text = seg.text.strip() 290 logger.debug("Streaming segment [%.1f-%.1f]: '%s'", seg.start, seg.end, text) 291 yield TranscriptSegment( 292 text=text, 293 start=seg.start, 294 end=seg.end, 295 no_speech_prob=seg.no_speech_prob, 296 )
Stream transcription segments as they become available.
Uses faster-whisper's generator mode: model.transcribe() returns a
(segments_iterator, info) tuple. This method yields each
TranscriptSegment one at a time as faster-whisper decodes it,
instead of collecting all segments first.
This is useful when:
- You want to display partial results before full transcription completes.
- You need to pipe segments to a downstream consumer (TTS, logging, etc.) without waiting for the full buffer to finish.
Note:
Segments with
no_speech_probaboveNO_SPEECH_THRESHOLDare silently skipped (not yielded).
Arguments:
- audio: Float32 numpy array at 16kHz mono, or 2-D stereo.
- channels_first: Layout hint for 2-D stereo audio (same semantics as
transcribe_full). - beam_size: Beam search width. Default 5.
- best_of: Number of candidates when sampling. Default 5.
- temperature: Temperature schedule. Default
(0.0, 0.2, 0.4).
Yields:
TranscriptSegment — one per decoded segment, in time order.
Example::
stt = STTEngine(model="base")
for seg in stt.transcribe_streaming(audio_np):
print(f"[{seg.start:.1f}s] {seg.text}")
298 def transcribe_full( 299 self, 300 audio: np.ndarray, 301 channels_first: bool | None = None, 302 ) -> TranscriptResult: 303 """Transcribe audio and return full result with segments, timing, and metadata. 304 305 Args: 306 audio: Float32 numpy array at 16kHz mono, or 2-D stereo. 307 channels_first: Layout hint for 2-D stereo audio. 308 ``True`` = (channels, samples) e.g. shape (2, 48000). 309 ``False`` = (samples, channels) e.g. shape (48000, 2) — the 310 standard layout. 311 ``None`` (default) = fall back to a shape heuristic (smaller 312 dimension is assumed to be channels). Prefer passing an 313 explicit value to avoid ambiguity with short audio clips. 314 315 Returns: 316 TranscriptResult with text, segments, language, and no_speech_prob. 317 """ 318 audio = np.asarray(audio, dtype=np.float32) 319 if audio.ndim > 1: 320 if channels_first is True: 321 # Explicit: (channels, samples) — e.g. shape (2, 48000) 322 audio = audio.mean(axis=0) 323 elif channels_first is False: 324 # Explicit: (samples, channels) — e.g. shape (48000, 2) 325 audio = audio.mean(axis=1) 326 else: 327 # Legacy heuristic: channels axis is the smaller dimension. 328 if audio.shape[0] < audio.shape[1]: 329 audio = audio.mean(axis=0) 330 else: 331 audio = audio.mean(axis=1) 332 333 # Determine language (use cache if available) 334 language = self._get_language() 335 336 model = self._get_model() 337 t0 = time.perf_counter() 338 339 segments_gen, info = model.transcribe( 340 audio, 341 language=language, 342 vad_filter=True, # Use Silero VAD for silence removal 343 vad_parameters={"min_silence_duration_ms": 500}, 344 word_timestamps=False, 345 beam_size=5, 346 best_of=5, 347 # Keep 0.0 in a short fallback schedule so the initial pass is greedy-safe 348 # without combining a lone temperature=0.0 decode with beam_size > 1. 349 temperature=_DEFAULT_TEMPERATURE_FALLBACK, 350 ) 351 352 # Consume the generator (transcription happens here) 353 segments = list(segments_gen) 354 elapsed_ms = (time.perf_counter() - t0) * 1000 355 356 # Update language cache (protected by _model_lock for thread safety) 357 if language is None and info.language_probability > 0.5: 358 with self._model_lock: 359 self._language_cache = (info.language, time.monotonic()) 360 361 transcript_segments = [ 362 TranscriptSegment( 363 text=s.text.strip(), 364 start=s.start, 365 end=s.end, 366 no_speech_prob=s.no_speech_prob, 367 ) 368 for s in segments 369 ] 370 371 full_text = " ".join(s.text for s in transcript_segments).strip() 372 overall_no_speech = max((s.no_speech_prob for s in transcript_segments), default=0.0) 373 374 if overall_no_speech > NO_SPEECH_THRESHOLD: 375 logger.debug( 376 "No speech detected (no_speech_prob=%.2f) — returning empty", 377 overall_no_speech, 378 ) 379 full_text = "" 380 381 logger.debug( 382 "Transcribed in %.0f ms: '%s'", 383 elapsed_ms, 384 full_text[:60] + "..." if len(full_text) > 60 else full_text, 385 ) 386 387 return TranscriptResult( 388 text=full_text, 389 segments=transcript_segments, 390 language=info.language, 391 language_prob=info.language_probability, 392 duration_s=info.duration, 393 no_speech_prob=overall_no_speech, 394 )
Transcribe audio and return full result with segments, timing, and metadata.
Arguments:
- audio: Float32 numpy array at 16kHz mono, or 2-D stereo.
- channels_first: Layout hint for 2-D stereo audio.
True= (channels, samples) e.g. shape (2, 48000).False= (samples, channels) e.g. shape (48000, 2) — the standard layout.None(default) = fall back to a shape heuristic (smaller dimension is assumed to be channels). Prefer passing an explicit value to avoid ambiguity with short audio clips.
Returns:
TranscriptResult with text, segments, language, and no_speech_prob.
412 def prewarm(self) -> None: 413 """Load the model eagerly (avoids cold-start latency on first transcription).""" 414 self._get_model() 415 logger.info("STTEngine prewarmed: model '%s' loaded", self.model_name)
Load the model eagerly (avoids cold-start latency on first transcription).
445@dataclass 446class StreamingSTTEngine: 447 """Incremental streaming STT: accepts audio chunks, yields segments. 448 449 Audio chunks are pushed one at a time via :meth:`push_chunk`. When the 450 accumulated buffer reaches ``min_buffer_seconds``, :meth:`push_chunk` 451 transparently transcribes the buffer and yields any new segments. You can 452 also force a transcription at any time with :meth:`flush`. 453 454 A sliding-window approach is supported via ``stride_seconds``: after each 455 transcription pass the engine retains the last ``stride_seconds`` of audio 456 so that words near the boundary are not lost on the next pass. Set 457 ``stride_seconds=0.0`` (default) to discard all audio after each pass. 458 459 Thread safety: **not** thread-safe. Call from a single thread or protect 460 externally with a lock. 461 462 Args: 463 model: Whisper model size. One of ``tiny``, ``base``, ``small``, 464 ``medium``, ``large-v3``. Default ``"base"``. 465 device: ``"cpu"`` or ``"cuda"``. Default ``"cpu"``. 466 compute_type: CTranslate2 compute type. Default ``"int8"``. 467 language: Force a specific language code (e.g. ``"en"``). ``None`` 468 for auto-detect. 469 min_buffer_seconds: Minimum seconds of audio to accumulate before 470 attempting a transcription pass. Shorter values 471 mean lower latency but more frequent (and 472 potentially noisier) passes. Default ``2.0``. 473 stride_seconds: Seconds of audio overlap to retain between passes 474 (sliding-window). Default ``0.0`` (no overlap). 475 sample_rate: Sample rate of incoming audio chunks. Default ``16000``. 476 477 Example:: 478 479 streaming = StreamingSTTEngine(model="base", min_buffer_seconds=2.0) 480 for chunk in mic_chunks: 481 for segment in streaming.push_chunk(chunk): 482 print(f"[{segment.start:.1f}s] {segment.text}") 483 484 # Force final transcription when done 485 for segment in streaming.flush(): 486 print(f"[{segment.start:.1f}s] {segment.text}") 487 """ 488 489 model: str = "base" 490 device: str = "cpu" 491 compute_type: str = "int8" 492 language: str | None = None 493 min_buffer_seconds: float = 2.0 494 stride_seconds: float = _DEFAULT_STRIDE_S 495 sample_rate: int = 16_000 496 497 # Internal state — populated post-init; not part of the public constructor. 498 _engine: STTEngine = field(init=False, repr=False) 499 _buffer: list[np.ndarray] = field(init=False, repr=False, default_factory=list) 500 _buffer_samples: int = field(init=False, repr=False, default=0) 501 502 def __post_init__(self) -> None: 503 self._engine = STTEngine( 504 model=self.model, 505 device=self.device, 506 compute_type=self.compute_type, 507 language=self.language, 508 ) 509 self._buffer = [] 510 self._buffer_samples = 0 511 logger.info( 512 "StreamingSTTEngine created: model=%s, min_buffer=%.1fs, stride=%.1fs", 513 self.model, 514 self.min_buffer_seconds, 515 self.stride_seconds, 516 ) 517 518 # ------------------------------------------------------------------ 519 # Public API 520 # ------------------------------------------------------------------ 521 522 @property 523 def buffer_duration_s(self) -> float: 524 """Current accumulated audio duration in seconds.""" 525 return self._buffer_samples / self.sample_rate 526 527 def push_chunk(self, chunk: np.ndarray | bytes) -> Iterator[TranscriptSegment]: 528 """Push an audio chunk into the buffer. 529 530 If the buffer has accumulated at least ``min_buffer_seconds`` of audio, 531 a transcription pass is run and any yielded segments are returned. 532 Otherwise, no segments are yielded and the chunk is silently buffered. 533 534 Args: 535 chunk: Float32 numpy array (16kHz mono) **or** raw ``int16`` PCM 536 bytes. Bytes are automatically converted to float32. 537 538 Yields: 539 TranscriptSegment — segments decoded in this pass (may be empty). 540 """ 541 arr = self._coerce_chunk(chunk) 542 self._buffer.append(arr) 543 self._buffer_samples += len(arr) 544 545 min_samples = int(self.min_buffer_seconds * self.sample_rate) 546 if self._buffer_samples >= min_samples: 547 yield from self._run_pass() 548 549 def flush(self) -> Iterator[TranscriptSegment]: 550 """Transcribe whatever remains in the buffer and clear it. 551 552 Call this when the audio stream ends to ensure trailing audio is 553 transcribed. 554 555 Yields: 556 TranscriptSegment — segments from the remaining buffer. 557 """ 558 if self._buffer_samples == 0: 559 return 560 561 logger.debug("StreamingSTTEngine.flush: %.2f s buffered", self.buffer_duration_s) 562 yield from self._run_pass(force=True) 563 564 def reset(self) -> None: 565 """Discard the current buffer without transcribing.""" 566 self._buffer = [] 567 self._buffer_samples = 0 568 logger.debug("StreamingSTTEngine buffer reset") 569 570 def prewarm(self) -> None: 571 """Eagerly load the underlying Whisper model.""" 572 self._engine.prewarm() 573 574 def close(self) -> None: 575 """Release model resources and discard the buffer.""" 576 self.reset() 577 self._engine.close() 578 579 def __enter__(self) -> StreamingSTTEngine: 580 """Enter sync context manager.""" 581 return self 582 583 def __exit__( 584 self, 585 exc_type: type[BaseException] | None, 586 exc_val: BaseException | None, 587 exc_tb: object, 588 ) -> None: 589 """Exit sync context manager. Releases engine resources.""" 590 self.close() 591 592 # ------------------------------------------------------------------ 593 # Internal helpers 594 # ------------------------------------------------------------------ 595 596 def _coerce_chunk(self, chunk: np.ndarray | bytes) -> np.ndarray: 597 """Convert raw int16 bytes or ensure float32 array.""" 598 if isinstance(chunk, (bytes, bytearray)): 599 arr = np.frombuffer(chunk, dtype=np.int16).astype(np.float32) / 32768.0 600 return arr 601 arr = np.asarray(chunk, dtype=np.float32) 602 if arr.ndim > 1: 603 # Best-effort stereo → mono using the shape heuristic from STTEngine 604 arr = arr.mean(axis=0) if arr.shape[0] < arr.shape[1] else arr.mean(axis=1) 605 return arr 606 607 def _run_pass(self, *, force: bool = False) -> Iterator[TranscriptSegment]: 608 """Concatenate buffer, transcribe, apply sliding window, yield segments.""" 609 audio = np.concatenate(self._buffer) 610 611 logger.debug( 612 "StreamingSTTEngine pass: %.2f s (force=%s)", 613 len(audio) / self.sample_rate, 614 force, 615 ) 616 617 yield from self._engine.transcribe_streaming(audio) 618 619 # Sliding window: retain the last stride_seconds of audio so that 620 # words near the boundary are not cut off on the next pass. 621 stride_samples = int(self.stride_seconds * self.sample_rate) 622 if stride_samples > 0 and len(audio) > stride_samples: 623 retained = audio[-stride_samples:] 624 self._buffer = [retained] 625 self._buffer_samples = len(retained) 626 else: 627 self._buffer = [] 628 self._buffer_samples = 0
Incremental streaming STT: accepts audio chunks, yields segments.
Audio chunks are pushed one at a time via push_chunk(). When the
accumulated buffer reaches min_buffer_seconds, push_chunk()
transparently transcribes the buffer and yields any new segments. You can
also force a transcription at any time with flush().
A sliding-window approach is supported via stride_seconds: after each
transcription pass the engine retains the last stride_seconds of audio
so that words near the boundary are not lost on the next pass. Set
stride_seconds=0.0 (default) to discard all audio after each pass.
Thread safety: not thread-safe. Call from a single thread or protect externally with a lock.
Arguments:
- model: Whisper model size. One of
tiny,base,small,medium,large-v3. Default"base". - device:
"cpu"or"cuda". Default"cpu". - compute_type: CTranslate2 compute type. Default
"int8". - language: Force a specific language code (e.g.
"en").Nonefor auto-detect. - min_buffer_seconds: Minimum seconds of audio to accumulate before
attempting a transcription pass. Shorter values
mean lower latency but more frequent (and
potentially noisier) passes. Default
2.0. - stride_seconds: Seconds of audio overlap to retain between passes
(sliding-window). Default
0.0(no overlap). - sample_rate: Sample rate of incoming audio chunks. Default
16000.
Example::
streaming = StreamingSTTEngine(model="base", min_buffer_seconds=2.0)
for chunk in mic_chunks:
for segment in streaming.push_chunk(chunk):
print(f"[{segment.start:.1f}s] {segment.text}")
# Force final transcription when done
for segment in streaming.flush():
print(f"[{segment.start:.1f}s] {segment.text}")
522 @property 523 def buffer_duration_s(self) -> float: 524 """Current accumulated audio duration in seconds.""" 525 return self._buffer_samples / self.sample_rate
Current accumulated audio duration in seconds.
527 def push_chunk(self, chunk: np.ndarray | bytes) -> Iterator[TranscriptSegment]: 528 """Push an audio chunk into the buffer. 529 530 If the buffer has accumulated at least ``min_buffer_seconds`` of audio, 531 a transcription pass is run and any yielded segments are returned. 532 Otherwise, no segments are yielded and the chunk is silently buffered. 533 534 Args: 535 chunk: Float32 numpy array (16kHz mono) **or** raw ``int16`` PCM 536 bytes. Bytes are automatically converted to float32. 537 538 Yields: 539 TranscriptSegment — segments decoded in this pass (may be empty). 540 """ 541 arr = self._coerce_chunk(chunk) 542 self._buffer.append(arr) 543 self._buffer_samples += len(arr) 544 545 min_samples = int(self.min_buffer_seconds * self.sample_rate) 546 if self._buffer_samples >= min_samples: 547 yield from self._run_pass()
Push an audio chunk into the buffer.
If the buffer has accumulated at least min_buffer_seconds of audio,
a transcription pass is run and any yielded segments are returned.
Otherwise, no segments are yielded and the chunk is silently buffered.
Arguments:
- chunk: Float32 numpy array (16kHz mono) or raw
int16PCM bytes. Bytes are automatically converted to float32.
Yields:
TranscriptSegment — segments decoded in this pass (may be empty).
549 def flush(self) -> Iterator[TranscriptSegment]: 550 """Transcribe whatever remains in the buffer and clear it. 551 552 Call this when the audio stream ends to ensure trailing audio is 553 transcribed. 554 555 Yields: 556 TranscriptSegment — segments from the remaining buffer. 557 """ 558 if self._buffer_samples == 0: 559 return 560 561 logger.debug("StreamingSTTEngine.flush: %.2f s buffered", self.buffer_duration_s) 562 yield from self._run_pass(force=True)
Transcribe whatever remains in the buffer and clear it.
Call this when the audio stream ends to ensure trailing audio is transcribed.
Yields:
TranscriptSegment — segments from the remaining buffer.
564 def reset(self) -> None: 565 """Discard the current buffer without transcribing.""" 566 self._buffer = [] 567 self._buffer_samples = 0 568 logger.debug("StreamingSTTEngine buffer reset")
Discard the current buffer without transcribing.
85class VoicePipeline: 86 """Wake -> listen -> transcribe -> respond voice pipeline.""" 87 88 def __init__( 89 self, 90 wake_word: str = "viola", 91 stt_model: str = "base", 92 tts_voice: str = "af_heart", 93 threshold: float = DEFAULT_THRESHOLD, 94 vad_backend: str = "auto", 95 vad_threshold: float = 0.4, 96 enable_tts: bool = True, 97 device_index: int | None = None, 98 on_wake: WakeCallback | None = None, 99 streaming_stt: bool = False, 100 ) -> None: 101 self._wake_detector = WakeDetector(model=wake_word, threshold=threshold) 102 self._vad = VADEngine(backend=vad_backend) 103 self._vad_threshold = vad_threshold 104 self._enable_tts = enable_tts 105 self._device_index = device_index 106 self._stt_model = stt_model 107 self._tts_voice = tts_voice 108 self._streaming_stt = streaming_stt 109 110 self._state = _STATE_IDLE 111 self._last_command: str | None = None 112 self._last_score: float | None = None 113 self._state_lock = threading.Lock() 114 self._stop_event = threading.Event() 115 self._worker_lock = threading.Lock() 116 self._event_lock = threading.Lock() 117 self._worker_thread: threading.Thread | None = None 118 119 self._stt: LazySTTEngine | None = None 120 self._tts: LazyTTSEngine | None = None 121 self._command_handlers: list[CommandHandler] = [] 122 self._event_handlers: dict[PipelineEventName, list[PipelineEventCallback]] = { 123 event: [] for event in _SUPPORTED_EVENTS 124 } 125 126 if on_wake is not None: 127 self.on("wake", on_wake) 128 129 logger.info( 130 "VoicePipeline initialized: wake=%s, stt=%s, tts=%s, streaming_stt=%s", 131 wake_word, 132 stt_model, 133 tts_voice, 134 streaming_stt, 135 ) 136 137 @property 138 def state(self) -> str: 139 """Return the current pipeline state.""" 140 with self._state_lock: 141 return self._state 142 143 @property 144 def last_command(self) -> str | None: 145 """Return the most recent transcription result.""" 146 with self._state_lock: 147 return self._last_command 148 149 @property 150 def last_score(self) -> float | None: 151 """Return the most recent wake score.""" 152 with self._state_lock: 153 return self._last_score 154 155 def on( 156 self, 157 event: PipelineEventName, 158 callback: PipelineEventCallback | None = None, 159 ) -> PipelineEventCallback | Callable[[PipelineEventCallback], PipelineEventCallback]: 160 """Register a callback for a pipeline event.""" 161 self._validate_event(event) 162 163 def decorator(fn: PipelineEventCallback) -> PipelineEventCallback: 164 with self._event_lock: 165 self._event_handlers[event].append(fn) 166 return fn 167 168 if callback is None: 169 return decorator 170 return decorator(callback) 171 172 def on_command(self, handler: CommandHandler) -> CommandHandler: 173 """Register a command handler.""" 174 self._command_handlers.append(handler) 175 return handler 176 177 def run(self) -> None: 178 """Run the blocking microphone pipeline.""" 179 logger.info("VoicePipeline started. Say the wake word to begin.") 180 self._stop_event.clear() 181 182 try: 183 self._run_loop() 184 except KeyboardInterrupt: 185 logger.info("Pipeline interrupted by user.") 186 except Exception as exc: 187 raise PipelineError(f"Pipeline error: {exc}") from exc 188 finally: 189 self.stop() 190 self._set_state(_STATE_IDLE) 191 logger.info("VoicePipeline stopped.") 192 193 def stop(self, timeout: float = 5.0) -> None: 194 """Signal the pipeline to stop and wait briefly for worker cleanup.""" 195 self._stop_event.set() 196 worker = self._get_worker_thread() 197 if worker is None or worker is threading.current_thread(): 198 return 199 200 worker.join(timeout=timeout) 201 if worker.is_alive(): 202 logger.warning("VoicePipeline worker thread did not exit within %.1f s", timeout) 203 else: 204 with self._worker_lock: 205 if self._worker_thread is worker: 206 self._worker_thread = None 207 208 def close(self) -> None: 209 """Stop the pipeline and release resources.""" 210 self.stop() 211 self._set_state(_STATE_IDLE) 212 self._wake_detector.close() 213 self._stt = None 214 self._tts = None 215 216 def __enter__(self) -> VoicePipeline: 217 """Enter sync context manager.""" 218 return self 219 220 def __exit__( 221 self, 222 exc_type: type[BaseException] | None, 223 exc_val: BaseException | None, 224 exc_tb: object, 225 ) -> None: 226 """Exit sync context manager.""" 227 self.close() 228 229 def speak(self, text: str) -> None: 230 """Synthesize and play text via TTS.""" 231 if not self._enable_tts or self._stop_event.is_set(): 232 return 233 if not text.strip(): 234 return 235 236 try: 237 tts = self._get_tts() 238 if tts is None: 239 self._fail( 240 "TTS not available - install 'violawake[tts]'", 241 stage="tts", 242 ) 243 audio = tts.synthesize(text) 244 if np.asarray(audio).size == 0: 245 self._fail("TTS synthesized empty audio for non-empty text", stage="tts") 246 tts.play(audio) 247 except PipelineError: 248 raise 249 except Exception as exc: 250 logger.exception("TTS playback failed for text '%.50s': %s", text, exc) 251 self._fail(f"TTS playback failed: {exc}", stage="tts", cause=exc) 252 253 def _run_loop(self) -> None: 254 """Main microphone capture and detection loop.""" 255 recording_buffer: list[bytes] = [] 256 silence_count = 0 257 258 for frame in self._wake_detector.stream_mic(device_index=self._device_index): 259 if self._stop_event.is_set(): 260 break 261 262 state = self.state 263 if state == _STATE_IDLE: 264 # Re-read the live state here; once we've branched on an IDLE snapshot, 265 # `state == _STATE_RESPONDING` is dead unless we check the current state again. 266 if self._wake_detector.detect(frame, is_playing=self._is_playing()): 267 score = self._get_detector_score() 268 with self._state_lock: 269 self._last_score = score 270 logger.info("Wake word detected -> listening for command") 271 recording_buffer.clear() 272 silence_count = 0 273 self._emit("wake", score=score) 274 if not self._transition_state(_STATE_IDLE, _STATE_LISTENING): 275 continue 276 self._emit("listen_start", score=score) 277 continue 278 279 if state != _STATE_LISTENING: 280 continue 281 282 recording_buffer.append(frame) 283 is_speech = self._vad.is_speech(frame, threshold=self._vad_threshold) 284 silence_count = 0 if is_speech else silence_count + 1 285 total_frames = len(recording_buffer) 286 287 if silence_count < SILENCE_FRAMES_STOP and total_frames < MAX_RECORDING_FRAMES: 288 continue 289 290 duration_s = total_frames * FRAME_SAMPLES / SAMPLE_RATE 291 audio_bytes = b"".join(recording_buffer) 292 self._emit("listen_end", duration_s=duration_s, frame_count=total_frames) 293 if not self._transition_state(_STATE_LISTENING, _STATE_TRANSCRIBING): 294 continue 295 self._emit("transcribe_start", duration_s=duration_s, frame_count=total_frames) 296 self._start_worker(audio_bytes) 297 298 def _transcribe_and_respond(self, audio_bytes: bytes) -> None: 299 """Transcribe audio and dispatch command handlers.""" 300 transcribe_end_emitted = False 301 try: 302 stt = self._get_stt() 303 if stt is None: 304 self._set_last_command(None) 305 self._emit("transcribe_end", text="") 306 transcribe_end_emitted = True 307 self._fail( 308 "STT not available - install 'violawake[stt]'", 309 stage="stt", 310 ) 311 312 if len(audio_bytes) % 2 != 0: 313 logger.warning( 314 "Audio buffer length %d is not a multiple of 2 bytes (int16); truncating", 315 len(audio_bytes), 316 ) 317 audio_bytes = audio_bytes[: len(audio_bytes) & ~1] 318 319 if len(audio_bytes) == 0: 320 logger.warning("Empty audio buffer - skipping transcription") 321 self._set_last_command(None) 322 self._emit("transcribe_end", text="") 323 transcribe_end_emitted = True 324 self._fail("empty audio buffer reached STT stage", stage="stt") 325 326 pcm = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0 327 if self._streaming_stt: 328 segment_texts: list[str] = [] 329 for segment in stt.transcribe_streaming(pcm): 330 if self._stop_event.is_set(): 331 logger.debug("Pipeline stopping; aborting streaming transcription") 332 return 333 logger.debug( 334 "Streaming segment [%.1f-%.1f]: '%s'", 335 segment.start, 336 segment.end, 337 segment.text, 338 ) 339 segment_texts.append(segment.text) 340 text = " ".join(segment_texts).strip() 341 else: 342 text = stt.transcribe(pcm) 343 344 stripped_text = text.strip() 345 self._set_last_command(stripped_text or None) 346 self._emit("transcribe_end", text=text) 347 transcribe_end_emitted = True 348 349 if not stripped_text: 350 self._fail("STT produced empty transcription", stage="stt") 351 352 if self._stop_event.is_set(): 353 logger.debug("Pipeline stopping; dropping transcription result") 354 return 355 356 logger.info("Command: '%s'", stripped_text) 357 self._dispatch_command(stripped_text) 358 except PipelineError: 359 raise 360 except ImportError as exc: 361 logger.exception("STT unavailable") 362 if not transcribe_end_emitted: 363 self._set_last_command(None) 364 self._emit("transcribe_end", text="") 365 self._fail(f"STT unavailable: {exc}", stage="stt", cause=exc) 366 except Exception as exc: 367 logger.exception("Transcription failed") 368 if not transcribe_end_emitted: 369 self._set_last_command(None) 370 self._emit("transcribe_end", text="") 371 self._fail(f"Transcription failed: {exc}", stage="stt", cause=exc) 372 finally: 373 self._clear_worker_thread() 374 self._set_state(_STATE_IDLE) 375 376 def _dispatch_command(self, text: str) -> None: 377 """Call registered command handlers.""" 378 if self._stop_event.is_set(): 379 return 380 381 if not self._transition_state( 382 (_STATE_IDLE, _STATE_TRANSCRIBING, _STATE_RESPONDING), 383 _STATE_RESPONDING, 384 ): 385 return 386 try: 387 for handler in self._command_handlers: 388 if self._stop_event.is_set(): 389 break 390 try: 391 response = handler(text) 392 except Exception: 393 logger.exception("Command handler '%s' failed", handler.__name__) 394 continue 395 396 if not response: 397 continue 398 self._emit( 399 "response", 400 command=text, 401 response=response, 402 handler=handler.__name__, 403 ) 404 if self._enable_tts and not self._stop_event.is_set(): 405 self.speak(response) 406 finally: 407 self._set_state(_STATE_IDLE) 408 409 def _start_worker(self, audio_bytes: bytes) -> None: 410 """Start the STT/TTS worker thread and retain it for shutdown.""" 411 with self._worker_lock: 412 if self._worker_thread is not None and self._worker_thread.is_alive(): 413 logger.warning("Previous worker thread still alive - skipping new spawn") 414 self._set_state(_STATE_IDLE) 415 return 416 worker = threading.Thread( 417 target=self._transcribe_and_respond, 418 args=(audio_bytes,), 419 daemon=True, 420 ) 421 self._worker_thread = worker 422 worker.start() 423 424 def _get_worker_thread(self) -> threading.Thread | None: 425 """Return the active worker thread, if any.""" 426 with self._worker_lock: 427 return self._worker_thread 428 429 def _clear_worker_thread(self) -> None: 430 """Clear the worker reference once the worker exits.""" 431 current = threading.current_thread() 432 with self._worker_lock: 433 if self._worker_thread is current: 434 self._worker_thread = None 435 436 def _validate_event(self, event: str) -> None: 437 """Validate a pipeline event name.""" 438 if event not in _SUPPORTED_EVENTS: 439 available = ", ".join(sorted(_SUPPORTED_EVENTS)) 440 raise ValueError(f"Unsupported pipeline event '{event}'. Available: {available}") 441 442 def _emit(self, event: PipelineEventName, **payload: object) -> None: 443 """Invoke callbacks registered for a pipeline event.""" 444 with self._event_lock: 445 callbacks = list(self._event_handlers[event]) 446 447 event_payload = {"event": event, "pipeline": self, **payload} 448 for callback in callbacks: 449 try: 450 self._invoke_callback(callback, event_payload) 451 except Exception: 452 logger.exception("Pipeline event callback failed for '%s'", event) 453 454 def _invoke_callback( 455 self, 456 callback: PipelineEventCallback, 457 payload: dict[str, object], 458 ) -> None: 459 """Call a callback with the subset of payload keys it accepts.""" 460 try: 461 signature = inspect.signature(callback) 462 except (TypeError, ValueError): 463 callback() 464 return 465 466 if any( 467 parameter.kind is inspect.Parameter.VAR_KEYWORD 468 for parameter in signature.parameters.values() 469 ): 470 callback(**payload) 471 return 472 473 accepted = {name: value for name, value in payload.items() if name in signature.parameters} 474 callback(**accepted) 475 476 def _fail( 477 self, 478 message: str, 479 *, 480 stage: str, 481 cause: BaseException | None = None, 482 ) -> NoReturn: 483 """Emit a pipeline error event and raise ``PipelineError``.""" 484 error = PipelineError(message) 485 self._emit("error", error=error, stage=stage, cause=cause) 486 if cause is not None: 487 raise error from cause 488 raise error 489 490 def _set_state(self, state: str) -> None: 491 """Update the pipeline state under lock.""" 492 with self._state_lock: 493 self._state = state 494 495 def _transition_state(self, expected_state: str | tuple[str, ...], new_state: str) -> bool: 496 """Atomically update the pipeline state when it still matches the expected value.""" 497 expected_states = (expected_state,) if isinstance(expected_state, str) else expected_state 498 with self._state_lock: 499 # The old code split state reads and writes across different lock scopes, 500 # which let stale snapshots overwrite newer RESPONDING/TRANSCRIBING states. 501 if self._state not in expected_states: 502 return False 503 self._state = new_state 504 return True 505 506 def _is_playing(self) -> bool: 507 """Return whether the pipeline is currently speaking a response.""" 508 with self._state_lock: 509 return self._state == _STATE_RESPONDING 510 511 def _set_last_command(self, command: str | None) -> None: 512 """Update the last transcribed command under lock.""" 513 with self._state_lock: 514 self._last_command = command 515 516 def _get_detector_score(self) -> float | None: 517 """Return the most recent wake detector score, if available.""" 518 scores = self._wake_detector.last_scores 519 if not scores: 520 return None 521 return float(scores[-1]) 522 523 def _get_stt(self) -> LazySTTEngine | None: 524 """Lazy-load the STT engine.""" 525 if self._stt is None: 526 from violawake_sdk.stt import STTEngine 527 528 self._stt = STTEngine(model=self._stt_model) 529 self._stt.prewarm() 530 return self._stt 531 532 def _get_tts(self) -> LazyTTSEngine | None: 533 """Lazy-load the TTS engine.""" 534 if self._tts is None and self._enable_tts: 535 try: 536 from violawake_sdk.tts import TTSEngine 537 538 self._tts = TTSEngine(voice=self._tts_voice) 539 except ImportError: 540 return None 541 return self._tts
Wake -> listen -> transcribe -> respond voice pipeline.
88 def __init__( 89 self, 90 wake_word: str = "viola", 91 stt_model: str = "base", 92 tts_voice: str = "af_heart", 93 threshold: float = DEFAULT_THRESHOLD, 94 vad_backend: str = "auto", 95 vad_threshold: float = 0.4, 96 enable_tts: bool = True, 97 device_index: int | None = None, 98 on_wake: WakeCallback | None = None, 99 streaming_stt: bool = False, 100 ) -> None: 101 self._wake_detector = WakeDetector(model=wake_word, threshold=threshold) 102 self._vad = VADEngine(backend=vad_backend) 103 self._vad_threshold = vad_threshold 104 self._enable_tts = enable_tts 105 self._device_index = device_index 106 self._stt_model = stt_model 107 self._tts_voice = tts_voice 108 self._streaming_stt = streaming_stt 109 110 self._state = _STATE_IDLE 111 self._last_command: str | None = None 112 self._last_score: float | None = None 113 self._state_lock = threading.Lock() 114 self._stop_event = threading.Event() 115 self._worker_lock = threading.Lock() 116 self._event_lock = threading.Lock() 117 self._worker_thread: threading.Thread | None = None 118 119 self._stt: LazySTTEngine | None = None 120 self._tts: LazyTTSEngine | None = None 121 self._command_handlers: list[CommandHandler] = [] 122 self._event_handlers: dict[PipelineEventName, list[PipelineEventCallback]] = { 123 event: [] for event in _SUPPORTED_EVENTS 124 } 125 126 if on_wake is not None: 127 self.on("wake", on_wake) 128 129 logger.info( 130 "VoicePipeline initialized: wake=%s, stt=%s, tts=%s, streaming_stt=%s", 131 wake_word, 132 stt_model, 133 tts_voice, 134 streaming_stt, 135 )
137 @property 138 def state(self) -> str: 139 """Return the current pipeline state.""" 140 with self._state_lock: 141 return self._state
Return the current pipeline state.
143 @property 144 def last_command(self) -> str | None: 145 """Return the most recent transcription result.""" 146 with self._state_lock: 147 return self._last_command
Return the most recent transcription result.
149 @property 150 def last_score(self) -> float | None: 151 """Return the most recent wake score.""" 152 with self._state_lock: 153 return self._last_score
Return the most recent wake score.
155 def on( 156 self, 157 event: PipelineEventName, 158 callback: PipelineEventCallback | None = None, 159 ) -> PipelineEventCallback | Callable[[PipelineEventCallback], PipelineEventCallback]: 160 """Register a callback for a pipeline event.""" 161 self._validate_event(event) 162 163 def decorator(fn: PipelineEventCallback) -> PipelineEventCallback: 164 with self._event_lock: 165 self._event_handlers[event].append(fn) 166 return fn 167 168 if callback is None: 169 return decorator 170 return decorator(callback)
Register a callback for a pipeline event.
172 def on_command(self, handler: CommandHandler) -> CommandHandler: 173 """Register a command handler.""" 174 self._command_handlers.append(handler) 175 return handler
Register a command handler.
177 def run(self) -> None: 178 """Run the blocking microphone pipeline.""" 179 logger.info("VoicePipeline started. Say the wake word to begin.") 180 self._stop_event.clear() 181 182 try: 183 self._run_loop() 184 except KeyboardInterrupt: 185 logger.info("Pipeline interrupted by user.") 186 except Exception as exc: 187 raise PipelineError(f"Pipeline error: {exc}") from exc 188 finally: 189 self.stop() 190 self._set_state(_STATE_IDLE) 191 logger.info("VoicePipeline stopped.")
Run the blocking microphone pipeline.
193 def stop(self, timeout: float = 5.0) -> None: 194 """Signal the pipeline to stop and wait briefly for worker cleanup.""" 195 self._stop_event.set() 196 worker = self._get_worker_thread() 197 if worker is None or worker is threading.current_thread(): 198 return 199 200 worker.join(timeout=timeout) 201 if worker.is_alive(): 202 logger.warning("VoicePipeline worker thread did not exit within %.1f s", timeout) 203 else: 204 with self._worker_lock: 205 if self._worker_thread is worker: 206 self._worker_thread = None
Signal the pipeline to stop and wait briefly for worker cleanup.
208 def close(self) -> None: 209 """Stop the pipeline and release resources.""" 210 self.stop() 211 self._set_state(_STATE_IDLE) 212 self._wake_detector.close() 213 self._stt = None 214 self._tts = None
Stop the pipeline and release resources.
229 def speak(self, text: str) -> None: 230 """Synthesize and play text via TTS.""" 231 if not self._enable_tts or self._stop_event.is_set(): 232 return 233 if not text.strip(): 234 return 235 236 try: 237 tts = self._get_tts() 238 if tts is None: 239 self._fail( 240 "TTS not available - install 'violawake[tts]'", 241 stage="tts", 242 ) 243 audio = tts.synthesize(text) 244 if np.asarray(audio).size == 0: 245 self._fail("TTS synthesized empty audio for non-empty text", stage="tts") 246 tts.play(audio) 247 except PipelineError: 248 raise 249 except Exception as exc: 250 logger.exception("TTS playback failed for text '%.50s': %s", text, exc) 251 self._fail(f"TTS playback failed: {exc}", stage="tts", cause=exc)
Synthesize and play text via TTS.
544class AsyncVoicePipeline: 545 """Async wrapper around ``VoicePipeline``.""" 546 547 def __init__(self, pipeline: VoicePipeline | None = None, **kwargs: Any) -> None: 548 if pipeline is not None and kwargs: 549 raise ValueError("Provide either an existing pipeline or constructor kwargs, not both") 550 self._pipeline = pipeline if pipeline is not None else VoicePipeline(**kwargs) 551 552 async def __aenter__(self) -> AsyncVoicePipeline: 553 """Enter async context manager.""" 554 return self 555 556 async def __aexit__( 557 self, 558 exc_type: type[BaseException] | None, 559 exc_val: BaseException | None, 560 exc_tb: object, 561 ) -> None: 562 """Exit async context manager and release resources.""" 563 await self.close() 564 565 @property 566 def pipeline(self) -> VoicePipeline: 567 """Return the wrapped sync pipeline.""" 568 return self._pipeline 569 570 @property 571 def state(self) -> str: 572 """Return the current wrapped pipeline state.""" 573 return self._pipeline.state 574 575 @property 576 def last_command(self) -> str | None: 577 """Return the most recent transcription result.""" 578 return self._pipeline.last_command 579 580 @property 581 def last_score(self) -> float | None: 582 """Return the most recent wake score.""" 583 return self._pipeline.last_score 584 585 def on( 586 self, 587 event: PipelineEventName, 588 callback: PipelineEventCallback | None = None, 589 ) -> PipelineEventCallback | Callable[[PipelineEventCallback], PipelineEventCallback]: 590 """Register an event callback on the wrapped pipeline.""" 591 return self._pipeline.on(event, callback) 592 593 def on_command(self, handler: CommandHandler) -> CommandHandler: 594 """Register a command handler on the wrapped pipeline.""" 595 return self._pipeline.on_command(handler) 596 597 async def run(self) -> None: 598 """Run the wrapped pipeline in a background thread.""" 599 await asyncio.to_thread(self._pipeline.run) 600 601 async def speak(self, text: str) -> None: 602 """Speak text without blocking the event loop.""" 603 await asyncio.to_thread(self._pipeline.speak, text) 604 605 async def stop(self, timeout: float = 5.0) -> None: 606 """Stop the wrapped pipeline without blocking the event loop.""" 607 await asyncio.to_thread(self._pipeline.stop, timeout) 608 609 async def close(self) -> None: 610 """Close the wrapped pipeline without blocking the event loop.""" 611 await asyncio.to_thread(self._pipeline.close) 612 613 async def aclose(self) -> None: 614 """Alias for ``close()``.""" 615 await self.close()
Async wrapper around VoicePipeline.
547 def __init__(self, pipeline: VoicePipeline | None = None, **kwargs: Any) -> None: 548 if pipeline is not None and kwargs: 549 raise ValueError("Provide either an existing pipeline or constructor kwargs, not both") 550 self._pipeline = pipeline if pipeline is not None else VoicePipeline(**kwargs)
565 @property 566 def pipeline(self) -> VoicePipeline: 567 """Return the wrapped sync pipeline.""" 568 return self._pipeline
Return the wrapped sync pipeline.
570 @property 571 def state(self) -> str: 572 """Return the current wrapped pipeline state.""" 573 return self._pipeline.state
Return the current wrapped pipeline state.
575 @property 576 def last_command(self) -> str | None: 577 """Return the most recent transcription result.""" 578 return self._pipeline.last_command
Return the most recent transcription result.
580 @property 581 def last_score(self) -> float | None: 582 """Return the most recent wake score.""" 583 return self._pipeline.last_score
Return the most recent wake score.
585 def on( 586 self, 587 event: PipelineEventName, 588 callback: PipelineEventCallback | None = None, 589 ) -> PipelineEventCallback | Callable[[PipelineEventCallback], PipelineEventCallback]: 590 """Register an event callback on the wrapped pipeline.""" 591 return self._pipeline.on(event, callback)
Register an event callback on the wrapped pipeline.
593 def on_command(self, handler: CommandHandler) -> CommandHandler: 594 """Register a command handler on the wrapped pipeline.""" 595 return self._pipeline.on_command(handler)
Register a command handler on the wrapped pipeline.
597 async def run(self) -> None: 598 """Run the wrapped pipeline in a background thread.""" 599 await asyncio.to_thread(self._pipeline.run)
Run the wrapped pipeline in a background thread.
601 async def speak(self, text: str) -> None: 602 """Speak text without blocking the event loop.""" 603 await asyncio.to_thread(self._pipeline.speak, text)
Speak text without blocking the event loop.
605 async def stop(self, timeout: float = 5.0) -> None: 606 """Stop the wrapped pipeline without blocking the event loop.""" 607 await asyncio.to_thread(self._pipeline.stop, timeout)
Stop the wrapped pipeline without blocking the event loop.
Base exception for all ViolaWake SDK errors.
11class ModelNotFoundError(ViolaWakeError): 12 """Raised when a model file is not found in the cache or at the given path. 13 14 Resolution: run ``violawake-download --model <model_name>`` to download. 15 """
Raised when a model file is not found in the cache or at the given path.
Resolution: run violawake-download --model <model_name> to download.
25class AudioCaptureError(ViolaWakeError): 26 """Raised when microphone capture fails to initialize or read frames. 27 28 Common causes: no audio input device, device already in use, 29 PortAudio not installed. 30 """
Raised when microphone capture fails to initialize or read frames.
Common causes: no audio input device, device already in use, PortAudio not installed.
18class ModelLoadError(ViolaWakeError): 19 """Raised when a model file exists but cannot be loaded by ONNX Runtime. 20 21 Possible causes: corrupted file, ONNX opset version mismatch. 22 """
Raised when a model file exists but cannot be loaded by ONNX Runtime.
Possible causes: corrupted file, ONNX opset version mismatch.
40class PipelineError(ViolaWakeError): 41 """Raised when the VoicePipeline encounters an unrecoverable error."""
Raised when the VoicePipeline encounters an unrecoverable error.
33class VADBackendError(ViolaWakeError): 34 """Raised when the requested VAD backend is unavailable. 35 36 Falls back to RMS heuristic if webrtcvad/silero not installed. 37 """
Raised when the requested VAD backend is unavailable.
Falls back to RMS heuristic if webrtcvad/silero not installed.
104def list_models() -> list[dict[str, str]]: 105 """Return available wake word models with their descriptions. 106 107 Each entry is a dict with keys: ``name``, ``description``, ``version``. 108 109 Example:: 110 111 >>> from violawake_sdk import list_models 112 >>> for m in list_models(): 113 ... print(f"{m['name']:20s} {m['description']}") 114 """ 115 from violawake_sdk.models import MODEL_REGISTRY 116 117 seen: set[str] = set() 118 result: list[dict[str, str]] = [] 119 for name, spec in MODEL_REGISTRY.items(): 120 # Deduplicate aliases (e.g. "viola" -> "temporal_cnn") 121 if spec.name in seen: 122 continue 123 # Hide deprecated, package-managed, and non-wake-word models 124 if "DEPRECATED" in spec.description: 125 continue 126 if spec.name in ("oww_backbone", "kokoro_v1_0", "kokoro_voices_v1_0"): 127 continue 128 seen.add(spec.name) 129 result.append( 130 { 131 "name": name, 132 "description": spec.description, 133 "version": spec.version, 134 } 135 ) 136 return result
Return available wake word models with their descriptions.
Each entry is a dict with keys: name, description, version.
Example::
>>> from violawake_sdk import list_models
>>> for m in list_models():
... print(f"{m['name']:20s} {m['description']}")
139def list_voices() -> list[str]: 140 """Return available TTS voice names for use with ``TTSEngine``. 141 142 Requires the ``[tts]`` extra to be installed for actual synthesis, 143 but this function always works for discovery. 144 145 Example:: 146 147 >>> from violawake_sdk import list_voices 148 >>> list_voices() 149 ['af_heart', 'af_bella', 'af_sarah', ...] 150 """ 151 from violawake_sdk.tts import AVAILABLE_VOICES 152 153 return list(AVAILABLE_VOICES)
Return available TTS voice names for use with TTSEngine.
Requires the [tts] extra to be installed for actual synthesis,
but this function always works for discovery.
Example::
>>> from violawake_sdk import list_voices
>>> list_voices()
['af_heart', 'af_bella', 'af_sarah', ...]