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]
@dataclass
class DetectorConfig:
 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).
DetectorConfig( models: list[str] | None = None, fusion_strategy: FusionStrategy | str = <FusionStrategy.AVERAGE: 'average'>, fusion_weights: list[float] | None = None, adaptive_threshold: bool = False, noise_profiler: NoiseProfiler | None = None, speaker_verify_fn: Callable[..., bool] | None = None, power_manager: PowerManager | None = None, confirm_count: int = 1, score_history_size: int = 50)
models: list[str] | None = None
fusion_strategy: FusionStrategy | str = <FusionStrategy.AVERAGE: 'average'>
fusion_weights: list[float] | None = None
adaptive_threshold: bool = False
noise_profiler: NoiseProfiler | None = None
speaker_verify_fn: Callable[..., bool] | None = None
power_manager: PowerManager | None = None
confirm_count: int = 1
score_history_size: int = 50
def build( self, model: str = 'temporal_cnn', **kwargs: Any) -> WakeDetector:
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.

class WakeDetector:
 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() and last_scores property.
  • K3 Multi-model ensemble: models parameter with fusion strategies.
  • K4 Adaptive threshold: adaptive_threshold parameter with noise profiling.
  • K5 Speaker verification: speaker_verify_fn callback for post-detection.
  • K6 Audio source abstraction: from_source() class method factory.
  • K7 Power management: power_manager parameter 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 DetectorConfig instance 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).
WakeDetector( model: str = 'temporal_cnn', threshold: float = 0.8, cooldown_s: float = 2.0, providers: list[str] | None = None, backend: str = 'auto', *, config: DetectorConfig | None = None, models: list[str] | None = <object object>, fusion_strategy: FusionStrategy | str = <object object>, fusion_weights: list[float] | None = <object object>, adaptive_threshold: bool = <object object>, noise_profiler: NoiseProfiler | None = <object object>, speaker_verify_fn: Callable[[numpy.ndarray], bool] | None = <object object>, power_manager: PowerManager | None = <object object>, confirm_count: int = <object object>, score_history_size: int = <object object>)
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))
threshold
def close(self) -> None:
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.

def process(self, audio_frame: bytes | numpy.ndarray) -> float:
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.
def detect( self, audio_frame: bytes | numpy.ndarray, is_playing: bool = False) -> bool:
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.
def reset_cooldown(self) -> None:
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.

def reset(self) -> None:
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.

def get_confidence(self) -> ConfidenceResult:
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).

last_scores: tuple[float, ...]
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).

def enroll_speaker(self, speaker_id: str, audio_frames: list[bytes | numpy.ndarray]) -> int:
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.
def verify_speaker( self, audio_frame: bytes | numpy.ndarray) -> violawake_sdk.speaker.SpeakerVerifyResult:
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.
@classmethod
def from_source( cls, source: violawake_sdk.audio_source.AudioSource, model: str = 'temporal_cnn', threshold: float = 0.8, cooldown_s: float = 2.0, **kwargs: Any) -> violawake_sdk.wake_detector._SourceDetector:
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.

def stream_mic(self, device_index: int | None = None) -> Generator[bytes, None, None]:
 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.

class WakewordDetector:
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.

WakewordDetector( wake_word: str = 'viola', threshold: float = 0.8, cooldown_s: float = 2.0, providers: list[str] | None = None, backend: str = 'auto')
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)
wake_word
threshold
cooldown_s
providers
backend
def process_audio( self, audio_frame: bytes | numpy.ndarray, is_playing: bool = False) -> bool:
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)
def process(self, audio_frame: bytes | numpy.ndarray) -> float:
1140    def process(self, audio_frame: bytes | np.ndarray) -> float:
1141        return self._get_detector().process(audio_frame)
def detect( self, audio_frame: bytes | numpy.ndarray, is_playing: bool = False) -> bool:
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)
def stream_mic(self, device_index: int | None = None) -> Generator[bytes, None, None]:
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)
def reset(self) -> None:
1149    def reset(self) -> None:
1150        self._get_detector().reset()
def reset_cooldown(self) -> None:
1152    def reset_cooldown(self) -> None:
1153        self._get_detector().reset_cooldown()
def get_confidence(self) -> ConfidenceResult:
1155    def get_confidence(self) -> ConfidenceResult:
1156        return self._get_detector().get_confidence()
last_scores: tuple[float, ...]
1158    @property
1159    def last_scores(self) -> tuple[float, ...]:
1160        return self._get_detector().last_scores
class AsyncWakeDetector:
 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:
AsyncWakeDetector(**kwargs: Any)
50    def __init__(self, **kwargs: Any) -> None:
51        self._detector = WakeDetector(**kwargs)
52        self._executor: ThreadPoolExecutor | None = None
async def detect( self, audio_frame: bytes | numpy.ndarray, is_playing: bool = False) -> bool:
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.

async def process(self, audio_frame: bytes | numpy.ndarray) -> float:
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.

async def stream( self, source: AsyncIterator[bytes | numpy.ndarray]) -> AsyncIterator[bool]:
 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.

def reset_cooldown(self) -> None:
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).

threshold: float
117    @property
118    def threshold(self) -> float:
119        """Current detection threshold."""
120        return self._detector.threshold

Current detection threshold.

def get_confidence(self) -> ConfidenceResult:
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).

last_scores: tuple[float, ...]
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).

async def aclose(self) -> None:
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.

def close(self) -> None:
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().

class WakeDecisionPolicy:
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)

WakeDecisionPolicy( threshold: float = 0.8, cooldown_s: float = 2.0, rms_floor: float = 1.0)
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
threshold
cooldown_s
rms_floor
def evaluate(self, score: float, rms: float = 100.0, is_playing: bool = False) -> bool:
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.

def reset_cooldown(self) -> None:
270    def reset_cooldown(self) -> None:
271        """Reset the cooldown window (useful for testing)."""
272        self._last_detection = 0.0

Reset the cooldown window (useful for testing).

def validate_audio_chunk(data: bytes | numpy.ndarray) -> numpy.ndarray:
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.
@dataclass(frozen=True)
class ConfidenceResult:
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).
ConfidenceResult( raw_score: float, confirm_count: int, confirm_required: int, confidence: ConfidenceLevel, score_history: tuple[float, ...])
raw_score: float
confirm_count: int
confirm_required: int
confidence: ConfidenceLevel
score_history: tuple[float, ...]
class ConfidenceLevel(builtins.str, enum.Enum):
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.

LOW = <ConfidenceLevel.LOW: 'LOW'>
MEDIUM = <ConfidenceLevel.MEDIUM: 'MEDIUM'>
HIGH = <ConfidenceLevel.HIGH: 'HIGH'>
CERTAIN = <ConfidenceLevel.CERTAIN: 'CERTAIN'>
class FusionStrategy(builtins.str, enum.Enum):
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.

AVERAGE = <FusionStrategy.AVERAGE: 'average'>
MAX = <FusionStrategy.MAX: 'max'>
VOTING = <FusionStrategy.VOTING: 'voting'>
WEIGHTED_AVERAGE = <FusionStrategy.WEIGHTED_AVERAGE: 'weighted_average'>
class NoiseProfiler:
 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_threshold defaults to 0.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_threshold defaults to 0.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_threshold with 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).
NoiseProfiler( base_threshold: float = 0.8, noise_window_s: float = 5.0, min_threshold: float = 0.6, max_threshold: float = 0.95, snr_boost_db: float = 6.0, snr_penalty_db: float = 3.0, frames_per_second: float = 50.0)
 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
base_threshold: float
105    @property
106    def base_threshold(self) -> float:
107        """The unadjusted detection threshold."""
108        return self._base_threshold

The unadjusted detection threshold.

noise_floor: float
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.

def update(self, audio_frame: numpy.ndarray) -> float:
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.

def get_profile(self) -> violawake_sdk.noise_profiler.NoiseProfile:
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.

def reset(self) -> None:
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

Clear noise history and reset estimates.

