From 0ce7f5c34453f7e847367e4492178f26ae6ceb41 Mon Sep 17 00:00:00 2001 From: Nora <49aa1f65411fd096d2e2ec144f1e7aa36fdc76d1b907cfdf7be000c66f9d3b8e@buzz.block.builderlab.xyz> Date: Tue, 20 Oct 2026 15:01:38 -0400 Subject: [PATCH 1/2] berd-call: persist Auto input muting and follow output changes Signed-off-by: Nora <49aa1f65411fd096d2e2ec144f1e7aa36fdc76d1b907cfdf7be000c66f9d3b8e@buzz.block.builderlab.xyz> --- src-tauri/crates/berd-call/README.md | 4 +- src-tauri/crates/berd-call/src/cli_help.rs | 2 +- .../crates/berd-call/src/host_session.rs | 6 +- src-tauri/crates/berd-call/src/input.rs | 142 +++++++++++++++++- src-tauri/crates/berd-call/src/main.rs | 39 ++++- src-tauri/crates/berd-call/src/menu_bar.rs | 51 +++++-- .../crates/berd-call/src/saved_settings.rs | 21 +++ src-tauri/src/commands/pocket_voice.rs | 30 +--- 8 files changed, 241 insertions(+), 54 deletions(-) diff --git a/src-tauri/crates/berd-call/README.md b/src-tauri/crates/berd-call/README.md index c372cd788..923c64d2e 100644 --- a/src-tauri/crates/berd-call/README.md +++ b/src-tauri/crates/berd-call/README.md @@ -99,7 +99,7 @@ to update the running call through the runtime's revision-checked TTS settings. The result includes `outcome` and the acknowledged `snapshot`; `status` reflects that snapshot. Changes apply to subsequent speech without restarting the call. -Use `berd-call settings --input-during-tts allow|suppress` to change whether +Use `berd-call settings --input-during-tts auto|allow|suppress` to change whether microphone input can interrupt speech, and `berd-call settings --muted true|false` to mute or unmute microphone input. Both apply live and are reflected by `status`. @@ -430,3 +430,5 @@ rejects a warmup-inclusive workload above 20 recognition commits or 120 seconds of streamed PCM. Endpoint and model overrides use the same environment variables as the session; reports record only which source supplied them and never include the key or endpoint value. + +Auto is the default input-during-speech preference. It mutes microphone input during playback on detected speakers and permits interruption on other routes. It follows the default output route during a call (within about 100 ms). Explicit allow/suppress overrides Auto. The preference is saved across calls and restarts. Speaker detection is best-effort; use suppress for an unrecognized external speaker. diff --git a/src-tauri/crates/berd-call/src/cli_help.rs b/src-tauri/crates/berd-call/src/cli_help.rs index bef26a44d..bf8e81ef3 100644 --- a/src-tauri/crates/berd-call/src/cli_help.rs +++ b/src-tauri/crates/berd-call/src/cli_help.rs @@ -93,7 +93,7 @@ Usage: berd-call settings [--port PORT] --spokesperson-voice NAME berd-call settings [--port PORT] --spokesperson-rate FLOAT berd-call settings [--port PORT] --tts JSON - berd-call settings [--port PORT] --input-during-tts allow|suppress + berd-call settings [--port PORT] --input-during-tts auto|allow|suppress berd-call settings [--port PORT] --muted true|false berd-call settings [--port PORT] --spokesperson true|false berd-call settings [--port PORT] --restart [session options] diff --git a/src-tauri/crates/berd-call/src/host_session.rs b/src-tauri/crates/berd-call/src/host_session.rs index c358edb38..2c517af04 100644 --- a/src-tauri/crates/berd-call/src/host_session.rs +++ b/src-tauri/crates/berd-call/src/host_session.rs @@ -479,11 +479,7 @@ fn start_session( } fn default_input_during_tts_policy() -> InputDuringTtsPolicy { - if berd_call::macos_audio_route::output_device_is_builtin_speaker(None) { - InputDuringTtsPolicy::SuppressInput - } else { - InputDuringTtsPolicy::AllowBargeIn - } + InputDuringTtsPolicy::Auto } /// Session options for these speech settings, keeping a Pocket model directory from `arguments`. diff --git a/src-tauri/crates/berd-call/src/input.rs b/src-tauri/crates/berd-call/src/input.rs index 36979ca46..225987939 100644 --- a/src-tauri/crates/berd-call/src/input.rs +++ b/src-tauri/crates/berd-call/src/input.rs @@ -170,6 +170,8 @@ struct ControlState { #[derive(Default)] struct ControlTransitionState { host_muted: bool, + auto_suppressed: bool, + auto_monitor_running: bool, assistant_activity: AssistantActivityState, } @@ -187,10 +189,43 @@ struct AssistantActivity { #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum InputDuringTtsPolicy { + Auto, AllowBargeIn, SuppressInput, } +impl InputDuringTtsPolicy { + pub fn resolve(self, output_device: Option<&str>) -> Self { + if self == Self::Auto { + self.resolve_for_speakers(output_device_uses_speakers(output_device)) + } else { + self + } + } + + pub fn resolve_for_speakers(self, speakers: bool) -> Self { + match self { + Self::Auto if speakers => Self::SuppressInput, + Self::Auto => Self::AllowBargeIn, + explicit => explicit, + } + } +} + +/// Shared best-effort route classification for desktop and CLI callers. +pub fn output_device_uses_speakers(output_device: Option<&str>) -> bool { + if output_device.is_some_and(|name| { + let name = name.to_lowercase(); + ["speaker", "altavo"].iter().any(|word| name.contains(word)) + }) { + return true; + } + #[cfg(target_os = "macos")] + return crate::macos_audio_route::output_device_is_builtin_speaker(output_device); + #[cfg(not(target_os = "macos"))] + false +} + #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] pub struct InputDuringTtsSnapshot { pub revision: u64, @@ -314,8 +349,46 @@ impl VoiceInputControls { input_policy, }, ); + let start_monitor = + input_policy == InputDuringTtsPolicy::Auto && !transition.auto_monitor_running; + if start_monitor { + transition.auto_suppressed = + InputDuringTtsPolicy::Auto.resolve(None) == InputDuringTtsPolicy::SuppressInput; + transition.auto_monitor_running = true; + } self.publish_assistant_activity(&transition.assistant_activity); self.publish_effective_mute(&transition); + drop(transition); + if start_monitor { + // Route queries stay off capture callbacks. The monitor owns no strong + // reference while sleeping and ends after the last Auto activity. + let shared = Arc::downgrade(&self.shared); + thread::spawn(move || loop { + thread::sleep(Duration::from_millis(100)); + let Some(shared) = shared.upgrade() else { + break; + }; + let controls = VoiceInputControls { shared }; + let suppress = + InputDuringTtsPolicy::Auto.resolve(None) == InputDuringTtsPolicy::SuppressInput; + let mut transition = controls + .shared + .transition + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !transition + .assistant_activity + .lifetimes + .values() + .any(|activity| activity.input_policy == InputDuringTtsPolicy::Auto) + { + transition.auto_monitor_running = false; + break; + } + transition.auto_suppressed = suppress; + controls.publish_effective_mute(&transition); + }); + } Ok(AssistantActivityGuard { controls: self.clone(), generation, @@ -335,11 +408,17 @@ impl VoiceInputControls { } fn publish_effective_mute(&self, transition: &ControlTransitionState) { - let assistant_suppressed = transition - .assistant_activity - .lifetimes - .values() - .any(|activity| activity.input_policy == InputDuringTtsPolicy::SuppressInput); + let assistant_suppressed = + transition + .assistant_activity + .lifetimes + .values() + .any(|activity| { + activity + .input_policy + .resolve_for_speakers(transition.auto_suppressed) + == InputDuringTtsPolicy::SuppressInput + }); let muted = transition.host_muted || assistant_suppressed; if self.shared.muted.swap(muted, Ordering::AcqRel) != muted { self.shared.mute_epoch.fetch_add(1, Ordering::AcqRel); @@ -2073,6 +2152,59 @@ mod tests { VoiceInputFrame::try_from_samples(&[0.0; INPUT_FRAME_SAMPLES]).unwrap() } + #[test] + fn auto_policy_follows_routes_without_overriding_explicit_choices() { + for (speakers, expected) in [ + (true, InputDuringTtsPolicy::SuppressInput), + (false, InputDuringTtsPolicy::AllowBargeIn), + ] { + assert_eq!( + InputDuringTtsPolicy::Auto.resolve_for_speakers(speakers), + expected + ); + for explicit in [ + InputDuringTtsPolicy::SuppressInput, + InputDuringTtsPolicy::AllowBargeIn, + ] { + assert_eq!(explicit.resolve_for_speakers(speakers), explicit); + } + } + } + + #[test] + fn auto_route_changes_advance_mute_epochs_and_preserve_host_mute() { + let controls = VoiceInputControls::default(); + let activity = controls + .begin_assistant_activity(0.65, InputDuringTtsPolicy::Auto) + .unwrap(); + // Hold the transition lock to inject route changes deterministically, + // excluding the native route monitor for the duration of the test. + let mut transition = controls.shared.transition.lock().unwrap(); + transition.auto_suppressed = false; + controls.publish_effective_mute(&transition); + let epoch = controls.mute_epoch(); + assert!(!controls.is_muted()); + transition.auto_suppressed = true; + controls.publish_effective_mute(&transition); + assert!(controls.is_muted()); + assert_eq!(controls.mute_epoch(), epoch + 1); + transition.auto_suppressed = false; + controls.publish_effective_mute(&transition); + assert!(!controls.is_muted()); + assert_eq!(controls.mute_epoch(), epoch + 2); + transition.host_muted = true; + controls.publish_effective_mute(&transition); + transition.auto_suppressed = true; + controls.publish_effective_mute(&transition); + transition.auto_suppressed = false; + controls.publish_effective_mute(&transition); + assert!(controls.is_muted()); + assert_eq!(controls.mute_epoch(), epoch + 3); + drop(transition); + drop(activity); + assert!(controls.is_muted()); + } + #[test] fn exact_frame_contract_rejects_shape_and_nonfinite_and_clamps_unit_scale() { assert!(VoiceInputFrame::try_from_samples(&[0.0; 959]).is_err()); diff --git a/src-tauri/crates/berd-call/src/main.rs b/src-tauri/crates/berd-call/src/main.rs index 3a116ed65..404076736 100644 --- a/src-tauri/crates/berd-call/src/main.rs +++ b/src-tauri/crates/berd-call/src/main.rs @@ -18,8 +18,8 @@ use berd_call::benchmark::{ use berd_call::endpoint_url::{is_allowed_endpoint_url, EndpointProtocol}; use berd_call::expert_spokesperson::{ExpertDirectiveOutcome, LiveSideEvent}; use berd_call::input::{ - AssistantActivityGuard, InputDuringTtsSlot, InputDuringTtsSnapshot, VoiceInputConfig, - VoiceInputControls, VoiceInputEngineConfig, VoiceInputEvent, VoiceInputFrame, + AssistantActivityGuard, InputDuringTtsPolicy, InputDuringTtsSlot, InputDuringTtsSnapshot, + VoiceInputConfig, VoiceInputControls, VoiceInputEngineConfig, VoiceInputEvent, VoiceInputFrame, VoiceInputRuntime, INPUT_FRAME_SAMPLES, }; use berd_call::openai_realtime_protocol::accepted_handoff_tool_output; @@ -1115,7 +1115,8 @@ fn parse_host_settings_args( policy: match args.get(index + 1).map(String::as_str) { Some("allow") => berd_call::input::InputDuringTtsPolicy::AllowBargeIn, Some("suppress") => berd_call::input::InputDuringTtsPolicy::SuppressInput, - _ => return Err("--input-during-tts requires allow or suppress".into()), + Some("auto") => InputDuringTtsPolicy::Auto, + _ => return Err("--input-during-tts requires auto, allow or suppress".into()), }, }, "--rate" => host_control::ControlRequest::Rate { @@ -3400,12 +3401,18 @@ fn run_expert_spokesperson_session( let mut session_tts: Option = None; let mut input_during_tts_slot: Option = None; let mut input_muted = false; + let mut route_checked = Instant::now() - Duration::from_secs(1); + let mut auto_input_policy = InputDuringTtsPolicy::AllowBargeIn; let mut input_resets = InputResets::default(); let mut queued_tts_settings = VoiceUpdateQueue::::default(); let mut pending_voice_update: Option = None; let mut status_sound_runtime = StatusSoundRuntime::default(); loop { + if route_checked.elapsed() >= Duration::from_millis(100) { + auto_input_policy = InputDuringTtsPolicy::Auto.resolve(None); + route_checked = Instant::now(); + } if initialized { input_resets.poll(&mut writer)?; let work = spokesperson_host_work( @@ -4467,7 +4474,13 @@ fn run_expert_spokesperson_session( input_during_tts_slot .as_ref() .expect("hello initialized input policy") - .snapshot()?, + .snapshot() + .map(|mut snapshot| { + snapshot.policy = snapshot.policy.resolve_for_speakers( + auto_input_policy == InputDuringTtsPolicy::SuppressInput, + ); + snapshot + })?, ) { if let Some(update) = pending_voice_update .as_mut() @@ -8433,7 +8446,6 @@ fn synthesize_to_output_with_finish( #[cfg(test)] mod tests { use super::*; - use berd_call::input::InputDuringTtsPolicy; use berd_call::{PcmAudioOutput, TtsOutcome, TtsPcmSpec}; use serde_json::{json, Value}; use std::io::{Cursor, Read, Write}; @@ -9178,6 +9190,23 @@ mod tests { assert_eq!(resets.poll(&mut output).unwrap_err(), "provider refused"); } + #[test] + fn cli_accepts_auto_input_policy() { + let (_, request) = parse_host_settings_args(&args(&[ + "berd-call", + "settings", + "--input-during-tts", + "auto", + ])) + .unwrap(); + assert!(matches!( + request, + host_control::ControlRequest::InputDuringTts { + policy: InputDuringTtsPolicy::Auto + } + )); + } + #[test] fn spokesperson_input_policy_is_nonfatal_revisioned_and_gates_pcm() { let mut output = Vec::new(); diff --git a/src-tauri/crates/berd-call/src/menu_bar.rs b/src-tauri/crates/berd-call/src/menu_bar.rs index fcd63260a..4a2afc616 100644 --- a/src-tauri/crates/berd-call/src/menu_bar.rs +++ b/src-tauri/crates/berd-call/src/menu_bar.rs @@ -128,7 +128,7 @@ fn menu_model(state: &MenuState) -> Vec { return vec![item("Berd Call is starting…", None), SEPARATOR, end_call]; }; let muted = status["muted"].as_bool().unwrap_or(false); - let suppressing = input_policy(status) == Some(InputDuringTtsPolicy::SuppressInput); + let input_policy = input_policy(status); let tts = &status["session"]["tts"]; let spokesperson = &status["session"]["spokesperson"]; let mut nodes = vec![item(format!("Berd Call · {}", mode_name(status)), None)]; @@ -166,15 +166,19 @@ fn menu_model(state: &MenuState) -> Vec { }), rate_menu(tts, spokesperson, rate_range(status)), MenuNode { - checked: suppressing, - ..item( - "Mute Input During TTS", - Some(MenuAction::InputDuringTts(if suppressing { - InputDuringTtsPolicy::AllowBargeIn - } else { - InputDuringTtsPolicy::SuppressInput - })), - ) + title: "Mute Input During TTS".into(), + children: [ + ("On", InputDuringTtsPolicy::SuppressInput), + ("Off", InputDuringTtsPolicy::AllowBargeIn), + ("Auto", InputDuringTtsPolicy::Auto), + ] + .into_iter() + .map(|(title, policy)| MenuNode { + checked: input_policy == Some(policy), + ..item(title, Some(MenuAction::InputDuringTts(policy))) + }) + .collect(), + ..MenuNode::default() }, SEPARATOR, MenuNode { @@ -835,6 +839,29 @@ mod tests { .unwrap_or_else(|| panic!("missing menu item {wanted}")) } + #[test] + fn menu_keeps_auto_selected() { + let nodes = menu_model(&MenuState { + status: Some(status(false, "auto", None)), + ..MenuState::default() + }); + let choices = &find(&nodes, "Mute Input During TTS").children; + assert_eq!( + choices + .iter() + .filter(|node| node.checked) + .map(|node| node.title.as_str()) + .collect::>(), + ["Auto"] + ); + assert!(matches!( + control_request(find(choices, "Auto").action.as_ref().unwrap()), + ControlRequest::InputDuringTts { + policy: InputDuringTtsPolicy::Auto + } + )); + } + #[test] fn menu_reflects_live_call_state() { let status = status(true, "suppress_input", Some("expert-spokesperson")); @@ -868,11 +895,11 @@ mod tests { } )); assert!(matches!( - find(&nodes, "Mute Input During TTS"), + find(&find(&nodes, "Mute Input During TTS").children, "On"), MenuNode { checked: true, action: Some(MenuAction::InputDuringTts( - InputDuringTtsPolicy::AllowBargeIn + InputDuringTtsPolicy::SuppressInput )), .. } diff --git a/src-tauri/crates/berd-call/src/saved_settings.rs b/src-tauri/crates/berd-call/src/saved_settings.rs index ea9674b87..9d6c0a4e4 100644 --- a/src-tauri/crates/berd-call/src/saved_settings.rs +++ b/src-tauri/crates/berd-call/src/saved_settings.rs @@ -228,6 +228,27 @@ pub(crate) fn with_spokesperson(arguments: &[String], on: bool, direct: bool) -> mod tests { use super::*; + #[test] + fn auto_and_explicit_input_preferences_survive_reopen() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("settings.json"); + for policy in [ + InputDuringTtsPolicy::Auto, + InputDuringTtsPolicy::AllowBargeIn, + InputDuringTtsPolicy::SuppressInput, + ] { + save( + &path, + &SavedSettings { + input_policy: Some(policy), + ..SavedSettings::default() + }, + ) + .unwrap(); + assert_eq!(load(&path).unwrap().input_policy, Some(policy)); + } + } + #[test] fn saved_settings_survive_reopen_and_explicit_backend_clears_dependent_flags() { let directory = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/commands/pocket_voice.rs b/src-tauri/src/commands/pocket_voice.rs index 3a15b1f51..68b0c48b6 100644 --- a/src-tauri/src/commands/pocket_voice.rs +++ b/src-tauri/src/commands/pocket_voice.rs @@ -383,23 +383,8 @@ pub(crate) fn effective_output_device_name(configured: Option<&str>) -> Option) -> bool { - if output_device.is_some_and(|name| { - let normalized = name.to_lowercase(); - ["speaker", "altavo"] - .iter() - .any(|keyword| normalized.contains(keyword)) - }) { - return true; - } - - #[cfg(target_os = "macos")] - { - output_device.is_some() - && berd_call::macos_audio_route::output_device_is_builtin_speaker(output_device) - } - - #[cfg(not(target_os = "macos"))] - false + // No configured/effective route remains unknown to the desktop host. + output_device.is_some() && berd_call::input::output_device_uses_speakers(output_device) } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] @@ -416,14 +401,9 @@ pub(crate) fn resolve_input_during_tts_policy( output_device: Option<&str>, ) -> InputDuringTtsPolicy { match mode { - // Automatic is best-effort because macOS cannot classify every external route. - // Prevent feedback remains the reliable fallback when this heuristic misses one. - VoiceInterruptionMode::Automatic if output_device_uses_speakers(output_device) => { - InputDuringTtsPolicy::SuppressInput - } - VoiceInterruptionMode::Automatic | VoiceInterruptionMode::AllowInterruptions => { - InputDuringTtsPolicy::AllowBargeIn - } + VoiceInterruptionMode::Automatic => InputDuringTtsPolicy::Auto + .resolve_for_speakers(output_device_uses_speakers(output_device)), + VoiceInterruptionMode::AllowInterruptions => InputDuringTtsPolicy::AllowBargeIn, VoiceInterruptionMode::PreventFeedback => InputDuringTtsPolicy::SuppressInput, } } From b2dcf8e7a1da440655d854b51748e4a95c3abafa Mon Sep 17 00:00:00 2001 From: Nora <49aa1f65411fd096d2e2ec144f1e7aa36fdc76d1b907cfdf7be000c66f9d3b8e@buzz.block.builderlab.xyz> Date: Tue, 20 Oct 2026 15:07:50 -0400 Subject: [PATCH 2/2] Verify Auto muting follows real macOS output changes Signed-off-by: Nora <49aa1f65411fd096d2e2ec144f1e7aa36fdc76d1b907cfdf7be000c66f9d3b8e@buzz.block.builderlab.xyz> --- src-tauri/crates/berd-call/src/input.rs | 45 +++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src-tauri/crates/berd-call/src/input.rs b/src-tauri/crates/berd-call/src/input.rs index 225987939..3f17c415f 100644 --- a/src-tauri/crates/berd-call/src/input.rs +++ b/src-tauri/crates/berd-call/src/input.rs @@ -2152,6 +2152,51 @@ mod tests { VoiceInputFrame::try_from_samples(&[0.0; INPUT_FRAME_SAMPLES]).unwrap() } + #[cfg(target_os = "macos")] + #[test] + #[ignore = "changes the default output; requires SwitchAudioSource and BlackHole 2ch"] + fn native_auto_mute_follows_default_output_during_speech() { + fn output(args: &[&str]) -> String { + let result = std::process::Command::new("SwitchAudioSource") + .args(args) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + String::from_utf8(result.stdout).unwrap().trim().to_owned() + } + struct RestoreOutput(String); + impl Drop for RestoreOutput { + fn drop(&mut self) { + let _ = std::process::Command::new("SwitchAudioSource") + .args(["-s", &self.0, "-t", "output"]) + .output(); + } + } + let _restore = RestoreOutput(output(&["-c", "-t", "output"])); + let devices = output(&["-a", "-t", "output"]); + assert!(devices.contains("MacBook Pro Speakers") && devices.contains("BlackHole 2ch")); + let controls = VoiceInputControls::default(); + let _activity = controls + .begin_assistant_activity(0.65, InputDuringTtsPolicy::Auto) + .unwrap(); + for (device, muted) in [ + ("MacBook Pro Speakers", true), + ("BlackHole 2ch", false), + ("MacBook Pro Speakers", true), + ] { + output(&["-s", device, "-t", "output"]); + let deadline = Instant::now() + Duration::from_secs(2); + while controls.is_muted() != muted && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + assert_eq!(controls.is_muted(), muted, "Auto did not follow {device}"); + } + } + #[test] fn auto_policy_follows_routes_without_overriding_explicit_choices() { for (speakers, expected) in [