Skip to content

fix(huddle-bridge): key "playing" and the cut on the voice's speech - #143

Merged
QuicksilverSlick merged 2 commits into
mainfrom
fix/bridge-silent-stream
Nov 6, 2026
Merged

QuicksilverSlick merged 2 commits into
mainfrom
fix/bridge-silent-stream

Conversation

@QuicksilverSlick

Copy link
Copy Markdown
Owner

Factory-Task: huddle-voices/bridge-silent-stream
Built-by: claude-code/claude-opus-5-5
Undo: revert the merge. The bridge goes back to treating any PCM as the voice playing, with 200 ms barge-in on headphones and a 300 ms desktop echo tail. Nothing is stored, and the frames v1 contract is unchanged; only its description text moves.

No desktop launch check: nothing under desktop/ changed. The crate is linked by buzz-acp's voice session, which Talos doesn't feed yet (Talos main has no /df/voice PCM sender as of 5 Nov). So the first live run of this fix is the first real Talos voice in a huddle, or the probe kit pointed at this branch.

Root cause (5 Nov live test, probe step 1)

GPT-Live (gpt-live-1) streams audio deltas all session, silence between answers included. The bridge treated any PCM as the voice playing (Output::until, from the last frame sent plus anything queued). So the first sound on Russell's mic counted as talking over the voice and cut it about 200 ms in. A cut released only after 300 ms with no PCM at all, which never came, so every answer for the next 69 s was dropped. The same mistake also kept Input's half-duplex window open for the whole session, so on speakers the voice would never have heard anyone. frames.rs and schema.json said Talos sends PCM "only during a response, none between responses". That isn't true for this model.

What changes

All in crates/buzz-huddle-bridge. These are the design's "silent-stream fix" (§6, "Before any voice's switch goes on"), tuning included:

  • Output keeps each frame's level. Only frames at the voice's speech level (Barge::voice_dbov, -45 dBov) count toward until(), which drives barge-in's "playing" check and Input's half-duplex window. Silence after the last speech, sent or queued, doesn't count.
  • Barge-in holds through the voice's pauses. A pause in the voice's speech shorter than release_ms (300 ms) is inside one answer, so a talker's run carries across the voice's phrases. Without this, a voice that paused for more than about 100 ms between phrases reset the run, and on speakers it could not be cut at all.
  • A cut releases on speech, not on PCM. While cut, the bridge still resamples what arrives, so it can tell when the voice stops speaking, but sends none of it. After release_ms with no speech-level frame, the cut ends. A newer floor token still ends it at once. Either way, the under-a-frame leftover of the cut response is flushed.
  • Barge-in takes 320 ms on every route. It was 200 ms on the owner's isolated desktop (headphones). In the 1:1 runs, any sound at the mic at -45 dBov for 200 ms, such as a cough or a chair, could cut a real answer. On headphones GPT-Live also hears a real interruption and stops itself.
  • The desktop's echo tail is 600 ms (Tuning::desktop_tail_ms, was 300). Step 6 heard the voice come back 361-508 ms after it played. With "playing" keyed on speech, Input's echo window now closes at every pause inside an answer, not only at its end, so a 300 ms tail would have leaked echo at every pause of 300 ms or more.
  • Silence still goes to the room, where Opus DTX shrinks it to a byte or two. The one exception is a backlog. A stream that never stops never runs dry, so a burst would have left about 300 ms of extra delay for the rest of the session, and the overflow warning would never have fired again. Now, once 2 frames are queued, a silent frame that follows a silent frame is dropped, so a backlog drains through the voice's silence. A lone quiet frame inside speech is kept. The overflow warning re-arms once the backlog is down to a frame.
  • Two lines, not one. The voice's speech line is its own knob (Barge::voice_dbov), apart from the people's line (Barge::speech_dbov, which stays equal to Tuning::speech_dbov). Both are -45 dBov. The people's line can now move without changing what counts as the voice speaking.
  • Docs. frames.rs and the schema.json description now say GPT-Live streams all session and that only speech-level PCM counts. Barge and Tuning explain the thresholds.

This fixes it at the root for every caller. The probe's own fix (keep sub-speech frames out of the bridge, probe commit e9de1c46f) is no longer needed, nor is PROBE.md's note that Talos has to do the same.

Thresholds

  • The voice's speech: -45 dBov per 20 ms frame. This is the line the probe used for step 1's re-run (turns of 1.4 to 1.5 s, 85 speech frames, 0 cuts). That evidence is thin: it was 5 one-word answers, with no one talking over them. The voice's PCM comes straight from GPT-Live, not through a mic, so room noise isn't in it. GPT-Live's silence level was never measured. The tests assume about -57 dBov. A voice softer than -45 counts as not playing, so it can't be cut.
  • People: unchanged at -45 dBov. People's speech starts ran -30 to -45 dBov in steps 1 and 6. The -24 to -29 starts in step 6 were the voice's own clips coming back. 19 of the 59 starts read -44 or -45, right on the line, which is most likely Russell's room noise crossing it. Before this fix, that noise could cut the silent stream at any moment, and the cut never let go. Now it counts only while the voice is speaking. On speakers, the echo threshold (about -17 dBov while the voice plays at -23) keeps it out. Raising the people's line means moving Tuning::speech_dbov with it, which is a calibration change of its own and isn't made here.
  • release_ms: kept at 300 ms. The next answer starts 0.5 to 0.7 s after a person stops (5 Nov), so a longer wait would swallow it.

Tests

