From 789f3947e0bcc8c882bfc7f337a39776f5061de3 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Tue, 20 Oct 2026 12:09:40 -0400 Subject: [PATCH 1/4] berd-call: turn the Spokesperson on or off from the menu bar 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 Co-Authored-By: Claude Opus 5.5 --- src-tauri/crates/berd-call/README.md | 5 + src-tauri/crates/berd-call/src/cli_help.rs | 9 +- .../crates/berd-call/src/host_control.rs | 10 ++ .../crates/berd-call/src/host_session.rs | 163 ++++++++++++++---- src-tauri/crates/berd-call/src/main.rs | 11 +- src-tauri/crates/berd-call/src/menu_bar.rs | 36 +++- .../crates/berd-call/src/saved_settings.rs | 83 +++++++++ 7 files changed, 279 insertions(+), 38 deletions(-) diff --git a/src-tauri/crates/berd-call/README.md b/src-tauri/crates/berd-call/README.md index 75d8628c6..aa918d9f5 100644 --- a/src-tauri/crates/berd-call/README.md +++ b/src-tauri/crates/berd-call/README.md @@ -109,6 +109,11 @@ complete `berd-call session` options. The call keeps its control port, stream, blocking policy, mute state, and input-during-TTS policy; the stream emits a lifecycle row because transcript cursors restart with the new session. +`berd-call settings --spokesperson true|false`, or Spokesperson in the menu bar, +restarts the call the same way in the other mode. Agents keep their current +voice, options only the other mode takes are left out, and in a `--stream live` +call the Spokesperson comes back with `--allow-direct-agent-speech`. + This crate owns the neutral PCM output contract and backend-neutral TTS stream used by Berd, plus the April ONNX runtime and text chunking used by Berd's native voice commands. It also owns the concrete Parakeet model loader and complete diff --git a/src-tauri/crates/berd-call/src/cli_help.rs b/src-tauri/crates/berd-call/src/cli_help.rs index 5c2ffc270..3aa05d756 100644 --- a/src-tauri/crates/berd-call/src/cli_help.rs +++ b/src-tauri/crates/berd-call/src/cli_help.rs @@ -95,13 +95,18 @@ Usage: berd-call settings [--port PORT] --tts JSON berd-call settings [--port PORT] --input-during-tts allow|suppress berd-call settings [--port PORT] --muted true|false + berd-call settings [--port PORT] --spokesperson true|false berd-call settings [--port PORT] --restart [session options] The setting applies to subsequent speak calls. In a call started with --allow-direct-agent-speech, --voice and --rate change the agents' voice, and --spokesperson-voice and --spokesperson-rate change the Spokesperson's (an -OpenAI realtime voice, 0.25 to 1.5). Non-blocking delivery requires -a call started with --stream or --codex. Successful delivery produces no notification."#; +OpenAI realtime voice, 0.25 to 1.5). --spokesperson turns the Spokesperson on +or off, restarting the call's session in the other mode with the same voice; +options only the other mode takes are dropped, and in a call started with +--stream live, agents keep their own voice beside the Spokesperson's. +Non-blocking delivery requires a call started with --stream or --codex. +Successful delivery produces no notification."#; const STATUS_HELP: &str = r#"Inspect the active local voice call. diff --git a/src-tauri/crates/berd-call/src/host_control.rs b/src-tauri/crates/berd-call/src/host_control.rs index 3cd9ea101..c288c8e34 100644 --- a/src-tauri/crates/berd-call/src/host_control.rs +++ b/src-tauri/crates/berd-call/src/host_control.rs @@ -44,6 +44,8 @@ pub(crate) trait HostControl: Send + Sync + 'static { ) -> Result; fn set_muted(&self, muted: bool) -> Result; fn restart(&self, session_arguments: Vec) -> Result; + /// Restarts the call's session with the Spokesperson on or off. + fn set_spokesperson_mode(&self, on: bool) -> Result; } pub(crate) struct ControlServer { @@ -154,6 +156,9 @@ pub(crate) enum ControlRequest { #[serde(rename = "sessionArguments")] session_arguments: Vec, }, + SpokespersonMode { + on: bool, + }, } #[derive(Deserialize, Serialize)] @@ -204,6 +209,7 @@ pub(crate) fn begin_request(port: u16, request: ControlRequest) -> Result Result ControlRequest::InputDuringTts { policy } => control.set_input_during_tts(policy), ControlRequest::Muted { muted } => control.set_muted(muted), ControlRequest::Restart { session_arguments } => control.restart(session_arguments), + ControlRequest::SpokespersonMode { on } => control.set_spokesperson_mode(on), }, ); let response = match result { @@ -416,6 +423,9 @@ mod tests { fn restart(&self, session_arguments: Vec) -> Result { Ok(json!({"sessionArguments": session_arguments})) } + fn set_spokesperson_mode(&self, on: bool) -> Result { + Ok(json!({"spokesperson": on})) + } fn status(&self) -> Result { Ok(json!({"running": !self.stopped.load(Ordering::SeqCst)})) } diff --git a/src-tauri/crates/berd-call/src/host_session.rs b/src-tauri/crates/berd-call/src/host_session.rs index 507bc9e82..cca4dcdbc 100644 --- a/src-tauri/crates/berd-call/src/host_session.rs +++ b/src-tauri/crates/berd-call/src/host_session.rs @@ -486,6 +486,43 @@ fn default_input_during_tts_policy() -> InputDuringTtsPolicy { } } +/// Session options for these speech settings, keeping a Pocket model directory from `arguments`. +fn tts_arguments(settings: &berd_call::TtsSettings, arguments: &[String]) -> Vec { + let pocket_model_dir = arguments + .chunks_exact(2) + .find(|pair| pair[0] == "--model-dir") + .map(|pair| pair[1].clone()); + let backend = match settings { + berd_call::TtsSettings::Siri { .. } => "siri", + berd_call::TtsSettings::Pocket { .. } => "pocket", + berd_call::TtsSettings::OpenAi { .. } => "openai", + }; + let mut replacement = vec![ + "--tts-backend".to_string(), + backend.to_string(), + "--rate".to_string(), + settings.rate().to_string(), + ]; + match settings { + berd_call::TtsSettings::Siri { + voice, language, .. + } => replacement.extend([ + "--voice".into(), + voice.clone(), + "--language".into(), + language.clone(), + ]), + berd_call::TtsSettings::Pocket { voice, .. } => { + if let Some(model_dir) = pocket_model_dir { + replacement.extend(["--model-dir".into(), model_dir]); + } + replacement.extend(["--voice".into(), voice.clone()]) + } + berd_call::TtsSettings::OpenAi { .. } => {} + } + replacement +} + #[derive(Clone)] struct ReadyState { session: Arc>, @@ -725,6 +762,34 @@ impl HostControl for SessionControl { .map_err(|_| "voice call stopped during restart".to_string())? } + fn set_spokesperson_mode(&self, on: bool) -> Result { + let arguments = &self.session_arguments[1..]; + let realtime = crate::validate_session_arguments(&self.session_arguments)?; + if on == realtime { + return self.status(); + } + // Agents keep the voice they have now; a realtime call's own voice is the Spokesperson's, which a direct call has no use for. + let arguments = if realtime && !self.speaks { + arguments.to_vec() + } else { + let session = self + .ready + .session + .lock() + .map_err(|_| "session settings lock failed")?; + crate::saved_settings::merge_arguments( + arguments, + &tts_arguments(&session.tts.settings, arguments), + ) + }; + let direct = matches!(*self.transcript, Transcript::Stdout { live: true }); + let mut session_arguments = vec!["session".to_string()]; + session_arguments.extend(crate::saved_settings::with_spokesperson( + &arguments, on, direct, + )); + self.restart(session_arguments) + } + fn set_non_blocking(&self, enabled: bool) -> Result { if enabled && !self.transcript.delivers() { return Err(NON_BLOCKING_REQUIRES_DELIVERY.into()); @@ -1490,40 +1555,7 @@ impl SessionActor { return; } saved.tts = Some(session.tts.settings.clone()); - let settings = &session.tts.settings; - let pocket_model_dir = saved - .arguments - .chunks_exact(2) - .find(|pair| pair[0] == "--model-dir") - .map(|pair| pair[1].clone()); - let backend = match settings { - berd_call::TtsSettings::Siri { .. } => "siri", - berd_call::TtsSettings::Pocket { .. } => "pocket", - berd_call::TtsSettings::OpenAi { .. } => "openai", - }; - let mut replacement = vec![ - "--tts-backend".to_string(), - backend.to_string(), - "--rate".to_string(), - settings.rate().to_string(), - ]; - match settings { - berd_call::TtsSettings::Siri { - voice, language, .. - } => replacement.extend([ - "--voice".into(), - voice.clone(), - "--language".into(), - language.clone(), - ]), - berd_call::TtsSettings::Pocket { voice, .. } => { - if let Some(model_dir) = pocket_model_dir { - replacement.extend(["--model-dir".into(), model_dir]); - } - replacement.extend(["--voice".into(), voice.clone()]) - } - berd_call::TtsSettings::OpenAi { .. } => {} - } + let replacement = tts_arguments(&session.tts.settings, &saved.arguments); saved.arguments = crate::saved_settings::merge_arguments(&saved.arguments, &replacement); if let Err(error) = crate::saved_settings::update(path, |latest| match changed { SavedPreference::SessionStart => { @@ -4341,6 +4373,69 @@ mod tests { assert!(stop_requested.load(Ordering::SeqCst)); } + #[test] + fn turning_the_spokesperson_on_restarts_the_call_with_the_agents_current_voice() { + let (commands, receiver) = mpsc::sync_channel(1); + let control = SessionControl { + commands, + running: Arc::new(AtomicBool::new(true)), + ready: ReadyState { + session: Arc::new(Mutex::new(test_snapshot())), + }, + non_blocking: Arc::new(AtomicBool::new(false)), + stop_requested: Arc::new(AtomicBool::new(false)), + muted: Arc::new(AtomicBool::new(false)), + transcript: Arc::new(Transcript::Stdout { live: true }), + speaks: true, + session_arguments: [ + "session", + "--voice", + "Samantha", + "--language", + "en-GB", + "--stt-backend", + "openai", + "--stt-url", + "ws://127.0.0.1:1/stt", + ] + .map(str::to_string) + .to_vec(), + }; + let worker = thread::spawn(move || match receiver.recv().unwrap() { + ControlCommand::Restart { + arguments, + expert_spokesperson, + response, + } => { + response.send(Ok(json!({}))).unwrap(); + (arguments, expert_spokesperson) + } + _ => panic!("expected a restart"), + }); + control.set_spokesperson_mode(true).unwrap(); + let (arguments, expert_spokesperson) = worker.join().unwrap(); + assert!(expert_spokesperson); + assert_eq!( + arguments, + [ + "session", + "--stt-backend", + "openai", + "--tts-backend", + "siri", + "--rate", + "1", + "--voice", + "Aaron", + "--language", + "en-US", + "--mode", + "expert-spokesperson", + crate::DIRECT_AGENT_SPEECH, + ] + ); + } + #[test] fn the_expert_speaks_only_through_the_spokesperson_unless_allowed() { let (commands, receiver) = mpsc::sync_channel(1); diff --git a/src-tauri/crates/berd-call/src/main.rs b/src-tauri/crates/berd-call/src/main.rs index 657f5f647..76bcae1e8 100644 --- a/src-tauri/crates/berd-call/src/main.rs +++ b/src-tauri/crates/berd-call/src/main.rs @@ -1106,6 +1106,9 @@ fn parse_host_settings_args( "--muted" => host_control::ControlRequest::Muted { muted: parse_bool_setting(flag, args.get(index + 1))?, }, + "--spokesperson" => host_control::ControlRequest::SpokespersonMode { + on: parse_bool_setting(flag, args.get(index + 1))?, + }, "--input-during-tts" => host_control::ControlRequest::InputDuringTts { policy: match args.get(index + 1).map(String::as_str) { Some("allow") => berd_call::input::InputDuringTtsPolicy::AllowBargeIn, @@ -1177,7 +1180,7 @@ fn parse_host_settings_args( } const SETTINGS_CHOICE_ERROR: &str = - "provide exactly one of --non-blocking, --rate, --voice, --spokesperson-voice, --spokesperson-rate, --tts, --input-during-tts, --muted, or --restart"; + "provide exactly one of --non-blocking, --rate, --voice, --spokesperson-voice, --spokesperson-rate, --tts, --input-during-tts, --muted, --spokesperson, or --restart"; fn parse_bool_setting(flag: &str, value: Option<&String>) -> Result { match value.map(String::as_str) { @@ -12872,6 +12875,12 @@ mod tests { .1, host_control::ControlRequest::Muted { muted: true } )); + assert!(matches!( + parse_host_settings_args(&args(&["berd-call", "settings", "--spokesperson", "false"])) + .unwrap() + .1, + host_control::ControlRequest::SpokespersonMode { on: false } + )); assert!(matches!( parse_host_settings_args(&args(&[ "berd-call", diff --git a/src-tauri/crates/berd-call/src/menu_bar.rs b/src-tauri/crates/berd-call/src/menu_bar.rs index be5eebab1..864f93ddd 100644 --- a/src-tauri/crates/berd-call/src/menu_bar.rs +++ b/src-tauri/crates/berd-call/src/menu_bar.rs @@ -69,6 +69,7 @@ pub(crate) enum MenuAction { SpokespersonRate(f32), Muted(bool), InputDuringTts(InputDuringTtsPolicy), + SpokespersonMode(bool), Stop, } @@ -133,7 +134,15 @@ fn menu_model(state: &MenuState) -> Vec { { nodes.push(item(format!("Control error: {error}"), None)); } - nodes.push(SEPARATOR); + let expert = mode_name(status) == "Expert-Spokesperson"; + nodes.extend([ + SEPARATOR, + MenuNode { + checked: expert, + ..item("Spokesperson", Some(MenuAction::SpokespersonMode(!expert))) + }, + SEPARATOR, + ]); // Replies with their own voice put the Spokesperson's beside it. let agents = if spokesperson.is_null() { "" } else { "Agent " }; nodes.extend(voice_menu( @@ -236,6 +245,7 @@ pub(crate) fn control_request(action: &MenuAction) -> ControlRequest { }, MenuAction::Muted(muted) => ControlRequest::Muted { muted: *muted }, MenuAction::InputDuringTts(policy) => ControlRequest::InputDuringTts { policy: *policy }, + MenuAction::SpokespersonMode(on) => ControlRequest::SpokespersonMode { on: *on }, MenuAction::Stop => ControlRequest::Stop, } } @@ -577,6 +587,7 @@ enum ActionDomain { Tts, Mute, InputDuringTts, + Mode, Stop, } @@ -588,6 +599,7 @@ fn action_domain(request: &ControlRequest) -> ActionDomain { | ControlRequest::Spokesperson { .. } => ActionDomain::Tts, ControlRequest::Muted { .. } => ActionDomain::Mute, ControlRequest::InputDuringTts { .. } => ActionDomain::InputDuringTts, + ControlRequest::SpokespersonMode { .. } => ActionDomain::Mode, ControlRequest::Stop => ActionDomain::Stop, _ => unreachable!("menu only sends supported actions"), } @@ -773,6 +785,28 @@ mod tests { .. } )); + assert!(matches!( + find(&nodes, "Spokesperson"), + MenuNode { + checked: true, + action: Some(MenuAction::SpokespersonMode(false)), + .. + } + )); + assert!(matches!( + find( + &menu_model(&MenuState { + status: Some(self::status(false, "allow_barge_in", None)), + ..MenuState::default() + }), + "Spokesperson" + ), + MenuNode { + checked: false, + action: Some(MenuAction::SpokespersonMode(true)), + .. + } + )); assert_eq!(icon_symbol(Some(&status)), "mic.slash"); find(&menu_model(&MenuState::default()), "End Call"); } diff --git a/src-tauri/crates/berd-call/src/saved_settings.rs b/src-tauri/crates/berd-call/src/saved_settings.rs index cc83d2396..d5f1964e4 100644 --- a/src-tauri/crates/berd-call/src/saved_settings.rs +++ b/src-tauri/crates/berd-call/src/saved_settings.rs @@ -186,6 +186,41 @@ pub(crate) fn without_tts(arguments: &[String]) -> Vec { .collect() } +/// The same call's session options with the Spokesperson on or off. Options only one mode takes are left out of the +/// other; with the Spokesperson on, `direct` lets agents also speak in their own voice. +pub(crate) fn with_spokesperson(arguments: &[String], on: bool, direct: bool) -> Vec { + let mut result: Vec<_> = arguments + .iter() + .filter(|argument| *argument != crate::DIRECT_AGENT_SPEECH) + .cloned() + .collect::>() + .chunks_exact(2) + .filter(|pair| match pair[0].as_str() { + "--mode" => false, + "--realtime-url" => on, + "--stt-url" => !on, + "--tts-url" => !on || direct, + _ => on || !spokesperson_flag(pair), + }) + .flatten() + .cloned() + .collect(); + result.extend([ + "--mode".into(), + if on { + "expert-spokesperson" + } else { + "conventional" + } + .into(), + ]); + if on && direct { + // Last, since it takes no value. + result.push(crate::DIRECT_AGENT_SPEECH.into()); + } + result +} + #[cfg(test)] mod tests { use super::*; @@ -260,4 +295,52 @@ mod tests { ["--mode", "chained", "--stt-backend", "macos"] ); } + + #[test] + fn turning_the_spokesperson_off_and_on_keeps_the_options_both_modes_take() { + let strings = |values: &[&str]| { + values + .iter() + .map(|value| value.to_string()) + .collect::>() + }; + let realtime = strings(&[ + "--mode", + "expert-spokesperson", + "--voice", + "Aaron", + "--spokesperson-voice", + "cedar", + "--realtime-url", + "wss://realtime.test/", + "--allow-direct-agent-speech", + ]); + let conventional = with_spokesperson(&realtime, false, true); + assert_eq!( + conventional, + strings(&["--voice", "Aaron", "--mode", "conventional"]) + ); + assert_eq!( + with_spokesperson( + &[ + conventional.clone(), + strings(&["--stt-url", "wss://stt.test/"]) + ] + .concat(), + true, + true + ), + strings(&[ + "--voice", + "Aaron", + "--mode", + "expert-spokesperson", + "--allow-direct-agent-speech" + ]) + ); + assert_eq!( + with_spokesperson(&conventional, true, false), + strings(&["--voice", "Aaron", "--mode", "expert-spokesperson"]) + ); + } } From 88948cbbf51cb66497bb8bb1cd36efabf11829e3 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Tue, 20 Oct 2026 12:13:35 -0400 Subject: [PATCH 2/4] berd-call: keep the agents' full voice and a valid speed across the toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-Authored-By: Claude Opus 5.5 --- .../crates/berd-call/src/host_session.rs | 187 +++++++++++------- .../crates/berd-call/src/saved_settings.rs | 2 + 2 files changed, 123 insertions(+), 66 deletions(-) diff --git a/src-tauri/crates/berd-call/src/host_session.rs b/src-tauri/crates/berd-call/src/host_session.rs index cca4dcdbc..bab50fb34 100644 --- a/src-tauri/crates/berd-call/src/host_session.rs +++ b/src-tauri/crates/berd-call/src/host_session.rs @@ -342,7 +342,7 @@ impl SessionActor { SessionStart { saved: self.saved.take().map(|(path, mut saved)| { saved.arguments = saved_settings::persistable_arguments(&restart.arguments[1..]); - saved.tts = None; + saved.tts = restart.tts; (path, saved) }), arguments: restart.arguments, @@ -570,6 +570,8 @@ enum ControlCommand { Restart { arguments: Vec, expert_spokesperson: bool, + // Speech settings to restore in the new session, beyond what its options can say. + tts: Option, response: SyncSender>, }, } @@ -577,6 +579,7 @@ enum ControlCommand { struct PendingRestart { arguments: Vec, expert_spokesperson: bool, + tts: Option, response: SyncSender>, } @@ -594,6 +597,26 @@ struct SessionControl { } impl SessionControl { + fn restart_with( + &self, + session_arguments: Vec, + tts: Option, + ) -> Result { + let expert_spokesperson = crate::validate_session_arguments(&session_arguments)?; + let (response, result) = mpsc::sync_channel(1); + self.commands + .send(ControlCommand::Restart { + arguments: session_arguments, + expert_spokesperson, + tts, + response, + }) + .map_err(|_| "voice call is not running")?; + result + .recv() + .map_err(|_| "voice call stopped during restart".to_string())? + } + fn request( &self, command: impl FnOnce(SyncSender>) -> ControlCommand, @@ -748,18 +771,7 @@ impl HostControl for SessionControl { } fn restart(&self, session_arguments: Vec) -> Result { - let expert_spokesperson = crate::validate_session_arguments(&session_arguments)?; - let (response, result) = mpsc::sync_channel(1); - self.commands - .send(ControlCommand::Restart { - arguments: session_arguments, - expert_spokesperson, - response, - }) - .map_err(|_| "voice call is not running")?; - result - .recv() - .map_err(|_| "voice call stopped during restart".to_string())? + self.restart_with(session_arguments, None) } fn set_spokesperson_mode(&self, on: bool) -> Result { @@ -768,26 +780,33 @@ impl HostControl for SessionControl { if on == realtime { return self.status(); } - // Agents keep the voice they have now; a realtime call's own voice is the Spokesperson's, which a direct call has no use for. - let arguments = if realtime && !self.speaks { - arguments.to_vec() + let direct = matches!(*self.transcript, Transcript::Stdout { live: true }); + // Agents keep the voice they have now, if they speak before and after; otherwise the Spokesperson's voice is its own. + let tts = if self.speaks && (!on || direct) { + Some( + self.ready + .session + .lock() + .map_err(|_| "session settings lock failed")? + .tts + .settings + .clone(), + ) } else { - let session = self - .ready - .session - .lock() - .map_err(|_| "session settings lock failed")?; - crate::saved_settings::merge_arguments( + None + }; + let arguments = match &tts { + Some(settings) => crate::saved_settings::merge_arguments( arguments, - &tts_arguments(&session.tts.settings, arguments), - ) + &tts_arguments(settings, arguments), + ), + None => arguments.to_vec(), }; - let direct = matches!(*self.transcript, Transcript::Stdout { live: true }); let mut session_arguments = vec!["session".to_string()]; session_arguments.extend(crate::saved_settings::with_spokesperson( &arguments, on, direct, )); - self.restart(session_arguments) + self.restart_with(session_arguments, tts) } fn set_non_blocking(&self, enabled: bool) -> Result { @@ -1221,6 +1240,7 @@ impl SessionActor { ControlCommand::Restart { arguments, expert_spokesperson, + tts, response, } => { if self.stopping { @@ -1232,6 +1252,7 @@ impl SessionActor { self.restart = Some(PendingRestart { arguments, expert_spokesperson, + tts, response, }); } @@ -2903,6 +2924,7 @@ mod tests { PendingRestart { arguments: vec!["session".into()], expert_spokesperson: false, + tts: None, response, }, InputDuringTtsPolicy::AllowBargeIn, @@ -2957,6 +2979,7 @@ mod tests { PendingRestart { arguments: arguments.clone(), expert_spokesperson: false, + tts: None, response, }, InputDuringTtsPolicy::AllowBargeIn, @@ -3001,6 +3024,7 @@ mod tests { .handle_command(ControlCommand::Restart { arguments: vec!["session".into()], expert_spokesperson: false, + tts: None, response, }) .unwrap(); @@ -4306,6 +4330,7 @@ mod tests { PendingRestart { arguments: vec!["session".into()], expert_spokesperson: false, + tts: None, response, }, InputDuringTtsPolicy::AllowBargeIn, @@ -4375,46 +4400,55 @@ mod tests { #[test] fn turning_the_spokesperson_on_restarts_the_call_with_the_agents_current_voice() { - let (commands, receiver) = mpsc::sync_channel(1); - let control = SessionControl { - commands, - running: Arc::new(AtomicBool::new(true)), - ready: ReadyState { - session: Arc::new(Mutex::new(test_snapshot())), - }, - non_blocking: Arc::new(AtomicBool::new(false)), - stop_requested: Arc::new(AtomicBool::new(false)), - muted: Arc::new(AtomicBool::new(false)), - transcript: Arc::new(Transcript::Stdout { live: true }), - speaks: true, - session_arguments: [ - "session", - "--voice", - "Samantha", - "--language", - "en-GB", - "--stt-backend", - "openai", - "--stt-url", - "ws://127.0.0.1:1/stt", - ] - .map(str::to_string) - .to_vec(), + let toggle = |transcript, rate| { + let mut snapshot = test_snapshot(); + snapshot.tts.settings = snapshot.tts.settings.with_rate(rate); + let (commands, receiver) = mpsc::sync_channel(1); + let control = SessionControl { + commands, + running: Arc::new(AtomicBool::new(true)), + ready: ReadyState { + session: Arc::new(Mutex::new(snapshot)), + }, + non_blocking: Arc::new(AtomicBool::new(false)), + stop_requested: Arc::new(AtomicBool::new(false)), + muted: Arc::new(AtomicBool::new(false)), + transcript: Arc::new(transcript), + speaks: true, + session_arguments: [ + "session", + "--voice", + "Samantha", + "--language", + "en-GB", + "--rate", + "2", + "--stt-backend", + "openai", + "--stt-url", + "ws://127.0.0.1:1/stt", + ] + .map(str::to_string) + .to_vec(), + }; + let worker = thread::spawn(move || match receiver.recv().unwrap() { + ControlCommand::Restart { + arguments, + expert_spokesperson, + tts, + response, + } => { + response.send(Ok(json!({}))).unwrap(); + assert!(expert_spokesperson); + (arguments, tts) + } + _ => panic!("expected a restart"), + }); + control.set_spokesperson_mode(true).unwrap(); + worker.join().unwrap() }; - let worker = thread::spawn(move || match receiver.recv().unwrap() { - ControlCommand::Restart { - arguments, - expert_spokesperson, - response, - } => { - response.send(Ok(json!({}))).unwrap(); - (arguments, expert_spokesperson) - } - _ => panic!("expected a restart"), - }); - control.set_spokesperson_mode(true).unwrap(); - let (arguments, expert_spokesperson) = worker.join().unwrap(); - assert!(expert_spokesperson); + // Agents with their own voice keep the one they have now. + let (arguments, tts) = toggle(Transcript::Stdout { live: true }, 1.25); assert_eq!( arguments, [ @@ -4424,7 +4458,7 @@ mod tests { "--tts-backend", "siri", "--rate", - "1", + "1.25", "--voice", "Aaron", "--language", @@ -4434,6 +4468,25 @@ mod tests { crate::DIRECT_AGENT_SPEECH, ] ); + assert_eq!(tts, Some(test_snapshot().tts.settings.with_rate(1.25))); + // Otherwise their speed would be the Spokesperson's, which can't go as fast. + let (arguments, tts) = toggle(Transcript::Silent, 2.0); + assert_eq!( + arguments, + [ + "session", + "--voice", + "Samantha", + "--language", + "en-GB", + "--stt-backend", + "openai", + "--mode", + "expert-spokesperson", + ] + ); + assert_eq!(tts, None); + assert!(crate::validate_session_arguments(&arguments).unwrap()); } #[test] @@ -4633,6 +4686,7 @@ mod tests { .handle_command(ControlCommand::Restart { arguments: vec!["session".into()], expert_spokesperson: false, + tts: None, response: restart_tx, }) .unwrap(); @@ -4648,6 +4702,7 @@ mod tests { .handle_command(ControlCommand::Restart { arguments: vec!["session".into()], expert_spokesperson: false, + tts: None, response: late_tx, }) .unwrap(); diff --git a/src-tauri/crates/berd-call/src/saved_settings.rs b/src-tauri/crates/berd-call/src/saved_settings.rs index d5f1964e4..710bd5950 100644 --- a/src-tauri/crates/berd-call/src/saved_settings.rs +++ b/src-tauri/crates/berd-call/src/saved_settings.rs @@ -200,6 +200,8 @@ pub(crate) fn with_spokesperson(arguments: &[String], on: bool, direct: bool) -> "--realtime-url" => on, "--stt-url" => !on, "--tts-url" => !on || direct, + // Without replies of their own, the agents' speed would be the Spokesperson's, which takes a narrower range. + "--rate" => !on || direct, _ => on || !spokesperson_flag(pair), }) .flatten() From c59556642923f647420820740c4677e49c9b9c1b Mon Sep 17 00:00:00 2001 From: John Tennant Date: Tue, 20 Oct 2026 12:40:11 -0400 Subject: [PATCH 3/4] berd-call: tick the Spokesperson checkmark as soon as it's chosen 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 Co-Authored-By: Claude Opus 5.5 --- src-tauri/crates/berd-call/src/menu_bar.rs | 33 ++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src-tauri/crates/berd-call/src/menu_bar.rs b/src-tauri/crates/berd-call/src/menu_bar.rs index 864f93ddd..e9512f7d3 100644 --- a/src-tauri/crates/berd-call/src/menu_bar.rs +++ b/src-tauri/crates/berd-call/src/menu_bar.rs @@ -111,6 +111,8 @@ struct MenuState { status: Option, voices: Vec, action_error: Option, + // The mode a Spokesperson switch is restarting the call in, shown until the switch finishes. + switching: Option, catalog_error: Option, } @@ -134,7 +136,9 @@ fn menu_model(state: &MenuState) -> Vec { { nodes.push(item(format!("Control error: {error}"), None)); } - let expert = mode_name(status) == "Expert-Spokesperson"; + let expert = state + .switching + .unwrap_or(mode_name(status) == "Expert-Spokesperson"); nodes.extend([ SEPARATOR, MenuNode { @@ -503,6 +507,11 @@ fn spawn_coordinator(port: u16, state: Arc>) -> mpsc::SyncSende if latest_by_domain.get(&domain) != Some(&generation) { continue; } + if domain == ActionDomain::Mode { + if let Ok(mut state) = state.lock() { + state.switching = None; + } + } if let Some(error) = menu_action_error(result) { errors.insert(domain, (generation, error)); } else { @@ -534,7 +543,10 @@ fn spawn_coordinator(port: u16, state: Arc>) -> mpsc::SyncSende latest }); if let Ok(mut state) = state.lock() { - state.status = latest; + // The call doesn't answer while it restarts in the other mode. + if latest.is_some() || state.switching.is_none() { + state.status = latest; + } state.voices = catalog .as_ref() .map(|(_, voices)| voices.clone()) @@ -623,6 +635,11 @@ fn begin_menu_action( results: &mpsc::Sender<(u64, ActionDomain, Result)>, state: &Mutex, ) { + if let ControlRequest::SpokespersonMode { on } = request { + if let Ok(mut state) = state.lock() { + state.switching = Some(on); + } + } match host_control::begin_request(port, request) { Ok(pending) => { let results = results.clone(); @@ -807,6 +824,18 @@ mod tests { .. } )); + // A switch shows its mode at once, before the call has restarted in it. + assert!( + find( + &menu_model(&MenuState { + status: Some(self::status(false, "allow_barge_in", None)), + switching: Some(true), + ..MenuState::default() + }), + "Spokesperson" + ) + .checked + ); assert_eq!(icon_symbol(Some(&status)), "mic.slash"); find(&menu_model(&MenuState::default()), "End Call"); } From 39ae41b1be8fafe57bb961beb9b352210cc04c76 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Tue, 20 Oct 2026 12:43:00 -0400 Subject: [PATCH 4/4] berd-call: group the Spokesperson's voice under its checkmark 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 Co-Authored-By: Claude Opus 5.5 --- src-tauri/crates/berd-call/src/menu_bar.rs | 49 ++++++++++++++++------ 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/src-tauri/crates/berd-call/src/menu_bar.rs b/src-tauri/crates/berd-call/src/menu_bar.rs index e9512f7d3..6e5952b82 100644 --- a/src-tauri/crates/berd-call/src/menu_bar.rs +++ b/src-tauri/crates/berd-call/src/menu_bar.rs @@ -145,9 +145,19 @@ fn menu_model(state: &MenuState) -> Vec { checked: expert, ..item("Spokesperson", Some(MenuAction::SpokespersonMode(!expert))) }, - SEPARATOR, ]); - // Replies with their own voice put the Spokesperson's beside it. + // The Spokesperson's own voice sits with it, and replies with their own voice get a section beside it. + if expert && !spokesperson.is_null() { + nodes.extend(voice_menu( + "Spokesperson ", + spokesperson, + &available_voices("openai").unwrap_or_default(), + SPOKESPERSON_RATES, + |entry| MenuAction::SpokespersonVoice(entry.voice.clone()), + MenuAction::SpokespersonRate, + )); + } + nodes.push(SEPARATOR); let agents = if spokesperson.is_null() { "" } else { "Agent " }; nodes.extend(voice_menu( agents, @@ -160,16 +170,6 @@ fn menu_model(state: &MenuState) -> Vec { }, MenuAction::Rate, )); - if !spokesperson.is_null() { - nodes.extend(voice_menu( - "Spokesperson ", - spokesperson, - &available_voices("openai").unwrap_or_default(), - SPOKESPERSON_RATES, - |entry| MenuAction::SpokespersonVoice(entry.voice.clone()), - MenuAction::SpokespersonRate, - )); - } nodes.extend([ MenuNode { checked: suppressing, @@ -873,6 +873,31 @@ mod tests { json!({"command": "spokesperson", "voice": "cedar", "rate": null}) ); find(&nodes, "Agent Voice: Aaron"); + // The Spokesperson's voice is in its section, and the agents' in the next. + let sections: Vec<_> = nodes[1..] + .split(|node| node.title.is_empty()) + .map(|section| { + section + .iter() + .map(|node| node.title.as_str()) + .collect::>() + }) + .collect(); + assert_eq!( + sections[1..3], + [ + vec![ + "Spokesperson", + "Spokesperson Voice: marin", + "Spokesperson Speech Rate: 1×" + ], + vec![ + "Agent Voice: Aaron", + "Agent Speech Rate: 1.25×", + "Mute Input During TTS" + ], + ] + ); } #[test]