class PowerManager:
 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:

  1. Duty cycling: Process every Nth frame when idle (no recent detections). When a score above activity_threshold is detected, switches to full-rate processing for active_window_s seconds.
  2. Silence skipping: Skip inference when audio RMS is below silence_rms (no speech possible).
  3. Battery-aware: When on battery and below battery_low_pct, increase the duty cycle factor by battery_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.
PowerManager( duty_cycle_n: int = 1, silence_rms: float = 10.0, activity_threshold: float = 0.3, active_window_s: float = 3.0, battery_low_pct: int = 20, battery_multiplier: int = 3, check_battery_interval_s: float = 60.0)
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
effective_duty_cycle: int
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.

def should_process(self, audio_frame: numpy.ndarray) -> bool:
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.

def report_score(self, score: float) -> None:
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.
def get_state(self) -> violawake_sdk.power_manager.PowerState:
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.

def reset(self) -> None:
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.

class VADEngine:
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
VADEngine( backend: str | violawake_sdk.vad.VADBackend = <VADBackend.AUTO: 'auto'>, **backend_kwargs: object)
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
backend_name: str
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.

def process_frame(self, audio: bytes | numpy.ndarray) -> float:
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.

def is_speech(self, audio: bytes | numpy.ndarray, threshold: float = 0.5) -> bool:
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.

def reset(self) -> None:
362    def reset(self) -> None:
363        """Reset internal state (useful between utterances)."""
364        self._backend.reset()

Reset internal state (useful between utterances).

def close(self) -> None:
366    def close(self) -> None:
367        """Release backend resources."""
368        self._backend = None  # type: ignore[assignment]

Release backend resources.

class TTSEngine:
 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
TTSEngine( voice: str = 'af_heart', speed: float = 1.0, sample_rate: int = 16000)
 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_VOICES for 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.
voice
speed
sample_rate
def synthesize(self, text: str) -> numpy.ndarray:
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.

def synthesize_chunked(self, text: str) -> Generator[numpy.ndarray, None, None]:
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.

def play(self, audio: numpy.ndarray, *, blocking: bool = True) -> None:
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.
def play_async(self, audio: numpy.ndarray) -> None:
229    def play_async(self, audio: np.ndarray) -> None:
230        """Play audio without blocking the calling thread."""
231        self.play(audio, blocking=False)

Play audio without blocking the calling thread.

def close(self) -> None:
307    def close(self) -> None:
308        """Release model resources."""
309        self._kokoro = None

Release model resources.

class STTEngine:
 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"
STTEngine( model: str = 'base', device: str = 'cpu', compute_type: str = 'int8', language: str | None = None, language_cache_ttl_s: float = 60.0)
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.
model_name
device
compute_type
forced_language
def transcribe(self, audio: numpy.ndarray) -> str:
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 as temperature_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.

def transcribe_streaming( self, audio: numpy.ndarray, channels_first: bool | None = None, beam_size: int = 5, best_of: int = 5, temperature: list[float] | None = None) -> Iterator[violawake_sdk.stt.TranscriptSegment]:
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_prob above NO_SPEECH_THRESHOLD are 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}")
def transcribe_full( self, audio: numpy.ndarray, channels_first: bool | None = None) -> violawake_sdk.stt.TranscriptResult:
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.

def prewarm(self) -> None:
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).

def close(self) -> None:
417    def close(self) -> None:
418        """Release model resources."""
419        with self._model_lock:
420            self._model = None

Release model resources.

@dataclass
class StreamingSTTEngine:
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"). None for 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}")
StreamingSTTEngine( model: str = 'base', device: str = 'cpu', compute_type: str = 'int8', language: str | None = None, min_buffer_seconds: float = 2.0, stride_seconds: float = 0.0, sample_rate: int = 16000)
model: str = 'base'
device: str = 'cpu'
compute_type: str = 'int8'
language: str | None = None
min_buffer_seconds: float = 2.0
stride_seconds: float = 0.0
sample_rate: int = 16000
buffer_duration_s: float
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.

