Skip to content

berd-call: download and preview Siri voices, finish speech across restarts, and steady the Spokesperson - #416

Open
johnmatthewtennant wants to merge 37 commits into
mainfrom
jtennant/berd-call-buzz-voice
Open

johnmatthewtennant wants to merge 37 commits into
mainfrom
jtennant/berd-call-buzz-voice

Conversation

@johnmatthewtennant

@johnmatthewtennant johnmatthewtennant commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The Buzz Berd Call plugin uses berd-call for voice calls in Buzz. These calls need speech that finishes playing, input muting that follows the output device, Siri voices people can download and audition, and a Spokesperson whose settings survive restarts. The plugin's voice preview and its advanced Spokesperson options require this PR. Older berd-call builds have neither.

Speech playback

  • Long Siri replies wait for room in the playback queue instead of failing when it fills.
  • Session restarts, including Spokesperson toggles, wait up to 60 seconds for playing and queued speech (Spokesperson audio included). End Call still interrupts immediately.
  • A call ends cleanly when the process reading its stream goes away.

Input muting

  • Auto input muting persists across session restarts and follows output changes.
  • It recognizes default outputs by name (for example "USB Speakers"), falling back to CoreAudio metadata.

Siri voices

  • The menu bar offers voice downloads, which work with the macOS 27 download service and fall back to the earlier one.
  • Downloading one voice keeps the others installed.
  • Listing and downloading share one check for whether a voice is installed, so voices whose assets are on disk show as installed even when the accessibility database is stale.
  • Code-named voices take their names from their installed assets.
  • berd-call voices preview --voice NAME --language BCP47 [--rate R] plays Apple's sample. When a voice has no sample, it speaks with that voice if its assets are on disk. Stopping the preview process stops playback.

Realtime Spokesperson (experimental)

  • The toggle reads "Enable Experimental Realtime Spokesperson" and sits with its voice controls just above End Call.
  • --spokesperson-options sets advanced session options.
  • The selected voice persists across restarts.
  • Limited playback gain brings its loudness close to Siri's.
  • Aborting a voice change returns without waiting on the provider, and input resets recover a session lost mid-reset.
  • Pending input controls get ordered replies before shutdown.
  • Realtime, speech to text and text to speech each accept their own key (OPENAI_REALTIME_API_KEY, OPENAI_STT_API_KEY, OPENAI_TTS_API_KEY), falling back to OPENAI_API_KEY.

Related issue

Combines and supersedes #403 and #405 through #415, which retain their original validation evidence.

Testing

  • Siri voice downloads (macOS 27): before the service fix, downloads failed with "Siri voice download service is unavailable". With it, they complete, and after successive downloads all seven en-US voices list as installed, including those with stale database entries.
  • Voice previews: Apple's samples play through the default output, and SIGTERM stops playback at once. Every installed voice tested had a sample, so the fallback that speaks without one has not been exercised live.
  • Long replies: with real Siri, a reply longer than a minute failed with exceeded its bounded PCM queue before the fix and plays to completion after it.
  • Restarts: red/green regressions cover waiting for agent and Spokesperson playback before a restart, and bounding a restart whose playback never ends.
  • Auto input muting: an isolated native call kept it across a restart. An opt-in macOS test switched between built-in speakers and BlackHole during speech, and suppression followed the output. Matching outputs by name has a hardware-free red/green regression.
  • Spokesperson, against a local Realtime server:
    • Aborting a voice change returned immediately.
    • Later resets waited for their own confirmations, and provider clears came before replayed audio.
    • Pending reset and unmute requests got ordered replies before the session exited.
  • Loudness: captured Marin PCM through the production gain moved from -23.0 to -19.0 LUFS, against Siri's -18.8, with a -0.4 dBTP peak. Audio comparison: Siri, then the original Spokesperson, then the adjusted Spokesperson.
  • Keys: berd-call synthesize against a local server sent the service key when set, the shared key when it was unset or blank, and the documented error when neither existed.

Screenshots of the changed menu bar surfaces (Voice ▸ Download and the renamed Spokesperson toggle) are still to come.

