mouth: render chunks ahead of playback (no gap between lines); brain: print tool calls - #4
mouth: render chunks ahead of playback (no gap between lines); brain: print tool calls#4FryD420 wants to merge 8 commits into
Conversation
The mouth was one thread doing render-then-play-then-render, so every chunk boundary paid synth latency plus the 0.75s prebuffer in series (about a second of dead air per boundary) while the text had long since reached the screen. Now a synth thread renders LOOKAHEAD (2) chunks ahead of the one playing; the next chunk's audio is already finished when the previous one ends and plays with no gap. Both audio laws hold: still one long-lived OutputStream, still the prebuffer (satisfied instantly for pre-rendered chunks). A generation counter ties the two threads together for barge-in: shut_up() bumps it and anything ordered under the old generation is dropped wherever it's found, so nothing stale plays; a pending counter replaces "queue empty" for the speaking flag and wait_done(), since the text queue stopped being the whole story. The brain now logs one line per tool call ([tool] Read: <path>, [tool] Bash: <description>, ...) from the AssistantMessage that lands as the call runs, so the terminal shows what the agent is doing while the voice is quiet instead of a silent thinking loop. tests/test_mouth_lookahead.py drives the mouth with a fake synth and a fake real-time output device: boundary gaps under 80ms (measured ~0-1ms), a sub-prebuffer chunk completes, barge-in plays nothing stale and the mouth speaks fresh text afterwards.
…he session The Agent SDK's stream-json reader defaults to 1 MB per message. Reading a 1080p screenshot (~4 MB PNG, ~5 MB base64 on the wire) exceeded it and crashed the voice session. 16 MB gives ~3x headroom over 1080p and covers 4K. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sh' verb When resume_last_session reattaches, the hidden warmup ping becomes a spoken turn: the agent says what was in flight and asks continue or start fresh. "start fresh" / "new session" / "start a new session" are added as synonyms for the clear verb so the answer is natural. Cold launches are unchanged. Docs and the spoken-console discipline text updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…other signal files Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…aunch right after a fresh start comes up cold Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Independent confirmation from macOS (M1, 8 GB, Kokoro in-process, voice Measurements on the unpatched mouth:
I wrote a lookahead fix before finding this PR and it converged on the same shape (synth thread + player thread, generation counter on barge-in, pending counter for So +1 on this PR — on CPU-only Macs it turns "statement… pause… statement" into continuous speech. Happy to share the harness or numbers if useful. |
…(.voice_activity, per tool call) so a face can tell thinking from dead Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ff-by-one) Background-task notifications (finished Bash jobs, Monitor events, timeouts) wake the model while the mic is quiet; its answer sits in the shared stream unread, and the next real question pairs with it — every reply one question late for the rest of the session. reset_turn can't catch it (_dirty is False: the turn wasn't ours). _drain_idle() pulls everything already buffered, non-blocking, logs the dropped text, and if a background turn is still mid-flight ask_stream waits (bounded, 30s) for its ResultMessage before sending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pasted paths, markup, and whole listing bodies were being read aloud (fences stripped to bare text by the old hygiene pass). _defence() splits chunks on ``` with fence state persisting across sentence chunks; the transcript keeps the full text, only the mouth mutes. Also: backtalk.json swaps the daily driver to claude-opus-5 with claude-fable-5 as the deep model (config is untracked; noted here). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two quality-of-life changes from daily use of backtalk as a voice line for my agent.
1. Lookahead synthesis in the mouth
Mouthwas one worker thread doing render, play, render, play. Every chunk boundary paid the synth latency plus the 0.75 s prebuffer in series, which on my machine is about a second of dead air between lines while the text had already streamed to the screen.This splits it into a synth thread and a player thread. The synth renders
LOOKAHEAD(2) chunks ahead of the one playing, so when a chunk ends, the next one's audio is already finished and starts with no gap. Both hard-won audio laws still hold: one long-livedOutputStream, and the prebuffer (which is satisfied instantly for chunks that finished rendering while the previous one played).Barge-in: a generation counter ties the two threads together.
shut_up()bumps it, and anything ordered under the old generation is dropped wherever it turns up (text queue, ready queue, mid-render, or in the player), so nothing stale ever plays. A pending counter replaces "queue empty" for thespeakingflag andwait_done(), since the text queue is no longer the whole story once rendering runs ahead.2.
[tool]lines from the brainask_streamnow logs one line per tool call from theAssistantMessagethat lands as the call runs:So the terminal shows what the agent is doing during a long quiet stretch instead of just the thinking sound.
Test
tests/test_mouth_lookahead.pydrives the mouth with a fake synth (0.3 s to first audio, streamed blocks) and a fake real-time output device, no speakers needed:Run with
.venv/Scripts/python tests/test_mouth_lookahead.py(or the posix equivalent). Also verified live: Kokoro voice, Windows 11, and the[tool]lines against a real session.Happy to adjust naming or fold the lookahead depth into
backtalk.jsonif you would rather have it configurable.🤖 Generated with Claude Code