Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src-tauri/crates/berd-call/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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
Expand Down
1 change: 1 addition & 0 deletions src-tauri/crates/berd-call/src/host_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5159,6 +5159,7 @@ mod tests {
}),
input_policy: Some(InputDuringTtsPolicy::SuppressInput),
status_sounds: None,
..Default::default()
}
}

Expand Down
115 changes: 113 additions & 2 deletions src-tauri/crates/berd-call/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ struct ControlState {
transition: Mutex<ControlTransitionState>,
muted: AtomicBool,
mute_epoch: AtomicU64,
cue_suppressions: AtomicU64,
assistant_speaking: AtomicBool,
assistant_vad_threshold: AtomicU32,
}
Expand Down Expand Up @@ -294,6 +295,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()),
}),
Expand All @@ -302,6 +304,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<CueInputActivityGuard, String> {
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)
}
Expand Down Expand Up @@ -455,6 +482,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,
Expand Down Expand Up @@ -590,11 +634,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 }) {
Expand Down Expand Up @@ -2166,6 +2210,45 @@ 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 default_output_is_classified_by_its_name() {
for name in ["USB Speakers", "Bluetooth SPEAKER", "Altavoces externos"] {
Expand Down Expand Up @@ -3104,6 +3187,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);
Expand Down
38 changes: 28 additions & 10 deletions src-tauri/crates/berd-call/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -2220,7 +2220,7 @@ fn run_session(config: SessionConfig, pcm_output_fd: RawFd) -> Result<(), String
let mut held: Option<HeldPrepare> = None;
let mut active: Option<ActivePlayback> = 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");
Expand Down Expand Up @@ -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}");
}
Expand Down Expand Up @@ -2463,11 +2464,13 @@ 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_output_device(status_sound_output_device)?;
status_sound_runtime.set_input_policy(input_during_tts)?;
let input_policy = InputDuringTtsSlot::new(input_during_tts);
let session = VoiceSessionSnapshot {
tts: slot.snapshot()?,
Expand Down Expand Up @@ -2500,7 +2503,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 {
Expand Down Expand Up @@ -2586,6 +2589,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 {
Expand Down Expand Up @@ -3613,7 +3617,11 @@ fn run_expert_spokesperson_session(
let mut queued_tts_settings = VoiceUpdateQueue::<PendingSpokespersonSettingsUpdate>::default();
let mut pending_voice_update: Option<VoiceUpdateTransaction> = None;
input_progress.enter("status_assets");
let mut status_sound_runtime = StatusSoundRuntime::default();
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");
Expand Down Expand Up @@ -4619,7 +4627,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}");
}
Expand Down Expand Up @@ -4695,7 +4704,7 @@ fn run_expert_spokesperson_session(
}
Input::Pcm(frame) => {
if spokesperson_pcm_allowed(
input_muted,
input_muted || cue_input_controls.is_input_suppressed(),
active.is_some(),
input_during_tts_slot
.as_ref()
Expand Down Expand Up @@ -4776,6 +4785,7 @@ fn run_expert_spokesperson_session(
rate: spokesperson_config.speed(),
},
};
status_sound_runtime.set_input_policy(input_during_tts)?;
let input_policy = InputDuringTtsSlot::new(input_during_tts);
let snapshot = VoiceSessionSnapshot {
tts: match &reply_tts {
Expand Down Expand Up @@ -4813,7 +4823,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,
Expand Down Expand Up @@ -5237,7 +5247,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 {
Expand Down Expand Up @@ -5305,6 +5315,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,
Expand Down Expand Up @@ -13460,6 +13477,7 @@ mod tests {
}),
input_policy: Some(berd_call::input::InputDuringTtsPolicy::SuppressInput),
status_sounds: None,
..Default::default()
},
)
.unwrap();
Expand Down
35 changes: 35 additions & 0 deletions src-tauri/crates/berd-call/src/saved_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +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<StatusSoundMode>,
// 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<serde::de::IgnoredAny>,
}

pub(crate) fn path() -> Result<PathBuf, String> {
Expand Down Expand Up @@ -235,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();
Expand Down
Loading
Loading