Repository navigation
berd-call: realtime calls for agents: live stream, own voices, and Spokesperson controls - #397
Conversation
An Expert driving a realtime call from the CLI should hear the conversation the way a GPT-Live delegate does: only when the Spokesperson hands off, with everything said since the last handoff. Until now `start --stream` and `--codex` also flushed after every Spokesperson reply, waking the Expert on each turn, while a host that shows the call as it happens saw the user's words only once the reply had played. Bare `--stream` (or `--stream handoffs`) and `--codex` now hold rows until a handoff. `--stream live` shows the conversation as it happens: a `user` row as soon as the words are transcribed, and a `spokesperson_generated` row as soon as a reply is generated, before it is spoken and only once no user speech is still being transcribed. The session emits these as `user_transcribed` and `spokesperson_generated` previews; the transcript recorded after playback stays authoritative. Co-Authored-By: Claude Opus 5.5 <[email protected]>
With `--expert-voice tts`, an Expert-Spokesperson session builds the configured TTS backend beside the realtime connection. Each reply plays through that backend in the Spokesperson's playback slot instead of being read aloud by the realtime model, so the same turn gates and barge-in apply to both voices. Afterwards the realtime session gets a silent note of what the user heard, and the handoffs the reply answers settle once any of it was heard. `session.tts` and `set_tts_settings` now describe the Expert's voice in that mode, and every Expert-Spokesperson session reports the realtime model and voice as `session.spokesperson`. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: John Tennant <[email protected]>
…pert's With --expert-voice tts, the one voice and speed control changed the Expert's voice, leaving no way to change the Spokesperson's during a call. The Spokesperson now has its own controls in these calls: - `set_spokesperson_settings` swaps in a replacement realtime session, the same way `set_tts_settings` changes the Spokesperson's voice elsewhere. - `settings --spokesperson-voice` and `--spokesperson-rate`, plus matching `start` options, set its voice and speed. - The menu bar shows Agent and Spokesperson voice and speed menus side by side, each limited to its own range of speeds. `session.spokesperson` is now the Spokesperson's full revisioned OpenAI snapshot. It appears only in calls whose replies have their own voice, since elsewhere `session.tts` is already the Spokesperson's. The Spokesperson's speed no longer follows the Expert's rate. The voice and speed it was last given are saved, and come back in the next call whose replies have their own voice. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: John Tennant <[email protected]>
`speak --voice NAME [--language BCP47]` speaks a single utterance in another voice of the call's reply backend, keeping its other settings and leaving the call's voice and saved settings alone. Agents sharing a call can then sound distinct. A voice the backend doesn't offer, or one for a call whose replies the Spokesperson speaks, is not admitted with the new `invalid_voice` reason. Built backends are cached per voice until the call's settings change. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: John Tennant <[email protected]>
sirittsd reports en-GB-C and en-GB-D as downloaded on a Mac where their assets aren't on disk, and then speaks both in the same low-quality substitute (byte-identical renders). Every voice that really speaks has a TTSAXResourceManager contentPath; these have none. The catalog now counts a voice as installed only when sirittsd reports it downloaded and its content path exists, so the menu bar, Berd's settings and the plugin stop offering it, and `voices download` fetches it instead. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: John Tennant <[email protected]>
A reply whose playback failed without the host confirming the cancellation left old audio possibly still playing, yet the session went on admitting output. Carry the playback failure's quiescence into the reply result and end the session after reporting the failure, as conventional playback already does. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
`speak --voice` built and validated a voice it hadn't used yet on the session loop. Siri validation can wait seconds for sirittsd, so a reply already playing could miss its audio acknowledgement deadlines and microphone input could back up. Build the voice on a worker and hand the request back to the loop once it's ready, or refuse it there if the voice can't be built. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
A speech request whose voice was still being built was invisible to `cancel`, which answered stale; the request then came back and played. Track the requests waiting on a voice, cancel them there, and drop a cancelled one when its voice arrives. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
The test host can now hold a drain and then keep serving, so a reply stays playing until something stops it. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
An admitted Expert-voice reply was queued for playback at once, so it could start before the parent sent output_ready. It now waits: a matching output_ready marks it ready and queues it, and the accepted result is written before playback can begin. Mismatched, repeated or dropped requests are stale. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
A failed Expert reply was treated as unheard, so its handoffs were released and the Spokesperson was told the user heard none of it. Playback failures now carry what had been played, so the heard prefix reaches the Spokesperson and an answered handoff is dismissed. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
morgmart
left a comment
There was a problem hiding this comment.
🤖 Automated code review
Request changes: the shared live rate update can commit the agents' rate before the Spokesperson update rejects or fails, leaving the voices and saved preference inconsistent even though the command reports failure. The supplied GitHub evidence is valid; Transcript virtualization and Tauri Rust checks (Linux) were still in progress when captured, while the other listed checks had passed.
Deterministic publication result: 1 blocking and 0 non-blocking inline finding(s) publishable; 0 duplicate(s) suppressed; 0 blocking screenshot-evidence requirement(s) in this review body.
With --expert-voice tts, a reply could be dropped: one written before the user's latest words came back pending, and one naming a handoff already settled was not admitted. An open handoff also kept the Spokesperson's session from renewing, and ended the call if the provider expired it. Such a reply is now always admitted, whatever its cursor, and settles every open handoff as soon as it is, so --resolves is ignored. Open handoffs no longer hold back renewal, recovery or a voice change, and the replacement session is told what they asked so it doesn't hand them off again. A reply that arrives during a replacement waits for it rather than failing it. Calls whose replies the Spokesperson says, and the Berd app, behave as before. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: John Tennant <[email protected]>
In a call with --expert-voice tts, every speak played in the Expert's voice, so an Expert couldn't have the Spokesperson tell the user something instead. `berd-call message-spokesperson TEXT` (protocol: `prepare_speak.relay`) hands the text to the Spokesperson to say, on the same terms as an own-voice reply there: admitted however stale, and answering every open handoff once admitted. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: John Tennant <[email protected]>
`message-spokesperson` is how an Expert answers through the Spokesperson, so it now works the same in a call without --expert-voice tts: admitted however much the user has said since, and answering every open handoff, with no --re or --resolves. `speak` there keeps today's rules. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: John Tennant <[email protected]>
…nt-speech In an Expert-Spokesperson call, the Expert now reaches the user through the Spokesperson with `message-spokesperson`, and `speak` is refused unless the call was started with --allow-direct-agent-speech. That flag replaces --expert-voice tts. With it, a reply in the Expert's own voice must acknowledge the latest input, as `speak --re` does everywhere, and once admitted it still answers every open handoff. A message to the Spokesperson stays admitted however stale. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: John Tennant <[email protected]>
A `--codex` task heard a realtime call only at handoffs. It now gets a batch after every Spokesperson reply and at each handoff, as Berd's Expert and berd-call before the handoffs stream did, so it can follow the conversation and answer what was said last. `--stream` (handoffs) still holds rows until a handoff, and `--stream live` is unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: John Tennant <[email protected]>
`message-spokesperson` was admitted however stale and answered every open handoff. It now takes the terms of a directive from Berd's Expert: `--re CURSOR` is required and must acknowledge the latest input (the Spokesperson's own speech doesn't count), or the message comes back `spoke:false` with what was missed, and each `--resolves HANDOFF_ID` settles that handoff once the Spokesperson has said it. This holds in any Expert-Spokesperson call, including one with --allow-direct-agent-speech, where `speak` still answers every handoff. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: John Tennant <[email protected]>
`--stream` (or `--stream handoffs`) printed nothing in an Expert-Spokesperson call until a handoff. It now prints the same batches as `--codex`, after every Spokesperson reply and at each handoff, so the CLI has two ways to hear a realtime call: as Berd's Expert does, or as it happens with `--stream live`. `handoffs` is no longer accepted after `--stream`. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: John Tennant <[email protected]>
With --allow-direct-agent-speech the Spokesperson still hands off and the Expert still hears each handoff, but nothing tracks it: no reminders, no renewal or recovery held back, no note for a replacement session, and nothing for --resolves to name. A reply is said once it acknowledges the latest input, as before. Calls without the flag keep Berd's handoff rules. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: John Tennant <[email protected]>
The voice session read its arguments in pairs to find --pcm-output-fd, which the host appends last. The valueless --allow-direct-agent-speech shifted the pairs, so every call started with it failed with "voice session event stream closed during startup". Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: John Tennant <[email protected]>
A Spokesperson voice or rate change builds a new realtime session, and if anyone spoke before it was ready, the change was rejected with "Spokesperson conversation changed during voice replacement", so a menu-bar change made mid-conversation never took. In a berd-call start call it is now tried again once the call is idle, with the conversation as it then stands, and reports one result when it applies. The Berd app's handling is unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: John Tennant <[email protected]>
OpenAI sends the Spokesperson's audio about as fast as it plays. Playback sent it to the host only in whole 170 ms chunks and started on the first one, so the speaker often ran dry before the next chunk was complete, and any late delta left an audible gap. Gather 300 ms of each reply before it plays, then send each delta as it arrives. A test plays a reply arriving in real time with one 150 ms stall: whole chunks leave a 109 ms silence; the lead plays through. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: John Tennant <[email protected]>
On a live realtime call, in both modes, change the Spokesperson's speed, then its voice, then the input policy, then mute and unmute, and check each one lands. With a separate agent voice, also change the agent's voice and speed. Check that the Spokesperson's realtime session is rebuilt with each new voice and speed. Run the retry test for a change the user overtook in both modes as well. With a separate agent voice, the Spokesperson rate change it retries fails without the retry. On the host, a Spokesperson voice or rate change keeps the other setting and the revision, and is refused when the call has no separate Spokesperson voice. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: John Tennant <[email protected]>
…dio" This reverts commit aad01ee. Its premise was wrong: measured on six live replies, OpenAI Realtime sends a reply's audio 3 to 5.5 times faster than it plays, in 150 to 400 ms deltas, so a lead covers no late arrival and only delays the first sound. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: John Tennant <[email protected]>
The host reports a chunk played only when the output device has played it: about 60 ms after it is heard on a MacBook's speakers, and a few hundred more on Bluetooth headphones. Playback held at most 400 ms beyond what was reported played (less 100 ms of delivery safety), so on headphones the player ran dry while waiting for credit, and the Spokesperson's speech, and an agent's, came out in short gaps. Let the host hold a second beyond its reports, which plays through reports up to about 800 ms late. A test plays 3 s of audio with reports 300 ms late: the old limit leaves 1041 ms of silence; this leaves none. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: John Tennant <[email protected]>
When the host's player stops, as when the output device changes, the host ends that speech and reports audio_failed. The session can still send chunk, end or cancel records for it before hearing the report. The host treated those as protocol errors and ended the call. It now drops late chunk and end records for the speech it retired and answers a late cancel with what it played. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: John Tennant <[email protected]>
A Spokesperson reply whose host player stopped ended the whole call with "host audio output failed". The session now ends that reply where the host stopped, as if the user had interrupted it: the provider's item is truncated to what played, the transcript is published as interrupted, and the next reply gets a new player. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: John Tennant <[email protected]>
Switching the output device restarts the microphone and resets the Spokesperson's input. The realtime loop waited for the provider to confirm the clear, so microphone audio stopped draining, the 32-frame session queue filled, and the session failed with "session PCM input queue is full". Input resets and mutes now send the clear without waiting. The loop answers them in order once the provider confirms, coalesces a request that arrives while a clear is outstanding into one follow-up clear, and holds a pending voice change until no clear is outstanding. Resets share the input queue with microphone audio, so a clear discards exactly the audio sent before it. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
When the host's player stops, the Spokesperson playback worker sends its result and stops taking audio. Provider audio that arrived in the meantime hit the closed worker first and ended the call, before the loop read the result that interrupts the reply where it was heard. Drop that audio instead and let the result settle the reply. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
The output_ready gate and failure tests restacked from below used the reply's old optional text and started sessions with `--expert-voice tts`. A call that lets the agent speak holds no handoff open, so the failure tests now check only what the Spokesperson is told. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
When the speakers change mid-reply, macOS stops the audio engine and the host reports its player failed. A conventional call reported that as `speech_failed: host audio output failed`, so `speak` printed an error even though the call carried on. Treat a quiescent host failure like a barge-in: the speech ends as interrupted where it was heard. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
A stopped host player now interrupts speech rather than failing it, so the Expert reply tests from below check that the reply is interrupted where it was heard and that the Spokesperson is told that much. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
A Spokesperson checkmark in the menu bar, and `berd-call settings --spokesperson true|false`, restart the running call's session in the other mode. Agents keep the voice they have now, options only the other mode takes are left out, and a `--stream live` call gets the Spokesperson back beside the agents' own voice. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
…oggle Turning the Spokesperson on or off now restores the agents' live speech settings in the new session, so an OpenAI voice and model chosen during the call survive, which startup options can't express. When agents have no voice of their own after the switch, their speed is left out rather than becoming the Spokesperson's, whose range is narrower; a conventional call at 2× no longer ends when the Spokesperson turns on. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
The menu showed the call's mode only once the restarted session answered, so the checkmark lagged a switch by the session's startup time. The menu now shows the chosen mode until the switch finishes, and keeps the last status while the restarting call doesn't answer. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
With the Spokesperson on, its voice and speed now sit in its menu section, under the checkmark, and the agents' voice, speed and input-during-TTS setting get the next section. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
`settings --rate`, and so the menu bar's Speech Rate, now also sets the Spokesperson's speed in a call whose agents have their own voice, as close as the Spokesperson can go (0.25 to 1.5). The menu shows one Speech Rate, noting the Spokesperson's when it differs. At startup the Spokesperson speaks as close to `--rate` as it can, instead of refusing a faster rate, and turning it on keeps the agents' speed. `--spokesperson-rate` still gives the Spokesperson a speed of its own. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
…efused A rate the agents' voice rejects returned the Spokesperson's applied result instead, and changed only the Spokesperson. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
9754301 to
8f9f63d
Compare
A shared speech rate applied the agents' rate first and then the Spokesperson's. If the Spokesperson refused, the agents kept the new rate, and it was saved, while the command reported the failure. The agents' previous settings are now restored, so a refused change leaves both voices as they were. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
A direct Expert reply admitted without the parent's output_ready stayed reserved forever, so every later reply was refused. Give it the two-second readiness deadline PROTOCOL.md sets for all admitted speech: on expiry it fails with speech_failed, the Spokesperson is told none of it was heard, and the slot is free again. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
A direct reply in the Expert's usual voice took its TTS lease only when playback began, so a set_tts_settings applied between admitted and output_ready changed the voice of speech already admitted. PROTOCOL.md says admitted speech keeps the configuration it was admitted with; take the lease in submit_expert_prepare instead, as per-utterance voices already are. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
merge_arguments reads flag-value pairs, so a call started with --allow-direct-agent-speech before other options, such as --mode expert-spokesperson --allow-direct-agent-speech --voice X, misaligned every later pair when the Spokesperson was switched off, and the restart failed validation. Remove the valueless flag first; with_spokesperson already adds it back when the new mode wants it. Signed-off-by: John Tennant <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
morgmart
left a comment
There was a problem hiding this comment.
🤖 Automated code review
Engineering review is clear. The full prepared comparison was inspected statically, the supplied GitHub evidence is structurally valid and all listed check runs passed, and the prior atomic shared-rate concern is both addressed in the current implementation and protected by a resolved thread with a substantive human reply.
Deterministic publication result: 0 blocking and 0 non-blocking inline finding(s) publishable; 0 duplicate(s) suppressed; 0 blocking screenshot-evidence requirement(s) in this review body.
Pending checks: 1 check(s) are not complete.
This approval reflects the completed code review only; merge readiness remains governed by the repository's required checks.
Summary
Berd Call's realtime (Expert-Spokesperson) calls only worked the way Berd's app drives them. An agent on the CLI, or in the Buzz Berd Call plugin, had four problems:
This PR fixes all four, plus two playback problems found in live calls.
Two modes for realtime calls
--codexand--stream) hears the call as Berd's Expert does: a batch of every row since the last one, after each Spokesperson reply and at each handoff.berd-call message-spokesperson --re CURSOR [--resolves HANDOFF_ID]... TEXTis a directive the Spokesperson says, and it settles the handoffs it names once they're heard.speakis refused. Handoffs follow Berd's rules: reminders, renewal waits for them, and a provider expiry with one open ends the call.--stream live --allow-direct-agent-speech) is for the plugin. As soon as the user's words are transcribed, auserrow appears. As soon as a reply is generated, before it's spoken, aspokesperson_generatedrow appears. These come from the new session messagesuser_transcribedandspokesperson_generated.speak --re CURSORplays in the agent's own voice through the call's TTS backend. Handoffs still reach the agent, but none is held open.Agents in their own voice
output_ready).cancelstops a reply where it was heard. A host that stops playing it, such as when the speakers change, also stops it there.speak --voice NAME [--language BCP47]speaks one reply in another voice of the same backend, so each agent can sound different. A new voice is built off the session loop, so audio never stalls. Siri voices with missing assets are listed as not installed.Spokesperson controls
berd-call settings --spokesperson true|falsedoes the same from the CLI.--stream livecall, the Spokesperson comes back with--allow-direct-agent-speech.Speech Rate: 2× (Spokesperson 1.5×)when they differ.--spokesperson-ratestill gives the Spokesperson a speed of its own.Reliability
speech_interruptedwhere it was heard, rather thanspeech_failed.The Berd app doesn't use these paths, so its behavior is unchanged.
Related issue
None found. The live stream follows OpenAI's GPT-Live client delegation.
Testing
Integration tests run against a fake realtime server and a fake OpenAI speech endpoint.
Modes
message-spokespersoncomes backpending. One that acknowledges the latest input and names a handoff is admitted and said by the Spokesperson, and only the other handoff stays open.Own voice
not_admitted/invalid_voice.output_ready: nothing is synthesized or reported for 500 ms without it. A mismatched or repeatedoutput_readyisstale.output_readyfails after two seconds withspeech_failed: output readiness timed out, and the next reply is admitted. One whose agents' voice changes between admission andoutput_readystill speaks in the voice it was admitted with.cancel, or a stopped player interrupts a reply where it was heard, and the Spokesperson is told that much (or that none was heard).speech_failed, and the session exits.Controls
--allow-direct-agent-speechfor a live stream. Options only the other mode takes are dropped both ways, including when--allow-direct-agent-speechcomes before other options.settings --rate 2sets the agents to 2× and the Spokesperson to 1.5×. A rate the agents' voice refuses changes neither. Starting a call with--rate 2.0starts the Spokesperson at 1.5×.Reliability
speech_failed: host audio output failed. It now givesspeech_interrupted, and the next reply completes.Live calls from the Buzz plugin (
berd-call start --stream live)speak --voicewere heard in distinct voices.Speech Rate: 2× (Spokesperson 1.5×), andberd-call statusreported 2.0 and 1.5.Screenshots
Before: a Spokesperson call on main
After: Spokesperson on, agents at 2×