Skip to content

mouth: render chunks ahead of playback (no gap between lines); brain: print tool calls - #4

Closed
FryD420 wants to merge 8 commits into
jaredrhod:mainfrom
FryD420:lookahead-and-tool-lines
Closed

FryD420 wants to merge 8 commits into
jaredrhod:mainfrom
FryD420:lookahead-and-tool-lines

Conversation

@FryD420

@FryD420 FryD420 commented Aug 21, 2026

Copy link
Copy Markdown

Two quality-of-life changes from daily use of backtalk as a voice line for my agent.

1. Lookahead synthesis in the mouth

Mouth was 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-lived OutputStream, 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 the speaking flag and wait_done(), since the text queue is no longer the whole story once rendering runs ahead.

2. [tool] lines from the brain

ask_stream now logs one line per tool call from the AssistantMessage that lands as the call runs:

[tool] Read: E:\my-agent\backtalk\LICENSE
[tool] Bash: Check git remote and status
[tool] Grep: say_chunk in backtalk/

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.py drives the mouth with a fake synth (0.3 s to first audio, streamed blocks) and a fake real-time output device, no speakers needed:

  • boundary gaps under 80 ms (measured 0 to 1 ms; the old mouth would have been ~1 s)
  • a chunk shorter than the prebuffer still completes
  • barge-in cuts mid-chunk, plays nothing stale, and the mouth speaks fresh text afterwards

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.json if you would rather have it configurable.

🤖 Generated with Claude Code

FryD420 and others added 5 commits August 21, 2026 01:12
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>
@rhuitt

rhuitt commented Aug 23, 2026

Copy link
Copy Markdown

Independent confirmation from macOS (M1, 8 GB, Kokoro in-process, voice bm_lewis), where this bug is much bigger than the ~1 s described above.

Measurements on the unpatched mouth:

  • Kokoro's pipeline yields a chunk's audio as one segment, so synth_stream never actually streams — first PCM arrives only when the whole chunk is rendered, and the 0.75 s prebuffer never gets a head start.
  • Render cost is ~0.25–0.3× real-time here: ~0.5 s for a short sentence, ~2.3 s for a typical one, ~3.3 s for the 2-sentence batches speak_reply builds.
  • Because render and play were serial, every chunk boundary was dead air equal to the next chunk's full render. On a realistic 4-chunk reply (~40 s of speech) I measured gaps of 4.3 s, 3.6 s, 0.9 s with a fake real-time output device.

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 speaking/wait_done, render handed over before it fills). Same harness after the change: no measurable gap at any boundary, reply wall time 49.6 s → 40.6 s, time-to-first-audio unchanged (~1.5 s); barge-in cuts within one block, drops the rendered-ahead and in-flight chunks, and the next turn plays clean. Both audio laws intact.

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.

FryD420 and others added 3 commits August 23, 2026 14:00
…(.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>
@jaredrhod

Copy link
Copy Markdown
Owner

Closing this because #10 contains all eight of these commits plus the warm gate, so merging #10 takes this with it. Not a rejection, the lookahead work is going in, it is just travelling in the other PR. Thanks for both.

@jaredrhod jaredrhod closed this Aug 29, 2026
pinnaclewd-314159 added a commit to pinnaclewd-314159/backtalk that referenced this pull request Sep 16, 2026
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
pinnaclewd-314159 added a commit to pinnaclewd-314159/backtalk that referenced this pull request Sep 16, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants