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..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, @@ -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>, @@ -533,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>, }, } @@ -540,6 +579,7 @@ enum ControlCommand { struct PendingRestart { arguments: Vec, expert_spokesperson: bool, + tts: Option, response: SyncSender>, } @@ -557,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, @@ -711,18 +771,42 @@ 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 { + let arguments = &self.session_arguments[1..]; + let realtime = crate::validate_session_arguments(&self.session_arguments)?; + if on == realtime { + return self.status(); + } + 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 { + None + }; + let arguments = match &tts { + Some(settings) => crate::saved_settings::merge_arguments( + arguments, + &tts_arguments(settings, arguments), + ), + None => arguments.to_vec(), + }; + let mut session_arguments = vec!["session".to_string()]; + session_arguments.extend(crate::saved_settings::with_spokesperson( + &arguments, on, direct, + )); + self.restart_with(session_arguments, tts) } fn set_non_blocking(&self, enabled: bool) -> Result { @@ -1156,6 +1240,7 @@ impl SessionActor { ControlCommand::Restart { arguments, expert_spokesperson, + tts, response, } => { if self.stopping { @@ -1167,6 +1252,7 @@ impl SessionActor { self.restart = Some(PendingRestart { arguments, expert_spokesperson, + tts, response, }); } @@ -1490,40 +1576,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 => { @@ -2871,6 +2924,7 @@ mod tests { PendingRestart { arguments: vec!["session".into()], expert_spokesperson: false, + tts: None, response, }, InputDuringTtsPolicy::AllowBargeIn, @@ -2925,6 +2979,7 @@ mod tests { PendingRestart { arguments: arguments.clone(), expert_spokesperson: false, + tts: None, response, }, InputDuringTtsPolicy::AllowBargeIn, @@ -2969,6 +3024,7 @@ mod tests { .handle_command(ControlCommand::Restart { arguments: vec!["session".into()], expert_spokesperson: false, + tts: None, response, }) .unwrap(); @@ -4274,6 +4330,7 @@ mod tests { PendingRestart { arguments: vec!["session".into()], expert_spokesperson: false, + tts: None, response, }, InputDuringTtsPolicy::AllowBargeIn, @@ -4341,6 +4398,97 @@ mod tests { assert!(stop_requested.load(Ordering::SeqCst)); } + #[test] + fn turning_the_spokesperson_on_restarts_the_call_with_the_agents_current_voice() { + 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() + }; + // Agents with their own voice keep the one they have now. + let (arguments, tts) = toggle(Transcript::Stdout { live: true }, 1.25); + assert_eq!( + arguments, + [ + "session", + "--stt-backend", + "openai", + "--tts-backend", + "siri", + "--rate", + "1.25", + "--voice", + "Aaron", + "--language", + "en-US", + "--mode", + "expert-spokesperson", + 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] fn the_expert_speaks_only_through_the_spokesperson_unless_allowed() { let (commands, receiver) = mpsc::sync_channel(1); @@ -4538,6 +4686,7 @@ mod tests { .handle_command(ControlCommand::Restart { arguments: vec!["session".into()], expert_spokesperson: false, + tts: None, response: restart_tx, }) .unwrap(); @@ -4553,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/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..6e5952b82 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, } @@ -110,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, } @@ -133,8 +136,28 @@ fn menu_model(state: &MenuState) -> Vec { { nodes.push(item(format!("Control error: {error}"), None)); } + let expert = state + .switching + .unwrap_or(mode_name(status) == "Expert-Spokesperson"); + nodes.extend([ + SEPARATOR, + MenuNode { + checked: expert, + ..item("Spokesperson", Some(MenuAction::SpokespersonMode(!expert))) + }, + ]); + // 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); - // Replies with their own voice put the Spokesperson's beside it. let agents = if spokesperson.is_null() { "" } else { "Agent " }; nodes.extend(voice_menu( agents, @@ -147,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, @@ -236,6 +249,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, } } @@ -493,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 { @@ -524,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()) @@ -577,6 +599,7 @@ enum ActionDomain { Tts, Mute, InputDuringTts, + Mode, Stop, } @@ -588,6 +611,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"), } @@ -611,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(); @@ -773,6 +802,40 @@ 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)), + .. + } + )); + // 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"); } @@ -810,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] diff --git a/src-tauri/crates/berd-call/src/saved_settings.rs b/src-tauri/crates/berd-call/src/saved_settings.rs index cc83d2396..710bd5950 100644 --- a/src-tauri/crates/berd-call/src/saved_settings.rs +++ b/src-tauri/crates/berd-call/src/saved_settings.rs @@ -186,6 +186,43 @@ 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, + // 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() + .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 +297,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"]) + ); + } }