Repository navigation
Description
Summary
Two related symptoms reported together on the Chess Coder (technical/coding Coder instance) in the console voice UI:
- Status pill shows "Speaking · tap to stop" but no audio is heard.
- In hands-free mode it never ends — it hangs in the speaking state forever instead of returning to listening and the mic never reopens.
Both symptoms share one root cause: the speaking state is entered but never exited, because the internal promise that signals playback completion is awaited on an event that is never fired.
Repro
- Open the Chess Coder instance in the console.
- Switch to hands-free mode.
- Send a message. Wait for a reply.
- Observe: pill shows "Speaking · tap to stop", no audio plays, mode never returns to "Listening…".
- Also reproducible in tap-to-talk/auto-speak mode: pill stays "Speaking · tap to stop" permanently.
Root-cause analysis
1. AudioBufferSourceNode.onerror is not handled — the playback promise never resolves
packages/sdk/src/voice/tts.ts:339-342:
await new Promise<void>((resolve) => {
source.onended = () => resolve();
source.start();
});This Promise has exactly one resolution path: source.onended. There is no source.onerror handler and no timeout. The Web Audio AudioBufferSourceNode can emit an error event (e.g. the decode succeeded but the output device is lost, suspended, or the audio session is interrupted mid-playback on iOS). That event goes unhandled — it does not reject the Promise and does not call resolve(). The Promise hangs indefinitely.
The outer try/catch at tts.ts:344 only catches thrown exceptions propagating from await; an error event on the source node does not become a rejected Promise.
Effect: _speakOpenAI never returns → speak() never exits its try block → the finally at tts.ts:178-182 never runs → this.speaking stays true forever.
2. use-voice.ts speakAndResume sets setSpeaking(true) and waits for tts.speak() to return
packages/sdk/src/voice/use-voice.ts:809-833:
const speakAndResume = useCallback(async (text: string) => {
...
setSpeaking(true);
try {
const tts = await ensureTts();
await tts.speak(text); // <-- hangs here forever
} catch {}
setSpeaking(false); // <-- never reached
speakEndedAtRef.current = Date.now();
setPaused(false);
if (convoOnRef.current) {
await startListening(); // <-- never reached
}
}, ...);Because tts.speak() hangs, setSpeaking(false) and startListening() are never called. In hands-free mode this means:
- The React
speakingstate staystrue→resolveVoiceStatusinconvo.ts:141returns{ label: "Speaking · tap to stop", ... }permanently. pausedForThinkingRefstaystrue→canOpenMic()returnsfalse→ the mic never reopens → the conversation dies.
3. No speaking watchdog exists
The transcription path has TRANSCRIBE_WATCHDOG_MS (use-voice.ts:70) that force-ends a stuck transcription. There is no equivalent for the speaking state. Any hang in tts.speak() — from this bug or any future one — produces a permanent stuck state with no recovery path short of a page reload.
4. Technical cleanForSpeech narrows the window but doesn't cause it
For a technical agent (Coder/Repo Chat), cleanForSpeech(text, { technical: true }) keeps identifiers and file basenames but strips fenced code, URLs, and git hashes. A reply composed mostly of code fences and links could reduce to (code) a link (code) or similar — non-empty, passes the if (!clean) return guard at tts.ts:161, and enters the full OpenAI TTS proxy path. Under normal conditions the proxy returns valid audio. The onerror bug is the trigger regardless of content, but a near-empty cleaned string may compound it (some TTS providers handle trivial inputs differently).
The if (!text?.trim()) return guard at tts.ts:159 and if (!clean) return at tts.ts:161 both correctly short-circuit before setting speaking = true, so an empty-string case does NOT hang. The hang only occurs when a non-empty cleaned string enters _speakOpenAI and source.onended never fires.
5. Verification via durable error log
TTS failures log to client:voice-tts (readable via MCP list_errors for this instance). Pulling those logs for the Chess Coder instance at the time of the report will confirm which branch fired — a non-OK response, an empty body, a context state fault, or a source.onerror event (which currently leaves no log entry, confirming the missing handler). The absence of a voice-tts log row while the pill is stuck would be diagnostic of the onerror path.
Affected files
packages/sdk/src/voice/tts.ts— lines 339-342 (missingsource.onerror) and lines 178-183 (thefinallythat never runs)packages/sdk/src/voice/use-voice.ts— lines 809-833 (speakAndResume), lines 777-806 (speak); no speaking watchdog anywhere
Acceptance criteria
-
source.onerrorhandled:_speakOpenAI's playbackPromiseresolves (not hangs) on anyAudioBufferSourceNodeerror event, the same way_speakBrowserusesu.onerror = finish. Logging the event toclient:voice-ttsis preferred over silent recovery so the audio session loss is observable. - Speaking watchdog added: a timeout (suggested ceiling: 120 s or
maxChars * 80 ms + 5 000 ms, mirroring the formula used by_speakBrowserattts.ts:274) fires iftts.speak()has not returned, callstts.cancel(), setssetSpeaking(false),setPaused(false), and (in hands-free mode) callsstartListening(). The watchdog is cleared whentts.speak()returns normally. - Consistent behaviour across paths:
speak()(manual tap-to-hear) andspeakAndResume()(auto-speak + hands-free) both use the watchdog — neither can strand the speaking state. - Unit test: a test in
tts.test.tscovering the case where abufferSourcefires anerrorevent instead ofended— verifies thattts.speakingisfalseafter the call, nottrueforever. (ThehangingSynthhelper in the existing test file (tts.test.ts:182) is the pattern to follow.) - No regression on the existing TTS test suite: the iOS
AudioContext "interrupted"recovery tests and the global-exclusivity tests continue to pass. - After fix: switching the Chess Coder instance to hands-free mode and receiving a reply results in audio playback (or silent graceful fallback), followed by the status pill returning to "Listening…" and the mic reopening, within the watchdog deadline even if audio fails.
Labels / priority
Labels: bug, voice
Priority: high — the stuck speaking state completely disables hands-free mode for any agent whose TTS hits this path; the Coder agent is the primary use case for hands-free (coding by voice).
Activity