Skip to content

berd-call: let the Expert speak in its own voice in realtime calls - #391

Closed
johnmatthewtennant wants to merge 10 commits into
jtennant/berd-call-stream-user-speechfrom
jtennant/berd-call-expert-voice
Closed

johnmatthewtennant wants to merge 10 commits into
jtennant/berd-call-stream-user-speechfrom
jtennant/berd-call-expert-voice

Conversation

@johnmatthewtennant

@johnmatthewtennant johnmatthewtennant commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary

In a realtime (Expert-Spokesperson) call, the Spokesperson reads the Expert's replies aloud in its own words, so the user never hears the Expert's actual wording, and the transcript repeats it paraphrased. This change lets the Expert speak for itself. The Spokesperson keeps talking to the user in realtime, and the Expert's replies play through the call's ordinary text-to-speech backend, in the Expert's own voice.

  • --expert-voice tts|spokesperson is a session option for Expert-Spokesperson calls only, and defaults to spokesperson. With tts, the session builds the configured TTS backend (Siri, OpenAI or Pocket, with its voice and rate) alongside the realtime connection.
  • Replies: an admitted prepare_speak is queued as a reply rather than sent to the Spokesperson as a directive. Replies play in the Spokesperson's single playback slot. User speech or cancel stops a reply, and speak reports speech_completed, speech_interrupted (with spoken_through_utf8) or speech_failed. If a reply's audio fails without the host confirming it stopped, the session ends, as it does for conventional playback.
  • Afterwards: the realtime session receives a silent context item, without a response.create, saying what the user heard. The Spokesperson can follow up without repeating it. Handoffs the reply answers are dismissed once any of it was heard, and released otherwise.
  • Voices: the Spokesperson and the Expert get separate voice and speed controls in the menu bar, the CLI (settings --spokesperson-voice/--spokesperson-rate) and the protocol. speak --voice NAME [--language BCP47] speaks a single reply in another voice of the same backend, so each agent can sound different. A voice the session hasn't used yet is built on a worker thread, so building it never stalls audio or microphone input. Siri voices whose assets are missing are listed as not installed.

Related issue

None found.

Testing

Integration tests run against a fake realtime server and a fake OpenAI speech endpoint.

  • expert_voice_speaks_replies_itself_and_tells_the_spokesperson_what_was_heard: a reply is admitted, the speech endpoint receives its exact text in the requested voice (coral), and speak gets speech_completed. The Spokesperson receives the note The Expert said this to the user in its own voice: I checked the repository., is never asked to say the reply itself, and starts no response. An unknown voice gets not_admitted / invalid_voice.

  • user_speech_interrupts_an_expert_reply_where_it_was_heard and cancel_interrupts_an_expert_reply_where_it_was_heard: a reply is still playing after the host has played part of it. The user starting to speak, or cancel for that prepare, gets speech_interrupted, with spoken_through_utf8 short of the full text.

  • expert_voice_reply_whose_audio_never_settles_ends_the_session: a host that never confirms the drain or the cancellation gets speech_failed, and then the session exits with failure. Before the fix, the session kept running.

  • expert_voice_keeps_a_separate_spokesperson_voice_control: changing the Expert's voice to new-voice leaves the Spokesperson's realtime voice at cedar and its speed at 1.25.

  • A unit test checks that a new voice is built off the session loop. The request comes back once the voice is ready, a voice already built is used immediately, and an unknown voice is refused. A request cancelled while its voice is being built gets cancel_result / cancelled and not_admitted / cancelled, and is not revived when the voice is ready.

  • menu_reflects_live_call_state: an Expert-Spokesperson call's menu has Agent Voice: Aaron and Agent Speech Rate: 1.25× next to Spokesperson Voice: marin and Spokesperson Speech Rate: 1× (0.5× to 1.5×). Choosing cedar sends {"command":"spokesperson","voice":"cedar","rate":null}.

  • In a live Buzz call, two agents replying with speak --voice were heard in two distinct voices.

johnmatthewtennant and others added 4 commits October 19, 2026 16:41
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]>
johnmatthewtennant and others added 4 commits October 20, 2026 11:21
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]>
@johnmatthewtennant
johnmatthewtennant marked this pull request as ready for review October 20, 2026 15:57

@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 prepared three-dot comparison found two blocking lifecycle defects in Expert-owned TTS replies. Admitted replies can enter playback before the parent accepts output readiness, and playback failures discard recorded delivery progress and finalize partially heard replies as unheard. Supplied GitHub evidence is structurally valid: all ten reported check runs succeeded while the combined status remains pending; required checks still govern merge readiness. Final self-check covered every changed user flow; accessibility and localization completeness; navigation and consent guards; async, error, never-completes, lifecycle, and race behavior; test honesty; project and design-system rules; duplicate and overlapping findings; and concrete evidence plus user effect for blocking severity.

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

Comment thread src-tauri/crates/berd-call/src/main.rs Outdated
Comment thread src-tauri/crates/berd-call/src/main.rs Outdated
johnmatthewtennant and others added 2 commits October 20, 2026 13:15
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]>
@johnmatthewtennant

Copy link
Copy Markdown
Contributor Author

🤖 Combined into #397 with its commits kept, so the stack no longer needs restacking and review covers one diff. Open review findings carry over there. (AI-generated comment)

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