Skip to content

Berd Call: clear stuck speech holds, show interim words, playback cancel fixes - #429

Merged
johnmatthewtennant merged 13 commits into
mainfrom
jtennant/henry/call-quick-wins
Oct 23, 2026
Merged

johnmatthewtennant merged 13 commits into
mainfrom
jtennant/henry/call-quick-wins

Conversation

@johnmatthewtennant

@johnmatthewtennant johnmatthewtennant commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Berd Call kept holding the agent's reply after the user had finished, and showed the user's words only once Apple finalized them. This collects the release-safe fixes from the call learning work, without its capture, replay or tuning infrastructure.

Stuck speech holds. The hold that keeps an agent from speaking over the user is released by Apple's finals, but Apple emits one final for a sentence that pauses split into several VAD turns. Resolving one turn per final left the rest outstanding, so in recent calls 55 of 57 holds ran the full 5 s timeout even though Apple had finished 0 to 4 s after the user stopped. Expiry also cancelled the recognizer, throwing away a transcript still on its way, and the hold lifted at the timeout while Apple was still transcribing, so a reply started and was then cut off by the late transcript.

  • Expiry releases only the hold. Recognition, its queued audio and the recognizer generation continue, so a late final still reaches the conversation.
  • Each final carries the end of its audio through the Swift bridge and resolves exactly the ended turns it covers. A final that arrives during the silence window, before its turn ends, resolves that turn when it ends. A final for an earlier phrase of a turn still being spoken resolves nothing.
  • The bridge reports Apple's interim results. While Apple's latest result is interim words, the hold lasts up to 10 s beyond the 5 s timeout, until a final follows. Noise with no interim words keeps 5 s.

Interim words. Conventional macOS sessions emit user_interim (--stream carries it as an ephemeral 0\tuser_interim row) with Apple's interim text, replaced by each update and cleared on its final, mute, input reset or restart. It is never stored, never takes a token and never interrupts output.

One VAD threshold. Listening and interruption during agent playback use the same speech probability, 0.9, instead of separate thresholds.

Playback and cancel fixes. A cancel wake could be lost between a playback worker's flag check and its wait, idle live playback did not wake on cancellation, and handoff terminal failures lost their order.

What came from where

Commit Source
wake idle live playback on cancellation 80b97e4c2 in #426 (cherry-pick)
cover a cancel wake racing the flag check (test) f6097e517 in #426 (cherry-pick)
order cancel wakes after the flag check 14943c91e in #426 (cherry-pick)
preserve ordered handoff terminal failures 6b5afdf00 in #426 (cherry-pick)
tolerate audio replies after child shutdown (test) 46fdf603b in #426 (cherry-pick)
validate partial audio EOF after intentional disconnect (test) 71214694e in #426 (cherry-pick, conflict resolved by dropping the capture-only replay test)
expire speech holds without cancelling recognition 2eda9918c in #426 (ported)
release the speech hold when a final covers its turns 43efb0e00 in #426 (ported)
keep the speech hold while Apple still has words to finalize 09ee43fd9, e173655ac, 4d2f03488 in #426 (ported, final combined behavior)
stream the user's words while they are transcribed 7b28c3ecd in #428 (ported)
ignore interim words in native voice conversations e347e12a6 in #428 (cherry-pick)

The ports differ from #426 only where #426 depended on infrastructure this branch leaves out:

  • The pending-utterance timeout is a fixed 5 s constant and the interim grace a fixed 10 s, not DetectionSettings fields, CLI flags or a protocol bump.
  • Capture hooks and the capture-only heard and settled records are gone, along with the detection-evidence handling in finals (result_is_current still gates hold accounting).
  • A turn's end is the pushed audio minus main's 1.2 s silence flush window, where berd-call: capture calls and replay speech detection profiles #426 subtracted its configurable release_ms.

Left in #426

Capture and replay, VAD debug cues, failure fixtures, speech-profile evaluation, and the learning and tuning work (dynamic timeouts, thresholds and release).

Related issue