Nora and others added 26 commits October 20, 2026 15:01
Signed-off-by: Nora <49aa1f65411fd096d2e2ec144f1e7aa36fdc76d1b907cfdf7be000c66f9d3b8e@buzz.block.builderlab.xyz>
Signed-off-by: Nora <49aa1f65411fd096d2e2ec144f1e7aa36fdc76d1b907cfdf7be000c66f9d3b8e@buzz.block.builderlab.xyz>
Siri renders faster than playback drains, and the native callback
failed the whole reply once 60 seconds of audio were pending. It now
waits for room when the bound is full and keeps it as a memory cap.

Signed-off-by: John Tennant <jtennant@block.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Nora <49aa1f65411fd096d2e2ec144f1e7aa36fdc76d1b907cfdf7be000c66f9d3b8e@buzz.block.builderlab.xyz>
The menu bar's voice submenu listed only installed Siri voices, so a
voice had to be downloaded with `berd-call voices download` before it
could be chosen. The submenu now offers the uninstalled voices in the
call's language under Download, with their sizes. Choosing one
downloads it off the main thread, shows its progress, and switches the
call to it once it is installed; a failure shows as a control error.

Signed-off-by: John Tennant <jtennant@block.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A voice whose catalog name is a code, such as en-US-F, now takes its
display name from the build paths in its installed asset's Info.plist
(.../assets/zack_v2_dual_prompt_with_fallback/p2a is Zack). The known
names and "Siri Voice N" remain the fallbacks.

Signed-off-by: John Tennant <jtennant@block.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Berd sets the Spokesperson's realtime session options (model, turn
detection, noise reduction and the rest of its Advanced settings), but
the standalone CLI always used the defaults. `--spokesperson-options
JSON` now takes the same camelCase fields and ranges, refuses unknown
ones and the voice and speed that have their own flags, and applies
them when the Spokesperson's session starts. Saved settings don't keep
it; the caller passes it on each start.

Signed-off-by: John Tennant <jtennant@block.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rolling back a Spokesperson voice change blocked the call until OpenAI
confirmed the cutover's input clear, and stalled the menu bar when that
confirmation was slow. Abandon the cutover immediately instead and
attribute its late confirmation by send order, so it can neither
complete a later reset nor report a stale cutover result. Retire the
candidate runtime in the background.

Signed-off-by: John Tennant <jtennant@block.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Now that aborting a cutover no longer waits for the worker, the frames
a rolled-back voice change replays could reach the provider before the
cutover's clear and be discarded with it. Send cutover commands on the
input queue, like resets, so they stay in order with the audio they
clear.

Signed-off-by: John Tennant <jtennant@block.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Nora <49aa1f65411fd096d2e2ec144f1e7aa36fdc76d1b907cfdf7be000c66f9d3b8e@buzz.block.builderlab.xyz>
Signed-off-by: Nora <49aa1f65411fd096d2e2ec144f1e7aa36fdc76d1b907cfdf7be000c66f9d3b8e@buzz.block.builderlab.xyz>
When the Realtime session was lost or expired while an input reset was
waiting on the provider's confirmation, the call ended with
"Spokesperson runtime is closed" instead of starting a replacement
session. The lost session takes its input buffer with it, so treat its
outstanding clears as done once its terminal event arrives, answer the
waiting input controls, and let recovery proceed.

Signed-off-by: John Tennant <jtennant@block.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A mute or input reset waits to be acknowledged until the provider has
cleared its input. When the host shut the session down, or closed its
input, while a clear was outstanding, the session exited without ever
answering those controls. Wait for the outstanding clears, which the
runtime settles within its acknowledgement timeout, and answer them
before the session ends.

Signed-off-by: John Tennant <jtennant@block.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* commit '18414f0a9c38d544142c1b69d715555b1ac91dd9':
  berd-call: keep reader-loss shutdown local during startup
  berd-call: stop quiet streams when their reader disconnects
* commit '8e1ccf7960b5d2b078bcb4d7b639d959e4fe8ee8':
  berd-call: download Siri voices from the menu bar
* commit 'b34be7491c50704c2a2dfe3c1f5ee2a1c23fed47':
  berd-call: name code-named Siri voices from their installed asset