def push_chunk( self, chunk: numpy.ndarray | bytes) -> Iterator[violawake_sdk.stt.TranscriptSegment]:
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 int16 PCM bytes. Bytes are automatically converted to float32.
Yields:

TranscriptSegment — segments decoded in this pass (may be empty).

def flush(self) -> Iterator[violawake_sdk.stt.TranscriptSegment]:
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.

def reset(self) -> None:
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.

def prewarm(self) -> None:
570    def prewarm(self) -> None:
571        """Eagerly load the underlying Whisper model."""
572        self._engine.prewarm()

Eagerly load the underlying Whisper model.

def close(self) -> None:
574    def close(self) -> None:
575        """Release model resources and discard the buffer."""
576        self.reset()
577        self._engine.close()

Release model resources and discard the buffer.

class VoicePipeline:
 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.

VoicePipeline( wake_word: str = 'viola', stt_model: str = 'base', tts_voice: str = 'af_heart', threshold: float = 0.8, vad_backend: str = 'auto', vad_threshold: float = 0.4, enable_tts: bool = True, device_index: int | None = None, on_wake: Callable[[], None] | None = None, streaming_stt: bool = False)
 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        )
state: str
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.

last_command: str | None
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.

last_score: float | None
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.

def on( self, event: Literal['wake', 'listen_start', 'listen_end', 'transcribe_start', 'transcribe_end', 'response', 'error'], callback: Callable[..., object] | None = None) -> Callable[..., object] | Callable[[Callable[..., object]], Callable[..., object]]:
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.

def on_command( self, handler: Callable[[str], str | None]) -> Callable[[str], str | None]:
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.

def run(self) -> None:
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.

def stop(self, timeout: float = 5.0) -> None:
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.

def close(self) -> None:
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.

def speak(self, text: str) -> None:
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.

class AsyncVoicePipeline:
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.

AsyncVoicePipeline( pipeline: VoicePipeline | None = None, **kwargs: Any)
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)
pipeline: VoicePipeline
565    @property
566    def pipeline(self) -> VoicePipeline:
567        """Return the wrapped sync pipeline."""
568        return self._pipeline

Return the wrapped sync pipeline.

state: str
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.

last_command: str | None
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.

last_score: float | None
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.

def on( self, event: Literal['wake', 'listen_start', 'listen_end', 'transcribe_start', 'transcribe_end', 'response', 'error'], callback: Callable[..., object] | None = None) -> Callable[..., object] | Callable[[Callable[..., object]], Callable[..., object]]:
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.

def on_command( self, handler: Callable[[str], str | None]) -> Callable[[str], str | None]:
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.

async def run(self) -> None:
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.

async def speak(self, text: str) -> None:
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.

async def stop(self, timeout: float = 5.0) -> None:
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.

async def close(self) -> None:
609    async def close(self) -> None:
610        """Close the wrapped pipeline without blocking the event loop."""
611        await asyncio.to_thread(self._pipeline.close)

Close the wrapped pipeline without blocking the event loop.

async def aclose(self) -> None:
613    async def aclose(self) -> None:
614        """Alias for ``close()``."""
615        await self.close()

Alias for close().

class ViolaWakeError(builtins.Exception):
7class ViolaWakeError(Exception):
8    """Base exception for all ViolaWake SDK errors."""

Base exception for all ViolaWake SDK errors.

class ModelNotFoundError(violawake_sdk.ViolaWakeError):
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.

class AudioCaptureError(violawake_sdk.ViolaWakeError):
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.

class ModelLoadError(violawake_sdk.ViolaWakeError):
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.

class PipelineError(violawake_sdk.ViolaWakeError):
40class PipelineError(ViolaWakeError):
41    """Raised when the VoicePipeline encounters an unrecoverable error."""

Raised when the VoicePipeline encounters an unrecoverable error.

class VADBackendError(violawake_sdk.ViolaWakeError):
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.

def list_models() -> list[dict[str, str]]:
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']}")
def list_voices() -> list[str]:
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', ...]
__version__ = '0.2.9'