Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src-tauri/crates/berd-call/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion src-tauri/crates/berd-call/src/cli_help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
6 changes: 1 addition & 5 deletions src-tauri/crates/berd-call/src/host_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
187 changes: 182 additions & 5 deletions src-tauri/crates/berd-call/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ struct ControlState {
#[derive(Default)]
struct ControlTransitionState {
host_muted: bool,
auto_suppressed: bool,
auto_monitor_running: bool,
assistant_activity: AssistantActivityState,
}

Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -2073,6 +2152,104 @@ 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 [
(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());
Expand Down
39 changes: 34 additions & 5 deletions src-tauri/crates/berd-call/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -3400,12 +3401,18 @@ fn run_expert_spokesperson_session(
let mut session_tts: Option<berd_call::TtsConfigurationSnapshot> = None;
let mut input_during_tts_slot: Option<InputDuringTtsSlot> = 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::<PendingSpokespersonSettingsUpdate>::default();
let mut pending_voice_update: Option<VoiceUpdateTransaction> = 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(
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading