refactor(tools): compact wake_word/write_approval/voice_mode_transcript/working_diff (-12% LOC, behavior-neutral)

This commit is contained in:
Teknium
2026-09-02 22:17:23 -07:00
parent 113f04616b
commit 3969d10e40
5 changed files with 279 additions and 524 deletions
+121 -232
View File
@@ -1,17 +1,11 @@
"""Wake-word ("Hey Hermes") detection — hands-free session trigger.
An always-on hotword listener shared by CLI, TUI and desktop GUI (one owns it,
gated by ``wake_surface_enabled``): on wake Hermes opens a fresh session and
captures voice via the existing pipeline. Engines (openwakeword default,
sherpa open-vocabulary, porcupine premium) are all on-device and live in
:mod:`tools.wake_word_engines`; this module owns config, the capture loop and
the process-wide listener singleton.
One always-on hotword listener shared by CLI, TUI and desktop GUI (a single owner,
gated by ``wake_surface_enabled``). Engines live in :mod:`tools.wake_word_engines`;
this module owns config, the capture loop and the process-wide listener singleton.
Capture reuses voice mode's 16 kHz mono int16 ``sounddevice`` path on a daemon
thread; callers ``pause()`` while a voice turn holds the mic and ``resume()``
once idle (two input streams on one device is unreliable cross-platform).
Nothing here touches agent context or the prompt cache — on wake the caller
gets a plain string, like a transcript.
thread; callers ``pause()`` while a voice turn holds the mic and ``resume()`` once
idle (two input streams on one device is unreliable cross-platform).
"""
from __future__ import annotations
@@ -32,22 +26,20 @@ from tools.wake_word_engines import ( # noqa: F401 (re-exported for callers/te
logger = logging.getLogger(__name__)
# 16 kHz mono int16 — Whisper-native and what every engine expects.
SAMPLE_RATE = 16000
SAMPLE_RATE = 16000 # 16 kHz mono int16 — Whisper-native and what every engine expects.
# Minimum gap between two wake fires, so one "hey hermes" can't retrigger
# across several frames while the caller is still reacting.
# Minimum gap between two wake fires, so one "hey hermes" can't retrigger across
# several frames while the caller is still reacting.
_FIRE_COOLDOWN_SECONDS = 2.0
_START_TIMEOUT_SECONDS = 5.0
# Ambient-speech rejection: require N consecutive over-threshold frames before
# firing (a stray phoneme spikes one frame; a real phrase holds several).
# Ambient-speech rejection: N consecutive over-threshold frames before firing
# (a stray phoneme spikes one frame; a real phrase holds several).
_DEFAULT_CONFIRMATION_FRAMES = 3
# Dead-mic detection: an int16 stream whose peak stays at/below _SILENCE_PEAK
# for this many consecutive seconds is flagged silent (desktop push-to-talk and
# the backend listener use different capture paths, so one can work while the
# backend-selected stream is all zeros).
# Dead-mic detection: an int16 stream whose peak stays at/below _SILENCE_PEAK for
# this many consecutive seconds is flagged silent (desktop push-to-talk and the
# backend listener capture differently, so one can work while the other is all zeros).
_SILENCE_PEAK = 10
_SILENCE_ALERT_SECONDS = 10
@@ -70,9 +62,8 @@ _DEFAULTS: Dict[str, Any] = {
"enabled": False,
"surface": "auto",
"input_device": None,
# Where PCM is captured: "local" (PortAudio on the backend host),
# "client" (desktop/TUI streams int16 frames via wake.feed), or
# "auto" (local when a device exists, else client capture).
# capture: "local" (PortAudio on the backend host), "client" (desktop/TUI streams
# int16 frames via wake.feed), or "auto" (local when a device exists, else client).
"capture": "auto",
"provider": "openwakeword",
"phrase": "hey hermes",
@@ -81,8 +72,8 @@ _DEFAULTS: Dict[str, Any] = {
"start_new_session": True,
}
# Bundled "hey hermes" model (tools/wakewords/) — the default. Config names in
# _ALIASES resolve to it, not to an openWakeWord built-in.
# Bundled "hey hermes" model (tools/wakewords/) — the default; alias names resolve
# to it, not to an openWakeWord built-in.
_BUNDLED_MODEL_NAME = "hey_hermes"
_BUNDLED_MODEL_ALIASES = frozenset({"", "hey_hermes", "hey hermes", "hermes"})
@@ -100,9 +91,8 @@ def _is_macos_arm64() -> bool:
def default_inference_framework() -> str:
"""tflite on macOS ARM64, onnx elsewhere: openWakeWord's ONNX *embedding*
model scores near-zero on Apple Silicon (upstream #336) — the detector arms
but no phrase ever crosses threshold."""
"""tflite on macOS ARM64, onnx elsewhere: openWakeWord's ONNX embedding model
scores near-zero on Apple Silicon — the detector arms but never fires."""
return "tflite" if _is_macos_arm64() else "onnx"
@@ -110,19 +100,15 @@ _warned_onnx_coerced = False
def resolve_inference_framework(cfg: Dict[str, Any]) -> str:
"""Effective openWakeWord backend: explicit ``openwakeword.inference_framework``
or the platform default. The one provably dead combination — explicit
``onnx`` on macOS ARM64 (upstream #336) — is coerced to tflite with a
one-time warning so a pre-fix pin doesn't keep a wake word that never fires.
"""
"""Effective openWakeWord backend: explicit ``openwakeword.inference_framework`` or
the platform default. Explicit ``onnx`` on macOS ARM64 is provably dead, so it is
coerced to tflite with a one-time warning (a pre-fix pin must not stay deaf)."""
global _warned_onnx_coerced
sub = cfg.get("openwakeword") if isinstance(cfg.get("openwakeword"), dict) else {}
framework = str(sub.get("inference_framework") or "").strip().lower()
if not framework:
return default_inference_framework()
if framework == "onnx" and _is_macos_arm64():
if not _warned_onnx_coerced:
_warned_onnx_coerced = True
@@ -133,16 +119,14 @@ def resolve_inference_framework(cfg: Dict[str, Any]) -> str:
"'tflite' in config.yaml to silence this."
)
return "tflite"
return framework
def ensure_tflite_runtime() -> bool:
"""Make ``import tflite_runtime.interpreter`` resolve, returning success.
openWakeWord hardcodes that import but only declares ``tflite-runtime`` on
Linux; on macOS the equivalent wheel is ``ai-edge-litert``. Alias the
module in-process (nothing is written to site-packages).
openWakeWord hardcodes that import but only declares ``tflite-runtime`` on Linux;
on macOS the wheel is ``ai-edge-litert``. Alias it in-process (site-packages untouched).
"""
try:
import tflite_runtime.interpreter # noqa: F401
@@ -150,12 +134,10 @@ def ensure_tflite_runtime() -> bool:
return True
except ImportError:
pass
try:
from ai_edge_litert import interpreter as _litert # type: ignore[import-not-found]
except ImportError:
return False
import types
pkg = types.ModuleType("tflite_runtime")
@@ -178,7 +160,7 @@ def load_wake_word_config() -> Dict[str, Any]:
def _get(cfg: Dict[str, Any], key: str) -> Any:
val = cfg.get(key, _DEFAULTS.get(key))
val = cfg.get(key)
return _DEFAULTS.get(key) if val is None else val
@@ -200,9 +182,7 @@ def _input_device(cfg: Dict[str, Any]) -> int | str | None:
raw = _get(cfg, "input_device")
if raw is None or isinstance(raw, bool):
return None
if isinstance(raw, int):
return raw
return str(raw).strip() or None
return raw if isinstance(raw, int) else (str(raw).strip() or None)
def _sensitivity(cfg: Dict[str, Any]) -> float:
@@ -210,11 +190,7 @@ def _sensitivity(cfg: Dict[str, Any]) -> float:
def _confirmation_frames(cfg: Dict[str, Any]) -> int:
"""Consecutive over-threshold frames required to fire, clamped 1..10.
``1`` restores single-frame behaviour; higher rejects ambient blips at the
cost of a few tens of ms of latency.
"""
"""Consecutive over-threshold frames required to fire, clamped 1..10 (1 = single-frame)."""
return _clamped(cfg, "confirmation_frames", int, 1, 10)
@@ -224,19 +200,14 @@ def wake_phrase(cfg: Optional[Dict[str, Any]] = None) -> str:
return str(_get(cfg, "phrase")) or "hey hermes"
def resolve_capture_mode(
cfg: Optional[Dict[str, Any]] = None,
*,
prefer_client: bool = False,
force_local: bool = False,
) -> str:
def resolve_capture_mode(cfg: Optional[Dict[str, Any]] = None, *, prefer_client: bool = False,
force_local: bool = False) -> str:
"""Return ``local`` or ``client`` capture mode for this arm.
``prefer_client`` is set by remote desktop; ``force_local`` keeps CLI/TUI on
the process mic. Under ``auto`` a working backend input always wins (local
desktops keep PortAudio + ``input_device``); client is the fallback only for
a preferring surface with no usable backend mic — CLI/TUI stay local so
status reports the real requirement rather than a path nothing will feed.
``prefer_client`` is set by remote desktop; ``force_local`` keeps CLI/TUI on the
process mic. Under ``auto`` a working backend input always wins; client is the
fallback only for a preferring surface with no usable backend mic — CLI/TUI stay
local so status reports the real requirement rather than a path nothing will feed.
"""
cfg = cfg if cfg is not None else load_wake_word_config()
if force_local:
@@ -244,9 +215,7 @@ def resolve_capture_mode(
raw = str(_get(cfg, "capture") or "auto").strip().lower()
if raw in ("client", "remote", "external"):
return "client"
if raw == "local":
return "local"
if prefer_client and not _local_input_device_ready():
if raw != "local" and prefer_client and not _local_input_device_ready():
return "client"
return "local"
@@ -262,9 +231,6 @@ def _local_input_device_ready() -> bool:
"""True when PortAudio is importable and at least one input device exists."""
try:
sd, _ = _import_audio()
except (ImportError, OSError):
return False
try:
devices = sd.query_devices()
if isinstance(devices, dict):
return _input_channels(devices) > 0
@@ -279,9 +245,8 @@ def _local_input_device_ready() -> bool:
def wake_surface_enabled(surface: str, cfg: Optional[Dict[str, Any]] = None) -> bool:
"""Should ``surface`` (``cli`` / ``tui`` / ``gui``) host the listener?
True when enabled and the configured ``surface`` is ``auto`` or this exact
surface. ``auto`` only makes a surface eligible; the process/machine
ownership lock still permits a single claimant.
True when enabled and the configured ``surface`` is ``auto`` or this exact surface.
``auto`` only makes a surface eligible; the ownership lock still admits one claimant.
"""
cfg = cfg if cfg is not None else load_wake_word_config()
if not cfg.get("enabled"):
@@ -304,10 +269,9 @@ def _active_profile_name() -> str:
def enrolled_profile_phrases() -> Dict[str, str]:
"""Map ``profile name -> wake phrase`` for every wake-enabled profile.
Reads each profile's own ``config.yaml`` raw (``load_config()`` targets only
the ACTIVE profile). Enrolled = ``wake_word.enabled`` truthy; phrase defaults
to ``"hey <profile>"``. The sherpa engine listens for all of them at once and
routes the wake to the matching profile. Best-effort: unreadable skipped.
Reads each profile's own ``config.yaml`` raw (``load_config()`` targets only the
ACTIVE profile). Phrase defaults to ``"hey <profile>"``; the sherpa engine listens
for all of them and routes the wake to the matching profile. Unreadable → skipped.
"""
phrases: Dict[str, str] = {}
try:
@@ -317,8 +281,7 @@ def enrolled_profile_phrases() -> Dict[str, str]:
for info in list_profiles():
name = getattr(info, "name", None) or str(info)
try:
raw = read_user_config_raw(Path(get_profile_dir(name)) / "config.yaml")
wc = raw.get("wake_word") or {}
wc = read_user_config_raw(Path(get_profile_dir(name)) / "config.yaml").get("wake_word") or {}
if not isinstance(wc, dict) or not wc.get("enabled"):
continue
phrase = str(wc.get("phrase") or f"hey {name}").strip()
@@ -351,8 +314,7 @@ def _audio_available() -> bool:
def _describe_input_device(sd, selector: int | str | None) -> Dict[str, Any]:
"""Resolve a PortAudio selector into JSON-safe diagnostics.
Diagnostic only: ``InputStream`` remains the authority on whether the
device can actually open at the requested format.
Diagnostic only: ``InputStream`` stays the authority on whether the device opens.
"""
details: Dict[str, Any] = {"selector": selector}
try:
@@ -362,14 +324,11 @@ def _describe_input_device(sd, selector: int | str | None) -> Dict[str, Any]:
return details
if not isinstance(info, dict):
return details
if info.get("name"):
details["name"] = str(info["name"])
for key, out_key, cast in (
("max_input_channels", "max_input_channels", int),
("default_samplerate", "default_samplerate", float),
("hostapi", "hostapi_index", int),
):
for key, out_key, cast in (("max_input_channels", "max_input_channels", int),
("default_samplerate", "default_samplerate", float),
("hostapi", "hostapi_index", int)):
if isinstance(info.get(key), (int, float)):
details[out_key] = cast(info[key])
if "hostapi_index" in details:
@@ -409,18 +368,15 @@ def _resample_audio_frame(np, frame, output_length: int):
return np.asarray(frame, dtype=np.int16).reshape(-1)
if source.size == 0:
return np.zeros(output_length, dtype=np.int16)
if source.size > output_length:
# Average each source window when reducing (matches the desktop wake
# capture path) so speech energy is retained instead of decimated.
# Average each source window when reducing (matches the desktop wake capture
# path) so speech energy is retained instead of decimated.
edges = np.linspace(0, source.size, output_length + 1, dtype=np.int64)
values = np.add.reduceat(source, edges[:-1]) / np.diff(edges)
else:
# Unusual low-rate devices: interpolate up to the 16 kHz frame size.
source_positions = np.arange(source.size, dtype=np.float64)
target_positions = np.linspace(0, source.size - 1, output_length)
values = np.interp(target_positions, source_positions, source)
values = np.interp(np.linspace(0, source.size - 1, output_length), source_positions, source)
return np.rint(values).clip(-32768, 32767).astype(np.int16)
@@ -432,16 +388,10 @@ def silent_audio_hint(details: Dict[str, Any]) -> str:
"microphone access in System Settings > Privacy & Security > "
"Microphone, then toggle the wake word."
)
if sys.platform == "win32":
return (
f"Microphone delivers only silence from {_device_label(details)}. "
"Set wake_word.input_device to a different PortAudio input device, "
"then toggle the wake word."
)
return (
f"Microphone delivers only silence from {_device_label(details)}. "
"Check the selected input device, then toggle the wake word."
)
fix = ("Set wake_word.input_device to a different PortAudio input device"
if sys.platform == "win32" else "Check the selected input device")
return (f"Microphone delivers only silence from {_device_label(details)}. "
f"{fix}, then toggle the wake word.")
# ── Engines (implementations live in tools.wake_word_engines) ──
@@ -456,11 +406,8 @@ def _build_engine(cfg: Dict[str, Any]) -> _Engine:
# ── Requirements probe (for /wake status + enable path) ──
def _stt_ready() -> bool:
"""Is a speech-to-text provider configured and enabled?
A wake without STT arms the mic but every utterance dies at transcription.
Same standard as voice mode's ``check_voice_requirements``.
"""
"""Is a speech-to-text provider configured and enabled? (A wake without STT arms the
mic but every utterance dies at transcription — same bar as ``check_voice_requirements``.)"""
try:
from tools.transcription_tools import _get_provider, _load_stt_config, is_stt_enabled
@@ -476,10 +423,9 @@ _LAZY_TTS_FEATURES = {"edge": "tts.edge", "elevenlabs": "tts.elevenlabs", "mistr
def _tts_ready() -> bool:
"""Can the configured TTS provider run (or install at first use)?
PROBE, not an installer: ``check_tts_requirements`` lazily pip-installs the
provider SDK, which froze wake.status polls for a whole pip run (a failed
install unmounted the desktop ear). Uninstalled deps count as ready iff
lazy installs are allowed; pip is never touched from here.
PROBE, not an installer: ``check_tts_requirements`` lazily pip-installs the provider
SDK, which froze wake.status polls for a whole pip run. Uninstalled deps count as
ready iff lazy installs are allowed; pip is never touched from here.
"""
try:
from tools.tts_tool import _get_provider, _load_tts_config
@@ -487,7 +433,6 @@ def _tts_ready() -> bool:
provider = _get_provider(_load_tts_config())
except Exception:
return False
feature = _LAZY_TTS_FEATURES.get(provider)
if feature is not None:
try:
@@ -497,7 +442,6 @@ def _tts_ready() -> bool:
return lazy_deps._allow_lazy_installs()
except Exception:
return False
try:
from tools.tts_tool import check_tts_requirements
@@ -515,41 +459,35 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s
feature = _PROVIDERS.get(provider, ("", "wake.openwakeword"))[1]
deps_ok = lazy_deps.is_available(feature)
lazy_ok = lazy_deps._allow_lazy_installs()
# The audio probe imports sounddevice + numpy — packages the lazy installer
# would fetch — so only trust it once deps are installed; on a fresh install
# the engine constructors' ``lazy_deps.ensure()`` + stream-open surface any
# real audio problem (gating on the probe made lazy install unreachable).
# The audio probe imports sounddevice + numpy — packages the lazy installer would
# fetch — so only trust it once deps are installed; on a fresh install the engine
# constructors' ``lazy_deps.ensure()`` + stream-open surface any real audio problem.
audio_ok = _audio_available() if deps_ok else False
key_ok = True
# Loop is wake → record → STT → agent → TTS; without either end the mic
# hears you and nothing perceptible happens — refuse with a hint.
stt_ok = _stt_ready()
tts_ok = _tts_ready()
hint = ""
# tflite needs a runtime openWakeWord doesn't declare off Linux; report it
# as a remediation instead of arming a detector that can't fire.
# Loop is wake → record → STT → agent → TTS; without either end the mic hears you
# and nothing perceptible happens — refuse with a hint.
stt_ok, tts_ok = _stt_ready(), _tts_ready()
# tflite needs a runtime openWakeWord doesn't declare off Linux; report it as a
# remediation instead of arming a detector that can't fire.
tflite_ok = True
if feature == "wake.openwakeword" and resolve_inference_framework(cfg) == "tflite":
tflite_ok = ensure_tflite_runtime() or lazy_deps.is_available("wake.openwakeword.tflite") or lazy_ok
if provider == "porcupine" and not (os.getenv("PORCUPINE_ACCESS_KEY") or "").strip():
key_ok = False
hint = "Set PORCUPINE_ACCESS_KEY (free key at https://console.picovoice.ai)."
elif not deps_ok and not lazy_ok:
hint = lazy_deps.feature_install_command(feature) or ""
elif not tflite_ok:
hint = "The wake word needs the tflite runtime on this Mac: pip install ai-edge-litert"
elif deps_ok and not audio_ok and resolve_capture_mode(cfg) == "local":
hint = "Microphone capture needs sounddevice + numpy and a working audio device."
elif not stt_ok or not tts_ok:
missing = " and ".join(
name for name, ok in (("speech-to-text", stt_ok), ("text-to-speech", tts_ok)) if not ok
)
hint = (f"Wake word needs {missing} configured — run `hermes tools` "
f"(Voice section) or see the voice-mode docs.")
key_ok = provider != "porcupine" or bool((os.getenv("PORCUPINE_ACCESS_KEY") or "").strip())
capture_mode = resolve_capture_mode(cfg)
missing = " and ".join(n for n, ok in (("speech-to-text", stt_ok), ("text-to-speech", tts_ok)) if not ok)
# Ordered remediation ladder: first true predicate wins.
ladder = (
(not key_ok, lambda: "Set PORCUPINE_ACCESS_KEY (free key at https://console.picovoice.ai)."),
(not deps_ok and not lazy_ok, lambda: lazy_deps.feature_install_command(feature) or ""),
(not tflite_ok,
lambda: "The wake word needs the tflite runtime on this Mac: pip install ai-edge-litert"),
(deps_ok and not audio_ok and capture_mode == "local",
lambda: "Microphone capture needs sounddevice + numpy and a working audio device."),
(bool(missing), lambda: (f"Wake word needs {missing} configured — run `hermes tools` "
f"(Voice section) or see the voice-mode docs.")),
)
hint = next((make() for cond, make in ladder if cond), "")
# Client capture needs deps (engine) but not a server-side PortAudio device.
if capture_mode == "client":
mic_ok = deps_ok or lazy_ok
@@ -590,8 +528,7 @@ class _Capture:
frame_length: int = 1280 # samples per read at ``rate``
def read(self):
"""One raw block; None when no client frame arrived within 250 ms.
Stream errors propagate."""
"""One raw block; None when no client frame arrived within 250 ms. Stream errors propagate."""
if self.stream is not None:
return self.stream.read(self.frame_length)[0]
try:
@@ -611,8 +548,8 @@ class _Capture:
class WakeWordDetector:
"""Background hotword listener. Fires ``on_wake()`` when the phrase is heard.
The engine is built once and kept alive across pause/resume; only the audio
stream + reader thread cycle, so toggling the mic for a voice turn is cheap.
The engine is built once and kept alive across pause/resume; only the audio stream
+ reader thread cycle, so toggling the mic for a voice turn is cheap.
"""
def __init__(self, engine: _Engine, on_wake: Callable[[], None],
@@ -620,6 +557,8 @@ class WakeWordDetector:
on_failure: Optional[Callable[["WakeWordDetector"], None]] = None,
input_device: int | str | None = None,
external_audio: bool = False):
import queue as _queue
self.engine = engine
self.on_wake = on_wake
self.cooldown = cooldown
@@ -628,8 +567,7 @@ class WakeWordDetector:
self.external_audio = bool(external_audio)
self.input_device_details: Dict[str, Any] = (
{"selector": "client", "name": "client capture", "hostapi": "remote"}
if self.external_audio
else {"selector": input_device}
if self.external_audio else {"selector": input_device}
)
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
@@ -637,11 +575,9 @@ class WakeWordDetector:
self._last_fire = 0.0
self._lock = threading.Lock()
# Client-capture PCM queue (int16 mono frames). Local mode ignores this.
import queue as _queue
self._audio_q: "_queue.Queue[Any]" = _queue.Queue(maxsize=64)
# True when the stream is open but every frame is (near-)silence, so
# status surfaces can tell "armed" from "deaf".
# True when the stream is open but every frame is (near-)silence, so status
# surfaces can tell "armed" from "deaf".
self.audio_silent = False
self._silent_frames = 0
@@ -653,8 +589,8 @@ class WakeWordDetector:
def feed(self, pcm_int16) -> None:
"""Enqueue one int16 mono frame (or raw bytes) for client capture.
Short frames are zero-padded to ``engine.frame_length``; long frames are
split. On queue overflow the oldest frame is dropped to stay real-time.
Short frames are zero-padded to ``engine.frame_length``; long frames are split.
On queue overflow the oldest frame is dropped to stay real-time.
"""
if not self.external_audio:
return
@@ -692,12 +628,8 @@ class WakeWordDetector:
self._stop.clear()
ready = threading.Event()
startup_errors: list[BaseException] = []
self._thread = threading.Thread(
target=self._run,
args=(ready, startup_errors),
daemon=True,
name="wake-word",
)
self._thread = threading.Thread(target=self._run, args=(ready, startup_errors),
daemon=True, name="wake-word")
self._thread.start()
if not ready.wait(_START_TIMEOUT_SECONDS):
self._halt_thread()
@@ -737,16 +669,13 @@ class WakeWordDetector:
def _open_capture(self, frame_length: int) -> _Capture:
"""Open the audio source; raises on any local-mic failure."""
if self.external_audio:
# Drain any stale frames from a previous arm.
try:
try: # drain stale frames from a previous arm
while True:
self._audio_q.get_nowait()
except Exception:
pass
logger.info(
"wake word: client-capture mode (frame=%d, rate=%d) — waiting for wake.feed",
frame_length, SAMPLE_RATE,
)
logger.info("wake word: client-capture mode (frame=%d, rate=%d) — waiting for wake.feed",
frame_length, SAMPLE_RATE)
return _Capture(queue=self._audio_q, frame_length=frame_length)
try:
@@ -754,28 +683,18 @@ class WakeWordDetector:
except (ImportError, OSError) as e:
logger.error("wake word: audio libraries unavailable: %s", e)
raise
self.input_device_details = _describe_input_device(sd, self.input_device)
cap = _Capture(np=np, rate=_capture_sample_rate(self.input_device_details))
details = self.input_device_details = _describe_input_device(sd, self.input_device)
cap = _Capture(np=np, rate=_capture_sample_rate(details))
cap.frame_length = max(1, int(round(frame_length * cap.rate / SAMPLE_RATE)))
logger.info(
"wake word: opening microphone device=%s selector=%r hostapi=%s "
"default_rate=%s capture_rate=%d engine_rate=%d",
self.input_device_details.get("name") or "system default",
self.input_device,
self.input_device_details.get("hostapi") or "unknown",
self.input_device_details.get("default_samplerate") or "unknown",
cap.rate,
SAMPLE_RATE,
details.get("name") or "system default", self.input_device, details.get("hostapi") or "unknown",
details.get("default_samplerate") or "unknown", cap.rate, SAMPLE_RATE,
)
try:
cap.stream = sd.InputStream(
device=self.input_device,
samplerate=cap.rate,
channels=1,
dtype="int16",
blocksize=cap.frame_length,
)
cap.stream = sd.InputStream(device=self.input_device, samplerate=cap.rate, channels=1,
dtype="int16", blocksize=cap.frame_length)
cap.stream.start()
except Exception as e:
logger.error("wake word: failed to open microphone: %s", e)
@@ -792,11 +711,8 @@ class WakeWordDetector:
self._silent_frames += 1
if self._silent_frames == silent_alert_frames:
self.audio_silent = True
logger.warning(
"wake word: mic delivers only silence (peak<=%d for %ds); %s",
_SILENCE_PEAK, _SILENCE_ALERT_SECONDS,
silent_audio_hint(self.input_device_details),
)
logger.warning("wake word: mic delivers only silence (peak<=%d for %ds); %s", _SILENCE_PEAK,
_SILENCE_ALERT_SECONDS, silent_audio_hint(self.input_device_details))
elif self._silent_frames:
if self.audio_silent:
logger.info("wake word: mic audio detected — stream healthy")
@@ -815,8 +731,7 @@ class WakeWordDetector:
self._callback_inflight.set()
threading.Thread(target=self._dispatch_wake, daemon=True, name="wake-word-callback").start()
def _run(self, ready: threading.Event,
startup_errors: list[BaseException]) -> None:
def _run(self, ready: threading.Event, startup_errors: list[BaseException]) -> None:
frame_length = self.engine.frame_length
try:
cap = self._open_capture(frame_length)
@@ -824,15 +739,12 @@ class WakeWordDetector:
startup_errors.append(e)
ready.set()
return
# Drop buffered audio/feature state so a resume right after a voice turn
# can't re-fire on audio captured before the pause (the wake → voice →
# resume → wake runaway loop).
# Drop buffered audio/feature state so a resume right after a voice turn can't
# re-fire on audio captured before the pause (wake → voice → resume → wake loop).
try:
self.engine.reset()
except Exception:
pass
logger.info("wake word: listening (frame=%d, rate=%d, external=%s)",
frame_length, SAMPLE_RATE, self.external_audio)
ready.set()
@@ -846,8 +758,7 @@ class WakeWordDetector:
logger.warning("wake word: stream read error: %s", e)
failed = not self._stop.is_set()
break
if data is None:
# No client frames yet — count as silence for status.
if data is None: # no client frames yet — counts as silence for status
self._silent_frames += 1
if self._silent_frames == silent_alert_frames:
self.audio_silent = True
@@ -930,9 +841,7 @@ def _clear_singleton_locked() -> tuple[Optional[WakeWordDetector], Any]:
"""Forget the armed detector (caller holds ``_detector_lock``); returns (detector, lock handle)."""
global _detector, _detector_owner, _detector_file_lock
det, handle = _detector, _detector_file_lock
_detector = None
_detector_owner = None
_detector_file_lock = None
_detector = _detector_owner = _detector_file_lock = None
return det, handle
@@ -953,18 +862,13 @@ def _detector_failed(detector: WakeWordDetector) -> None:
_release_machine_lock(lock_handle)
def start_listening(
on_wake: Callable[[], None],
*,
owner: object,
config: Optional[Dict[str, Any]] = None,
external_audio: bool = False,
) -> WakeWordDetector:
def start_listening(on_wake: Callable[[], None], *, owner: object, config: Optional[Dict[str, Any]] = None,
external_audio: bool = False) -> WakeWordDetector:
"""Claim, build, and start the detector. Idempotent for the same owner.
Raises if engine construction fails (missing deps / access key / model);
callers should probe :func:`check_wake_word_requirements` first. A different
owner, including another process, receives :class:`WakeWordInUse`.
Raises if engine construction fails (missing deps / access key / model); callers
should probe :func:`check_wake_word_requirements` first. A different owner,
including another process, receives :class:`WakeWordInUse`.
"""
if owner is None:
raise ValueError("wake-word owner must not be None")
@@ -980,17 +884,9 @@ def start_listening(
lock_handle = _acquire_machine_lock()
try:
cfg = config if config is not None else load_wake_word_config()
engine = _build_engine(cfg)
detector = WakeWordDetector(
engine,
on_wake,
on_failure=_detector_failed,
input_device=_input_device(cfg),
external_audio=external_audio,
)
_detector = detector
_detector_owner = owner
_detector_file_lock = lock_handle
detector = WakeWordDetector(_build_engine(cfg), on_wake, on_failure=_detector_failed,
input_device=_input_device(cfg), external_audio=external_audio)
_detector, _detector_owner, _detector_file_lock = detector, owner, lock_handle
detector.start()
return detector
except Exception:
@@ -1052,11 +948,8 @@ def is_listening() -> bool:
def audio_is_silent() -> bool:
"""True when the armed stream has delivered only silence (dead mic).
The stream opens fine but every frame is zeros, so detection can never
fire; status surfaces show "listening but the microphone appears silent".
"""
"""True when the armed stream opens fine but delivers only silence (dead mic), so
detection can never fire; status shows "listening but the microphone appears silent"."""
det = _current_detector()
return det is not None and det.audio_silent
@@ -1066,7 +959,6 @@ def get_input_device_status(cfg: Optional[Dict[str, Any]] = None) -> Dict[str, A
det = _current_detector()
if det is not None:
return dict(det.input_device_details)
cfg = cfg if cfg is not None else load_wake_word_config()
selector = _input_device(cfg)
try:
@@ -1077,17 +969,14 @@ def get_input_device_status(cfg: Optional[Dict[str, Any]] = None) -> Dict[str, A
def get_last_match() -> Optional[tuple[str, str]]:
"""(matched phrase, profile) of the most recent wake fire, if the engine
reports per-phrase matches (sherpa multi-profile routing). None otherwise."""
"""(matched phrase, profile) of the most recent wake fire when the engine reports
per-phrase matches (sherpa multi-profile routing); None otherwise."""
det = _current_detector()
return None if det is None else getattr(det.engine, "last_match", None)
def feed_audio(*, owner: object, pcm_int16) -> bool:
"""Push client-captured PCM into the armed detector (client capture mode).
Returns True when the frame was accepted for ``owner``'s armed detector.
"""
"""Push client-captured PCM into ``owner``'s armed detector; True when accepted."""
with _detector_lock:
det = _owned_detector(owner)
if det is None or not det.external_audio: