feat(wake): client-capture wake word for remote desktop

Remote headless backends have no PortAudio mic, so "hey hermes" fails even
when openWakeWord is installed. Let the desktop stream 16 kHz int16 PCM via
wake.feed while detection stays server-side.

- wake_word.capture: auto|local|client (+ GUI client_capture prefer)
- WakeWordDetector external_audio queue + feed_audio API
- wake.feed RPC; wake.start/status report capture + frame_length
- Desktop getUserMedia feeder; stop on wake.detected, re-arm after voice
- Docs + unit tests (26 pass in tests/tools/test_wake_word.py)
This commit is contained in:
Andrew 2026-07-30 23:10:09 -07:00 committed by Brooklyn Nicholson
parent ee7c614eef
commit 105fbf6b7d
8 changed files with 698 additions and 53 deletions

View File

@ -66,7 +66,7 @@ import {
setMessages
} from '@/store/session'
import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos'
import { armWakeWord } from '@/store/wake-word'
import { armWakeWord, stopClientCapture } from '@/store/wake-word'
import { isSecondaryWindow } from '@/store/windows'
import { useSkinCommand } from '@/themes/use-skin-command'
@ -689,6 +689,10 @@ export function ContribWiring({ children }: { children: ReactNode }) {
if (event.type === 'wake.detected') {
const payload = event.payload as { profile?: null | string; start_new_session?: boolean } | undefined
// Free the Mac mic so voice conversation can open getUserMedia.
// Server already pauses the detector lease; this stops client PCM feed.
stopClientCapture()
// Audible confirmation that the wake registered, before voice capture
// starts. Gated by the shared sound-mute toggle.
playWakeSound()

View File

@ -0,0 +1,178 @@
/**
* Client-side mic capture for remote wake word.
*
* When the backend arms with `capture: "client"`, PortAudio runs on a headless
* VM with no mic. The desktop opens getUserMedia here, resamples to 16 kHz
* mono int16 frames, and pushes them via `wake.feed` so openWakeWord still
* runs server-side without requiring a server sound device.
*/
const TARGET_RATE = 16_000
const DEFAULT_FRAME = 1280 // 80 ms @ 16 kHz — matches tools/wake_word.py
export type WakeFeedRequester = (
method: string,
params?: Record<string, unknown>
) => Promise<unknown>
export interface ClientWakeCaptureOptions {
/** Samples per frame at 16 kHz (from wake.start response). */
frameLength?: number
request: WakeFeedRequester
onError?: (error: Error) => void
}
export interface ClientWakeCaptureHandle {
stop: () => void
readonly active: boolean
}
function downsampleTo16k(input: Float32Array, inputRate: number): Float32Array {
if (inputRate === TARGET_RATE) {
return input
}
if (inputRate <= 0) {
return new Float32Array(0)
}
const ratio = inputRate / TARGET_RATE
const outLen = Math.max(1, Math.floor(input.length / ratio))
const out = new Float32Array(outLen)
for (let i = 0; i < outLen; i++) {
const start = Math.floor(i * ratio)
const end = Math.min(input.length, Math.floor((i + 1) * ratio))
let sum = 0
let count = 0
for (let j = start; j < end; j++) {
sum += input[j] ?? 0
count++
}
out[i] = count > 0 ? sum / count : 0
}
return out
}
function floatToInt16LE(input: Float32Array): ArrayBuffer {
const buf = new ArrayBuffer(input.length * 2)
const view = new DataView(buf)
for (let i = 0; i < input.length; i++) {
const s = Math.max(-1, Math.min(1, input[i] ?? 0))
view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true)
}
return buf
}
function bytesToBase64(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf)
let binary = ''
const chunk = 0x8000
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk))
}
return btoa(binary)
}
/**
* Start streaming the default microphone to `wake.feed`.
* Returns a handle whose `stop()` ends tracks + audio graph.
*/
export async function startClientWakeCapture(
options: ClientWakeCaptureOptions
): Promise<ClientWakeCaptureHandle> {
const frameLength = Math.max(160, Math.trunc(options.frameLength || DEFAULT_FRAME))
const audioWindow = window as Window & { webkitAudioContext?: typeof AudioContext }
const AudioContextCtor = window.AudioContext || audioWindow.webkitAudioContext
if (!AudioContextCtor) {
throw new Error('AudioContext unavailable for client wake capture')
}
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error('getUserMedia unavailable for client wake capture')
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
},
video: false
})
const context = new AudioContextCtor()
const source = context.createMediaStreamSource(stream)
// ScriptProcessor is deprecated but widely available and simple for PCM export.
// Buffer size 4096 keeps callback rate reasonable on desktop.
const processor = context.createScriptProcessor(4096, 1, 1)
const mute = context.createGain()
mute.gain.value = 0
let pending = new Float32Array(0)
let stopped = false
let inflight = false
const pushFrame = async (frame: Float32Array) => {
if (stopped || inflight) {
return
}
inflight = true
try {
const pcm = floatToInt16LE(frame)
await options.request('wake.feed', {
pcm: bytesToBase64(pcm),
sample_rate: TARGET_RATE
})
} catch (error) {
options.onError?.(error instanceof Error ? error : new Error(String(error)))
} finally {
inflight = false
}
}
processor.onaudioprocess = event => {
if (stopped) {
return
}
const input = event.inputBuffer.getChannelData(0)
const at16k = downsampleTo16k(input, context.sampleRate)
// Append to pending and emit full frames
const merged = new Float32Array(pending.length + at16k.length)
merged.set(pending, 0)
merged.set(at16k, pending.length)
let offset = 0
while (offset + frameLength <= merged.length) {
const frame = merged.subarray(offset, offset + frameLength)
offset += frameLength
void pushFrame(new Float32Array(frame))
}
pending = merged.subarray(offset)
}
source.connect(processor)
processor.connect(mute)
mute.connect(context.destination)
if (context.state === 'suspended') {
await context.resume().catch(() => undefined)
}
return {
get active() {
return !stopped
},
stop() {
if (stopped) {
return
}
stopped = true
try {
processor.disconnect()
source.disconnect()
mute.disconnect()
} catch {
// ignore
}
void context.close().catch(() => undefined)
stream.getTracks().forEach(t => t.stop())
}
}
}

