diff --git a/src-tauri/crates/berd-call/README.md b/src-tauri/crates/berd-call/README.md index c372cd788..0aaf26d5b 100644 --- a/src-tauri/crates/berd-call/README.md +++ b/src-tauri/crates/berd-call/README.md @@ -103,6 +103,8 @@ Use `berd-call settings --input-during-tts allow|suppress` to change whether microphone input can interrupt speech, and `berd-call settings --muted true|false` to mute or unmute microphone input. Both apply live and are reflected by `status`. +`berd-call settings --conversation-status working|waiting` tells the call whether its agent is working or waiting for you, for hosts such as the Buzz plugin that know. The call plays no status sounds until it is first set. `berd-call settings --status-sounds off|working|working-and-waiting`, or Status Sounds in the menu bar, chooses what plays: Pop every five seconds while the agent works (the default), also Purr while it waits, or nothing. The mode is saved for later calls. `status` shows both as `statusSounds` and `conversationStatus`, and both survive `settings --restart`. + Settings the runtime cannot change live, such as STT backend or call mode, use `berd-call settings --restart [session options]`. The replacement options are complete `berd-call session` options. The call keeps its control port, stream, diff --git a/src-tauri/crates/berd-call/src/cli_help.rs b/src-tauri/crates/berd-call/src/cli_help.rs index bef26a44d..c15c9ab1b 100644 --- a/src-tauri/crates/berd-call/src/cli_help.rs +++ b/src-tauri/crates/berd-call/src/cli_help.rs @@ -96,6 +96,8 @@ Usage: 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] --status-sounds off|working|working-and-waiting + berd-call settings [--port PORT] --conversation-status working|waiting berd-call settings [--port PORT] --restart [session options] The setting applies to subsequent speak calls. In a call started with @@ -106,6 +108,11 @@ The setting applies to subsequent speak calls. In a call started with 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. +--conversation-status tells the call whether the agent is working or waiting +for you; the call plays nothing until it is first set. --status-sounds chooses +what plays and is saved for later calls: Pop every five seconds while the agent +works (working, the default), also Purr while it waits (working-and-waiting), or +nothing (off). Both survive --restart. Non-blocking delivery requires a call started with --stream or --codex. Successful delivery produces no notification."#; diff --git a/src-tauri/crates/berd-call/src/host_control.rs b/src-tauri/crates/berd-call/src/host_control.rs index c288c8e34..23748c84e 100644 --- a/src-tauri/crates/berd-call/src/host_control.rs +++ b/src-tauri/crates/berd-call/src/host_control.rs @@ -43,6 +43,13 @@ pub(crate) trait HostControl: Send + Sync + 'static { policy: berd_call::input::InputDuringTtsPolicy, ) -> Result; fn set_muted(&self, muted: bool) -> Result; + /// Which status sounds the call plays, saved for later calls. + fn set_status_sounds(&self, mode: berd_call::StatusSoundMode) -> Result; + /// Whether the agent is working or waiting for the user, as its host reports it. + fn set_conversation_status( + &self, + status: berd_call::ConversationStatus, + ) -> 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; @@ -152,6 +159,12 @@ pub(crate) enum ControlRequest { Muted { muted: bool, }, + StatusSounds { + mode: berd_call::StatusSoundMode, + }, + ConversationStatus { + status: berd_call::ConversationStatus, + }, Restart { #[serde(rename = "sessionArguments")] session_arguments: Vec, @@ -278,6 +291,10 @@ fn handle_connection(mut stream: TcpStream, control: &dyn HostControl) -> Result ControlRequest::NonBlocking { enabled } => control.set_non_blocking(enabled), ControlRequest::InputDuringTts { policy } => control.set_input_during_tts(policy), ControlRequest::Muted { muted } => control.set_muted(muted), + ControlRequest::StatusSounds { mode } => control.set_status_sounds(mode), + ControlRequest::ConversationStatus { status } => { + control.set_conversation_status(status) + } ControlRequest::Restart { session_arguments } => control.restart(session_arguments), ControlRequest::SpokespersonMode { on } => control.set_spokesperson_mode(on), }, @@ -420,6 +437,15 @@ mod tests { fn set_muted(&self, muted: bool) -> Result { Ok(json!({"muted": muted})) } + fn set_status_sounds(&self, mode: berd_call::StatusSoundMode) -> Result { + Ok(json!({"statusSounds": mode})) + } + fn set_conversation_status( + &self, + status: berd_call::ConversationStatus, + ) -> Result { + Ok(json!({"conversationStatus": status})) + } fn restart(&self, session_arguments: Vec) -> Result { Ok(json!({"sessionArguments": session_arguments})) } diff --git a/src-tauri/crates/berd-call/src/host_session.rs b/src-tauri/crates/berd-call/src/host_session.rs index c358edb38..c4af6ad6b 100644 --- a/src-tauri/crates/berd-call/src/host_session.rs +++ b/src-tauri/crates/berd-call/src/host_session.rs @@ -23,7 +23,7 @@ use berd_call::protocol::{ NotAdmittedReason, OutputReadyOutcome, SessionMessage, SessionRequest, UtteranceOrigin, VoiceSessionSnapshot, }; -use berd_call::PocketAudioPlayer; +use berd_call::{ConversationStatus, PocketAudioPlayer, StatusSoundMode, StatusSoundSettings}; use crate::codex::{self, CodexRecord, CodexRelay, CodexTarget}; use crate::host_control::{ControlServer, HostControl}; @@ -108,6 +108,14 @@ pub(crate) fn run(options: StartOptions) -> Result<(), String> { .as_ref() .and_then(|(_, saved)| saved.input_policy) .unwrap_or_else(default_input_during_tts_policy), + status_sounds: Arc::new(Mutex::new(StatusSounds { + mode: options + .saved + .as_ref() + .and_then(|(_, saved)| saved.status_sounds) + .unwrap_or_default(), + status: None, + })), restarted: None, }; loop { @@ -241,9 +249,32 @@ struct SessionStart { mute_epoch: Arc, system_input_mute: Option, input_during_tts: InputDuringTtsPolicy, + status_sounds: Arc>, restarted: Option>>, } +/// The call's status sounds: which ones play, and whether the agent is working, +/// once its host has said. The session plays nothing until then. +#[derive(Clone, Copy, Default)] +struct StatusSounds { + mode: StatusSoundMode, + status: Option, +} + +impl StatusSounds { + fn request(self, id: u64) -> Option { + self.status + .map(|status| SessionRequest::SetConversationStatus { + id, + status, + settings: StatusSoundSettings { + mode: self.mode, + ..StatusSoundSettings::default() + }, + }) + } +} + fn run_session( server: &ControlServer, start: SessionStart, @@ -259,6 +290,7 @@ fn run_session( mute_epoch, system_input_mute, input_during_tts, + status_sounds, restarted, } = start; let started = start_session( @@ -268,6 +300,7 @@ fn run_session( mute_epoch, system_input_mute, input_during_tts, + status_sounds, transcript, non_blocking, stop_requested, @@ -357,6 +390,7 @@ impl SessionActor { .lock() .map(|session| session.input_during_tts.policy) .unwrap_or(input_during_tts), + status_sounds: Arc::clone(&self.status_sounds), restarted: Some(restart.response), } } @@ -379,6 +413,7 @@ fn start_session( mute_epoch: Arc, system_input_mute: Option, input_during_tts: InputDuringTtsPolicy, + status_sounds: Arc>, transcript: &Arc, non_blocking: &Arc, stop_requested: &Arc, @@ -418,6 +453,7 @@ fn start_session( expert_spokesperson, ); actor.saved = saved; + actor.status_sounds = status_sounds; actor .muted .store(muted.load(Ordering::SeqCst), Ordering::SeqCst); @@ -447,6 +483,11 @@ fn start_session( actor.restore_saved_tts(settings, revision, Duration::from_secs(30))?; } actor.restore_input_mute()?; + // A restarted session picks up the status its host last reported. + let sounds = *actor.status_sounds.lock().map_err(|_| "status sounds lock failed")?; + if let Some(request) = sounds.request(actor.next_id()) { + send_request(&actor.writer, &request)?; + } let capture = DefaultInputCapture::start( writer, failure_tx, @@ -461,6 +502,7 @@ fn start_session( non_blocking: Arc::clone(non_blocking), stop_requested: Arc::clone(stop_requested), muted: Arc::clone(&actor.muted), + status_sounds: Arc::clone(&actor.status_sounds), transcript: Arc::clone(transcript), speaks: !expert_spokesperson || arguments @@ -544,6 +586,11 @@ enum ControlCommand { muted: bool, response: SyncSender>, }, + StatusSounds { + mode: Option, + status: Option, + response: SyncSender>, + }, TtsSettings { settings: berd_call::TtsSettings, expected_revision: u64, @@ -590,6 +637,7 @@ struct SessionControl { non_blocking: Arc, stop_requested: Arc, muted: Arc, + status_sounds: Arc>, transcript: Arc, // Whether the Expert may speak to the user rather than only through the Spokesperson. speaks: bool, @@ -804,6 +852,24 @@ impl HostControl for SessionControl { self.status() } + fn set_status_sounds(&self, mode: StatusSoundMode) -> Result { + self.request(|response| ControlCommand::StatusSounds { + mode: Some(mode), + status: None, + response, + })?; + self.status() + } + + fn set_conversation_status(&self, status: ConversationStatus) -> Result { + self.request(|response| ControlCommand::StatusSounds { + mode: None, + status: Some(status), + response, + })?; + self.status() + } + fn restart(&self, session_arguments: Vec) -> Result { self.restart_with(session_arguments, None) } @@ -855,11 +921,17 @@ impl HostControl for SessionControl { } fn status(&self) -> Result { + let sounds = *self + .status_sounds + .lock() + .map_err(|_| "status sounds lock failed")?; Ok(json!({ "running": self.running.load(Ordering::SeqCst), "session": *self.ready.session.lock().map_err(|_| "session settings lock failed")?, "nonBlocking": self.non_blocking.load(Ordering::SeqCst), "muted": self.muted.load(Ordering::SeqCst), + "statusSounds": sounds.mode, + "conversationStatus": sounds.status, "sessionArguments": &self.session_arguments[1..], })) } @@ -933,6 +1005,7 @@ enum SavedPreference { Tts, Spokesperson, InputPolicy, + StatusSounds, } struct SessionActor { @@ -958,6 +1031,7 @@ struct SessionActor { stopping: bool, restart: Option, muted: Arc, + status_sounds: Arc>, suppress_startup_persist: bool, // Immediate requested mute gates capture before the child acknowledges it. // Native callbacks and manual controls write it; `muted` above holds only @@ -1020,6 +1094,7 @@ impl SessionActor { stopping: false, restart: None, muted: Arc::new(AtomicBool::new(false)), + status_sounds: Arc::default(), suppress_startup_persist: false, capture_muted: Arc::new(AtomicBool::new(false)), capture_mute_epoch: Arc::new(AtomicU64::new(0)), @@ -1194,6 +1269,38 @@ impl SessionActor { )?; self.pending_settings.insert(id, response); } + ControlCommand::StatusSounds { + mode, + status, + response, + } => { + // Committed now, so a restart before the reply keeps it. + let sounds = { + let mut sounds = self + .status_sounds + .lock() + .map_err(|_| "status sounds lock failed")?; + sounds.mode = mode.unwrap_or(sounds.mode); + sounds.status = status.or(sounds.status); + *sounds + }; + if mode.is_some() { + if let Some((_, saved)) = &mut self.saved { + saved.status_sounds = mode; + } + self.persist_settings(SavedPreference::StatusSounds); + } + let id = self.next_id(); + match sounds.request(id) { + Some(request) => { + send_request(&self.writer, &request)?; + self.pending_settings.insert(id, response); + } + None => { + let _ = response.send(Ok(json!({}))); + } + } + } ControlCommand::TtsSettings { settings, expected_revision, @@ -1403,6 +1510,11 @@ impl SessionActor { let _ = response.send(Ok(json!({"outcome":outcome,"snapshot":snapshot}))); } } + SessionMessage::ConversationStatusApplied { id, .. } => { + if let Some(response) = self.pending_settings.remove(&id) { + let _ = response.send(Ok(json!({}))); + } + } SessionMessage::InputMuteApplied { id, active } => { // Commit before acknowledging so a restart cannot read a stale value. self.muted.store(active, Ordering::SeqCst); @@ -1589,6 +1701,15 @@ impl SessionActor { let Some((path, saved)) = &mut self.saved else { return; }; + if let SavedPreference::StatusSounds = changed { + let mode = saved.status_sounds; + if let Err(error) = + crate::saved_settings::update(path, |latest| latest.status_sounds = mode) + { + eprintln!("berd-call: live settings applied but could not be saved: {error}"); + } + return; + } let Ok(session) = self.session.lock() else { return; }; @@ -1626,7 +1747,9 @@ impl SessionActor { latest.tts = saved.tts.clone(); } SavedPreference::InputPolicy => latest.input_policy = saved.input_policy, - SavedPreference::Spokesperson => unreachable!("saved above"), + SavedPreference::Spokesperson | SavedPreference::StatusSounds => { + unreachable!("saved above") + } }) { eprintln!("berd-call: live settings applied but could not be saved: {error}"); } @@ -4104,6 +4227,7 @@ mod tests { rate: 1.0, }), input_policy: Some(InputDuringTtsPolicy::SuppressInput), + status_sounds: None, } } @@ -4413,6 +4537,84 @@ mod tests { assert!(child.wait().unwrap().success()); } + #[test] + fn status_sounds_wait_for_a_reported_status_and_save_only_their_mode() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("settings.json"); + let voice = ["--voice", "Aaron", "--language", "en-US"].map(str::to_string); + crate::saved_settings::save( + &path, + &crate::saved_settings::SavedSettings { + arguments: voice.to_vec(), + ..crate::saved_settings::SavedSettings::default() + }, + ) + .unwrap(); + let mut child = Command::new("/bin/cat") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let writer = Arc::new(Mutex::new(child.stdin.take().unwrap())); + let (_events_tx, events) = mpsc::sync_channel(1); + let (_commands_tx, commands) = mpsc::sync_channel(1); + let (audio_commands, _audio_rx) = mpsc::sync_channel(1); + let mut actor = test_actor(writer, events, commands, audio_commands); + actor.saved = Some((path.clone(), crate::saved_settings::SavedSettings::default())); + let sounds = Arc::clone(&actor.status_sounds); + + // Without a status there is nothing to play, so the mode applies at once. + let (response, result) = mpsc::sync_channel(1); + actor + .handle_command(ControlCommand::StatusSounds { + mode: Some(StatusSoundMode::WorkingAndWaiting), + status: None, + response, + }) + .unwrap(); + result.try_recv().unwrap().unwrap(); + let saved = crate::saved_settings::load(&path).unwrap(); + assert_eq!(saved.status_sounds, Some(StatusSoundMode::WorkingAndWaiting)); + assert_eq!(saved.arguments, voice); + + // A reported status reaches the session with the mode, and is + // committed before the session acknowledges it. + let (response, result) = mpsc::sync_channel(1); + let id = actor.next_id; + actor + .handle_command(ControlCommand::StatusSounds { + mode: None, + status: Some(ConversationStatus::Working), + response, + }) + .unwrap(); + assert_eq!( + sounds.lock().unwrap().status, + Some(ConversationStatus::Working) + ); + assert!(result.try_recv().is_err()); + actor + .handle_event(SessionMessage::ConversationStatusApplied { + id, + status: ConversationStatus::Working, + settings: StatusSoundSettings { + mode: StatusSoundMode::WorkingAndWaiting, + ..StatusSoundSettings::default() + }, + }) + .unwrap(); + result.recv().unwrap().unwrap(); + + drop(actor); + let mut sent = Vec::new(); + child.stdout.take().unwrap().read_to_end(&mut sent).unwrap(); + let sent = String::from_utf8_lossy(&sent); + assert!(child.wait().unwrap().success()); + assert_eq!(sent.matches("set_conversation_status").count(), 1); + assert!(sent.contains(r#""status":"working""#)); + assert!(sent.contains(r#""mode":"working-and-waiting""#)); + } + #[test] fn stop_is_recorded_even_when_the_session_never_reads_it() { let (commands, receiver) = mpsc::sync_channel(1); @@ -4426,6 +4628,7 @@ mod tests { non_blocking: Arc::new(AtomicBool::new(false)), stop_requested: Arc::clone(&stop_requested), muted: Arc::new(AtomicBool::new(false)), + status_sounds: Arc::default(), transcript: Arc::new(Transcript::Silent), speaks: true, session_arguments: vec!["session".into()], @@ -4450,6 +4653,7 @@ mod tests { non_blocking: Arc::new(AtomicBool::new(false)), stop_requested: Arc::new(AtomicBool::new(false)), muted: Arc::new(AtomicBool::new(false)), + status_sounds: Arc::default(), transcript: Arc::new(transcript), speaks: true, session_arguments: [ @@ -4540,6 +4744,7 @@ mod tests { non_blocking: Arc::new(AtomicBool::new(false)), stop_requested: Arc::new(AtomicBool::new(false)), muted: Arc::new(AtomicBool::new(false)), + status_sounds: Arc::default(), transcript: Arc::new(Transcript::Stdout { live: true }), speaks: true, session_arguments: [ @@ -4599,6 +4804,7 @@ mod tests { non_blocking: Arc::new(AtomicBool::new(false)), stop_requested: Arc::new(AtomicBool::new(false)), muted: Arc::new(AtomicBool::new(false)), + status_sounds: Arc::default(), transcript: Arc::new(Transcript::Silent), speaks: false, session_arguments: vec!["session".into()], @@ -4637,6 +4843,7 @@ mod tests { non_blocking: Arc::new(AtomicBool::new(false)), stop_requested: Arc::new(AtomicBool::new(false)), muted: Arc::new(AtomicBool::new(false)), + status_sounds: Arc::default(), transcript: Arc::new(Transcript::Silent), speaks: true, session_arguments: vec!["session".into()], @@ -4680,6 +4887,7 @@ mod tests { non_blocking: Arc::new(AtomicBool::new(false)), stop_requested: Arc::new(AtomicBool::new(false)), muted: Arc::new(AtomicBool::new(false)), + status_sounds: Arc::default(), transcript: Arc::new(Transcript::Silent), speaks: true, session_arguments: vec!["session".into()], @@ -4728,6 +4936,7 @@ mod tests { non_blocking: Arc::new(AtomicBool::new(false)), stop_requested: Arc::new(AtomicBool::new(false)), muted: Arc::new(AtomicBool::new(false)), + status_sounds: Arc::default(), transcript: Arc::new(Transcript::Silent), speaks: true, session_arguments: vec!["session".into()], @@ -4782,6 +4991,7 @@ mod tests { non_blocking: Arc::new(AtomicBool::new(false)), stop_requested: Arc::new(AtomicBool::new(false)), muted: Arc::new(AtomicBool::new(false)), + status_sounds: Arc::default(), transcript: Arc::new(Transcript::Silent), speaks: true, session_arguments: vec!["session".into()], diff --git a/src-tauri/crates/berd-call/src/main.rs b/src-tauri/crates/berd-call/src/main.rs index 3a116ed65..019ff654e 100644 --- a/src-tauri/crates/berd-call/src/main.rs +++ b/src-tauri/crates/berd-call/src/main.rs @@ -1118,6 +1118,18 @@ fn parse_host_settings_args( _ => return Err("--input-during-tts requires allow or suppress".into()), }, }, + "--status-sounds" => host_control::ControlRequest::StatusSounds { + mode: args + .get(index + 1) + .and_then(|value| serde_json::from_value(value.as_str().into()).ok()) + .ok_or("--status-sounds requires off, working, or working-and-waiting")?, + }, + "--conversation-status" => host_control::ControlRequest::ConversationStatus { + status: args + .get(index + 1) + .and_then(|value| serde_json::from_value(value.as_str().into()).ok()) + .ok_or("--conversation-status requires working or waiting")?, + }, "--rate" => host_control::ControlRequest::Rate { rate: args .get(index + 1) @@ -1182,7 +1194,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, --spokesperson, or --restart"; + "provide exactly one of --non-blocking, --rate, --voice, --spokesperson-voice, --spokesperson-rate, --tts, --input-during-tts, --muted, --spokesperson, --status-sounds, --conversation-status, or --restart"; fn parse_bool_setting(flag: &str, value: Option<&String>) -> Result { match value.map(String::as_str) { @@ -12768,6 +12780,7 @@ mod tests { rate: 1.5, }), input_policy: Some(berd_call::input::InputDuringTtsPolicy::SuppressInput), + status_sounds: None, }, ) .unwrap(); @@ -12936,6 +12949,41 @@ mod tests { .1, host_control::ControlRequest::SpokespersonMode { on: false } )); + assert!(matches!( + parse_host_settings_args(&args(&[ + "berd-call", + "settings", + "--status-sounds", + "working-and-waiting", + ])) + .unwrap() + .1, + host_control::ControlRequest::StatusSounds { + mode: berd_call::StatusSoundMode::WorkingAndWaiting + } + )); + assert!(matches!( + parse_host_settings_args(&args(&[ + "berd-call", + "settings", + "--conversation-status", + "waiting", + ])) + .unwrap() + .1, + host_control::ControlRequest::ConversationStatus { + status: berd_call::ConversationStatus::Waiting + } + )); + for (flag, value) in [ + ("--status-sounds", "loud"), + ("--status-sounds", "Working"), + ("--conversation-status", "idle"), + ] { + assert!( + parse_host_settings_args(&args(&["berd-call", "settings", flag, value])).is_err() + ); + } 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 fcd63260a..00876d072 100644 --- a/src-tauri/crates/berd-call/src/menu_bar.rs +++ b/src-tauri/crates/berd-call/src/menu_bar.rs @@ -24,6 +24,7 @@ use objc2_foundation::{NSObjectProtocol, NSString, NSTimer}; use serde_json::Value; use berd_call::input::InputDuringTtsPolicy; +use berd_call::StatusSoundMode; use crate::host_control::{self, ControlRequest}; use crate::StartOptions; @@ -71,6 +72,7 @@ pub(crate) enum MenuAction { SpokespersonVoice(String), Muted(bool), InputDuringTts(InputDuringTtsPolicy), + StatusSounds(StatusSoundMode), SpokespersonMode(bool), Stop, } @@ -177,6 +179,8 @@ fn menu_model(state: &MenuState) -> Vec { ) }, SEPARATOR, + status_sounds_menu(status), + SEPARATOR, MenuNode { checked: muted, key: "m", @@ -209,6 +213,30 @@ fn voice_menu( } } +/// What plays while the agent works or waits, as the call reports it. +fn status_sounds_menu(status: &Value) -> MenuNode { + let mode: StatusSoundMode = + serde_json::from_value(status["statusSounds"].clone()).unwrap_or_default(); + let title = |mode| match mode { + StatusSoundMode::Off => "Off", + StatusSoundMode::Working => "While Working", + StatusSoundMode::WorkingAndWaiting => "While Working and Waiting", + }; + MenuNode { + children: [ + StatusSoundMode::Off, + StatusSoundMode::Working, + StatusSoundMode::WorkingAndWaiting, + ] + .map(|option| MenuNode { + checked: option == mode, + ..item(title(option), Some(MenuAction::StatusSounds(option))) + }) + .into(), + ..item(format!("Status Sounds: {}", title(mode)), None) + } +} + /// One speed for every voice, noting the Spokesperson's when it can't go as fast. fn rate_menu(tts: &Value, spokesperson: &Value, range: std::ops::RangeInclusive) -> MenuNode { let speed = |tts: &Value| tts["rate"].as_f64().unwrap_or(1.0) as f32; @@ -244,6 +272,7 @@ pub(crate) fn control_request(action: &MenuAction) -> ControlRequest { }, MenuAction::Muted(muted) => ControlRequest::Muted { muted: *muted }, MenuAction::InputDuringTts(policy) => ControlRequest::InputDuringTts { policy: *policy }, + MenuAction::StatusSounds(mode) => ControlRequest::StatusSounds { mode: *mode }, MenuAction::SpokespersonMode(on) => ControlRequest::SpokespersonMode { on: *on }, MenuAction::Stop => ControlRequest::Stop, } @@ -594,6 +623,7 @@ enum ActionDomain { Tts, Mute, InputDuringTts, + StatusSounds, Mode, Stop, } @@ -606,6 +636,7 @@ fn action_domain(request: &ControlRequest) -> ActionDomain { | ControlRequest::Spokesperson { .. } => ActionDomain::Tts, ControlRequest::Muted { .. } => ActionDomain::Mute, ControlRequest::InputDuringTts { .. } => ActionDomain::InputDuringTts, + ControlRequest::StatusSounds { .. } => ActionDomain::StatusSounds, ControlRequest::SpokespersonMode { .. } => ActionDomain::Mode, ControlRequest::Stop => ActionDomain::Stop, _ => unreachable!("menu only sends supported actions"), @@ -969,6 +1000,48 @@ mod tests { ); } + #[test] + fn status_sounds_menu_shows_and_sets_the_calls_mode() { + let mut status = status(false, "allow_barge_in", None); + let menu = |status: &Value| { + let nodes = menu_model(&MenuState { + status: Some(status.clone()), + ..MenuState::default() + }); + let node = nodes + .iter() + .find(|node| node.title.starts_with("Status Sounds: ")) + .expect("status sounds menu"); + let options: Vec<_> = node + .children + .iter() + .map(|node| (node.title.clone(), node.checked)) + .collect(); + (node.title.clone(), options) + }; + // A call that doesn't report a mode plays the working sound, berd-call's default. + assert_eq!(menu(&status).0, "Status Sounds: While Working"); + status["statusSounds"] = json!("working-and-waiting"); + assert_eq!( + menu(&status), + ( + "Status Sounds: While Working and Waiting".into(), + vec![ + ("Off".into(), false), + ("While Working".into(), false), + ("While Working and Waiting".into(), true), + ] + ) + ); + assert_eq!( + serde_json::to_value(control_request(&MenuAction::StatusSounds( + StatusSoundMode::Off + ))) + .unwrap(), + json!({"command": "status_sounds", "mode": "off"}) + ); + } + #[test] fn actions_become_the_same_requests_as_the_cli() { assert_eq!( diff --git a/src-tauri/crates/berd-call/src/saved_settings.rs b/src-tauri/crates/berd-call/src/saved_settings.rs index ea9674b87..d9a101bae 100644 --- a/src-tauri/crates/berd-call/src/saved_settings.rs +++ b/src-tauri/crates/berd-call/src/saved_settings.rs @@ -1,7 +1,7 @@ use std::io::{Read, Write}; use std::path::{Path, PathBuf}; -use berd_call::{input::InputDuringTtsPolicy, TtsSettings}; +use berd_call::{input::InputDuringTtsPolicy, StatusSoundMode, TtsSettings}; use serde::{Deserialize, Serialize}; /// Call preferences only. Agent identity, transcript routing and credentials @@ -12,6 +12,8 @@ pub(crate) struct SavedSettings { pub arguments: Vec, pub tts: Option, pub input_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status_sounds: Option, } pub(crate) fn path() -> Result {