fix(wake): address review on client-capture re-arm and feed queue

- wake.status reports effective capture from the armed detector (client vs
  local), plus frame_length/sample_rate; GUI status probes prefer client
- Gateway test doubles accept external_audio on start_listening
- Desktop PCM feeder uses a bounded ordered queue instead of dropping frames
  while a wake.feed RPC is in flight
- /wake on and status/re-arm paths pass client_capture so remote reattach works
This commit is contained in:
Andrew 2026-07-31 00:48:51 -07:00 committed by Brooklyn Nicholson
parent 105fbf6b7d
commit d401c27edf
5 changed files with 104 additions and 23 deletions

View File

@ -659,7 +659,10 @@ export function useSlashCommand(deps: SlashCommandDeps) {
}
const status = async (): Promise<WakeStatusResponse> => {
const current = await requestGateway<WakeStatusResponse>('wake.status', {})
const current = await requestGateway<WakeStatusResponse>('wake.status', {
client_capture: true,
surface: 'gui'
})
applyWakeStatus(current)
return current
@ -677,7 +680,7 @@ export function useSlashCommand(deps: SlashCommandDeps) {
if (action === 'on') {
const started = await requestGateway<WakeStartResponse>(
'wake.start',
{ persist: true, surface: 'gui' },
{ persist: true, surface: 'gui', client_capture: true },
WAKE_START_TIMEOUT_MS
)

View File

@ -108,26 +108,55 @@ export async function startClientWakeCapture(
let pending = new Float32Array(0)
let stopped = false
let inflight = false
// Bounded ordered queue of 16 kHz frames. We never drop the frame that is
// currently being sent; under remote latency we drop the oldest queued
// frames so the detector still sees contiguous recent PCM rather than gaps
// from fire-and-forget discard-while-inflight.
const MAX_QUEUED_FRAMES = 24 // ~1.9s at 80 ms/frame
const queue: Float32Array[] = []
let draining = false
const pushFrame = async (frame: Float32Array) => {
if (stopped || inflight) {
const drainQueue = async () => {
if (draining) {
return
}
inflight = true
draining = 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)))
while (!stopped && queue.length > 0) {
const frame = queue.shift()
if (!frame) {
break
}
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)))
// Keep draining later frames; one failed RPC should not freeze the ear.
}
}
} finally {
inflight = false
draining = false
if (!stopped && queue.length > 0) {
void drainQueue()
}
}
}
const enqueueFrame = (frame: Float32Array) => {
if (stopped) {
return
}
queue.push(frame)
while (queue.length > MAX_QUEUED_FRAMES) {
queue.shift()
}
void drainQueue()
}
processor.onaudioprocess = event => {
if (stopped) {
return
@ -142,7 +171,7 @@ export async function startClientWakeCapture(
while (offset + frameLength <= merged.length) {
const frame = merged.subarray(offset, offset + frameLength)
offset += frameLength
void pushFrame(new Float32Array(frame))
enqueueFrame(new Float32Array(frame))
}
pending = merged.subarray(offset)
}
@ -164,6 +193,7 @@ export async function startClientWakeCapture(
return
}
stopped = true
queue.length = 0
try {
processor.disconnect()
source.disconnect()

View File

@ -89,6 +89,7 @@ export interface WakeStatusResponse {
configured_surface?: string
/** Config truth (wake_word.enabled) — drives post-voice re-arm. */
enabled?: boolean
frame_length?: number
hint?: string
input_device?: WakeInputDeviceStatus
listening?: boolean
@ -97,6 +98,7 @@ export interface WakeStatusResponse {
owner_surface?: string | null
phrase?: string
provider?: string
sample_rate?: number
}
export interface WakeStartResponse {
@ -249,10 +251,24 @@ export function applyWakeStopResult(result: WakeStopResponse | null | undefined)
*/
export async function armWakeWord(request: WakeRequester = gatewayRequester): Promise<void> {
try {
const status = await request<WakeStatusResponse>('wake.status', {})
const status = await request<WakeStatusResponse>('wake.status', {
client_capture: true,
surface: 'gui'
})
applyWakeStatus(status)
if (!status?.available || status.listening) {
// Armed already (e.g. another surface/restart) — reattach feeder if client.
if (status?.listening) {
const mode = (status.capture || '').toLowerCase()
if (mode === 'client' || mode === 'remote' || mode === 'external') {
void maybeStartClientCapture({
started: true,
capture: 'client',
frame_length: status.frame_length ?? 1280
})
}
}
return
}
@ -330,7 +346,10 @@ export async function resumeWakeAfterVoice(request: WakeRequester = gatewayReque
for (let attempt = 0; attempt < 3; attempt++) {
try {
const status = await request<WakeStatusResponse>('wake.status', {})
const status = await request<WakeStatusResponse>('wake.status', {
client_capture: true,
surface: 'gui'
})
applyWakeStatus(status)
// Config says off (or the feature can't run) — off is the correct rest
@ -347,7 +366,7 @@ export async function resumeWakeAfterVoice(request: WakeRequester = gatewayReque
void maybeStartClientCapture({
started: true,
capture: 'client',
frame_length: 1280
frame_length: status.frame_length ?? 1280
})
}
return

View File

@ -1473,10 +1473,15 @@ def test_wake_owner_is_sticky_and_routes_detection_to_first_transport(monkeypatc
state = {"owner": None, "callback": None, "paused": False}
voice_callbacks = {}
def start_listening(callback, *, owner, config):
def start_listening(callback, *, owner, config, external_audio=False):
if state["owner"] is not None and state["owner"] is not owner:
raise wake_word.WakeWordInUse
state.update(owner=owner, callback=callback, paused=False)
state.update(
owner=owner,
callback=callback,
paused=False,
external_audio=bool(external_audio),
)
def pause_listening(*, owner):
if state["owner"] is not owner:
@ -1670,7 +1675,9 @@ def test_wake_toggle_persists_enabled_flag_only_on_explicit_gesture(monkeypatch)
listener = {"owner": None}
monkeypatch.setattr(
wake_word, "start_listening",
lambda callback, *, owner, config: listener.update(owner=owner),
lambda callback, *, owner, config, external_audio=False: listener.update(
owner=owner, external_audio=bool(external_audio)
),
)
monkeypatch.setattr(
wake_word, "stop_listening",

View File

@ -13312,14 +13312,22 @@ def _(rid, params: dict) -> dict:
from tools.wake_word import (
audio_is_silent,
check_wake_word_requirements,
detector_frame_info,
get_input_device_status,
is_listening,
load_wake_word_config,
owns_listener,
resolve_capture_mode,
silent_audio_hint,
)
cfg = load_wake_word_config()
reqs = check_wake_word_requirements(cfg)
# Prefer client when the GUI asks (desktop remote re-arm / status).
prefer_client = bool(params.get("client_capture")) or str(
params.get("surface") or ""
).strip().lower() in ("gui", "desktop")
probe_cfg = dict(cfg)
probe_cfg["capture"] = resolve_capture_mode(cfg, prefer_client=prefer_client)
reqs = check_wake_word_requirements(probe_cfg)
transport = current_transport() or _stdio_transport
owner, owner_surface = _wake_owner_snapshot()
owned_by_caller = owns_listener(transport)
@ -13331,7 +13339,19 @@ 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")
# Effective capture: prefer the *armed* detector over config/auto.
# With capture:auto the GUI arms client mode, but a bare status probe
# would otherwise report "local" and the desktop would not reattach
# the PCM feeder after wake.detected.
frame = detector_frame_info()
if owned_by_caller and frame.get("external_audio"):
capture = "client"
elif owned_by_caller and listening:
capture = "local"
else:
capture = probe_cfg.get("capture") or reqs.get("capture") or str(
cfg.get("capture") or "auto"
)
return _ok(rid, {
"listening": listening,
"owned_by_caller": owned_by_caller,
@ -13349,6 +13369,8 @@ def _(rid, params: dict) -> dict:
"audio_silent": silent,
"capture": capture,
"local_input_available": bool(reqs.get("local_input_available")),
"sample_rate": frame.get("sample_rate", 16000),
"frame_length": frame.get("frame_length", 1280),
})
except Exception as e:
return _err(rid, 5026, str(e))