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>
Wire framing and sample rate (Critical jaredrhod#1 + jaredrhod#2): send_reply() now takes an iterable of (rate, pcm) tuples -- synth_stream's own native yield shape -- instead of bare PCM plus a separate source_rate argument, and resamples each chunk from its own rate. That removes the eager-rate-capture bug structurally: the old call read rate_holder BEFORE the generator had yielded anything, so the captured TTS rate was always discarded and 24000 assumed (silently fine on Kokoro, garbled the moment ElevenLabs' 44100 is enabled). speak_reply's satellite path now makes exactly ONE send_reply call per TURN, wrapping a single async generator that drives ask_stream + synth_stream together, so a multi-sentence reply is one audio-start / chunks / audio-stop envelope as the spec requires, while still streaming incrementally. The error apology now rides inside that same envelope instead of opening a second one. speak_reply is split into a local branch (restored byte-for-byte to the pre-satellite code, stage directions and all) and a satellite branch, so the two paths can't drift into each other. Turn-lock leak (Critical jaredrhod#3): handle() acquired the lock near the top but the only release lived in speak_reply's finally, so every early return that never created a speak_task -- a console verb, "staying as we are", a permission-gate or confirm answer, a quit phrase -- leaked it forever, locking every satellite out. The body is now inside try/finally, releasing unless a fresh speak_task actually took ownership. Signal bus (Important jaredrhod#2): a satellite turn never touches mouth.py's playback worker, the only thing that moved the bus off "thinking". The satellite path now publishes speaking when audio starts going out and idle + reply_done on every exit: completion, write failure, cancellation and the apology path. Write failure (Important jaredrhod#3): one send_reply per turn means a False return stops synthesis outright; the generator is aclose()d immediately and nothing further -- not even an apology -- is written to a dead socket. Dropped connections (Important jaredrhod#4): start_server takes an optional on_disconnect callback (sync or async), and connections are registered at ACCEPT time rather than at first utterance, matching the spec's data flow. main.py uses it to cancel the turn of a satellite that vanishes while owning it, so the lock comes back through the normal finally. Wire robustness (Important jaredrhod#6): payload_length is capped at 1 MB per chunk and a buffered utterance at 60s of 16kHz audio; both log, discard the utterance and keep the connection open, matching the existing malformed-message contract on this unauthenticated listener. Minors: a non-dict JSON line (valid JSON, e.g. a bare number) no longer raises AttributeError out of the parser; an over-length line is skipped rather than fatal; SatelliteConnection grows a __repr__ so no log line can dump raw mic PCM, and handle()'s dropped-utterance log uses .name; stale "Task 6" docstring references corrected to Task 5. fake_satellite_client's _read_reply no longer returns at the first audio-stop -- which is why it could not see the framing bug -- and both tests now assert exactly one audio-start / audio-stop pair. The basic test takes an optional prompt so a multi-sentence reply can be exercised. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0195kZWBMbDGPwRf9Ke9EYuC
Wire framing and sample rate (Critical jaredrhod#1 + jaredrhod#2): send_reply() now takes an iterable of (rate, pcm) tuples -- synth_stream's own native yield shape -- instead of bare PCM plus a separate source_rate argument, and resamples each chunk from its own rate. That removes the eager-rate-capture bug structurally: the old call read rate_holder BEFORE the generator had yielded anything, so the captured TTS rate was always discarded and 24000 assumed (silently fine on Kokoro, garbled the moment ElevenLabs' 44100 is enabled). speak_reply's satellite path now makes exactly ONE send_reply call per TURN, wrapping a single async generator that drives ask_stream + synth_stream together, so a multi-sentence reply is one audio-start / chunks / audio-stop envelope as the spec requires, while still streaming incrementally. The error apology now rides inside that same envelope instead of opening a second one. speak_reply is split into a local branch (restored byte-for-byte to the pre-satellite code, stage directions and all) and a satellite branch, so the two paths can't drift into each other. Turn-lock leak (Critical jaredrhod#3): handle() acquired the lock near the top but the only release lived in speak_reply's finally, so every early return that never created a speak_task -- a console verb, "staying as we are", a permission-gate or confirm answer, a quit phrase -- leaked it forever, locking every satellite out. The body is now inside try/finally, releasing unless a fresh speak_task actually took ownership. Signal bus (Important jaredrhod#2): a satellite turn never touches mouth.py's playback worker, the only thing that moved the bus off "thinking". The satellite path now publishes speaking when audio starts going out and idle + reply_done on every exit: completion, write failure, cancellation and the apology path. Write failure (Important jaredrhod#3): one send_reply per turn means a False return stops synthesis outright; the generator is aclose()d immediately and nothing further -- not even an apology -- is written to a dead socket. Dropped connections (Important jaredrhod#4): start_server takes an optional on_disconnect callback (sync or async), and connections are registered at ACCEPT time rather than at first utterance, matching the spec's data flow. main.py uses it to cancel the turn of a satellite that vanishes while owning it, so the lock comes back through the normal finally. Wire robustness (Important jaredrhod#6): payload_length is capped at 1 MB per chunk and a buffered utterance at 60s of 16kHz audio; both log, discard the utterance and keep the connection open, matching the existing malformed-message contract on this unauthenticated listener. Minors: a non-dict JSON line (valid JSON, e.g. a bare number) no longer raises AttributeError out of the parser; an over-length line is skipped rather than fatal; SatelliteConnection grows a __repr__ so no log line can dump raw mic PCM, and handle()'s dropped-utterance log uses .name; stale "Task 6" docstring references corrected to Task 5. fake_satellite_client's _read_reply no longer returns at the first audio-stop -- which is why it could not see the framing bug -- and both tests now assert exactly one audio-start / audio-stop pair. The basic test takes an optional prompt so a multi-sentence reply can be exercised. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0195kZWBMbDGPwRf9Ke9EYuC
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