From 5324811786fb26e1d8343273732bbc8c280be6e7 Mon Sep 17 00:00:00 2001 From: Gigi <65cc00ce5da00abcd27be4a87ad29d7ef8431766d769a24d94f004fb8fe07066@buzz.block.builderlab.xyz> Date: Thu, 22 Oct 2026 09:11:47 -0400 Subject: [PATCH 1/7] fix(berd-call): preserve status cue cadence across speech Signed-off-by: Gigi <65cc00ce5da00abcd27be4a87ad29d7ef8431766d769a24d94f004fb8fe07066@buzz.block.builderlab.xyz> --- .../crates/berd-call/src/status_sounds.rs | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src-tauri/crates/berd-call/src/status_sounds.rs b/src-tauri/crates/berd-call/src/status_sounds.rs index 67e8a462c..06262cf83 100644 --- a/src-tauri/crates/berd-call/src/status_sounds.rs +++ b/src-tauri/crates/berd-call/src/status_sounds.rs @@ -101,7 +101,6 @@ pub struct StatusSoundRuntime { next_tick: Option, player: StatusSoundPlayer, output_device: Option, - conversation_active: bool, input_controls: Option, } @@ -112,7 +111,6 @@ impl Default for StatusSoundRuntime { next_tick: None, player: StatusSoundPlayer::new(), output_device: None, - conversation_active: false, input_controls: None, } } @@ -145,22 +143,18 @@ impl StatusSoundRuntime { fn update_conversation_activity(&mut self, conversation_active: bool) { if conversation_active { self.stop(); - } else if self.conversation_active { - self.next_tick = Some(Instant::now()); } - self.conversation_active = conversation_active; } pub fn poll(&mut self, conversation_active: bool) -> Result { self.update_conversation_activity(conversation_active); - if conversation_active { - self.player.reap(); - return Ok(false); - } self.player.reap(); let now = Instant::now(); if self.next_tick.is_some_and(|deadline| now >= deadline) { - self.next_tick = Some(now + STATUS_SOUND_INTERVAL); + let deadline = self.next_tick.unwrap(); + let elapsed = now.duration_since(deadline).as_nanos(); + let ticks = elapsed / STATUS_SOUND_INTERVAL.as_nanos() + 1; + self.next_tick = Some(deadline + STATUS_SOUND_INTERVAL.mul_f64(ticks as f64)); if let Some(cue) = self.machine.tick(conversation_active) { self.player.play( cue, @@ -740,27 +734,33 @@ mod tests { } #[test] - fn working_cue_is_due_immediately_after_speech() { - let mut runtime = StatusSoundRuntime::default(); - runtime.update( - ConversationStatus::Working, - settings(StatusSoundMode::Working), - ); - runtime.update_conversation_activity(true); - runtime.update_conversation_activity(false); - assert!(runtime.next_tick.unwrap() <= Instant::now()); + fn speech_preserves_the_existing_cue_schedule() { + for status in [ConversationStatus::Working, ConversationStatus::Waiting] { + let mut runtime = StatusSoundRuntime::default(); + runtime.update(status, settings(StatusSoundMode::WorkingAndWaiting)); + let deadline = Instant::now() + STATUS_SOUND_INTERVAL; + runtime.next_tick = Some(deadline); + runtime.poll(true).unwrap(); + runtime.update_conversation_activity(false); + assert_eq!(runtime.next_tick, Some(deadline)); + } } #[test] - fn waiting_cue_is_due_immediately_after_speech() { + fn cues_due_during_speech_are_skipped_without_catching_up() { let mut runtime = StatusSoundRuntime::default(); runtime.update( - ConversationStatus::Waiting, - settings(StatusSoundMode::WorkingAndWaiting), + ConversationStatus::Working, + settings(StatusSoundMode::Working), ); - runtime.update_conversation_activity(true); - runtime.update_conversation_activity(false); - assert!(runtime.next_tick.unwrap() <= Instant::now()); + let deadline = Instant::now() - Duration::from_secs(1); + runtime.next_tick = Some(deadline); + assert!(!runtime.poll(true).unwrap()); + let next = runtime.next_tick.unwrap(); + assert!(next > Instant::now()); + assert_eq!(next, deadline + STATUS_SOUND_INTERVAL); + assert!(!runtime.poll(false).unwrap()); + assert_eq!(runtime.next_tick, Some(next)); } #[test] From 68b33639b2417992b4e88fe92d6807cd2effbb67 Mon Sep 17 00:00:00 2001 From: Gigi <65cc00ce5da00abcd27be4a87ad29d7ef8431766d769a24d94f004fb8fe07066@buzz.block.builderlab.xyz> Date: Thu, 22 Oct 2026 09:14:39 -0400 Subject: [PATCH 2/7] fix(berd-call): defer changed cues and finish active samples Signed-off-by: Gigi <65cc00ce5da00abcd27be4a87ad29d7ef8431766d769a24d94f004fb8fe07066@buzz.block.builderlab.xyz> --- src-tauri/crates/berd-call/README.md | 2 +- .../crates/berd-call/src/status_sounds.rs | 59 ++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src-tauri/crates/berd-call/README.md b/src-tauri/crates/berd-call/README.md index 7f01b1039..09135d90f 100644 --- a/src-tauri/crates/berd-call/README.md +++ b/src-tauri/crates/berd-call/README.md @@ -143,7 +143,7 @@ device; the existing Berd Siri player and the CLI use the same decoder. in [PROTOCOL.md](PROTOCOL.md). The host supplies persisted status-sound settings and semantic `working` / `waiting` updates through that protocol; the runtime owns the five-second cadence, speech suppression, and macOS Pop/Purr playback. -The default mode is `working`; `working-and-waiting` also plays idle cues, and `off` disables both. Status sounds default to a gain of `0.8`. Cues play immediately on a status transition and repeat every five seconds. Conversation audio cancels the current cue and restarts the full cadence when it ends; missed cues are never replayed. Repeated identical status updates preserve the cadence. The host determines when work begins; the chained client signals working on its first live tool call and stays working until the run ends. +The default mode is `working`; `working-and-waiting` also plays idle cues, and `off` disables both. Status sounds default to a gain of `0.8`. Cues play immediately on a status transition and repeat every five seconds. Conversation audio lets an already-playing cue finish and skips scheduled cues without shifting the cadence. A status transition during conversation audio is announced when that audio ends; skipped periodic cues are never replayed. Repeated identical status updates preserve the cadence. The host determines when work begins; the chained client signals working on its first live tool call and stays working until the run ends. Status-cue playback is single-flight: a cadence tick never queues behind a cue that is still draining. Hosts claim overlapping user and assistant audio through shared activity guards, and the runtime resumes cues only after the final guard diff --git a/src-tauri/crates/berd-call/src/status_sounds.rs b/src-tauri/crates/berd-call/src/status_sounds.rs index 06262cf83..81bd6f2a6 100644 --- a/src-tauri/crates/berd-call/src/status_sounds.rs +++ b/src-tauri/crates/berd-call/src/status_sounds.rs @@ -99,6 +99,7 @@ impl StatusSoundStateMachine { pub struct StatusSoundRuntime { machine: StatusSoundStateMachine, next_tick: Option, + pending_status_cue: bool, player: StatusSoundPlayer, output_device: Option, input_controls: Option, @@ -109,6 +110,7 @@ impl Default for StatusSoundRuntime { Self { machine: StatusSoundStateMachine::default(), next_tick: None, + pending_status_cue: false, player: StatusSoundPlayer::new(), output_device: None, input_controls: None, @@ -129,6 +131,10 @@ impl StatusSoundRuntime { } pub fn update(&mut self, status: ConversationStatus, settings: StatusSoundSettings) { + self.pending_status_cue |= self + .machine + .current + .is_some_and(|(previous, _)| previous != status); let changed = self.machine.update(status, settings); if changed { self.player.stop(); @@ -141,8 +147,9 @@ impl StatusSoundRuntime { } fn update_conversation_activity(&mut self, conversation_active: bool) { - if conversation_active { - self.stop(); + if !conversation_active && self.pending_status_cue { + self.next_tick = Some(Instant::now()); + self.pending_status_cue = false; } } @@ -746,6 +753,23 @@ mod tests { } } + #[test] + fn status_change_during_speech_is_announced_when_speech_finishes() { + for (before, after) in [ + (ConversationStatus::Working, ConversationStatus::Waiting), + (ConversationStatus::Waiting, ConversationStatus::Working), + ] { + let mut runtime = StatusSoundRuntime::default(); + let settings = settings(StatusSoundMode::WorkingAndWaiting); + runtime.update(before, settings); + runtime.poll(true).unwrap(); + runtime.update(after, settings); + runtime.poll(true).unwrap(); + runtime.update_conversation_activity(false); + assert!(runtime.next_tick.unwrap() <= Instant::now()); + } + } + #[test] fn cues_due_during_speech_are_skipped_without_catching_up() { let mut runtime = StatusSoundRuntime::default(); @@ -781,6 +805,37 @@ mod tests { runtime.finish().unwrap(); } + #[cfg(target_os = "macos")] + #[test] + fn speech_does_not_cancel_a_sample_already_playing() { + struct PendingOutput; + impl crate::PcmAudioOutput for PendingOutput { + fn write(&self, _: &[f32]) -> Result<(), String> { + Ok(()) + } + fn cancel(&self) {} + fn is_drained(&self) -> bool { + false + } + fn check_health(&self) -> Result<(), String> { + Ok(()) + } + fn played_frames(&self) -> u64 { + 0 + } + } + let mut runtime = StatusSoundRuntime::default(); + runtime.player.active = Some(ActiveStatusSound { + player: Box::new(PendingOutput), + drain_deadline: Instant::now() + Duration::from_secs(10), + output_tail_deadline: None, + _input_activity: None, + }); + assert!(runtime.poll(true).unwrap()); + assert!(runtime.player.active.is_some()); + runtime.stop(); + } + #[cfg(target_os = "macos")] #[test] fn failed_or_expired_pending_cue_releases_microphone_suppression() { From 8f08d6153b12e2f89e6e2ab2c709d9145014dda3 Mon Sep 17 00:00:00 2001 From: Nora <49aa1f65411fd096d2e2ec144f1e7aa36fdc76d1b907cfdf7be000c66f9d3b8e@buzz.block.builderlab.xyz> Date: Thu, 22 Oct 2026 08:23:44 -0400 Subject: [PATCH 3/7] fix(berd-call): keep status audio off the PCM processing loop Signed-off-by: Nora <49aa1f65411fd096d2e2ec144f1e7aa36fdc76d1b907cfdf7be000c66f9d3b8e@buzz.block.builderlab.xyz> --- src-tauri/crates/berd-call/src/main.rs | 20 ++++---- .../crates/berd-call/src/status_sounds.rs | 49 ++++++++++++++++++- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src-tauri/crates/berd-call/src/main.rs b/src-tauri/crates/berd-call/src/main.rs index 7d4872a73..ed94a06ae 100644 --- a/src-tauri/crates/berd-call/src/main.rs +++ b/src-tauri/crates/berd-call/src/main.rs @@ -50,7 +50,7 @@ use berd_call::spokesperson_voice_update::{ validate_voice_update_settings, VoiceBarrierAction, VoiceUpdateAction, VoiceUpdatePurpose, VoiceUpdateQueue, VoiceUpdateRequest, VoiceUpdateTransaction, }; -use berd_call::StatusSoundRuntime; +use berd_call::ManagedStatusSoundRuntime; use berd_call::{ estimated_spoken_through_utf8, local_assets::{ @@ -2220,7 +2220,7 @@ fn run_session(config: SessionConfig, pcm_output_fd: RawFd) -> Result<(), String let mut held: Option = None; let mut active: Option = None; input_progress.enter("status_assets"); - let mut status_sound_runtime = StatusSoundRuntime::default(); + let status_sound_runtime = ManagedStatusSoundRuntime::spawn(None)?; loop { input_progress.enter("voice_events"); @@ -2295,7 +2295,8 @@ fn run_session(config: SessionConfig, pcm_output_fd: RawFd) -> Result<(), String } let conversation_active = standard_session_status_cues_suppressed(&core, active.is_some()); input_progress.enter("status_sounds"); - let status_sound_result = status_sound_runtime.poll(conversation_active); + let status_sound_result = + status_sound_runtime.queue_conversation_active(conversation_active); if let Err(message) = status_sound_result { eprintln!("status sound playback disabled: {message}"); } @@ -2467,7 +2468,7 @@ fn run_session(config: SessionConfig, pcm_output_fd: RawFd) -> Result<(), String input_runtime = Some(runtime); input_events = Some(events); initialized = true; - status_sound_runtime.set_output_device(status_sound_output_device); + status_sound_runtime.set_output_device(status_sound_output_device)?; let input_policy = InputDuringTtsSlot::new(input_during_tts); let session = VoiceSessionSnapshot { tts: slot.snapshot()?, @@ -2500,7 +2501,7 @@ fn run_session(config: SessionConfig, pcm_output_fd: RawFd) -> Result<(), String status, settings, }) => { - status_sound_runtime.update(status, settings); + status_sound_runtime.update(status, settings)?; write_message( &mut writer, &SessionMessage::ConversationStatusApplied { @@ -3613,7 +3614,7 @@ fn run_expert_spokesperson_session( let mut queued_tts_settings = VoiceUpdateQueue::::default(); let mut pending_voice_update: Option = None; input_progress.enter("status_assets"); - let mut status_sound_runtime = StatusSoundRuntime::default(); + let status_sound_runtime = ManagedStatusSoundRuntime::spawn(None)?; loop { input_progress.enter("maintenance"); @@ -4619,7 +4620,8 @@ fn run_expert_spokesperson_session( let conversation_active = expert_session_status_cues_suppressed(&turn_gate, active.is_some()); input_progress.enter("status_sounds"); - let status_sound_result = status_sound_runtime.poll(conversation_active); + let status_sound_result = + status_sound_runtime.queue_conversation_active(conversation_active); if let Err(message) = status_sound_result { eprintln!("status sound playback disabled: {message}"); } @@ -4813,7 +4815,7 @@ fn run_expert_spokesperson_session( session_tts = Some(tts); input_during_tts_slot = Some(input_policy); initialized = true; - status_sound_runtime.set_output_device(status_sound_output_device); + status_sound_runtime.set_output_device(status_sound_output_device)?; turn_gate.lifecycle.session_started(Instant::now()); write_message( &mut writer, @@ -5237,7 +5239,7 @@ fn run_expert_spokesperson_session( status, settings, }) => { - status_sound_runtime.update(status, settings); + status_sound_runtime.update(status, settings)?; write_message( &mut writer, &SessionMessage::ConversationStatusApplied { diff --git a/src-tauri/crates/berd-call/src/status_sounds.rs b/src-tauri/crates/berd-call/src/status_sounds.rs index 81bd6f2a6..da8534188 100644 --- a/src-tauri/crates/berd-call/src/status_sounds.rs +++ b/src-tauri/crates/berd-call/src/status_sounds.rs @@ -283,7 +283,17 @@ impl ManagedStatusSoundRuntime { self.send(StatusSoundCommand::Update(status, settings)) } + /// Queue activity without waiting for native audio operations. PCM-owning loops + /// must use this path so a slow output device cannot stop microphone draining. + pub fn queue_conversation_active(&self, active: bool) -> Result<(), String> { + self.update_explicit_activity(active, false) + } + pub fn set_conversation_active(&self, active: bool) -> Result<(), String> { + self.update_explicit_activity(active, true) + } + + fn update_explicit_activity(&self, active: bool, wait: bool) -> Result<(), String> { let mut activity = self .inner .activity @@ -294,7 +304,12 @@ impl ManagedStatusSoundRuntime { activity.explicit_active = active; let current = activity.is_active(); if previous != current { - if let Err(error) = self.apply_conversation_active(current) { + let result = if wait { + self.apply_conversation_active(current) + } else { + self.send_conversation_active(current, None) + }; + if let Err(error) = result { activity.explicit_active = previous_explicit; let _ = self.send_conversation_active(previous, None); return Err(error); @@ -583,6 +598,38 @@ mod tests { assert_eq!(actual, Some(expected)); } + #[test] + fn session_activity_does_not_wait_for_a_stalled_audio_worker() { + let (commands, receiver) = mpsc::channel(); + let runtime = ManagedStatusSoundRuntime { + inner: Arc::new(StatusSoundWorker { + commands, + worker: Mutex::new(None), + activity: Mutex::new(StatusSoundActivityState::default()), + }), + }; + let (done, result) = mpsc::channel(); + let caller = thread::spawn(move || { + let outcome = runtime.queue_conversation_active(true); + done.send(outcome).unwrap(); + }); + // The audio worker deliberately never services the command. + let outcome = result.recv_timeout(Duration::from_millis(100)); + // Release the old blocking implementation before asserting the result. + if let Ok(StatusSoundCommand::ConversationActive { + applied: Some(applied), + .. + }) = receiver.try_recv() + { + let _ = applied.send(Ok(())); + } + caller.join().unwrap(); + assert!( + outcome.is_ok(), + "microphone processing waited for the audio worker" + ); + } + #[test] fn managed_worker_finish_joins_and_is_idempotent_across_clones() { let runtime = ManagedStatusSoundRuntime::spawn(None).unwrap(); From a027f2a3e9f4199f574a2c97bb5355c658602c54 Mon Sep 17 00:00:00 2001 From: Gigi <65cc00ce5da00abcd27be4a87ad29d7ef8431766d769a24d94f004fb8fe07066@buzz.block.builderlab.xyz> Date: Thu, 22 Oct 2026 10:09:12 -0400 Subject: [PATCH 4/7] feat(berd-call): play native VAD cues using the TTS input policy Signed-off-by: Gigi <65cc00ce5da00abcd27be4a87ad29d7ef8431766d769a24d94f004fb8fe07066@buzz.block.builderlab.xyz> --- src-tauri/crates/berd-call/README.md | 2 +- src-tauri/crates/berd-call/src/main.rs | 25 +- .../crates/berd-call/src/status_sounds.rs | 289 +++++++++++++++++- 3 files changed, 301 insertions(+), 15 deletions(-) diff --git a/src-tauri/crates/berd-call/README.md b/src-tauri/crates/berd-call/README.md index 09135d90f..0c560ba83 100644 --- a/src-tauri/crates/berd-call/README.md +++ b/src-tauri/crates/berd-call/README.md @@ -143,7 +143,7 @@ device; the existing Berd Siri player and the CLI use the same decoder. in [PROTOCOL.md](PROTOCOL.md). The host supplies persisted status-sound settings and semantic `working` / `waiting` updates through that protocol; the runtime owns the five-second cadence, speech suppression, and macOS Pop/Purr playback. -The default mode is `working`; `working-and-waiting` also plays idle cues, and `off` disables both. Status sounds default to a gain of `0.8`. Cues play immediately on a status transition and repeat every five seconds. Conversation audio lets an already-playing cue finish and skips scheduled cues without shifting the cadence. A status transition during conversation audio is announced when that audio ends; skipped periodic cues are never replayed. Repeated identical status updates preserve the cadence. The host determines when work begins; the chained client signals working on its first live tool call and stays working until the run ends. +The default mode is `working`; `working-and-waiting` also plays idle cues, and `off` disables both. Status sounds default to a gain of `0.8`. Cues play immediately on a status transition and repeat every five seconds. Conversation audio lets an already-playing cue finish and skips scheduled cues without shifting the cadence. A status transition during conversation audio is announced when that audio ends; skipped periodic cues are never replayed. Repeated identical status updates preserve the cadence. Optional VAD debug sounds are off by default. `berd-call settings --vad-debug-sounds on|off` enables distinct quiet clicks when detected speech starts and stops. Initial state and duplicate activity reports stay silent. Status cues and debug clicks follow the current `--input-during-tts` policy, including automatic speaker routing. The host determines when work begins; the chained client signals working on its first live tool call and stays working until the run ends. Status-cue playback is single-flight: a cadence tick never queues behind a cue that is still draining. Hosts claim overlapping user and assistant audio through shared activity guards, and the runtime resumes cues only after the final guard diff --git a/src-tauri/crates/berd-call/src/main.rs b/src-tauri/crates/berd-call/src/main.rs index ed94a06ae..f34130527 100644 --- a/src-tauri/crates/berd-call/src/main.rs +++ b/src-tauri/crates/berd-call/src/main.rs @@ -2226,6 +2226,9 @@ fn run_session(config: SessionConfig, pcm_output_fd: RawFd) -> Result<(), String input_progress.enter("voice_events"); if let Some(events) = input_events.as_mut() { while let Ok(event) = events.try_recv() { + if let VoiceInputEvent::SpeakingChanged(speaking) = &event { + status_sound_runtime.observe_speaking(*speaking)?; + } handle_voice_input_event( event, &mut core, @@ -2464,11 +2467,14 @@ fn run_session(config: SessionConfig, pcm_output_fd: RawFd) -> Result<(), String return Ok(()); } input_progress.enter("hello_ready"); + status_sound_runtime.set_input_controls(runtime.controls())?; input_controls = Some(runtime.controls()); input_runtime = Some(runtime); input_events = Some(events); initialized = true; status_sound_runtime.set_output_device(status_sound_output_device)?; + status_sound_runtime.set_input_policy(input_during_tts)?; + status_sound_runtime.observe_speaking(false)?; let input_policy = InputDuringTtsSlot::new(input_during_tts); let session = VoiceSessionSnapshot { tts: slot.snapshot()?, @@ -2587,6 +2593,7 @@ fn run_session(config: SessionConfig, pcm_output_fd: RawFd) -> Result<(), String Ok(snapshot) => (InputDuringTtsOutcome::Applied, snapshot), Err(snapshot) => (InputDuringTtsOutcome::Rejected, snapshot), }; + status_sound_runtime.set_input_policy(snapshot.policy)?; write_message( &mut writer, &SessionMessage::InputDuringTtsResult { @@ -3614,7 +3621,11 @@ fn run_expert_spokesperson_session( let mut queued_tts_settings = VoiceUpdateQueue::::default(); let mut pending_voice_update: Option = None; input_progress.enter("status_assets"); - let status_sound_runtime = ManagedStatusSoundRuntime::spawn(None)?; + let cue_input_controls = VoiceInputControls::default(); + let status_sound_runtime = ManagedStatusSoundRuntime::spawn_with_input_controls( + None, + Some(cue_input_controls.clone()), + )?; loop { input_progress.enter("maintenance"); @@ -3842,6 +3853,7 @@ fn run_expert_spokesperson_session( } else { turn_gate.finish_user_speaking(); } + status_sound_runtime.observe_speaking(speaking)?; write_message( &mut writer, &SessionMessage::InputSpeaking { active: speaking }, @@ -4697,7 +4709,7 @@ fn run_expert_spokesperson_session( } Input::Pcm(frame) => { if spokesperson_pcm_allowed( - input_muted, + input_muted || cue_input_controls.is_muted(), active.is_some(), input_during_tts_slot .as_ref() @@ -4778,6 +4790,8 @@ fn run_expert_spokesperson_session( rate: spokesperson_config.speed(), }, }; + status_sound_runtime.set_input_policy(input_during_tts)?; + status_sound_runtime.observe_speaking(false)?; let input_policy = InputDuringTtsSlot::new(input_during_tts); let snapshot = VoiceSessionSnapshot { tts: match &reply_tts { @@ -5307,6 +5321,13 @@ fn run_expert_spokesperson_session( .expect("hello initialized input policy"), &mut writer, )?; + status_sound_runtime.set_input_policy( + input_during_tts_slot + .as_ref() + .expect("initialized input policy") + .snapshot()? + .policy, + )?; } Input::Request(SessionRequest::SetTtsSettings { id, diff --git a/src-tauri/crates/berd-call/src/status_sounds.rs b/src-tauri/crates/berd-call/src/status_sounds.rs index da8534188..858db3c44 100644 --- a/src-tauri/crates/berd-call/src/status_sounds.rs +++ b/src-tauri/crates/berd-call/src/status_sounds.rs @@ -34,6 +34,8 @@ pub struct StatusSoundSettings { pub mode: StatusSoundMode, #[serde(default = "default_status_sound_volume")] pub volume: f32, + #[serde(default)] + pub vad_debug_sounds: bool, } impl Default for StatusSoundSettings { @@ -41,6 +43,7 @@ impl Default for StatusSoundSettings { Self { mode: StatusSoundMode::default(), volume: DEFAULT_STATUS_SOUND_VOLUME, + vad_debug_sounds: false, } } } @@ -74,7 +77,9 @@ pub struct StatusSoundStateMachine { impl StatusSoundStateMachine { pub fn update(&mut self, status: ConversationStatus, settings: StatusSoundSettings) -> bool { let next = (status, settings); - let changed = self.current != Some(next); + let changed = self.current.is_none_or(|(previous, old)| { + previous != status || old.mode != settings.mode || old.volume != settings.volume + }); self.current = Some(next); changed } @@ -101,6 +106,9 @@ pub struct StatusSoundRuntime { next_tick: Option, pending_status_cue: bool, player: StatusSoundPlayer, + debug_player: StatusSoundPlayer, + speaking: Option, + input_policy: crate::input::InputDuringTtsPolicy, output_device: Option, input_controls: Option, } @@ -112,6 +120,9 @@ impl Default for StatusSoundRuntime { next_tick: None, pending_status_cue: false, player: StatusSoundPlayer::new(), + debug_player: StatusSoundPlayer::new(), + speaking: None, + input_policy: crate::input::InputDuringTtsPolicy::Auto, output_device: None, input_controls: None, } @@ -123,14 +134,37 @@ impl StatusSoundRuntime { self.input_controls = controls; } + pub fn observe_speaking(&mut self, speaking: bool) -> Result<(), String> { + let previous = self.speaking.replace(speaking); + if debug_transition( + previous, + speaking, + self.machine + .current + .is_some_and(|(_, settings)| settings.vad_debug_sounds), + ) { + self.debug_player.play_debug( + speaking, + self.output_device.as_deref(), + self.input_controls.as_ref(), + self.input_policy, + )?; + } + Ok(()) + } + pub fn set_output_device(&mut self, output_device: Option) { if self.output_device != output_device { self.player.stop(); + self.debug_player.stop(); self.output_device = output_device; } } pub fn update(&mut self, status: ConversationStatus, settings: StatusSoundSettings) { + if !settings.vad_debug_sounds { + self.debug_player.stop(); + } self.pending_status_cue |= self .machine .current @@ -143,6 +177,7 @@ impl StatusSoundRuntime { } pub fn stop(&mut self) { + self.debug_player.stop(); self.player.stop(); } @@ -156,6 +191,7 @@ impl StatusSoundRuntime { pub fn poll(&mut self, conversation_active: bool) -> Result { self.update_conversation_activity(conversation_active); self.player.reap(); + self.debug_player.reap(); let now = Instant::now(); if self.next_tick.is_some_and(|deadline| now >= deadline) { let deadline = self.next_tick.unwrap(); @@ -167,6 +203,7 @@ impl StatusSoundRuntime { cue, self.output_device.as_deref(), self.input_controls.as_ref(), + self.input_policy, )?; } } @@ -174,6 +211,10 @@ impl StatusSoundRuntime { } } +fn debug_transition(previous: Option, speaking: bool, enabled: bool) -> bool { + enabled && previous.is_some_and(|previous| previous != speaking) +} + enum StatusSoundCommand { Update(ConversationStatus, StatusSoundSettings), ConversationActive { @@ -181,6 +222,9 @@ enum StatusSoundCommand { applied: Option>>, }, OutputDevice(Option), + InputPolicy(crate::input::InputDuringTtsPolicy), + InputControls(crate::input::VoiceInputControls), + Speaking(bool), Shutdown, } @@ -249,13 +293,29 @@ impl ManagedStatusSoundRuntime { runtime.set_output_device(device); None } + Ok(StatusSoundCommand::InputPolicy(policy)) => { + runtime.input_policy = policy; + None + } + Ok(StatusSoundCommand::InputControls(controls)) => { + runtime.set_input_controls(Some(controls)); + None + } + Ok(StatusSoundCommand::Speaking(speaking)) => { + if let Err(message) = runtime.observe_speaking(speaking) { + eprintln!("VAD debug sound failed: {message}"); + } + None + } Ok(StatusSoundCommand::Shutdown) | Err(RecvTimeoutError::Disconnected) => { runtime.stop(); break; } Err(RecvTimeoutError::Timeout) => None, }; - let poll_result = runtime.poll(conversation_active).map(|_| ()); + let poll_result = runtime + .poll(conversation_active || runtime.speaking == Some(true)) + .map(|_| ()); if let Some(applied) = applied { let _ = applied.send(poll_result.clone()); } @@ -274,6 +334,24 @@ impl ManagedStatusSoundRuntime { }) } + pub fn set_input_policy( + &self, + policy: crate::input::InputDuringTtsPolicy, + ) -> Result<(), String> { + self.send(StatusSoundCommand::InputPolicy(policy)) + } + + pub fn set_input_controls( + &self, + controls: crate::input::VoiceInputControls, + ) -> Result<(), String> { + self.send(StatusSoundCommand::InputControls(controls)) + } + + pub fn observe_speaking(&self, speaking: bool) -> Result<(), String> { + self.send(StatusSoundCommand::Speaking(speaking)) + } + pub fn update( &self, status: ConversationStatus, @@ -444,10 +522,21 @@ impl StatusSoundPlayer { _cue: StatusSoundCue, _output_device: Option<&str>, _input_controls: Option<&crate::input::VoiceInputControls>, + _input_policy: crate::input::InputDuringTtsPolicy, ) -> Result<(), String> { Err("status sound playback is only available on macOS".into()) } + fn play_debug( + &mut self, + _speaking: bool, + _device: Option<&str>, + _controls: Option<&crate::input::VoiceInputControls>, + _policy: crate::input::InputDuringTtsPolicy, + ) -> Result<(), String> { + Err("VAD debug sound playback is only available on macOS".into()) + } + fn reap(&mut self) {} fn is_active(&self) -> bool { @@ -493,6 +582,7 @@ impl StatusSoundPlayer { cue: StatusSoundCue, output_device: Option<&str>, input_controls: Option<&crate::input::VoiceInputControls>, + input_policy: crate::input::InputDuringTtsPolicy, ) -> Result<(), String> { self.reap(); if self.active.is_some() { @@ -504,27 +594,89 @@ impl StatusSoundPlayer { } .as_ref() .map_err(Clone::clone)?; + Self::play_asset( + &mut self.active, + asset, + cue.volume, + output_device, + input_controls, + input_policy, + ) + } + + fn play_debug( + &mut self, + speaking: bool, + output_device: Option<&str>, + input_controls: Option<&crate::input::VoiceInputControls>, + input_policy: crate::input::InputDuringTtsPolicy, + ) -> Result<(), String> { + self.reap(); + self.stop(); + let sample_rate = 48_000; + let frequency = if speaking { 1200.0 } else { 650.0 }; + let samples = (0..960) + .map(|index| { + let phase = index as f32 / sample_rate as f32; + (phase * frequency * std::f32::consts::TAU).sin() + * (std::f32::consts::PI * index as f32 / 960.0).sin() + }) + .collect(); + Self::play_asset( + &mut self.active, + &StatusSoundAsset { + sample_rate, + samples, + }, + 0.08, + output_device, + input_controls, + input_policy, + ) + } + + fn play_asset( + active: &mut Option, + asset: &StatusSoundAsset, + volume: f32, + output_device: Option<&str>, + input_controls: Option<&crate::input::VoiceInputControls>, + input_policy: crate::input::InputDuringTtsPolicy, + ) -> Result<(), String> { let player = crate::macos_audio_output::PocketAudioPlayer::new( asset.sample_rate, 1.0, output_device, )?; + Self::enqueue_asset( + active, + Box::new(player), + asset, + volume, + input_controls, + input_policy, + ) + } + + fn enqueue_asset( + active: &mut Option, + player: Box, + asset: &StatusSoundAsset, + volume: f32, + input_controls: Option<&crate::input::VoiceInputControls>, + input_policy: crate::input::InputDuringTtsPolicy, + ) -> Result<(), String> { let samples = asset .samples .iter() - .map(|sample| sample * cue.volume) + .map(|sample| sample * volume) .collect::>(); let input_activity = input_controls - .map(|controls| { - controls.begin_assistant_activity( - 0.65, - crate::input::InputDuringTtsPolicy::SuppressInput, - ) - }) + .map(|controls| controls.begin_assistant_activity(0.65, input_policy)) .transpose()?; - player.enqueue(&samples)?; - self.active = Some(ActiveStatusSound { - player: Box::new(player), + player.write(&samples)?; + *active = Some(ActiveStatusSound { + player, drain_deadline: Instant::now() + Duration::from_secs_f64(samples.len() as f64 / f64::from(asset.sample_rate)) + STATUS_SOUND_DRAIN_GRACE, @@ -586,6 +738,115 @@ fn load_system_sound(name: &str) -> Result { mod tests { use super::*; + #[test] + fn debug_cues_require_enabled_real_transitions() { + assert!(!debug_transition(None, true, true)); + assert!(!debug_transition(None, false, true)); + assert!(!debug_transition(Some(false), false, true)); + assert!(!debug_transition(Some(true), true, true)); + assert!(!debug_transition(Some(false), true, false)); + assert!(debug_transition(Some(false), true, true)); + assert!(debug_transition(Some(true), false, true)); + let settings: StatusSoundSettings = serde_json::from_str(r#"{"mode":"off"}"#).unwrap(); + assert!(!settings.vad_debug_sounds); + } + + #[test] + fn debug_toggle_preserves_status_deadline() { + let mut runtime = StatusSoundRuntime::default(); + let mut settings = settings(StatusSoundMode::Working); + runtime.update(ConversationStatus::Working, settings); + let deadline = runtime.next_tick; + settings.vad_debug_sounds = true; + runtime.update(ConversationStatus::Working, settings); + assert_eq!(runtime.next_tick, deadline); + } + + #[cfg(target_os = "macos")] + #[test] + fn native_cue_enqueue_obeys_policy_and_releases_guard_on_failure() { + struct Output { + fail: bool, + } + impl crate::PcmAudioOutput for Output { + fn write(&self, _: &[f32]) -> Result<(), String> { + if self.fail { + Err("enqueue failed".into()) + } else { + Ok(()) + } + } + fn cancel(&self) {} + fn is_drained(&self) -> bool { + false + } + fn check_health(&self) -> Result<(), String> { + Ok(()) + } + fn played_frames(&self) -> u64 { + 0 + } + } + use crate::input::{InputDuringTtsPolicy, VoiceInputControls}; + for policy in [ + InputDuringTtsPolicy::AllowBargeIn, + InputDuringTtsPolicy::SuppressInput, + ] { + for fail in [false, true] { + let controls = VoiceInputControls::default(); + let mut active = None; + let result = StatusSoundPlayer::enqueue_asset( + &mut active, + Box::new(Output { fail }), + &StatusSoundAsset { + sample_rate: 48000, + samples: vec![0.01; 960], + }, + 0.08, + Some(&controls), + policy, + ); + assert_eq!(result.is_err(), fail); + assert_eq!( + controls.is_muted(), + !fail && policy == InputDuringTtsPolicy::SuppressInput + ); + drop(active); + assert!(!controls.is_muted()); + } + } + } + + #[cfg(target_os = "macos")] + #[test] + #[ignore = "opens CoreAudio output and plays short VAD debug clicks"] + fn macos_debug_clicks_follow_input_policy() { + use crate::input::{InputDuringTtsPolicy, VoiceInputControls}; + for policy in [ + InputDuringTtsPolicy::AllowBargeIn, + InputDuringTtsPolicy::SuppressInput, + ] { + let controls = VoiceInputControls::default(); + let mut player = StatusSoundPlayer::new(); + for speaking in [true, false] { + player + .play_debug(speaking, None, Some(&controls), policy) + .unwrap(); + assert_eq!( + controls.is_muted(), + policy == InputDuringTtsPolicy::SuppressInput + ); + let deadline = Instant::now() + Duration::from_secs(5); + while player.is_active() { + assert!(Instant::now() < deadline); + player.reap(); + thread::sleep(Duration::from_millis(10)); + } + assert!(!controls.is_muted()); + } + } + } + fn settings(mode: StatusSoundMode) -> StatusSoundSettings { StatusSoundSettings { mode, @@ -746,6 +1007,7 @@ mod tests { let explicit = StatusSoundSettings { mode: StatusSoundMode::Working, volume: 0.25, + ..Default::default() }; assert_eq!(explicit.validate().unwrap().volume, 0.25); } @@ -756,6 +1018,7 @@ mod tests { assert!(StatusSoundSettings { mode: StatusSoundMode::Working, volume, + ..Default::default() } .validate() .is_err()); @@ -970,6 +1233,7 @@ mod tests { }, None, Some(&controls), + crate::input::InputDuringTtsPolicy::SuppressInput, ) .unwrap(); } @@ -993,6 +1257,7 @@ mod tests { }, None, Some(&controls), + crate::input::InputDuringTtsPolicy::SuppressInput, ) .unwrap(); let deadline = Instant::now() + Duration::from_secs(5); From d91aef64e726cf1be7b3260de2e0a61c5a2db8ef Mon Sep 17 00:00:00 2001 From: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> Date: Thu, 22 Oct 2026 10:17:37 -0400 Subject: [PATCH 5/7] feat(berd-call): persist and control native VAD debug sounds Signed-off-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> --- src-tauri/crates/berd-call/src/cli_help.rs | 1 + .../crates/berd-call/src/host_control.rs | 9 + .../crates/berd-call/src/host_session.rs | 193 ++++++++++++++---- src-tauri/crates/berd-call/src/main.rs | 26 ++- .../crates/berd-call/src/saved_settings.rs | 2 + 5 files changed, 192 insertions(+), 39 deletions(-) diff --git a/src-tauri/crates/berd-call/src/cli_help.rs b/src-tauri/crates/berd-call/src/cli_help.rs index 68e43ce2d..3ae7662af 100644 --- a/src-tauri/crates/berd-call/src/cli_help.rs +++ b/src-tauri/crates/berd-call/src/cli_help.rs @@ -95,6 +95,7 @@ Usage: berd-call settings [--port PORT] --tts JSON berd-call settings [--port PORT] --input-during-tts auto|allow|suppress berd-call settings [--port PORT] --muted true|false + berd-call settings [--port PORT] --vad-debug-sounds on|off 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] --spokesperson true|false diff --git a/src-tauri/crates/berd-call/src/host_control.rs b/src-tauri/crates/berd-call/src/host_control.rs index 2fbea133a..57537ca0a 100644 --- a/src-tauri/crates/berd-call/src/host_control.rs +++ b/src-tauri/crates/berd-call/src/host_control.rs @@ -49,6 +49,7 @@ pub(crate) trait HostControl: Send + Sync + 'static { mode: Option, status: Option, ) -> Result; + fn set_vad_debug_sounds(&self, enabled: 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; @@ -158,6 +159,9 @@ pub(crate) enum ControlRequest { Muted { muted: bool, }, + VadDebugSounds { + enabled: bool, + }, StatusSounds { mode: Option, status: Option, @@ -219,6 +223,7 @@ fn read_timeout(request: &ControlRequest) -> Result, String> { | ControlRequest::Rate { .. } | ControlRequest::Voice { .. } | ControlRequest::Spokesperson { .. } + | ControlRequest::VadDebugSounds { .. } | ControlRequest::StatusSounds { .. } | ControlRequest::Restart { .. } | ControlRequest::SpokespersonMode { .. } @@ -295,6 +300,7 @@ 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::VadDebugSounds { enabled } => control.set_vad_debug_sounds(enabled), ControlRequest::StatusSounds { mode, status } => { control.set_status_sounds(mode, status) } @@ -447,6 +453,9 @@ mod tests { ) -> Result { Ok(json!({"statusSounds": mode, "conversationStatus": status})) } + fn set_vad_debug_sounds(&self, enabled: bool) -> Result { + Ok(json!({"vadDebugSounds": enabled})) + } 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 166c44ede..9ba9af9b8 100644 --- a/src-tauri/crates/berd-call/src/host_session.rs +++ b/src-tauri/crates/berd-call/src/host_session.rs @@ -61,6 +61,10 @@ pub(crate) fn run(options: StartOptions) -> Result<(), String> { .and_then(|(_, saved)| saved.status_sounds) .unwrap_or_default(), status: None, + vad_debug_sounds: options + .saved + .as_ref() + .is_some_and(|(_, saved)| saved.vad_debug_sounds), })); let mut session = SessionStart { saved: options.saved.clone(), @@ -530,6 +534,7 @@ fn tts_arguments(settings: &berd_call::TtsSettings, arguments: &[String]) -> Vec /// The call's status sounds, kept across session restarts. #[derive(Clone, Copy, Default)] struct StatusSounds { + vad_debug_sounds: bool, mode: berd_call::StatusSoundMode, // Unknown until a host reports it, so a call nothing reports on stays silent. status: Option, @@ -556,6 +561,10 @@ enum ControlCommand { muted: bool, response: SyncSender>, }, + VadDebugSounds { + enabled: bool, + response: SyncSender>, + }, StatusSounds { mode: Option, status: Option, @@ -822,6 +831,11 @@ impl HostControl for SessionControl { self.status() } + fn set_vad_debug_sounds(&self, enabled: bool) -> Result { + self.request(|response| ControlCommand::VadDebugSounds { enabled, response })?; + self.status() + } + fn set_status_sounds( &self, mode: Option, @@ -896,6 +910,7 @@ impl HostControl for SessionControl { "nonBlocking": self.non_blocking.load(Ordering::SeqCst), "muted": self.muted.load(Ordering::SeqCst), "statusSounds": sounds.mode, + "vadDebugSounds": sounds.vad_debug_sounds, "conversationStatus": sounds.status, "sessionArguments": &self.session_arguments[1..], })) @@ -972,6 +987,7 @@ enum SavedPreference { Spokesperson, InputPolicy, StatusSounds(berd_call::StatusSoundMode), + VadDebugSounds(bool), } struct SessionActor { @@ -1003,7 +1019,14 @@ struct SessionActor { muted: Arc, status_sounds: Arc>, // Requested sounds and any mode change, committed when the session applies them. - pending_status_sounds: BTreeMap)>, + pending_status_sounds: BTreeMap< + u64, + ( + StatusSounds, + Option, + Option, + ), + >, 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 @@ -1015,6 +1038,54 @@ struct SessionActor { } impl SessionActor { + fn change_sounds( + &mut self, + mode: Option, + status: Option, + debug: Option, + response: SyncSender>, + ) -> Result<(), String> { + // Build on the newest request, since the session applies them in order. + let latest = match self.pending_status_sounds.last_key_value() { + Some((_, (sounds, _, _))) if !self.stopping => *sounds, + _ => *self + .status_sounds + .lock() + .map_err(|_| "status sounds lock failed")?, + }; + let sounds = StatusSounds { + mode: mode.unwrap_or(latest.mode), + vad_debug_sounds: debug.unwrap_or(latest.vad_debug_sounds), + status: status.or(latest.status), + }; + // A stopping child may already have exited; a restarted session + // is sent the committed status when it starts. + let sent = if self.stopping { + None + } else { + self.send_status_sounds(if debug.is_some() && sounds.status.is_none() { + StatusSounds { + mode: berd_call::StatusSoundMode::Off, + status: Some(berd_call::ConversationStatus::Waiting), + ..sounds + } + } else { + sounds + })? + }; + match sent { + Some(id) => { + self.pending_status_sounds.insert(id, (sounds, mode, debug)); + self.pending_settings.insert(id, response); + } + None => { + self.commit_status_sounds(sounds, mode, debug); + let _ = response.send(Ok(json!({}))); + } + } + Ok(()) + } + fn restore_input_mute(&mut self) -> Result<(), String> { // Restore current capture intent rather than stale acknowledged state. let (muted, mute_epoch) = crate::system_input_mute::mute_state_snapshot( @@ -1038,9 +1109,12 @@ impl SessionActor { /// Tells the session its status sounds, once a host has said whether the agent is working. fn send_status_sounds(&mut self, sounds: StatusSounds) -> Result, String> { - let Some(status) = sounds.status else { + if sounds.status.is_none() && !sounds.vad_debug_sounds { return Ok(None); - }; + } + let status = sounds + .status + .unwrap_or(berd_call::ConversationStatus::Waiting); let id = self.next_id(); send_request( &self.writer, @@ -1048,7 +1122,12 @@ impl SessionActor { id, status, settings: berd_call::StatusSoundSettings { - mode: sounds.mode, + mode: if sounds.status.is_some() { + sounds.mode + } else { + berd_call::StatusSoundMode::Off + }, + vad_debug_sounds: sounds.vad_debug_sounds, ..Default::default() }, }, @@ -1060,10 +1139,14 @@ impl SessionActor { &mut self, sounds: StatusSounds, mode: Option, + debug: Option, ) { if let Ok(mut committed) = self.status_sounds.lock() { *committed = sounds; } + if let Some(enabled) = debug { + self.persist_settings(SavedPreference::VadDebugSounds(enabled)); + } if let Some(mode) = mode { self.persist_settings(SavedPreference::StatusSounds(mode)); } @@ -1311,41 +1394,14 @@ impl SessionActor { )?; self.pending_settings.insert(id, response); } + ControlCommand::VadDebugSounds { enabled, response } => { + self.change_sounds(None, None, Some(enabled), response)? + } ControlCommand::StatusSounds { mode, status, response, - } => { - // Build on the newest request, since the session applies them in order. - let latest = match self.pending_status_sounds.last_key_value() { - Some((_, (sounds, _))) if !self.stopping => *sounds, - _ => *self - .status_sounds - .lock() - .map_err(|_| "status sounds lock failed")?, - }; - let sounds = StatusSounds { - mode: mode.unwrap_or(latest.mode), - status: status.or(latest.status), - }; - // A stopping child may already have exited; a restarted session - // is sent the committed status when it starts. - let sent = if self.stopping { - None - } else { - self.send_status_sounds(sounds)? - }; - match sent { - Some(id) => { - self.pending_status_sounds.insert(id, (sounds, mode)); - self.pending_settings.insert(id, response); - } - None => { - self.commit_status_sounds(sounds, mode); - let _ = response.send(Ok(json!({}))); - } - } - } + } => self.change_sounds(mode, status, None, response)?, ControlCommand::TtsSettings { settings, expected_revision, @@ -1576,8 +1632,8 @@ impl SessionActor { } } SessionMessage::ConversationStatusApplied { id, .. } => { - if let Some((sounds, mode)) = self.pending_status_sounds.remove(&id) { - self.commit_status_sounds(sounds, mode); + if let Some((sounds, mode, debug)) = self.pending_status_sounds.remove(&id) { + self.commit_status_sounds(sounds, mode, debug); } if let Some(response) = self.pending_settings.remove(&id) { let _ = response.send(Ok(json!({}))); @@ -1791,6 +1847,15 @@ impl SessionActor { let Ok(session) = self.session.lock() else { return; }; + if let SavedPreference::VadDebugSounds(enabled) = changed { + saved.vad_debug_sounds = enabled; + if let Err(error) = + crate::saved_settings::update(path, |latest| latest.vad_debug_sounds = enabled) + { + eprintln!("berd-call: VAD debug sounds applied but could not be saved: {error}"); + } + return; + } if let SavedPreference::StatusSounds(mode) = changed { saved.status_sounds = Some(mode); if let Err(error) = @@ -1834,7 +1899,9 @@ impl SessionActor { latest.tts = saved.tts.clone(); } SavedPreference::InputPolicy => latest.input_policy = saved.input_policy, - SavedPreference::Spokesperson | SavedPreference::StatusSounds(_) => { + SavedPreference::Spokesperson + | SavedPreference::StatusSounds(_) + | SavedPreference::VadDebugSounds(_) => { unreachable!("saved above") } }) { @@ -3382,6 +3449,54 @@ mod tests { ]))); } + #[test] + fn debug_sound_changes_wait_for_ack_and_preserve_unknown_host_status() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("settings.json"); + let mut child = Command::new("/bin/cat") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .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, _audio_rx) = mpsc::sync_channel(1); + let mut actor = test_actor(writer, events, commands, audio); + actor.saved = Some((path.clone(), Default::default())); + for enabled in [true, false] { + let (response, result) = mpsc::sync_channel(1); + actor + .handle_command(ControlCommand::VadDebugSounds { enabled, response }) + .unwrap(); + assert!(result.try_recv().is_err()); + assert_eq!( + actor.status_sounds.lock().unwrap().vad_debug_sounds, + !enabled + ); + let id = *actor.pending_settings.keys().next().unwrap(); + actor + .handle_event(SessionMessage::ConversationStatusApplied { + id, + status: berd_call::ConversationStatus::Waiting, + settings: berd_call::StatusSoundSettings { + mode: berd_call::StatusSoundMode::Off, + vad_debug_sounds: enabled, + ..Default::default() + }, + }) + .unwrap(); + result.try_recv().unwrap().unwrap(); + assert_eq!( + crate::saved_settings::load(&path).unwrap().vad_debug_sounds, + enabled + ); + assert!(actor.status_sounds.lock().unwrap().status.is_none()); + } + drop(actor); + assert!(child.wait().unwrap().success()); + } + #[test] fn status_sounds_wait_for_a_reported_status_and_save_their_mode() { let directory = tempfile::tempdir().unwrap(); @@ -3455,6 +3570,7 @@ mod tests { let mut actor = test_actor(writer, events, commands, audio); actor.saved = Some((path.clone(), Default::default())); *actor.status_sounds.lock().unwrap() = StatusSounds { + vad_debug_sounds: false, mode: berd_call::StatusSoundMode::Working, status: Some(berd_call::ConversationStatus::Working), }; @@ -5159,6 +5275,7 @@ mod tests { }), input_policy: Some(InputDuringTtsPolicy::SuppressInput), status_sounds: None, + vad_debug_sounds: false, } } diff --git a/src-tauri/crates/berd-call/src/main.rs b/src-tauri/crates/berd-call/src/main.rs index f34130527..9dfbc1413 100644 --- a/src-tauri/crates/berd-call/src/main.rs +++ b/src-tauri/crates/berd-call/src/main.rs @@ -1135,6 +1135,13 @@ fn parse_host_settings_args( "--muted" => host_control::ControlRequest::Muted { muted: parse_bool_setting(flag, args.get(index + 1))?, }, + "--vad-debug-sounds" => host_control::ControlRequest::VadDebugSounds { + enabled: match args.get(index + 1).map(String::as_str) { + Some("on") => true, + Some("off") => false, + _ => return Err("--vad-debug-sounds requires on or off".into()), + }, + }, "--status-sounds" => host_control::ControlRequest::StatusSounds { mode: Some(match args.get(index + 1).map(String::as_str) { Some("off") => berd_call::StatusSoundMode::Off, @@ -1231,7 +1238,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, --status-sounds, --conversation-status, --spokesperson, or --restart"; + "provide exactly one of --non-blocking, --rate, --voice, --spokesperson-voice, --spokesperson-rate, --tts, --input-during-tts, --muted, --vad-debug-sounds, --status-sounds, --conversation-status, --spokesperson, or --restart"; fn parse_bool_setting(flag: &str, value: Option<&String>) -> Result { match value.map(String::as_str) { @@ -8771,6 +8778,22 @@ mod tests { use std::os::unix::net::UnixStream; use std::sync::Mutex; + #[test] + fn debug_sound_cli_accepts_only_on_and_off() { + for (value, enabled) in [("on", true), ("off", false)] { + assert!( + matches!(parse_host_settings_args(&args(&["berd-call", "settings", "--vad-debug-sounds", value])).unwrap().1, host_control::ControlRequest::VadDebugSounds { enabled: actual } if actual == enabled) + ); + } + assert!(parse_host_settings_args(&args(&[ + "berd-call", + "settings", + "--vad-debug-sounds", + "true" + ])) + .is_err()); + } + #[cfg(target_os = "macos")] #[test] fn update_check_only_targets_the_owning_app_bundle() { @@ -13483,6 +13506,7 @@ mod tests { }), input_policy: Some(berd_call::input::InputDuringTtsPolicy::SuppressInput), status_sounds: None, + vad_debug_sounds: false, }, ) .unwrap(); diff --git a/src-tauri/crates/berd-call/src/saved_settings.rs b/src-tauri/crates/berd-call/src/saved_settings.rs index a5bb2253c..1abc27b32 100644 --- a/src-tauri/crates/berd-call/src/saved_settings.rs +++ b/src-tauri/crates/berd-call/src/saved_settings.rs @@ -15,6 +15,8 @@ pub(crate) struct SavedSettings { // Left out until chosen, so a file without one still opens in older berd-call. #[serde(default, skip_serializing_if = "Option::is_none")] pub status_sounds: Option, + #[serde(default)] + pub vad_debug_sounds: bool, } pub(crate) fn path() -> Result { From 9a8108842c35626ca50e4c933b17a7bf723c89ec Mon Sep 17 00:00:00 2001 From: Gigi <65cc00ce5da00abcd27be4a87ad29d7ef8431766d769a24d94f004fb8fe07066@buzz.block.builderlab.xyz> Date: Thu, 22 Oct 2026 10:28:44 -0400 Subject: [PATCH 6/7] fix(berd-call): preserve recognition while suppressing cue input Signed-off-by: Gigi <65cc00ce5da00abcd27be4a87ad29d7ef8431766d769a24d94f004fb8fe07066@buzz.block.builderlab.xyz> --- .../crates/berd-call/src/benchmark/stt.rs | 6 + src-tauri/crates/berd-call/src/input.rs | 144 +++++++++++++++++- src-tauri/crates/berd-call/src/main.rs | 15 +- .../crates/berd-call/src/status_sounds.rs | 67 +++++--- src-tauri/src/commands/native_voice.rs | 11 ++ 5 files changed, 217 insertions(+), 26 deletions(-) diff --git a/src-tauri/crates/berd-call/src/benchmark/stt.rs b/src-tauri/crates/berd-call/src/benchmark/stt.rs index f9cb82bf0..ea4e8d54a 100644 --- a/src-tauri/crates/berd-call/src/benchmark/stt.rs +++ b/src-tauri/crates/berd-call/src/benchmark/stt.rs @@ -555,6 +555,12 @@ fn observe_event( Some(VoiceInputEvent::Ready) => { tracker.error = Some("voice input emitted duplicate readiness".into()) } + Some(VoiceInputEvent::SpeakingReset) => { + tracker.speaking = false; + if tracker.speaking_started.is_some() { + tracker.speaking_ended = Some(now); + } + } Some(VoiceInputEvent::SpeakingChanged(active)) => { tracker.speaking = active; if active { diff --git a/src-tauri/crates/berd-call/src/input.rs b/src-tauri/crates/berd-call/src/input.rs index e0b4c51dd..59b3f4075 100644 --- a/src-tauri/crates/berd-call/src/input.rs +++ b/src-tauri/crates/berd-call/src/input.rs @@ -122,6 +122,8 @@ impl FinalTranscriptStorageReceipt { pub enum VoiceInputEvent { Ready, SpeakingChanged(bool), + /// Detection state was discarded, rather than a measured speech edge. + SpeakingReset, RecognitionPendingChanged(bool), FinalTranscript { text: String, @@ -165,6 +167,7 @@ struct ControlState { transition: Mutex, muted: AtomicBool, mute_epoch: AtomicU64, + cue_suppressions: AtomicU64, assistant_speaking: AtomicBool, assistant_vad_threshold: AtomicU32, } @@ -294,6 +297,7 @@ impl Default for VoiceInputControls { transition: Mutex::new(ControlTransitionState::default()), muted: AtomicBool::new(false), mute_epoch: AtomicU64::new(0), + cue_suppressions: AtomicU64::new(0), assistant_speaking: AtomicBool::new(false), assistant_vad_threshold: AtomicU32::new(0.5_f32.to_bits()), }), @@ -302,6 +306,31 @@ impl Default for VoiceInputControls { } impl VoiceInputControls { + /// Pauses new captured frames without invalidating buffered speech or recognition. + pub fn begin_cue_activity( + &self, + policy: InputDuringTtsPolicy, + output_device: Option<&str>, + ) -> Result { + let suppress = policy.resolve(output_device) == InputDuringTtsPolicy::SuppressInput; + if suppress { + self.shared + .cue_suppressions + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| { + count.checked_add(1) + }) + .map_err(|_| "Too many overlapping cue activity guards".to_string())?; + } + Ok(CueInputActivityGuard { + controls: self.clone(), + suppress, + }) + } + + pub fn is_input_suppressed(&self) -> bool { + self.is_muted() || self.shared.cue_suppressions.load(Ordering::Acquire) > 0 + } + pub fn is_muted(&self) -> bool { self.shared.muted.load(Ordering::Acquire) } @@ -455,6 +484,23 @@ impl VoiceInputControls { } } +#[must_use = "cue input suppression ends when the guard is dropped"] +pub struct CueInputActivityGuard { + controls: VoiceInputControls, + suppress: bool, +} + +impl Drop for CueInputActivityGuard { + fn drop(&mut self) { + if self.suppress { + self.controls + .shared + .cue_suppressions + .fetch_sub(1, Ordering::AcqRel); + } + } +} + #[must_use = "assistant activity ends when the guard is dropped"] pub struct AssistantActivityGuard { controls: VoiceInputControls, @@ -590,11 +636,11 @@ impl VoiceInputRuntime { if self.shutdown.load(Ordering::Acquire) { return Err("Voice input recognition is no longer running.".to_string()); } - if self.controls.is_muted() { + if self.controls.is_input_suppressed() { return Ok(()); } let mute_epoch = self.controls.mute_epoch(); - if self.controls.is_muted() || mute_epoch != self.controls.mute_epoch() { + if self.controls.is_input_suppressed() || mute_epoch != self.controls.mute_epoch() { return Ok(()); } match self.frame_tx.try_send(QueuedFrame { frame, mute_epoch }) { @@ -807,7 +853,7 @@ fn clear_speech_state( leftover_16k.clear(); *silence_frames = 0; if std::mem::take(in_speech) { - let _ = events.blocking_send(VoiceInputEvent::SpeakingChanged(false)); + let _ = events.blocking_send(VoiceInputEvent::SpeakingReset); } } @@ -1562,7 +1608,7 @@ fn mac_speech_worker( leftover_16k.clear(); silence_frames = 0; if std::mem::take(&mut in_speech) { - let _ = events.blocking_send(VoiceInputEvent::SpeakingChanged(false)); + let _ = events.blocking_send(VoiceInputEvent::SpeakingReset); } let _ = pending.reset(&events); settle_deadline = None; @@ -1939,7 +1985,7 @@ fn openai_worker( completed.clear(); let _ = pending.reset(&events); if std::mem::take(&mut in_speech) { - let _ = events.blocking_send(VoiceInputEvent::SpeakingChanged(false)); + let _ = events.blocking_send(VoiceInputEvent::SpeakingReset); } send_operation!(client.clear(), "clear reset OpenAI transcription audio", { break 'worker; @@ -2166,6 +2212,62 @@ fn openai_worker( mod tests { use super::*; + #[test] + fn cue_auto_policy_uses_selected_speaker_route() { + let controls = VoiceInputControls::default(); + let guard = controls + .begin_cue_activity(InputDuringTtsPolicy::Auto, Some("Test Speakers")) + .unwrap(); + assert!(controls.is_input_suppressed()); + assert_eq!(controls.mute_epoch(), 0); + drop(guard); + assert!(!controls.is_input_suppressed()); + } + + #[test] + fn cue_suppression_preserves_recognition_epoch_and_composes_with_host_mute() { + let controls = VoiceInputControls::default(); + let epoch = controls.mute_epoch(); + let first = controls + .begin_cue_activity(InputDuringTtsPolicy::SuppressInput, None) + .unwrap(); + let second = controls + .begin_cue_activity(InputDuringTtsPolicy::SuppressInput, None) + .unwrap(); + assert!(controls.is_input_suppressed()); + assert!(!controls.is_muted()); + assert_eq!(controls.mute_epoch(), epoch); + drop(first); + assert!(controls.is_input_suppressed()); + drop(second); + assert!(!controls.is_input_suppressed()); + assert_eq!(controls.mute_epoch(), epoch); + let allow = controls + .begin_cue_activity(InputDuringTtsPolicy::AllowBargeIn, None) + .unwrap(); + assert!(!controls.is_input_suppressed()); + controls.set_host_muted(true); + drop(allow); + assert!(controls.is_input_suppressed()); + } + + #[test] + fn clearing_speech_reports_a_reset_instead_of_a_vad_edge() { + let (events, mut receiver) = tokio_mpsc::channel(2); + clear_speech_state( + &mut Vec::new(), + &mut Vec::new(), + &mut Vec::new(), + &mut 0, + &mut true, + &events, + ); + assert!(matches!( + receiver.try_recv(), + Ok(VoiceInputEvent::SpeakingReset) + )); + } + #[test] fn default_output_is_classified_by_its_name() { for name in ["USB Speakers", "Bluetooth SPEAKER", "Altavoces externos"] { @@ -2643,7 +2745,9 @@ mod tests { match event { VoiceInputEvent::Ready => observed.push("ready"), VoiceInputEvent::SpeakingChanged(true) => observed.push("speaking:true"), - VoiceInputEvent::SpeakingChanged(false) => observed.push("speaking:false"), + VoiceInputEvent::SpeakingChanged(false) | VoiceInputEvent::SpeakingReset => { + observed.push("speaking:false") + } VoiceInputEvent::RecognitionPendingChanged(true) => observed.push("pending:true"), VoiceInputEvent::RecognitionPendingChanged(false) => observed.push("pending:false"), VoiceInputEvent::FinalTranscript { @@ -3104,6 +3208,34 @@ mod tests { assert_ne!(queued.mute_epoch, controls.mute_epoch()); } + #[test] + fn cue_pause_drops_only_new_pcm_and_keeps_recognition_epoch() { + let (frame_tx, frame_rx) = mpsc::sync_channel(INPUT_QUEUE_FRAMES); + let controls = VoiceInputControls::default(); + let runtime = VoiceInputRuntime { + frame_tx, + controls: controls.clone(), + shutdown: Arc::new(AtomicBool::new(false)), + discard_on_shutdown: Arc::new(AtomicBool::new(false)), + shutdown_mute_epoch: Arc::new(AtomicU64::new(0)), + worker: None, + }; + runtime.try_push_frame(silence_frame()).unwrap(); + let before = frame_rx.try_recv().unwrap(); + let cue = controls + .begin_cue_activity(InputDuringTtsPolicy::SuppressInput, None) + .unwrap(); + runtime.try_push_frame(silence_frame()).unwrap(); + assert!(frame_rx.try_recv().is_err()); + assert_eq!(before.mute_epoch, controls.mute_epoch()); + drop(cue); + runtime.try_push_frame(silence_frame()).unwrap(); + let after = frame_rx.try_recv().unwrap(); + assert_eq!(before.mute_epoch, after.mute_epoch); + runtime.signal_shutdown(false); + assert!(!runtime.discard_on_shutdown.load(Ordering::Acquire)); + } + #[test] fn input_during_tts_policy_controls_frame_admission() { let (frame_tx, frame_rx) = mpsc::sync_channel(INPUT_QUEUE_FRAMES); diff --git a/src-tauri/crates/berd-call/src/main.rs b/src-tauri/crates/berd-call/src/main.rs index 9dfbc1413..75875da76 100644 --- a/src-tauri/crates/berd-call/src/main.rs +++ b/src-tauri/crates/berd-call/src/main.rs @@ -2233,8 +2233,12 @@ fn run_session(config: SessionConfig, pcm_output_fd: RawFd) -> Result<(), String input_progress.enter("voice_events"); if let Some(events) = input_events.as_mut() { while let Ok(event) = events.try_recv() { - if let VoiceInputEvent::SpeakingChanged(speaking) = &event { - status_sound_runtime.observe_speaking(*speaking)?; + match &event { + VoiceInputEvent::SpeakingChanged(speaking) => { + status_sound_runtime.observe_speaking(*speaking)? + } + VoiceInputEvent::SpeakingReset => status_sound_runtime.reset_speaking()?, + _ => {} } handle_voice_input_event( event, @@ -4716,7 +4720,7 @@ fn run_expert_spokesperson_session( } Input::Pcm(frame) => { if spokesperson_pcm_allowed( - input_muted || cue_input_controls.is_muted(), + input_muted || cue_input_controls.is_input_suppressed(), active.is_some(), input_during_tts_slot .as_ref() @@ -7851,6 +7855,11 @@ fn handle_voice_input_event( VoiceInputEvent::Ready => { return Err("voice input emitted a duplicate readiness event".into()) } + VoiceInputEvent::SpeakingReset => { + core.set_user_speaking(false); + write_message(writer, &SessionMessage::InputSpeaking { active: false })?; + update_provisional_suspension(core, active)?; + } VoiceInputEvent::SpeakingChanged(speaking) => { core.set_user_speaking(speaking); write_message(writer, &SessionMessage::InputSpeaking { active: speaking })?; diff --git a/src-tauri/crates/berd-call/src/status_sounds.rs b/src-tauri/crates/berd-call/src/status_sounds.rs index 858db3c44..bd49bf215 100644 --- a/src-tauri/crates/berd-call/src/status_sounds.rs +++ b/src-tauri/crates/berd-call/src/status_sounds.rs @@ -134,6 +134,10 @@ impl StatusSoundRuntime { self.input_controls = controls; } + pub fn reset_speaking(&mut self) { + self.speaking = None; + } + pub fn observe_speaking(&mut self, speaking: bool) -> Result<(), String> { let previous = self.speaking.replace(speaking); if debug_transition( @@ -225,6 +229,7 @@ enum StatusSoundCommand { InputPolicy(crate::input::InputDuringTtsPolicy), InputControls(crate::input::VoiceInputControls), Speaking(bool), + ResetSpeaking, Shutdown, } @@ -301,6 +306,10 @@ impl ManagedStatusSoundRuntime { runtime.set_input_controls(Some(controls)); None } + Ok(StatusSoundCommand::ResetSpeaking) => { + runtime.reset_speaking(); + None + } Ok(StatusSoundCommand::Speaking(speaking)) => { if let Err(message) = runtime.observe_speaking(speaking) { eprintln!("VAD debug sound failed: {message}"); @@ -348,6 +357,10 @@ impl ManagedStatusSoundRuntime { self.send(StatusSoundCommand::InputControls(controls)) } + pub fn reset_speaking(&self) -> Result<(), String> { + self.send(StatusSoundCommand::ResetSpeaking) + } + pub fn observe_speaking(&self, speaking: bool) -> Result<(), String> { self.send(StatusSoundCommand::Speaking(speaking)) } @@ -557,7 +570,7 @@ struct ActiveStatusSound { player: Box, drain_deadline: Instant, output_tail_deadline: Option, - _input_activity: Option, + _input_activity: Option, } #[cfg(target_os = "macos")] @@ -654,7 +667,7 @@ impl StatusSoundPlayer { asset, volume, input_controls, - input_policy, + input_policy.resolve(output_device), ) } @@ -672,7 +685,7 @@ impl StatusSoundPlayer { .map(|sample| sample * volume) .collect::>(); let input_activity = input_controls - .map(|controls| controls.begin_assistant_activity(0.65, input_policy)) + .map(|controls| controls.begin_cue_activity(input_policy, None)) .transpose()?; player.write(&samples)?; *active = Some(ActiveStatusSound { @@ -808,11 +821,11 @@ mod tests { ); assert_eq!(result.is_err(), fail); assert_eq!( - controls.is_muted(), + controls.is_input_suppressed(), !fail && policy == InputDuringTtsPolicy::SuppressInput ); drop(active); - assert!(!controls.is_muted()); + assert!(!controls.is_input_suppressed()); } } } @@ -833,7 +846,7 @@ mod tests { .play_debug(speaking, None, Some(&controls), policy) .unwrap(); assert_eq!( - controls.is_muted(), + controls.is_input_suppressed(), policy == InputDuringTtsPolicy::SuppressInput ); let deadline = Instant::now() + Duration::from_secs(5); @@ -842,11 +855,31 @@ mod tests { player.reap(); thread::sleep(Duration::from_millis(10)); } - assert!(!controls.is_muted()); + assert!(!controls.is_input_suppressed()); } } } + #[test] + fn restored_speech_after_reset_is_silent() { + let mut runtime = StatusSoundRuntime::default(); + runtime.update( + ConversationStatus::Waiting, + StatusSoundSettings { + mode: StatusSoundMode::Off, + vad_debug_sounds: true, + ..Default::default() + }, + ); + runtime.speaking = Some(true); + runtime.reset_speaking(); + runtime.observe_speaking(false).unwrap(); + assert!(!runtime.debug_player.is_active()); + runtime.reset_speaking(); + runtime.observe_speaking(true).unwrap(); + assert!(!runtime.debug_player.is_active()); + } + fn settings(mode: StatusSoundMode) -> StatusSoundSettings { StatusSoundSettings { mode, @@ -1161,7 +1194,7 @@ mod tests { } fn cancel(&self) { assert!( - self.controls.is_muted(), + self.controls.is_input_suppressed(), "cancel before releasing input suppression" ); self.cancelled.store(true, Ordering::SeqCst); @@ -1200,21 +1233,21 @@ mod tests { output_tail_deadline: None, _input_activity: Some( controls - .begin_assistant_activity( - 0.65, + .begin_cue_activity( crate::input::InputDuringTtsPolicy::SuppressInput, + None, ) .unwrap(), ), }), }; - assert!(controls.is_muted()); + assert!(controls.is_input_suppressed()); player.reap(); assert_eq!(player.is_active(), !failed && !expired); - assert_eq!(controls.is_muted(), !failed && !expired); + assert_eq!(controls.is_input_suppressed(), !failed && !expired); assert_eq!(cancelled.load(Ordering::SeqCst), failed || expired); player.stop(); - assert!(!controls.is_muted()); + assert!(!controls.is_input_suppressed()); } } @@ -1238,9 +1271,9 @@ mod tests { .unwrap(); } assert!(player.active.is_some()); - assert!(controls.is_muted()); + assert!(controls.is_input_suppressed()); player.stop(); - assert!(!controls.is_muted()); + assert!(!controls.is_input_suppressed()); } #[cfg(target_os = "macos")] @@ -1262,11 +1295,11 @@ mod tests { .unwrap(); let deadline = Instant::now() + Duration::from_secs(5); while player.is_active() { - assert!(controls.is_muted()); + assert!(controls.is_input_suppressed()); assert!(Instant::now() < deadline, "status cue did not drain"); player.reap(); thread::sleep(Duration::from_millis(10)); } - assert!(!controls.is_muted()); + assert!(!controls.is_input_suppressed()); } } diff --git a/src-tauri/src/commands/native_voice.rs b/src-tauri/src/commands/native_voice.rs index 9e5c3c1f2..b53e83489 100644 --- a/src-tauri/src/commands/native_voice.rs +++ b/src-tauri/src/commands/native_voice.rs @@ -1761,6 +1761,17 @@ pub async fn start_native_voice_conversation( berd_call::input::VoiceInputEvent::Ready => { log::warn!("Voice input emitted duplicate readiness"); } + berd_call::input::VoiceInputEvent::SpeakingReset => { + admission.set_user_speaking(false); + event_state.set_status_sound_input_activity(&session_id, revision, false); + let event = NativeVoiceEvent::Activity { + session_id: session_id.clone(), + activity: "user-idle", + revision, + }; + let _ = event_window.emit(EVENT_NAME, event.clone()); + super::voice_buddy::emit(&event_app, event); + } berd_call::input::VoiceInputEvent::SpeakingChanged(speaking) => { admission.set_user_speaking(speaking); event_state.set_status_sound_input_activity(&session_id, revision, speaking); From 6d4c1e8f239707e417968925d1f7246241d11f84 Mon Sep 17 00:00:00 2001 From: Gigi <65cc00ce5da00abcd27be4a87ad29d7ef8431766d769a24d94f004fb8fe07066@buzz.block.builderlab.xyz> Date: Thu, 22 Oct 2026 12:32:27 -0400 Subject: [PATCH 7/7] refactor(berd-call): move optional VAD debug cues out of release fixes Signed-off-by: Gigi <65cc00ce5da00abcd27be4a87ad29d7ef8431766d769a24d94f004fb8fe07066@buzz.block.builderlab.xyz> --- src-tauri/crates/berd-call/README.md | 2 +- .../crates/berd-call/src/benchmark/stt.rs | 6 - src-tauri/crates/berd-call/src/cli_help.rs | 1 - .../crates/berd-call/src/host_control.rs | 9 - .../crates/berd-call/src/host_session.rs | 194 ++++-------------- src-tauri/crates/berd-call/src/input.rs | 29 +-- src-tauri/crates/berd-call/src/main.rs | 42 +--- .../crates/berd-call/src/saved_settings.rs | 37 +++- .../crates/berd-call/src/status_sounds.rs | 183 +---------------- src-tauri/src/commands/native_voice.rs | 11 - 10 files changed, 83 insertions(+), 431 deletions(-) diff --git a/src-tauri/crates/berd-call/README.md b/src-tauri/crates/berd-call/README.md index 0c560ba83..819d49db5 100644 --- a/src-tauri/crates/berd-call/README.md +++ b/src-tauri/crates/berd-call/README.md @@ -143,7 +143,7 @@ device; the existing Berd Siri player and the CLI use the same decoder. in [PROTOCOL.md](PROTOCOL.md). The host supplies persisted status-sound settings and semantic `working` / `waiting` updates through that protocol; the runtime owns the five-second cadence, speech suppression, and macOS Pop/Purr playback. -The default mode is `working`; `working-and-waiting` also plays idle cues, and `off` disables both. Status sounds default to a gain of `0.8`. Cues play immediately on a status transition and repeat every five seconds. Conversation audio lets an already-playing cue finish and skips scheduled cues without shifting the cadence. A status transition during conversation audio is announced when that audio ends; skipped periodic cues are never replayed. Repeated identical status updates preserve the cadence. Optional VAD debug sounds are off by default. `berd-call settings --vad-debug-sounds on|off` enables distinct quiet clicks when detected speech starts and stops. Initial state and duplicate activity reports stay silent. Status cues and debug clicks follow the current `--input-during-tts` policy, including automatic speaker routing. The host determines when work begins; the chained client signals working on its first live tool call and stays working until the run ends. +The default mode is `working`; `working-and-waiting` also plays idle cues, and `off` disables both. Status sounds default to a gain of `0.8`. Cues play immediately on a status transition and repeat every five seconds. Conversation audio lets an already-playing cue finish and skips scheduled cues without shifting the cadence. A status transition during conversation audio is announced when that audio ends; skipped periodic cues are never replayed. Repeated identical status updates preserve the cadence. Status cues follow the current `--input-during-tts` policy, including the selected output route. Cue suppression pauses incoming PCM without discarding active speech or pending recognition. The host determines when work begins; the chained client signals working on its first live tool call and stays working until the run ends. Status-cue playback is single-flight: a cadence tick never queues behind a cue that is still draining. Hosts claim overlapping user and assistant audio through shared activity guards, and the runtime resumes cues only after the final guard diff --git a/src-tauri/crates/berd-call/src/benchmark/stt.rs b/src-tauri/crates/berd-call/src/benchmark/stt.rs index ea4e8d54a..f9cb82bf0 100644 --- a/src-tauri/crates/berd-call/src/benchmark/stt.rs +++ b/src-tauri/crates/berd-call/src/benchmark/stt.rs @@ -555,12 +555,6 @@ fn observe_event( Some(VoiceInputEvent::Ready) => { tracker.error = Some("voice input emitted duplicate readiness".into()) } - Some(VoiceInputEvent::SpeakingReset) => { - tracker.speaking = false; - if tracker.speaking_started.is_some() { - tracker.speaking_ended = Some(now); - } - } Some(VoiceInputEvent::SpeakingChanged(active)) => { tracker.speaking = active; if active { diff --git a/src-tauri/crates/berd-call/src/cli_help.rs b/src-tauri/crates/berd-call/src/cli_help.rs index 3ae7662af..68e43ce2d 100644 --- a/src-tauri/crates/berd-call/src/cli_help.rs +++ b/src-tauri/crates/berd-call/src/cli_help.rs @@ -95,7 +95,6 @@ Usage: berd-call settings [--port PORT] --tts JSON berd-call settings [--port PORT] --input-during-tts auto|allow|suppress berd-call settings [--port PORT] --muted true|false - berd-call settings [--port PORT] --vad-debug-sounds on|off 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] --spokesperson true|false diff --git a/src-tauri/crates/berd-call/src/host_control.rs b/src-tauri/crates/berd-call/src/host_control.rs index 57537ca0a..2fbea133a 100644 --- a/src-tauri/crates/berd-call/src/host_control.rs +++ b/src-tauri/crates/berd-call/src/host_control.rs @@ -49,7 +49,6 @@ pub(crate) trait HostControl: Send + Sync + 'static { mode: Option, status: Option, ) -> Result; - fn set_vad_debug_sounds(&self, enabled: 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; @@ -159,9 +158,6 @@ pub(crate) enum ControlRequest { Muted { muted: bool, }, - VadDebugSounds { - enabled: bool, - }, StatusSounds { mode: Option, status: Option, @@ -223,7 +219,6 @@ fn read_timeout(request: &ControlRequest) -> Result, String> { | ControlRequest::Rate { .. } | ControlRequest::Voice { .. } | ControlRequest::Spokesperson { .. } - | ControlRequest::VadDebugSounds { .. } | ControlRequest::StatusSounds { .. } | ControlRequest::Restart { .. } | ControlRequest::SpokespersonMode { .. } @@ -300,7 +295,6 @@ 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::VadDebugSounds { enabled } => control.set_vad_debug_sounds(enabled), ControlRequest::StatusSounds { mode, status } => { control.set_status_sounds(mode, status) } @@ -453,9 +447,6 @@ mod tests { ) -> Result { Ok(json!({"statusSounds": mode, "conversationStatus": status})) } - fn set_vad_debug_sounds(&self, enabled: bool) -> Result { - Ok(json!({"vadDebugSounds": enabled})) - } 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 9ba9af9b8..cfa4e0d47 100644 --- a/src-tauri/crates/berd-call/src/host_session.rs +++ b/src-tauri/crates/berd-call/src/host_session.rs @@ -61,10 +61,6 @@ pub(crate) fn run(options: StartOptions) -> Result<(), String> { .and_then(|(_, saved)| saved.status_sounds) .unwrap_or_default(), status: None, - vad_debug_sounds: options - .saved - .as_ref() - .is_some_and(|(_, saved)| saved.vad_debug_sounds), })); let mut session = SessionStart { saved: options.saved.clone(), @@ -534,7 +530,6 @@ fn tts_arguments(settings: &berd_call::TtsSettings, arguments: &[String]) -> Vec /// The call's status sounds, kept across session restarts. #[derive(Clone, Copy, Default)] struct StatusSounds { - vad_debug_sounds: bool, mode: berd_call::StatusSoundMode, // Unknown until a host reports it, so a call nothing reports on stays silent. status: Option, @@ -561,10 +556,6 @@ enum ControlCommand { muted: bool, response: SyncSender>, }, - VadDebugSounds { - enabled: bool, - response: SyncSender>, - }, StatusSounds { mode: Option, status: Option, @@ -831,11 +822,6 @@ impl HostControl for SessionControl { self.status() } - fn set_vad_debug_sounds(&self, enabled: bool) -> Result { - self.request(|response| ControlCommand::VadDebugSounds { enabled, response })?; - self.status() - } - fn set_status_sounds( &self, mode: Option, @@ -910,7 +896,6 @@ impl HostControl for SessionControl { "nonBlocking": self.non_blocking.load(Ordering::SeqCst), "muted": self.muted.load(Ordering::SeqCst), "statusSounds": sounds.mode, - "vadDebugSounds": sounds.vad_debug_sounds, "conversationStatus": sounds.status, "sessionArguments": &self.session_arguments[1..], })) @@ -987,7 +972,6 @@ enum SavedPreference { Spokesperson, InputPolicy, StatusSounds(berd_call::StatusSoundMode), - VadDebugSounds(bool), } struct SessionActor { @@ -1019,14 +1003,7 @@ struct SessionActor { muted: Arc, status_sounds: Arc>, // Requested sounds and any mode change, committed when the session applies them. - pending_status_sounds: BTreeMap< - u64, - ( - StatusSounds, - Option, - Option, - ), - >, + pending_status_sounds: BTreeMap)>, 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 @@ -1038,54 +1015,6 @@ struct SessionActor { } impl SessionActor { - fn change_sounds( - &mut self, - mode: Option, - status: Option, - debug: Option, - response: SyncSender>, - ) -> Result<(), String> { - // Build on the newest request, since the session applies them in order. - let latest = match self.pending_status_sounds.last_key_value() { - Some((_, (sounds, _, _))) if !self.stopping => *sounds, - _ => *self - .status_sounds - .lock() - .map_err(|_| "status sounds lock failed")?, - }; - let sounds = StatusSounds { - mode: mode.unwrap_or(latest.mode), - vad_debug_sounds: debug.unwrap_or(latest.vad_debug_sounds), - status: status.or(latest.status), - }; - // A stopping child may already have exited; a restarted session - // is sent the committed status when it starts. - let sent = if self.stopping { - None - } else { - self.send_status_sounds(if debug.is_some() && sounds.status.is_none() { - StatusSounds { - mode: berd_call::StatusSoundMode::Off, - status: Some(berd_call::ConversationStatus::Waiting), - ..sounds - } - } else { - sounds - })? - }; - match sent { - Some(id) => { - self.pending_status_sounds.insert(id, (sounds, mode, debug)); - self.pending_settings.insert(id, response); - } - None => { - self.commit_status_sounds(sounds, mode, debug); - let _ = response.send(Ok(json!({}))); - } - } - Ok(()) - } - fn restore_input_mute(&mut self) -> Result<(), String> { // Restore current capture intent rather than stale acknowledged state. let (muted, mute_epoch) = crate::system_input_mute::mute_state_snapshot( @@ -1109,12 +1038,9 @@ impl SessionActor { /// Tells the session its status sounds, once a host has said whether the agent is working. fn send_status_sounds(&mut self, sounds: StatusSounds) -> Result, String> { - if sounds.status.is_none() && !sounds.vad_debug_sounds { + let Some(status) = sounds.status else { return Ok(None); - } - let status = sounds - .status - .unwrap_or(berd_call::ConversationStatus::Waiting); + }; let id = self.next_id(); send_request( &self.writer, @@ -1122,12 +1048,7 @@ impl SessionActor { id, status, settings: berd_call::StatusSoundSettings { - mode: if sounds.status.is_some() { - sounds.mode - } else { - berd_call::StatusSoundMode::Off - }, - vad_debug_sounds: sounds.vad_debug_sounds, + mode: sounds.mode, ..Default::default() }, }, @@ -1139,14 +1060,10 @@ impl SessionActor { &mut self, sounds: StatusSounds, mode: Option, - debug: Option, ) { if let Ok(mut committed) = self.status_sounds.lock() { *committed = sounds; } - if let Some(enabled) = debug { - self.persist_settings(SavedPreference::VadDebugSounds(enabled)); - } if let Some(mode) = mode { self.persist_settings(SavedPreference::StatusSounds(mode)); } @@ -1394,14 +1311,41 @@ impl SessionActor { )?; self.pending_settings.insert(id, response); } - ControlCommand::VadDebugSounds { enabled, response } => { - self.change_sounds(None, None, Some(enabled), response)? - } ControlCommand::StatusSounds { mode, status, response, - } => self.change_sounds(mode, status, None, response)?, + } => { + // Build on the newest request, since the session applies them in order. + let latest = match self.pending_status_sounds.last_key_value() { + Some((_, (sounds, _))) if !self.stopping => *sounds, + _ => *self + .status_sounds + .lock() + .map_err(|_| "status sounds lock failed")?, + }; + let sounds = StatusSounds { + mode: mode.unwrap_or(latest.mode), + status: status.or(latest.status), + }; + // A stopping child may already have exited; a restarted session + // is sent the committed status when it starts. + let sent = if self.stopping { + None + } else { + self.send_status_sounds(sounds)? + }; + match sent { + Some(id) => { + self.pending_status_sounds.insert(id, (sounds, mode)); + self.pending_settings.insert(id, response); + } + None => { + self.commit_status_sounds(sounds, mode); + let _ = response.send(Ok(json!({}))); + } + } + } ControlCommand::TtsSettings { settings, expected_revision, @@ -1632,8 +1576,8 @@ impl SessionActor { } } SessionMessage::ConversationStatusApplied { id, .. } => { - if let Some((sounds, mode, debug)) = self.pending_status_sounds.remove(&id) { - self.commit_status_sounds(sounds, mode, debug); + if let Some((sounds, mode)) = self.pending_status_sounds.remove(&id) { + self.commit_status_sounds(sounds, mode); } if let Some(response) = self.pending_settings.remove(&id) { let _ = response.send(Ok(json!({}))); @@ -1847,15 +1791,6 @@ impl SessionActor { let Ok(session) = self.session.lock() else { return; }; - if let SavedPreference::VadDebugSounds(enabled) = changed { - saved.vad_debug_sounds = enabled; - if let Err(error) = - crate::saved_settings::update(path, |latest| latest.vad_debug_sounds = enabled) - { - eprintln!("berd-call: VAD debug sounds applied but could not be saved: {error}"); - } - return; - } if let SavedPreference::StatusSounds(mode) = changed { saved.status_sounds = Some(mode); if let Err(error) = @@ -1899,9 +1834,7 @@ impl SessionActor { latest.tts = saved.tts.clone(); } SavedPreference::InputPolicy => latest.input_policy = saved.input_policy, - SavedPreference::Spokesperson - | SavedPreference::StatusSounds(_) - | SavedPreference::VadDebugSounds(_) => { + SavedPreference::Spokesperson | SavedPreference::StatusSounds(_) => { unreachable!("saved above") } }) { @@ -3449,54 +3382,6 @@ mod tests { ]))); } - #[test] - fn debug_sound_changes_wait_for_ack_and_preserve_unknown_host_status() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("settings.json"); - let mut child = Command::new("/bin/cat") - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .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, _audio_rx) = mpsc::sync_channel(1); - let mut actor = test_actor(writer, events, commands, audio); - actor.saved = Some((path.clone(), Default::default())); - for enabled in [true, false] { - let (response, result) = mpsc::sync_channel(1); - actor - .handle_command(ControlCommand::VadDebugSounds { enabled, response }) - .unwrap(); - assert!(result.try_recv().is_err()); - assert_eq!( - actor.status_sounds.lock().unwrap().vad_debug_sounds, - !enabled - ); - let id = *actor.pending_settings.keys().next().unwrap(); - actor - .handle_event(SessionMessage::ConversationStatusApplied { - id, - status: berd_call::ConversationStatus::Waiting, - settings: berd_call::StatusSoundSettings { - mode: berd_call::StatusSoundMode::Off, - vad_debug_sounds: enabled, - ..Default::default() - }, - }) - .unwrap(); - result.try_recv().unwrap().unwrap(); - assert_eq!( - crate::saved_settings::load(&path).unwrap().vad_debug_sounds, - enabled - ); - assert!(actor.status_sounds.lock().unwrap().status.is_none()); - } - drop(actor); - assert!(child.wait().unwrap().success()); - } - #[test] fn status_sounds_wait_for_a_reported_status_and_save_their_mode() { let directory = tempfile::tempdir().unwrap(); @@ -3570,7 +3455,6 @@ mod tests { let mut actor = test_actor(writer, events, commands, audio); actor.saved = Some((path.clone(), Default::default())); *actor.status_sounds.lock().unwrap() = StatusSounds { - vad_debug_sounds: false, mode: berd_call::StatusSoundMode::Working, status: Some(berd_call::ConversationStatus::Working), }; @@ -5275,7 +5159,7 @@ mod tests { }), input_policy: Some(InputDuringTtsPolicy::SuppressInput), status_sounds: None, - vad_debug_sounds: false, + ..Default::default() } } diff --git a/src-tauri/crates/berd-call/src/input.rs b/src-tauri/crates/berd-call/src/input.rs index 59b3f4075..3583e74c7 100644 --- a/src-tauri/crates/berd-call/src/input.rs +++ b/src-tauri/crates/berd-call/src/input.rs @@ -122,8 +122,6 @@ impl FinalTranscriptStorageReceipt { pub enum VoiceInputEvent { Ready, SpeakingChanged(bool), - /// Detection state was discarded, rather than a measured speech edge. - SpeakingReset, RecognitionPendingChanged(bool), FinalTranscript { text: String, @@ -853,7 +851,7 @@ fn clear_speech_state( leftover_16k.clear(); *silence_frames = 0; if std::mem::take(in_speech) { - let _ = events.blocking_send(VoiceInputEvent::SpeakingReset); + let _ = events.blocking_send(VoiceInputEvent::SpeakingChanged(false)); } } @@ -1608,7 +1606,7 @@ fn mac_speech_worker( leftover_16k.clear(); silence_frames = 0; if std::mem::take(&mut in_speech) { - let _ = events.blocking_send(VoiceInputEvent::SpeakingReset); + let _ = events.blocking_send(VoiceInputEvent::SpeakingChanged(false)); } let _ = pending.reset(&events); settle_deadline = None; @@ -1985,7 +1983,7 @@ fn openai_worker( completed.clear(); let _ = pending.reset(&events); if std::mem::take(&mut in_speech) { - let _ = events.blocking_send(VoiceInputEvent::SpeakingReset); + let _ = events.blocking_send(VoiceInputEvent::SpeakingChanged(false)); } send_operation!(client.clear(), "clear reset OpenAI transcription audio", { break 'worker; @@ -2251,23 +2249,6 @@ mod tests { assert!(controls.is_input_suppressed()); } - #[test] - fn clearing_speech_reports_a_reset_instead_of_a_vad_edge() { - let (events, mut receiver) = tokio_mpsc::channel(2); - clear_speech_state( - &mut Vec::new(), - &mut Vec::new(), - &mut Vec::new(), - &mut 0, - &mut true, - &events, - ); - assert!(matches!( - receiver.try_recv(), - Ok(VoiceInputEvent::SpeakingReset) - )); - } - #[test] fn default_output_is_classified_by_its_name() { for name in ["USB Speakers", "Bluetooth SPEAKER", "Altavoces externos"] { @@ -2745,9 +2726,7 @@ mod tests { match event { VoiceInputEvent::Ready => observed.push("ready"), VoiceInputEvent::SpeakingChanged(true) => observed.push("speaking:true"), - VoiceInputEvent::SpeakingChanged(false) | VoiceInputEvent::SpeakingReset => { - observed.push("speaking:false") - } + VoiceInputEvent::SpeakingChanged(false) => observed.push("speaking:false"), VoiceInputEvent::RecognitionPendingChanged(true) => observed.push("pending:true"), VoiceInputEvent::RecognitionPendingChanged(false) => observed.push("pending:false"), VoiceInputEvent::FinalTranscript { diff --git a/src-tauri/crates/berd-call/src/main.rs b/src-tauri/crates/berd-call/src/main.rs index 75875da76..dc6137581 100644 --- a/src-tauri/crates/berd-call/src/main.rs +++ b/src-tauri/crates/berd-call/src/main.rs @@ -1135,13 +1135,6 @@ fn parse_host_settings_args( "--muted" => host_control::ControlRequest::Muted { muted: parse_bool_setting(flag, args.get(index + 1))?, }, - "--vad-debug-sounds" => host_control::ControlRequest::VadDebugSounds { - enabled: match args.get(index + 1).map(String::as_str) { - Some("on") => true, - Some("off") => false, - _ => return Err("--vad-debug-sounds requires on or off".into()), - }, - }, "--status-sounds" => host_control::ControlRequest::StatusSounds { mode: Some(match args.get(index + 1).map(String::as_str) { Some("off") => berd_call::StatusSoundMode::Off, @@ -1238,7 +1231,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, --vad-debug-sounds, --status-sounds, --conversation-status, --spokesperson, or --restart"; + "provide exactly one of --non-blocking, --rate, --voice, --spokesperson-voice, --spokesperson-rate, --tts, --input-during-tts, --muted, --status-sounds, --conversation-status, --spokesperson, or --restart"; fn parse_bool_setting(flag: &str, value: Option<&String>) -> Result { match value.map(String::as_str) { @@ -2233,13 +2226,6 @@ fn run_session(config: SessionConfig, pcm_output_fd: RawFd) -> Result<(), String input_progress.enter("voice_events"); if let Some(events) = input_events.as_mut() { while let Ok(event) = events.try_recv() { - match &event { - VoiceInputEvent::SpeakingChanged(speaking) => { - status_sound_runtime.observe_speaking(*speaking)? - } - VoiceInputEvent::SpeakingReset => status_sound_runtime.reset_speaking()?, - _ => {} - } handle_voice_input_event( event, &mut core, @@ -2485,7 +2471,6 @@ fn run_session(config: SessionConfig, pcm_output_fd: RawFd) -> Result<(), String initialized = true; status_sound_runtime.set_output_device(status_sound_output_device)?; status_sound_runtime.set_input_policy(input_during_tts)?; - status_sound_runtime.observe_speaking(false)?; let input_policy = InputDuringTtsSlot::new(input_during_tts); let session = VoiceSessionSnapshot { tts: slot.snapshot()?, @@ -3864,7 +3849,6 @@ fn run_expert_spokesperson_session( } else { turn_gate.finish_user_speaking(); } - status_sound_runtime.observe_speaking(speaking)?; write_message( &mut writer, &SessionMessage::InputSpeaking { active: speaking }, @@ -4802,7 +4786,6 @@ fn run_expert_spokesperson_session( }, }; status_sound_runtime.set_input_policy(input_during_tts)?; - status_sound_runtime.observe_speaking(false)?; let input_policy = InputDuringTtsSlot::new(input_during_tts); let snapshot = VoiceSessionSnapshot { tts: match &reply_tts { @@ -7855,11 +7838,6 @@ fn handle_voice_input_event( VoiceInputEvent::Ready => { return Err("voice input emitted a duplicate readiness event".into()) } - VoiceInputEvent::SpeakingReset => { - core.set_user_speaking(false); - write_message(writer, &SessionMessage::InputSpeaking { active: false })?; - update_provisional_suspension(core, active)?; - } VoiceInputEvent::SpeakingChanged(speaking) => { core.set_user_speaking(speaking); write_message(writer, &SessionMessage::InputSpeaking { active: speaking })?; @@ -8787,22 +8765,6 @@ mod tests { use std::os::unix::net::UnixStream; use std::sync::Mutex; - #[test] - fn debug_sound_cli_accepts_only_on_and_off() { - for (value, enabled) in [("on", true), ("off", false)] { - assert!( - matches!(parse_host_settings_args(&args(&["berd-call", "settings", "--vad-debug-sounds", value])).unwrap().1, host_control::ControlRequest::VadDebugSounds { enabled: actual } if actual == enabled) - ); - } - assert!(parse_host_settings_args(&args(&[ - "berd-call", - "settings", - "--vad-debug-sounds", - "true" - ])) - .is_err()); - } - #[cfg(target_os = "macos")] #[test] fn update_check_only_targets_the_owning_app_bundle() { @@ -13515,7 +13477,7 @@ mod tests { }), input_policy: Some(berd_call::input::InputDuringTtsPolicy::SuppressInput), status_sounds: None, - vad_debug_sounds: false, + ..Default::default() }, ) .unwrap(); diff --git a/src-tauri/crates/berd-call/src/saved_settings.rs b/src-tauri/crates/berd-call/src/saved_settings.rs index 1abc27b32..80c87ce2b 100644 --- a/src-tauri/crates/berd-call/src/saved_settings.rs +++ b/src-tauri/crates/berd-call/src/saved_settings.rs @@ -15,8 +15,9 @@ pub(crate) struct SavedSettings { // Left out until chosen, so a file without one still opens in older berd-call. #[serde(default, skip_serializing_if = "Option::is_none")] pub status_sounds: Option, - #[serde(default)] - pub vad_debug_sounds: bool, + // Read preferences written by the optional debug-sound build without saving its setting. + #[serde(default, rename = "vad_debug_sounds", skip_serializing)] + pub _legacy_debug_sounds: Option, } pub(crate) fn path() -> Result { @@ -237,6 +238,38 @@ pub(crate) fn with_spokesperson(arguments: &[String], on: bool, direct: bool) -> mod tests { use super::*; + #[test] + fn optional_preferences_are_ignored_without_losing_other_settings() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("settings.json"); + let settings = SavedSettings { + arguments: vec!["--rate".into(), "1.5".into()], + tts: Some(TtsSettings::Siri { + voice: "Aaron".into(), + language: "en-GB".into(), + rate: 1.5, + }), + input_policy: Some(InputDuringTtsPolicy::SuppressInput), + status_sounds: Some(StatusSoundMode::WorkingAndWaiting), + ..Default::default() + }; + let expected = serde_json::to_value(&settings).unwrap(); + let mut legacy = expected.clone(); + legacy["vad_debug_sounds"] = serde_json::json!(true); + std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap(); + let restored = load(&path).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), expected); + save(&path, &restored).unwrap(); + let written: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert!(written.get("vad_debug_sounds").is_none()); + assert_eq!(written, expected); + assert_eq!( + serde_json::to_value(load(&path).unwrap()).unwrap(), + expected + ); + } + #[test] fn auto_and_explicit_input_preferences_survive_reopen() { let directory = tempfile::tempdir().unwrap(); diff --git a/src-tauri/crates/berd-call/src/status_sounds.rs b/src-tauri/crates/berd-call/src/status_sounds.rs index bd49bf215..b588f38a7 100644 --- a/src-tauri/crates/berd-call/src/status_sounds.rs +++ b/src-tauri/crates/berd-call/src/status_sounds.rs @@ -34,8 +34,6 @@ pub struct StatusSoundSettings { pub mode: StatusSoundMode, #[serde(default = "default_status_sound_volume")] pub volume: f32, - #[serde(default)] - pub vad_debug_sounds: bool, } impl Default for StatusSoundSettings { @@ -43,7 +41,6 @@ impl Default for StatusSoundSettings { Self { mode: StatusSoundMode::default(), volume: DEFAULT_STATUS_SOUND_VOLUME, - vad_debug_sounds: false, } } } @@ -77,9 +74,7 @@ pub struct StatusSoundStateMachine { impl StatusSoundStateMachine { pub fn update(&mut self, status: ConversationStatus, settings: StatusSoundSettings) -> bool { let next = (status, settings); - let changed = self.current.is_none_or(|(previous, old)| { - previous != status || old.mode != settings.mode || old.volume != settings.volume - }); + let changed = self.current != Some(next); self.current = Some(next); changed } @@ -106,8 +101,6 @@ pub struct StatusSoundRuntime { next_tick: Option, pending_status_cue: bool, player: StatusSoundPlayer, - debug_player: StatusSoundPlayer, - speaking: Option, input_policy: crate::input::InputDuringTtsPolicy, output_device: Option, input_controls: Option, @@ -120,8 +113,6 @@ impl Default for StatusSoundRuntime { next_tick: None, pending_status_cue: false, player: StatusSoundPlayer::new(), - debug_player: StatusSoundPlayer::new(), - speaking: None, input_policy: crate::input::InputDuringTtsPolicy::Auto, output_device: None, input_controls: None, @@ -134,41 +125,14 @@ impl StatusSoundRuntime { self.input_controls = controls; } - pub fn reset_speaking(&mut self) { - self.speaking = None; - } - - pub fn observe_speaking(&mut self, speaking: bool) -> Result<(), String> { - let previous = self.speaking.replace(speaking); - if debug_transition( - previous, - speaking, - self.machine - .current - .is_some_and(|(_, settings)| settings.vad_debug_sounds), - ) { - self.debug_player.play_debug( - speaking, - self.output_device.as_deref(), - self.input_controls.as_ref(), - self.input_policy, - )?; - } - Ok(()) - } - pub fn set_output_device(&mut self, output_device: Option) { if self.output_device != output_device { self.player.stop(); - self.debug_player.stop(); self.output_device = output_device; } } pub fn update(&mut self, status: ConversationStatus, settings: StatusSoundSettings) { - if !settings.vad_debug_sounds { - self.debug_player.stop(); - } self.pending_status_cue |= self .machine .current @@ -181,7 +145,6 @@ impl StatusSoundRuntime { } pub fn stop(&mut self) { - self.debug_player.stop(); self.player.stop(); } @@ -195,7 +158,6 @@ impl StatusSoundRuntime { pub fn poll(&mut self, conversation_active: bool) -> Result { self.update_conversation_activity(conversation_active); self.player.reap(); - self.debug_player.reap(); let now = Instant::now(); if self.next_tick.is_some_and(|deadline| now >= deadline) { let deadline = self.next_tick.unwrap(); @@ -215,10 +177,6 @@ impl StatusSoundRuntime { } } -fn debug_transition(previous: Option, speaking: bool, enabled: bool) -> bool { - enabled && previous.is_some_and(|previous| previous != speaking) -} - enum StatusSoundCommand { Update(ConversationStatus, StatusSoundSettings), ConversationActive { @@ -228,8 +186,6 @@ enum StatusSoundCommand { OutputDevice(Option), InputPolicy(crate::input::InputDuringTtsPolicy), InputControls(crate::input::VoiceInputControls), - Speaking(bool), - ResetSpeaking, Shutdown, } @@ -306,25 +262,13 @@ impl ManagedStatusSoundRuntime { runtime.set_input_controls(Some(controls)); None } - Ok(StatusSoundCommand::ResetSpeaking) => { - runtime.reset_speaking(); - None - } - Ok(StatusSoundCommand::Speaking(speaking)) => { - if let Err(message) = runtime.observe_speaking(speaking) { - eprintln!("VAD debug sound failed: {message}"); - } - None - } Ok(StatusSoundCommand::Shutdown) | Err(RecvTimeoutError::Disconnected) => { runtime.stop(); break; } Err(RecvTimeoutError::Timeout) => None, }; - let poll_result = runtime - .poll(conversation_active || runtime.speaking == Some(true)) - .map(|_| ()); + let poll_result = runtime.poll(conversation_active).map(|_| ()); if let Some(applied) = applied { let _ = applied.send(poll_result.clone()); } @@ -357,14 +301,6 @@ impl ManagedStatusSoundRuntime { self.send(StatusSoundCommand::InputControls(controls)) } - pub fn reset_speaking(&self) -> Result<(), String> { - self.send(StatusSoundCommand::ResetSpeaking) - } - - pub fn observe_speaking(&self, speaking: bool) -> Result<(), String> { - self.send(StatusSoundCommand::Speaking(speaking)) - } - pub fn update( &self, status: ConversationStatus, @@ -540,16 +476,6 @@ impl StatusSoundPlayer { Err("status sound playback is only available on macOS".into()) } - fn play_debug( - &mut self, - _speaking: bool, - _device: Option<&str>, - _controls: Option<&crate::input::VoiceInputControls>, - _policy: crate::input::InputDuringTtsPolicy, - ) -> Result<(), String> { - Err("VAD debug sound playback is only available on macOS".into()) - } - fn reap(&mut self) {} fn is_active(&self) -> bool { @@ -617,37 +543,6 @@ impl StatusSoundPlayer { ) } - fn play_debug( - &mut self, - speaking: bool, - output_device: Option<&str>, - input_controls: Option<&crate::input::VoiceInputControls>, - input_policy: crate::input::InputDuringTtsPolicy, - ) -> Result<(), String> { - self.reap(); - self.stop(); - let sample_rate = 48_000; - let frequency = if speaking { 1200.0 } else { 650.0 }; - let samples = (0..960) - .map(|index| { - let phase = index as f32 / sample_rate as f32; - (phase * frequency * std::f32::consts::TAU).sin() - * (std::f32::consts::PI * index as f32 / 960.0).sin() - }) - .collect(); - Self::play_asset( - &mut self.active, - &StatusSoundAsset { - sample_rate, - samples, - }, - 0.08, - output_device, - input_controls, - input_policy, - ) - } - fn play_asset( active: &mut Option, asset: &StatusSoundAsset, @@ -751,30 +646,6 @@ fn load_system_sound(name: &str) -> Result { mod tests { use super::*; - #[test] - fn debug_cues_require_enabled_real_transitions() { - assert!(!debug_transition(None, true, true)); - assert!(!debug_transition(None, false, true)); - assert!(!debug_transition(Some(false), false, true)); - assert!(!debug_transition(Some(true), true, true)); - assert!(!debug_transition(Some(false), true, false)); - assert!(debug_transition(Some(false), true, true)); - assert!(debug_transition(Some(true), false, true)); - let settings: StatusSoundSettings = serde_json::from_str(r#"{"mode":"off"}"#).unwrap(); - assert!(!settings.vad_debug_sounds); - } - - #[test] - fn debug_toggle_preserves_status_deadline() { - let mut runtime = StatusSoundRuntime::default(); - let mut settings = settings(StatusSoundMode::Working); - runtime.update(ConversationStatus::Working, settings); - let deadline = runtime.next_tick; - settings.vad_debug_sounds = true; - runtime.update(ConversationStatus::Working, settings); - assert_eq!(runtime.next_tick, deadline); - } - #[cfg(target_os = "macos")] #[test] fn native_cue_enqueue_obeys_policy_and_releases_guard_on_failure() { @@ -830,56 +701,6 @@ mod tests { } } - #[cfg(target_os = "macos")] - #[test] - #[ignore = "opens CoreAudio output and plays short VAD debug clicks"] - fn macos_debug_clicks_follow_input_policy() { - use crate::input::{InputDuringTtsPolicy, VoiceInputControls}; - for policy in [ - InputDuringTtsPolicy::AllowBargeIn, - InputDuringTtsPolicy::SuppressInput, - ] { - let controls = VoiceInputControls::default(); - let mut player = StatusSoundPlayer::new(); - for speaking in [true, false] { - player - .play_debug(speaking, None, Some(&controls), policy) - .unwrap(); - assert_eq!( - controls.is_input_suppressed(), - policy == InputDuringTtsPolicy::SuppressInput - ); - let deadline = Instant::now() + Duration::from_secs(5); - while player.is_active() { - assert!(Instant::now() < deadline); - player.reap(); - thread::sleep(Duration::from_millis(10)); - } - assert!(!controls.is_input_suppressed()); - } - } - } - - #[test] - fn restored_speech_after_reset_is_silent() { - let mut runtime = StatusSoundRuntime::default(); - runtime.update( - ConversationStatus::Waiting, - StatusSoundSettings { - mode: StatusSoundMode::Off, - vad_debug_sounds: true, - ..Default::default() - }, - ); - runtime.speaking = Some(true); - runtime.reset_speaking(); - runtime.observe_speaking(false).unwrap(); - assert!(!runtime.debug_player.is_active()); - runtime.reset_speaking(); - runtime.observe_speaking(true).unwrap(); - assert!(!runtime.debug_player.is_active()); - } - fn settings(mode: StatusSoundMode) -> StatusSoundSettings { StatusSoundSettings { mode, diff --git a/src-tauri/src/commands/native_voice.rs b/src-tauri/src/commands/native_voice.rs index b53e83489..9e5c3c1f2 100644 --- a/src-tauri/src/commands/native_voice.rs +++ b/src-tauri/src/commands/native_voice.rs @@ -1761,17 +1761,6 @@ pub async fn start_native_voice_conversation( berd_call::input::VoiceInputEvent::Ready => { log::warn!("Voice input emitted duplicate readiness"); } - berd_call::input::VoiceInputEvent::SpeakingReset => { - admission.set_user_speaking(false); - event_state.set_status_sound_input_activity(&session_id, revision, false); - let event = NativeVoiceEvent::Activity { - session_id: session_id.clone(), - activity: "user-idle", - revision, - }; - let _ = event_window.emit(EVENT_NAME, event.clone()); - super::voice_buddy::emit(&event_app, event); - } berd_call::input::VoiceInputEvent::SpeakingChanged(speaking) => { admission.set_user_speaking(speaking); event_state.set_status_sound_input_activity(&session_id, revision, speaking);