* commit '3d1859f7cf11e0c0800f54ea1394cd6512d1a1d0':
  berd-call: set the Spokesperson's advanced session options
* commit 'c32c15fb80800c7f4128f3b1b131a75fa3158fde':
  Keep cutover commands in order with input
  Abort a voice change without waiting on the provider
* commit '3202d1c8c17e38862730a56b3aec299760bb1bf2':
  berd-call: bring Spokesperson playback closer to Siri loudness
* commit 'b2fb76e6f7a461db6205b73faab99c220b44f6c6':
  Recover a Spokesperson session lost during an input reset
* https://github.com/block/berd:
  Verify Auto muting follows real macOS output changes
  berd-call: persist Auto input muting and follow output changes
A restart replaced the saved arguments with the new session's, so turning
the Spokesperson off (a mode that can't carry --spokesperson-voice and
--spokesperson-rate) erased them, and turning it back on started with the
default voice. Keep them as call start does, and start a restart that can
carry them with the saved voice unless it sets its own.

Signed-off-by: John Tennant <jtennant@block.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Realtime, speech to text and text to speech each read their own key
(OPENAI_REALTIME_API_KEY, OPENAI_STT_API_KEY, OPENAI_TTS_API_KEY) and
fall back to OPENAI_API_KEY when it is unset or blank. Error messages,
public startup errors and the STT benchmark credential_source name the
variable that applies.

Signed-off-by: John Tennant <jtennant@block.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: John Tennant <jtennant@block.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: John Tennant <jtennant@block.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Auto resolved with no device name for every CLI call, and the shared
classifier only applied its speaker-name check to a named device. A
default output called "USB Speakers" or a Bluetooth speaker was judged
not-speakers, so Auto allowed microphone input during playback and the
call could hear itself.

With no name, look up the default output device's name (CoreAudio) and
apply the same name check, keeping the built-in speaker metadata as the
fallback. The composition takes injected name and metadata lookups so
the classification is tested without audio hardware.

Signed-off-by: John Tennant <jtennant@block.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@johnmatthewtennant
johnmatthewtennant marked this pull request as ready for review October 21, 2026 12:25
@johnmatthewtennant
johnmatthewtennant requested a review from a team October 21, 2026 12:25
johnmatthewtennant and others added 7 commits October 21, 2026 08:35
macOS 27 removed UAFAssetUtilitiesService from UnifiedAssetFramework,
so every voice download failed with "Siri voice download service is
unavailable." The service now lives in SiriAssetUtilities as
SAUAssetUtilitiesService, with the same switchLanguage: and
downloadSiriAssets methods. Look for it there first and keep the old
class for earlier releases.

On macOS 27.0.1, `berd-call voices download --voice Simone --language
en-US` failed with the old lookup; with this one, `--voice Quinn`
downloads and reports installed in 19 seconds.

Signed-off-by: John Tennant <jtennant@block.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hosts such as the Buzz plugin need to let a user hear a Siri voice
before choosing it, but berd-call could only list and download voices.
Previewing lived only in Berd's app command.

`berd-call voices preview --voice NAME --language BCP47 [--rate R]`
follows the app's preview: Apple's system sample plays first, since it
works for voices that are not downloaded, and an installed voice speaks
a short fixed sentence only when no sample plays. Playback uses the
default output device and the command exits when it ends, printing one
`voices.preview` result envelope with the voice, rate, and whether a
`sample` or `speech` played. Rate defaults to 1.0 and is bounded to
0.5..2.0 like the other Siri commands.

SIGINT and SIGTERM stop playback through the bridge's stop callback and
exit 130 without a result, so a host can end a preview by killing the
process.

The playback logic is a reusable `siri::preview_voice` beside
`download_voice`. Berd's app command is left unchanged; it can adopt the
function later, but that change could not be validated in this
berd-call-only build.

Signed-off-by: John Tennant <jtennant@block.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Downloading a Siri voice evicted the voice downloaded before it, and
the voice it downloaded then listed as not installed.

Eviction: `voices download` subscribed every voice under the one client
id com.apple.speech. sirittsd keeps a single subscription per client id
and a new one replaces the last, unsubscribing its voice from the
com.apple.siri.tts asset set (its log shows subscribe Damon and
unsubscribe Simone, then subscribe Quinn and unsubscribe Simone). Apple's
own clients keep their voices because each holds its own client id.
Each voice is now subscribed under its own id, so a download only adds.

Stale list: `download` trusted sirittsd's downloadedVoicesMatching:,
while `list` also requires an existing TTSAXResourceManager contentPath.
axassetsd's voice database stays stale after a download made outside
System Settings, so a voice that was downloaded, on disk and speaking
(Damon) listed as not installed, and download returned success for it
without changing that. The content check now falls back to the voice's
UAF asset on disk, matched by the CFBundleName in its Info.plist, which
is current right after a download. Download and its polling use the same
test as list (sirittsd downloaded and assets on disk), so en-GB-C, which
sirittsd claims but has no assets, is still not installed and is now
fetched instead of reported done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: John Tennant <jtennant@block.xyz>
Turning the Spokesperson on or off restarts the voice session, and the
restart sent Shutdown at once, which cancelled the reply being played
and failed the speaks queued behind it with "voice session restarted".

A restart now waits until no speech is playing or queued, then shuts the
session down, like voice and speed changes that take effect between
replies. The wait is bounded at 60 seconds. A second restart while one
waits is refused, and stopping the call still cuts speech off at once and
cancels a waiting restart.

Signed-off-by: John Tennant <jtennant@block.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A normal Spokesperson reply plays without any Speak command, so
pending_speak and waiting_speaks are empty and a restart (such as
turning the Spokesperson off) cut it off. The audio host now reports
whether it is playing speech, and a restart waits for that too. The
60 s fallback still applies, and the flag clears when the audio host
thread ends, so a dead session cannot hold a restart.

Signed-off-by: John Tennant <jtennant@block.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a voice has no system sample, `voices preview` spoke a sentence
after `validate_installed_voice`, which asks sirittsd alone. sirittsd can
claim a voice whose assets are missing and then speak another voice, so
the preview could play the wrong voice and report `source: speech` for
the requested one. Speak only when the catalog lists the exact voice as
installed, the same test `voices list` and `voices download` use.

Signed-off-by: John Tennant <jtennant@block.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The menu bar's "Spokesperson" checkmark didn't say what it turns on.
It is now "Enable Experimental Realtime Spokesperson", and it sits at
the bottom of the menu with the Spokesperson's voice, just above End
Call.

Signed-off-by: John Tennant <jtennant@block.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@johnmatthewtennant johnmatthewtennant changed the title berd-call: Buzz voice calls (consolidated) berd-call: reliable Buzz voice calls and voice controls Oct 21, 2026
@johnmatthewtennant johnmatthewtennant changed the title berd-call: reliable Buzz voice calls and voice controls berd-call: download Siri voices and preserve speech across restarts Oct 21, 2026
Signed-off-by: John Tennant <jtennant@block.xyz>
Co-authored-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@johnmatthewtennant johnmatthewtennant changed the title berd-call: download Siri voices and preserve speech across restarts berd-call: download and preview Siri voices, finish speech across restarts, and steady the Spokesperson Oct 21, 2026
johnmatthewtennant and others added 2 commits October 21, 2026 13:30
Co-authored-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz>
Signed-off-by: John Tennant <jtennant@block.xyz>
Request write readiness so Darwin reports a pipe reader closing, and throttle writable polls to avoid spinning.

Co-authored-by: Nora <49aa1f65411fd096d2e2ec144f1e7aa36fdc76d1b907cfdf7be000c66f9d3b8e@buzz.block.builderlab.xyz>
Signed-off-by: John Tennant <jtennant@block.xyz>

@morgmart morgmart left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated code review

REQUEST_CHANGES. Fresh static Engineering review of the exact three-dot comparison found three blocking defects: desktop Automatic interruption mode is resolved to a fixed policy before the live route monitor can follow output changes; the new Spokesperson loudness processing is wired only into the managed desktop Realtime host rather than the berd-call Expert-Spokesperson playback path; and restart can still observe audio_playing as false while Spokesperson audio is entering the audio host, then shut down and cut off that reply. Supplied GitHub evidence is structurally valid: nine checks are successful and the macOS Rust check is in progress at the reviewed head SHA; required checks independently govern merge readiness. Final self-check covered every changed call, restart, muting, download, preview, Spokesperson, credential, persistence, and lifecycle flow; accessibility; localization; navigation and consent guards; async, error, never-completes, lifecycle, and race behavior; test honesty; project rules; duplicate and overlapping findings; and concrete evidence plus user effect for blocking severity.

Deterministic publication result: 3 blocking and 0 non-blocking inline finding(s) publishable; 0 duplicate(s) suppressed; 0 blocking screenshot-evidence requirement(s) in this review body.

VoiceInterruptionMode::Automatic | VoiceInterruptionMode::AllowInterruptions => {
InputDuringTtsPolicy::AllowBargeIn
}
VoiceInterruptionMode::Automatic => InputDuringTtsPolicy::Auto

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 P1 · Keep Automatic policy live (blocking)

Automatic is immediately resolved to AllowBargeIn or SuppressInput using the output route observed when playback starts. VoiceInputControls starts its new route-monitor thread only for an unresolved Auto activity, so the desktop Siri, Pocket, and OpenAI playback paths never activate that monitor and cannot follow an output-device change during speech.

User effect: If someone starts speech on headphones and switches to speakers while it is playing, microphone input can remain enabled and feed the assistant's audio back into the call instead of muting automatically.

Recommended fix: Return InputDuringTtsPolicy::Auto for VoiceInterruptionMode::Automatic and let VoiceInputControls resolve and monitor the current route. Keep the explicit interruption modes mapped to their fixed policies.

Test: Add a desktop playback regression that starts an Automatic Siri, Pocket, or OpenAI assistant activity on headphones, changes the reported route to speakers while the activity remains active, and verifies effective mute changes. Assert that the admission path receives Auto; the current implementation should fail before the route change assertion.

samples.len() as u64,
false,
)?;
active.loudness.process(&mut samples);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 P1 · Level berd-call playback too (blocking)

The new gain and limiter are applied only in RealtimePlaybackHost, which is used by the managed desktop OpenAI Realtime command. The berd-call Expert-Spokesperson session has a separate AudioDelta to LivePlayback pipeline in main.rs and still forwards the provider samples unchanged. Consequently the production path this PR describes and the Buzz plugin consumes does not receive the advertised loudness compensation.

User effect: Spokesperson replies in Berd Call remain noticeably quieter than Siri even though this change claims to normalize them, so people still experience inconsistent call volume.

Recommended fix: Apply the shared SpokespersonLoudness processor to each berd-call live response before samples enter its playback queue, preserving one processor across chunks for that response. Keep both playback paths on the same shared implementation.

Test: Add an integration-level berd-call live-playback test that sends quiet and near-ceiling AudioDelta chunks through the Expert-Spokesperson session path and asserts the audio pipe receives boosted, capped samples with state preserved across chunks. The current CLI path should emit the original samples.

if let Some((speech_id, played_frames)) = finished {
playback.finish(speech_id, played_frames);
}
playing.0.store(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 P1 · Synchronize restart audio ingress (blocking)

The session actor decides that restart is quiet from pending speak work plus an atomic audio_playing snapshot, while the audio-host thread publishes that snapshot only after reading and processing available audio records. A spontaneous Spokesperson reply has no pending_speak entry, so a restart can read false while an audio-begin record is already entering the host and send Shutdown before the host publishes true. The added tests set audio_playing directly and do not exercise this interleaving.

User effect: Changing a setting or toggling the Spokesperson at the start of a reply can cut off that reply even though restart is supposed to wait for playing and queued speech.

Recommended fix: Use an ordered drain handshake between the restart actor and audio host. Before sending Shutdown, establish that all audio records ordered before the restart have been consumed and playback is drained, while retaining the 60-second bound for a host that never drains.

Test: Add a deterministic concurrency regression that pauses the audio host after it accepts a Spokesperson audio-begin record but before audio_playing becomes visible, requests restart, and verifies Shutdown is withheld until that audio drains. The current implementation should send Shutdown during the pause.

This branch has not been deployed

No deployments
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.

2 participants