View File

@ -1,6 +1,10 @@
import { atom } from 'nanostores'
import { $gateway } from '@/store/gateway'
import {
startClientWakeCapture,
type ClientWakeCaptureHandle
} from '@/lib/wake-client-capture'
// "Hey Hermes" wake-word listener state for the composer toggle. The gateway is
// the single source of truth (the listener lives in the backend and is shared
@ -33,16 +37,62 @@ const INITIAL_WAKE_WORD_STATE: WakeWordState = {
export const $wakeWord = atom<WakeWordState>(INITIAL_WAKE_WORD_STATE)
/** Active client mic stream for remote wake (capture: client). */
let clientCapture: ClientWakeCaptureHandle | null = null
/** Stop client-side PCM capture (also called on wake.detected before voice). */
export function stopClientCapture(): void {
clientCapture?.stop()
clientCapture = null
}
async function maybeStartClientCapture(result: WakeStartResponse | null | undefined): Promise<void> {
stopClientCapture()
if (!result?.started) {
return
}
const mode = (result.capture || '').toLowerCase()
if (mode !== 'client' && mode !== 'remote' && mode !== 'external') {
return
}
try {
clientCapture = await startClientWakeCapture({
frameLength: result.frame_length,
request: gatewayRequester
})
} catch (error) {
const current = $wakeWord.get()
$wakeWord.set({
...current,
listening: false,
notice:
error instanceof Error
? error.message
: 'Failed to open the client microphone for wake word',
pending: false
})
// Best-effort: release server lease if client mic failed.
try {
await gatewayRequester('wake.stop', {})
} catch {
// ignore
}
}
}
export interface WakeStatusResponse {
/** Armed but the selected backend input delivers only silence. */
audio_silent?: boolean
available?: boolean
/** local | client | auto — where PCM is captured. */
capture?: string
configured_surface?: string
/** Config truth (wake_word.enabled) — drives post-voice re-arm. */
enabled?: boolean
hint?: string
input_device?: WakeInputDeviceStatus
listening?: boolean
local_input_available?: boolean
owned_by_caller?: boolean
owner_surface?: string | null
phrase?: string
@ -50,12 +100,15 @@ export interface WakeStatusResponse {
}
export interface WakeStartResponse {
capture?: string
enabled_persisted?: boolean
frame_length?: number
hint?: string
owner_surface?: string | null
phrase?: string
provider?: string
reason?: string
sample_rate?: number
started?: boolean
}
@ -152,10 +205,13 @@ export function applyWakeStartResult(result: WakeStartResponse | null | undefine
pending: false,
phrase: result.phrase?.trim() || current.phrase
})
void maybeStartClientCapture(result)
return
}
stopClientCapture()
$wakeWord.set({
...current,
// The backend probes requirements on start; an explicit "unavailable"
@ -174,6 +230,7 @@ export function applyWakeStartResult(result: WakeStartResponse | null | undefine
export function applyWakeStopResult(result: WakeStopResponse | null | undefined): void {
const current = $wakeWord.get()
stopClientCapture()
$wakeWord.set({
...current,
enabled: result?.disabled_persisted ? false : current.enabled,
@ -199,7 +256,10 @@ export async function armWakeWord(request: WakeRequester = gatewayRequester): Pr
return
}
const result = await request<WakeStartResponse>('wake.start', { surface: 'gui' })
const result = await request<WakeStartResponse>('wake.start', {
surface: 'gui',
client_capture: true
})
applyWakeStartResult(result)
} catch {
// Older backends / transient failures — keep whatever we last knew.
@ -229,7 +289,13 @@ export async function toggleWakeWord(request: WakeRequester = gatewayRequester):
// persist: true — a deliberate click is consent, so the backend flips
// wake_word.enabled in config.yaml (on/off) and the choice sticks for
// future sessions. Auto-arm (armWakeWord) never passes it.
applyWakeStartResult(await request<WakeStartResponse>('wake.start', { persist: true, surface: 'gui' }))
applyWakeStartResult(
await request<WakeStartResponse>('wake.start', {
persist: true,
surface: 'gui',
client_capture: true
})
)
}
} catch (error) {
const current = $wakeWord.get()
@ -274,10 +340,23 @@ export async function resumeWakeAfterVoice(request: WakeRequester = gatewayReque
}
if (status.listening) {
// Server lease is still armed (e.g. wake.resume after voice).
// Client PCM was stopped on wake.detected — reattach if needed.
const mode = (status.capture || '').toLowerCase()
if (mode === 'client' || mode === 'remote' || mode === 'external') {
void maybeStartClientCapture({
started: true,
capture: 'client',
frame_length: 1280
})
}
return
}
const started = await request<WakeStartResponse>('wake.start', { surface: 'gui' })
const started = await request<WakeStartResponse>('wake.start', {
surface: 'gui',
client_capture: true
})
applyWakeStartResult(started)
if (started?.started) {
@ -298,5 +377,6 @@ export async function resumeWakeAfterVoice(request: WakeRequester = gatewayReque
/** Test-only reset. */
export function resetWakeWordState(): void {
stopClientCapture()
$wakeWord.set(INITIAL_WAKE_WORD_STATE)
}

View File

@ -1577,6 +1577,7 @@ DEFAULT_CONFIG = {
"enabled": False,
"surface": "auto", # eligible surface: "auto" (first claimant) | "cli" | "tui" | "gui"
"input_device": None, # PortAudio input device index/name; null uses the process default
"capture": "auto", # auto | local | client — where PCM is captured (client = desktop streams mic via wake.feed)
"provider": "openwakeword", # "openwakeword" (free, local) | "sherpa" (free, ANY phrase, no training) | "porcupine" (premium; needs PORCUPINE_ACCESS_KEY)
"phrase": "hey hermes", # for "sherpa" this IS the detected phrase (any text works); for other engines it's a cosmetic label — detection is keyed by the model/keyword below
"sensitivity": 0.6, # 0.0-1.0 detection threshold, consistent across engines (higher = stricter, fewer false triggers)

View File

@ -171,12 +171,14 @@ def test_requirements_fresh_install_lazy_allowed(monkeypatch):
def test_requirements_deps_present_but_no_audio_hint(monkeypatch):
"""Once deps ARE installed, a failing audio probe blocks with a mic hint
(lazy installs can't fix a missing audio device)."""
_voice_loop_ready(monkeypatch)
monkeypatch.setattr(ww, "_audio_available", lambda: False)
monkeypatch.setattr(ww, "_local_input_device_ready", lambda: False)
monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True)
monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True)
r = ww.check_wake_word_requirements({"provider": "openwakeword"})
r = ww.check_wake_word_requirements({"provider": "openwakeword", "capture": "local"})
assert r["available"] is False
assert "audio device" in r["hint"]
assert "audio device" in r["hint"] or "microphone" in r["hint"].lower()
# ── openWakeWord engine (bundled model + base-model fetch) ───────────────
@ -624,3 +626,85 @@ def test_machine_lock_is_released_when_owner_process_exits(tmp_path):
if process.is_alive():
process.terminate()
process.join(10)
# ── Client capture (remote desktop mic → wake.feed) ──────────────────────
def test_resolve_capture_mode_auto_and_prefer_client(monkeypatch):
monkeypatch.setattr(ww, "_local_input_device_ready", lambda: False)
# auto without prefer_client stays local (CLI/TUI/status semantics)
assert ww.resolve_capture_mode({"capture": "auto"}) == "local"
assert ww.resolve_capture_mode({"capture": "auto"}, prefer_client=True) == "client"
assert ww.resolve_capture_mode({"capture": "local"}, prefer_client=True) == "local"
assert ww.resolve_capture_mode({"capture": "client"}) == "client"
assert ww.resolve_capture_mode({"capture": "auto"}, force_local=True) == "local"
monkeypatch.setattr(ww, "_local_input_device_ready", lambda: True)
assert ww.resolve_capture_mode({"capture": "auto"}) == "local"
assert ww.resolve_capture_mode({"capture": "auto"}, prefer_client=True) == "client"
def test_requirements_client_capture_without_local_mic(monkeypatch):
monkeypatch.setattr(ww, "_audio_available", lambda: False)
monkeypatch.setattr(ww, "_local_input_device_ready", lambda: False)
monkeypatch.setattr(ww, "_stt_ready", lambda: True)
monkeypatch.setattr(ww, "_tts_ready", lambda: True)
class _LD:
@staticmethod
def is_available(feature):
return True
@staticmethod
def _allow_lazy_installs():
return False
@staticmethod
def feature_install_command(feature):
return ""
monkeypatch.setattr(ww, "lazy_deps", _LD, raising=False)
import tools.lazy_deps as real_ld
monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True)
monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: False)
reqs = ww.check_wake_word_requirements({"capture": "client", "provider": "openwakeword"})
assert reqs["available"] is True
assert reqs["capture"] == "client"
def test_client_capture_feed_fires(monkeypatch, tmp_path):
import numpy as np
monkeypatch.setattr(ww, "_build_engine", lambda cfg: _FakeEngine(fire=True))
monkeypatch.setattr(ww, "_lock_path", lambda: tmp_path / "wake.lock")
# External mode must not import sounddevice
monkeypatch.setattr(
ww,
"_import_audio",
lambda: (_ for _ in ()).throw(OSError("no local mic")),
)
owner = object()
fired = threading.Event()
def _on_wake():
fired.set()
ww.start_listening(_on_wake, owner=owner, config={}, external_audio=True)
assert ww.is_listening() is True
info = ww.detector_frame_info()
fl = int(info["frame_length"])
# Non-silent frame so silence flag does not dominate
pcm = (np.ones(fl, dtype=np.int16) * 5000).tobytes()
assert ww.feed_audio(owner=owner, pcm_int16=pcm) is True
assert fired.wait(2.0)
assert ww.stop_listening(owner=owner) is True
def test_feed_audio_rejects_wrong_owner(monkeypatch, tmp_path):
monkeypatch.setattr(ww, "_build_engine", lambda cfg: _FakeEngine(fire=False))
monkeypatch.setattr(ww, "_lock_path", lambda: tmp_path / "wake.lock")
owner = object()
ww.start_listening(lambda: None, owner=owner, config={}, external_audio=True)
assert ww.feed_audio(owner=object(), pcm_int16=b"\x00\x00") is False
assert ww.stop_listening(owner=owner) is True

View File

@ -76,6 +76,11 @@ _DEFAULTS: Dict[str, Any] = {
"enabled": False,
"surface": "auto",
"input_device": None,
# Where PCM is captured:
# "local" — PortAudio on the backend host (historic default)
# "client" — desktop/TUI streams int16 frames via wake.feed
# "auto" — local when a device exists, else client capture
"capture": "auto",
"provider": "openwakeword",
"phrase": "hey hermes",
"sensitivity": 0.6,
@ -244,6 +249,68 @@ 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:
"""Return ``local`` or ``client`` capture mode for this arm.
``prefer_client`` is set by remote desktop (Mac mic, headless backend).
``force_local`` keeps CLI/TUI on the process mic. Config ``capture`` is
``auto`` | ``local`` | ``client``.
"""
cfg = cfg if cfg is not None else load_wake_word_config()
if force_local:
return "local"
raw = str(_get(cfg, "capture") or "auto").strip().lower()
if raw in ("client", "remote", "external"):
return "client"
if raw == "local":
return "local"
# auto
if prefer_client:
return "client"
# Prefer local when a PortAudio input exists. Without an explicit client
# preference (desktop remote), stay on local so CLI/TUI/status still
# require a server mic instead of advertising a capture path only the
# desktop can feed.
if _local_input_device_ready():
return "local"
return "local"
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()
except Exception:
return False
if isinstance(devices, dict):
return int(devices.get("max_input_channels") or 0) > 0
try:
for dev in devices:
channels = dev.get("max_input_channels") if isinstance(dev, dict) else None
if channels is None:
channels = getattr(dev, "max_input_channels", 0)
if int(channels or 0) > 0:
return True
except Exception:
return False
# Also accept a resolvable default input (some hosts list devices oddly).
try:
info = sd.query_devices(None, "input")
channels = info.get("max_input_channels") if isinstance(info, dict) else 0
return int(channels or 0) > 0
except Exception:
return False
def wake_surface_enabled(surface: str, cfg: Optional[Dict[str, Any]] = None) -> bool:
"""Should ``surface`` (``cli`` / ``tui`` / ``gui``) host the listener?
@ -838,7 +905,7 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s
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:
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(
@ -847,12 +914,31 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s
hint = (f"Wake word needs {missing} configured — run `hermes tools` "
f"(Voice section) or see the voice-mode docs.")
capture_mode = resolve_capture_mode(cfg)
local_input_ok = _local_input_device_ready() if deps_ok else False
# Client capture needs deps (engine) but not a server-side PortAudio device.
if capture_mode == "client":
mic_ok = deps_ok or (not deps_ok and lazy_ok)
if deps_ok and not hint:
# No server mic required; clear the local-device hint if that was set.
if hint.startswith("Microphone capture needs"):
hint = ""
else:
mic_ok = (deps_ok and audio_ok) or (not deps_ok and lazy_ok)
if deps_ok and not audio_ok and not hint:
hint = (
"No local microphone on this backend. Remote desktop can stream "
"the client mic — set wake_word.capture: client or use a desktop "
"build with client-capture wake support."
)
return {
"available": key_ok and stt_ok and tts_ok and tflite_ok
and ((deps_ok and audio_ok) or (not deps_ok and lazy_ok)),
"available": key_ok and stt_ok and tts_ok and tflite_ok and mic_ok,
"provider": provider,
"deps_available": deps_ok,
"audio_available": audio_ok,
"local_input_available": local_input_ok,
"capture": capture_mode,
"access_key_set": key_ok,
"stt_available": stt_ok,
"tts_available": tts_ok,
@ -875,18 +961,28 @@ class WakeWordDetector:
def __init__(self, engine: _Engine, on_wake: Callable[[], None],
cooldown: float = _FIRE_COOLDOWN_SECONDS,
on_failure: Optional[Callable[["WakeWordDetector"], None]] = None,
input_device: int | str | None = None):
input_device: int | str | None = None,
external_audio: bool = False):
self.engine = engine
self.on_wake = on_wake
self.cooldown = cooldown
self.on_failure = on_failure
self.input_device = input_device
self.input_device_details: Dict[str, Any] = {"selector": input_device}
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}
)
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
self._callback_inflight = threading.Event()
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.
# Surfaced via wake.status / /wake status so users can tell "armed"
# from "deaf".
@ -898,8 +994,50 @@ class WakeWordDetector:
t = self._thread
return t is not None and t.is_alive()
def feed(self, pcm_int16) -> None:
"""Enqueue one int16 mono frame (or raw bytes) for client capture.
Frame length should match ``engine.frame_length`` (typically 1280 samples
at 16 kHz). Short frames are zero-padded; long frames are split.
"""
if not self.external_audio:
return
try:
import numpy as np
except Exception:
return
if isinstance(pcm_int16, (bytes, bytearray, memoryview)):
arr = np.frombuffer(pcm_int16, dtype=np.int16)
else:
arr = np.asarray(pcm_int16, dtype=np.int16).reshape(-1)
fl = int(self.engine.frame_length)
if fl <= 0:
return
# Split / pad into engine frames
offset = 0
n = int(arr.shape[0])
while offset < n:
chunk = arr[offset : offset + fl]
offset += fl
if chunk.shape[0] < fl:
pad = np.zeros(fl, dtype=np.int16)
pad[: chunk.shape[0]] = chunk
chunk = pad
try:
self._audio_q.put_nowait(chunk)
except Exception:
# Drop oldest on overflow so we stay real-time
try:
self._audio_q.get_nowait()
except Exception:
pass
try:
self._audio_q.put_nowait(chunk)
except Exception:
pass
def start(self) -> None:
"""Open the mic and begin listening. Idempotent."""
"""Open the mic (or client feeder) and begin listening. Idempotent."""
with self._lock:
if self._thread is not None and self._thread.is_alive():
return
@ -950,39 +1088,53 @@ class WakeWordDetector:
def _run(self, ready: threading.Event,
startup_errors: list[BaseException]) -> None:
try:
sd, _ = _import_audio()
except (ImportError, OSError) as e:
logger.error("wake word: audio libraries unavailable: %s", e)
startup_errors.append(e)
ready.set()
return
frame_length = self.engine.frame_length
self.input_device_details = _describe_input_device(sd, self.input_device)
logger.info(
"wake word: opening microphone device=%s selector=%r hostapi=%s "
"default_rate=%s requested_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",
SAMPLE_RATE,
)
try:
stream = sd.InputStream(
device=self.input_device,
samplerate=SAMPLE_RATE,
channels=1,
dtype="int16",
blocksize=frame_length,
stream = None
if self.external_audio:
# Drain any stale frames from a previous arm.
try:
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,
)
stream.start()
except Exception as e:
logger.error("wake word: failed to open microphone: %s", e)
startup_errors.append(e)
ready.set()
return
else:
try:
sd, _ = _import_audio()
except (ImportError, OSError) as e:
logger.error("wake word: audio libraries unavailable: %s", e)
startup_errors.append(e)
ready.set()
return
self.input_device_details = _describe_input_device(sd, self.input_device)
logger.info(
"wake word: opening microphone device=%s selector=%r hostapi=%s "
"default_rate=%s requested_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",
SAMPLE_RATE,
)
try:
stream = sd.InputStream(
device=self.input_device,
samplerate=SAMPLE_RATE,
channels=1,
dtype="int16",
blocksize=frame_length,
)
stream.start()
except Exception as e:
logger.error("wake word: failed to open microphone: %s", e)
startup_errors.append(e)
ready.set()
return
# Drop any buffered audio/feature state so a resume right after a voice
# turn can't immediately re-fire on audio captured before the pause (the
@ -992,7 +1144,8 @@ class WakeWordDetector:
except Exception:
pass
logger.info("wake word: listening (frame=%d, rate=%d)", frame_length, SAMPLE_RATE)
logger.info("wake word: listening (frame=%d, rate=%d, external=%s)",
frame_length, SAMPLE_RATE, self.external_audio)
ready.set()
failed = False
# ~seconds of consecutive near-zero frames before we flag the stream
@ -1001,7 +1154,18 @@ class WakeWordDetector:
try:
while not self._stop.is_set():
try:
data, _overflow = stream.read(frame_length)
if self.external_audio:
try:
frame = self._audio_q.get(timeout=0.25)
except Exception:
# No client frames yet — count as silence for status.
self._silent_frames += 1
if self._silent_frames == silent_alert_frames:
self.audio_silent = True
continue
data = frame
else:
data, _overflow = stream.read(frame_length)
except Exception as e:
logger.warning("wake word: stream read error: %s", e)
failed = not self._stop.is_set()
@ -1045,11 +1209,12 @@ class WakeWordDetector:
else:
logger.debug("wake word: detection within cooldown — ignored")
finally:
try:
stream.stop()
stream.close()
except Exception:
pass
if stream is not None:
try:
stream.stop()
stream.close()
except Exception:
pass
logger.info("wake word: stream closed")
if failed and self.on_failure is not None:
self.on_failure(self)
@ -1136,6 +1301,7 @@ def start_listening(
*,
owner: object,
config: Optional[Dict[str, Any]] = None,
external_audio: bool = False,
) -> WakeWordDetector:
"""Claim, build, and start the detector. Idempotent for the same owner.
@ -1163,6 +1329,7 @@ def start_listening(
on_wake,
on_failure=_detector_failed,
input_device=_input_device(cfg),
external_audio=external_audio,
)
_detector = detector
_detector_owner = owner
@ -1265,3 +1432,31 @@ def get_last_match() -> Optional[tuple[str, str]]:
if det is None:
return None
return 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.
"""
with _detector_lock:
if _detector is None or _detector_owner is not owner:
return False
if not _detector.external_audio:
return False
det = _detector
det.feed(pcm_int16)
return True
def detector_frame_info() -> Dict[str, Any]:
"""Sample rate + frame length for client capture streamers."""
with _detector_lock:
det = _detector
if det is None:
return {"sample_rate": SAMPLE_RATE, "frame_length": 1280}
return {
"sample_rate": SAMPLE_RATE,
"frame_length": int(getattr(det.engine, "frame_length", 1280) or 1280),
"external_audio": bool(det.external_audio),
}

View File

@ -13119,8 +13119,10 @@ def _(rid, params: dict) -> dict:
from tools.wake_word import (
WakeWordInUse,
check_wake_word_requirements,
detector_frame_info,
load_wake_word_config,
owns_listener,
resolve_capture_mode,
start_listening,
wake_phrase,
wake_surface_enabled,
@ -13129,16 +13131,25 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5026, f"wake module unavailable: {e}")
cfg = load_wake_word_config()
# Desktop remote (gui) prefers client capture: Mac mic → wake.feed PCM,
# while the engine still runs on the backend. CLI/TUI stay local.
prefer_client = surface in ("gui", "desktop") or bool(params.get("client_capture"))
capture_mode = resolve_capture_mode(cfg, prefer_client=prefer_client)
external_audio = capture_mode == "client"
# Requirements first: a gesture on an unarmed-able setup (no STT/TTS, no
# mic, missing key) must refuse WITHOUT flipping wake_word.enabled — else
# config says on while nothing can ever arm, and auto-arm paths churn.
reqs = check_wake_word_requirements(cfg)
# Temporarily stamp capture so the probe matches the arm mode.
probe_cfg = dict(cfg)
probe_cfg["capture"] = capture_mode
reqs = check_wake_word_requirements(probe_cfg)
if not reqs["available"]:
logger.warning("wake.start(%s): not available — %s", surface, reqs.get("hint"))
return _ok(rid, {
"started": False,
"reason": "unavailable",
"hint": reqs.get("hint") or "",
"capture": capture_mode,
})
enabled_persisted = False
if persist and not cfg.get("enabled"):
@ -13202,7 +13213,12 @@ def _(rid, params: dict) -> dict:
reset_transport(token)
try:
start_listening(_on_detect, owner=transport, config=cfg)
start_listening(
_on_detect,
owner=transport,
config=cfg,
external_audio=external_audio,
)
except WakeWordInUse:
return _ok(rid, {
"started": False,
@ -13216,13 +13232,20 @@ def _(rid, params: dict) -> dict:
with _wake_lock:
_wake_owner_transport = transport
_wake_owner_surface = surface
logger.info("wake.start(%s): listening for %r (%s)", surface, reqs["phrase"], reqs["provider"])
frame = detector_frame_info()
logger.info(
"wake.start(%s): listening for %r (%s) capture=%s frame=%s",
surface, reqs["phrase"], reqs["provider"], capture_mode, frame.get("frame_length"),
)
return _ok(rid, {
"started": True,
"phrase": reqs["phrase"],
"provider": reqs["provider"],
"owner_surface": surface,
"enabled_persisted": enabled_persisted,
"capture": capture_mode,
"sample_rate": frame.get("sample_rate", 16000),
"frame_length": frame.get("frame_length", 1280),
})
@ -13308,6 +13331,7 @@ def _(rid, params: dict) -> dict:
hint = f"Wake-word input device could not be resolved: {input_device['error']}"
if silent and not hint:
hint = silent_audio_hint(input_device)
capture = reqs.get("capture") or str(cfg.get("capture") or "auto")
return _ok(rid, {
"listening": listening,
"owned_by_caller": owned_by_caller,
@ -13323,11 +13347,52 @@ def _(rid, params: dict) -> dict:
"enabled": bool(cfg.get("enabled")),
# Armed but deaf despite an open stream; see platform-specific hint.
"audio_silent": silent,
"capture": capture,
"local_input_available": bool(reqs.get("local_input_available")),
})
except Exception as e:
return _err(rid, 5026, str(e))
@method("wake.feed")
def _(rid, params: dict) -> dict:
"""Push client-captured PCM into the armed wake detector.
Params:
pcm: base64-encoded int16 mono little-endian samples (preferred), OR
pcm_b64: alias of pcm
Optional:
sample_rate: must be 16000 (ignored if missing; mismatched rates rejected)
Used when ``wake.start`` returned ``capture: "client"`` so remote backends
without a microphone can still run openWakeWord on Mac/desktop audio.
"""
transport = current_transport() or _stdio_transport
raw_b64 = params.get("pcm") or params.get("pcm_b64") or ""
if not isinstance(raw_b64, str) or not raw_b64.strip():
return _err(rid, 4001, "wake.feed requires base64 pcm")
try:
import base64
pcm = base64.b64decode(raw_b64, validate=False)
except Exception as e:
return _err(rid, 4001, f"invalid base64 pcm: {e}")
if not pcm:
return _ok(rid, {"fed": False, "reason": "empty"})
# Soft size cap (~0.5s of 16kHz int16 mono = 16000 bytes)
if len(pcm) > 64000:
return _err(rid, 4001, "pcm frame too large")
sr = params.get("sample_rate")
if sr is not None and int(sr) not in (0, 16000):
return _err(rid, 4001, "wake.feed only accepts 16 kHz PCM")
try:
from tools.wake_word import feed_audio
ok = feed_audio(owner=transport, pcm_int16=pcm)
except Exception as e:
logger.debug("wake.feed failed: %s", e)
return _err(rid, 5026, str(e))
return _ok(rid, {"fed": bool(ok), "reason": None if ok else "not_owner"})
@method("voice.toggle")
def _(rid, params: dict) -> dict:
"""CLI parity for the ``/voice`` slash command.

View File

@ -35,6 +35,43 @@ spoken command ends the conversation instead of being sent to the agent. Only a
whole-utterance stop command matches, so a real request like "stop the docker
container" still goes through normally.
## Remote desktop (client capture)
When the desktop app connects to a **remote** Hermes backend (for example a
headless Docker host or a machine in another room), the backend often has **no
microphone**. Server-side PortAudio then fails with “Failed to open the
wake-word microphone.”
Hermes supports **client capture** for that case:
1. The desktop arms wake with `capture: client` (automatic for the GUI when the
backend has no local input device, or set explicitly below).
2. openWakeWord still runs **on the backend** (same engines, same models).
3. The desktop opens the **local Mac/PC microphone**, resamples to 16 kHz mono
int16, and streams short frames via the `wake.feed` RPC.
4. On detection the backend emits `wake.detected` as usual; the desktop starts
the normal voice pipeline on the client mic.
```yaml
wake_word:
enabled: true
capture: auto # auto | local | client
# auto — local PortAudio unless the desktop arms with client_capture
# local — always open the backend mic (CLI/TUI default)
# client — always expect wake.feed PCM from the desktop (remote-friendly)
```
The desktop GUI always passes `client_capture: true` on `wake.start`, so remote
backends without a mic arm in client mode automatically. CLI and TUI keep local
capture unless you set `capture: client` explicitly.
Privacy note: with client capture, wake PCM travels over the authenticated
desktop↔backend WebSocket (same channel as the rest of the session). Detection
still does not send audio to third-party wake APIs; the engine is local to the
backend process.
## Engines
| Engine | Cost | API key | Notes |
@ -82,6 +119,7 @@ wake_word:
enabled: false
surface: auto # eligible surface: "auto" | "cli" | "tui" | "gui"
input_device: null # PortAudio input index or device-name substring; null = process default
capture: auto # auto | local | client — where PCM is captured (see Remote desktop)
provider: openwakeword # "openwakeword" (free, local) | "sherpa" (free, any phrase) | "porcupine"
phrase: "hey hermes" # cosmetic label only — detection is keyed by the model/keyword below
sensitivity: 0.6 # 0.0-1.0 — higher = stricter (fewer false triggers), consistent across all engines