Follows #426 and #428. None found otherwise.

Testing

  • Real Apple recognition: the ignored on-device test mac_speech::tests::final_end_is_measured_on_the_pushed_audio_timeline passes, so a final's end is on the same pushed-audio timeline the VAD turns use. That is what makes the coverage rule sound.
  • Hold release, red/green: mac_final_before_its_turn_ends_releases_the_hold_when_it_does fails before the coverage fix (the turn stays pending until the 5 s timeout) and passes after it. Companion tests cover a final resolving every turn its audio covers, an earlier-phrase final keeping the hold, the hold waiting for interim words and returning to 5 s once a final follows, and late finals surviving hold expiry.
  • Interim words: tests cover the stream through mute, reset and restart, and the interim token matching the next final.
  • Playback cancellation: the cherry-picked session_protocol regressions run the real berd-call child against a scripted audio host.

Not yet exercised on a live call: the end-to-end hold timing and interim captions with a real microphone.

Zack and others added 13 commits October 23, 2026 09:28
Cancellation revoked output authority and notified output waiters, but a live playback worker could already be waiting for its next provider message. Queue Finish as well so the existing cancellation path can serialize Cancel without another provider event. A full input queue already gives the worker work that observes the revoked authority.

The regression synchronizes after one chunk and checks cancellation with no tail, one buffered sample and a nearly full partial chunk.

Signed-off-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz>
(cherry picked from commit 80b97e4)
The playback worker checks the cancellation flag under the state lock
and then waits on the condvar. A cancel request that stores the flag
and notifies between those two steps is lost, so the worker sleeps
until its operation timeout. This reproduces the CI timeout in
autonomous_spokesperson_cancel_is_correlated_before_audio_cancellation_and_then_stale.

Signed-off-by: Henry <a0d9f90f86da5f023099d22b8e3599af162d9423a422d8f385a12815de879cf2@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit f6097e5)
notify_cancel_requested stored the flag and notified without the state
lock, so a worker that had checked the flag but not yet waited missed
the wake and slept until its operation timeout. Taking the lock before
notifying orders the wake after any such check.

Signed-off-by: Henry <a0d9f90f86da5f023099d22b8e3599af162d9423a422d8f385a12815de879cf2@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 14943c9)
Signed-off-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz>
(cherry picked from commit 6b5afdf)
Signed-off-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz>
(cherry picked from commit 46fdf60)
Signed-off-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz>
(cherry picked from commit 7121469)
The idle timeout after local speech ended used to cancel the native
recognizer and start a new generation, discarding Apple's queued audio
and any final still on its way. A slow recognition therefore lost its
transcript, and a final the recognizer had already produced could not
release a newer hold.

Expiry now releases only the agent speech admission hold. Recognition,
its queued audio and the recognizer generation continue, so a late final
is still forwarded. Native finals have no utterance identity, so hold
credits are tracked separately from unresolved recognitions: an expired
credit is consumed before a newer blocking one, and a final for an old
utterance cannot release a newer hold. Credit accounting is capped at
1024 and stays ambiguous until the recognizer resets.

The pending-utterance timeout is the existing fixed five seconds.

Ported from 2eda991 in #426 without the detection settings, CLI flag
and protocol field it added there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz>
Each VAD turn adds a pending recognition credit, but SpeechTranscriber
emits one final for a sentence that pauses split into several turns.
Resolving one credit per final left the rest outstanding, so in John's
recent calls 55 of 57 holds ran the full pending-utterance timeout even
though Apple had finished 0 to 4 seconds after he stopped.

Pass each final's audio end through the native bridge and resolve
exactly the ended turns whose speech it covers. A final for an earlier
phrase of a still-pending turn resolves nothing, so the rest of the
sentence keeps the hold. Finals without an end keep the old behaviour.

Ported from 43efb0e in #426 without its capture hooks. A turn's end is
the pushed audio minus the host's silence flush window, since the host
declares speech over after that much silence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz>
…inalize

The hold lifted at the pending-utterance timeout even when Apple was
still transcribing. On a live call a second sentence took about seven
seconds to finalize, so an agent's reply to the first sentence started
playing and was then cut off by the late transcript.

The bridge now reports Apple's interim results. While Apple's latest
result is interim words, the hold lasts up to ten seconds beyond the
timeout, until a final follows them; noise with no interim words keeps
the timeout. Apple re-reports words a final leaves out as a fresh
interim result, and interim results end where its audio does rather than
at the last word, so the hold follows result order, not sample
positions. Empty finals now reach the host so they settle their audio.

Ported from 09ee43f, e173655 and 4d2f034 in #426 as their final
combined behavior. The ten-second grace is a fixed constant, and the
capture-only `heard` and `settled` records are left out with the capture
infrastructure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz>
Hosts could only show what the user said once Apple finalized it, often
seconds after they stopped talking. Conventional sessions on macOS now
emit `user_interim` with Apple's interim text, replaced by each update
and cleared when its final arrives, on mute, input reset or restart.

`--stream` carries it as an ephemeral `0\tuser_interim` row whose JSON
gives the text and the cursor the next `user` row will take. A client
can show the words as a caption, or let an agent start on them and keep
its reply current when the final lands. The row never advances a cursor
or enters transcript history, and Codex tasks never see it.

Ported from 7b28c3e in #428, re-applied on the speech-hold commits
without the capture trace calls that branch carried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz>
Berd's native voice handler matches every voice input event, so the new
interim transcript event broke its build. Berd doesn't show interim
words yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Henry <a0d9f90f86da5f023099d22b8e3599af162d9423a422d8f385a12815de879cf2@buzz.block.builderlab.xyz>
(cherry picked from commit e347e12)
The call detected the user's speech at 0.5 while listening and 0.65
during agent playback. One threshold is simpler to reason about and to
tune, so both now use 0.9, the value John converged on in live calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz>
Apple can finalize a complete utterance during the 1.2-second silence
window, before the detector records the turn's end. That final could
not cover a turn still in progress, and its coverage was then lost,
so the hold waited the full five-second timeout. Remember the furthest
final and resolve the turns it covers when a turn ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Henry <a0d9f90f86da5f023099d22b8e3599af162d9423a422d8f385a12815de879cf2@buzz.block.builderlab.xyz>
@johnmatthewtennant
johnmatthewtennant marked this pull request as ready for review October 23, 2026 18:22
@johnmatthewtennant
johnmatthewtennant requested a review from a team October 23, 2026 18:22
@johnmatthewtennant
johnmatthewtennant merged commit 3217cdc into main Oct 23, 2026
10 checks passed
@johnmatthewtennant
johnmatthewtennant deleted the jtennant/henry/call-quick-wins branch October 23, 2026 19:37
johnmatthewtennant pushed a commit that referenced this pull request Oct 23, 2026
Main now carries the speech-hold, interim-word and playback-cancel
fixes from #429, which this branch and #428 already had in their own
form, plus the Swift link-path fix from #430.

Conflicts take this branch's versions, with #428's interim words and
the final_end coverage fix layered on, so the configurable and learned
thresholds stay in place of #429's single fixed VAD threshold. The
swiftbuild link-path change from #430 is kept as is. The merged tree
matches the combined build that passed the full berd-call suite, plus
the two build.rs changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
johnmatthewtennant pushed a commit that referenced this pull request Oct 23, 2026
Main now carries the speech-hold, interim-word and playback-cancel
fixes from #429, which this branch and #428 already had in their own
form, plus the Swift link-path fix from #430.

Conflicts take this branch's versions, with #428's interim words and
the final_end coverage fix layered on, so the configurable and learned
thresholds stay in place of #429's single fixed VAD threshold. The
swiftbuild link-path change from #430 is kept as is. The merged tree
matches the combined build that passed the full berd-call suite, plus
the two build.rs changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Henry <a0d9f90f86da5f023099d22b8e3599af162d9423a422d8f385a12815de879cf2@buzz.block.builderlab.xyz>
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.

1 participant