These three failed on main before the fix:

  • a_live_models_silence_is_not_the_voice_playing is the field failure, run for an isolated route and for speakers. On main, silence held the echo window open, the question cut the silent stream 190 ms in, and the answer that followed sent 0 of its 50 frames. Now there is no cut and all 50 are sent.
  • talking_over_a_live_answer_still_cuts_it_and_drops_the_rest: a question over silence, then an answer, then the owner talks over it. The cut comes after 320 ms of the owner's energy, and none of the 2 s that follows plays. On main, the question itself was cut.
  • after_a_cut_the_streamed_silence_ends_it_and_the_next_answer_plays: a 200 ms pause holds the cut, a 400 ms one ends it, and the next answer sends all 50 frames. On main, the silence held the cut.

Added after review:

  • a_talker_cuts_an_answer_that_pauses_between_phrases: phrases of 250 ms with 150 ms pauses, talked over on headphones and on speakers. Both cut at 320 ms. Without the pause hold, neither cuts.
  • a_soft_answer_plays_and_cuts_and_a_cut_leaves_no_scrap: unaligned 30 ms deltas of silence, and a voice at about -40 dBov, between the two lines. It also covers a cut answer's leftover under a frame, after the release and after a newer token.
  • a_backlog_drains_through_the_streamed_silence: a 400 ms burst fills the queue; 1 s of streamed silence drains it and re-arms the warning; a lone quiet frame inside speech still plays.
  • half_duplex_unless_the_desktop_here_is_isolated now also runs the desktop on speakers, so it pins the 600 ms tail.

A mutation check covered 14 changes: removing each new branch or reverting each new value. 13 were caught. The one that survived lowers the voice's line to -50, which the tests' -57 silence can't tell apart.

cargo test -p buzz-huddle-bridge passes: 43 unit tests and the 4 loopback tests. buzz-acp's 12 voice_session tests pass against it. cargo clippy -p buzz-huddle-bridge --all-targets -- -D warnings is clean, and so is cargo fmt --check.

Known limits

  • A cut answer comes back after its next pause of 300 ms or more, on every route. GPT-Live never stops sending, so speech level is the only end the bridge can see. On headphones GPT-Live hears the interruption and should stop itself. On speakers it doesn't hear you (next item), so it goes on, and the rest of its answer plays after its next pause. A response-boundary frame would mean a v1 contract change in both repos.
  • On speakers, about the first second of what you say over a voice is lost. The 320 ms run that cuts it falls in Input's echo window. After the cut, Input holds your mic back for the 600 ms tail. So GPT-Live doesn't hear about 0.9 s of you, and it never hears a short interjection at all.
  • Steady noise at the people's line still chops answers on headphones. In the reviewer's run, noise at -45 dBov cut each phrase of an answer about 200 ms in. With 320 ms it cuts each about 320 ms in, after every pause of 300 ms or more. A single cough under 320 ms no longer cuts. The fix is raising the people's line above the room's noise, which is a calibration change of its own.
  • 600 ms may not cover all the echo. Step 6's echo came back as late as 508 ms, and the bridge's own decode delay comes on top. The design's step 6 re-run at 600 ms decides. Its fallback is full half-duplex on speakers.
  • All the huddle evidence comes from one-word answers. Every huddle answer on 5 Nov was 1-2 words. Answers with pauses between phrases have been run only in the tests here, not live.

Size: about 315 changed lines, about 180 of them tests.

🤖 Generated with Claude Code

QuicksilverSlick and others added 2 commits November 5, 2026 13:07
GPT-Live streams audio deltas all session, silence between answers
included. The bridge treated any PCM as the voice playing, so in the
5 Nov live test the first sound on Russell's mic cut a silent stream
about 200 ms in. A cut released only after 300 ms with no PCM, which
never came, so every answer for the next 69 s was dropped.

Output now keeps each frame's level. Only frames at speech level
(Barge::speech_dbov, -45 dBov) count toward until(), which drives
barge-in and Input's half-duplex window. A cut releases after
release_ms with no speech, so streamed silence ends it. Silence still
goes to the room, where Opus DTX makes it tiny. A real answer that a
person talks over is still cut, and the rest of it is dropped while it
keeps speaking. frames.rs and the schema description no longer say
Talos sends nothing between responses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Barge-in now counts the voice as playing through a pause shorter than
release_ms (300 ms), so a talker's run carries across the voice's
phrases. Keyed on speech alone, a voice that paused over ~100 ms reset
the run and could not be cut on speakers.

The voice's speech line is its own knob, Barge::voice_dbov (-45, as
before), apart from the people's line, so either can move alone.

A stream that never stops never runs dry, so a burst's backlog lasted
the session. Silence after silence is now dropped once two frames are
queued, so a backlog drains through the voice's silence; a lone quiet
frame inside speech is kept. The overflow warning re-arms once the
backlog is down to a frame.

Barge-in on the isolated desktop takes 320 ms, as on speakers, and the
desktop's echo tail is 600 ms: probe step 6 heard the voice come back
361-508 ms after it played, and with "playing" keyed on speech the
echo window now closes at every pause, not only at an answer's end.

New tests cover phrases with pauses on both routes, a soft voice
between the two lines, unaligned deltas, a cut answer's scrap under a
frame (after the release and after a newer token), the backlog, and
the desktop's speaker tail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@QuicksilverSlick QuicksilverSlick added the lane:ready Builder is done: green, agent-reviewed, exercised. The steward puts it in the next train. label Nov 6, 2026
@QuicksilverSlick
QuicksilverSlick merged commit de1c106 into main Nov 6, 2026
65 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lane:ready Builder is done: green, agent-reviewed, exercised. The steward puts it in the next train.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant