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
132 changes: 132 additions & 0 deletions desktop/src-tauri/src/huddle/commands.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//! Small Huddle controls that mutate an active session.

use std::sync::{atomic::Ordering, Arc};

use tauri::State;
use uuid::Uuid;

use crate::{app_state::AppState, events, relay::submit_event};

use super::{relay_api::validate_pubkey_hex, HuddlePhase};

/// Update the clickable microphone control independently from the PTT shortcut.
#[tauri::command]
pub fn set_huddle_manual_mic_unmuted(
enabled: bool,
state: State<'_, AppState>,
) -> Result<(), String> {
let huddle = state.huddle()?;
if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) {
return Err("no active huddle".to_string());
}
huddle.manual_mic_unmuted.store(enabled, Ordering::Release);
Ok(())
}

/// Immediately interrupt the agent utterance that is currently speaking.
#[tauri::command]
pub fn interrupt_huddle_speech(
agent_pubkey: String,
state: State<'_, AppState>,
) -> Result<(), String> {
validate_pubkey_hex(&agent_pubkey)?;
let tts_pipeline = {
let huddle = state.huddle()?;
if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) {
return Err("no active huddle".to_string());
}
huddle.tts_pipeline.as_ref().map(Arc::clone)
};
if let Some(tts_pipeline) = tts_pipeline {
tts_pipeline.cancel_active_speaker(&agent_pubkey);
}
Ok(())
}

/// Remove an agent from the active huddle without removing its parent-channel
/// membership. Keeping the parent membership intact means it remains available
/// to rejoin this huddle from the agent picker.
#[tauri::command]
pub async fn remove_agent_from_huddle(
agent_pubkey: String,
state: State<'_, AppState>,
) -> Result<(), String> {
validate_pubkey_hex(&agent_pubkey)?;

let (ephemeral_channel_id, huddle_generation) = {
let huddle = state.huddle()?;
if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) {
return Err("no active huddle".to_string());
}

let is_huddle_agent = huddle
.agent_pubkeys
.lock()
.unwrap_or_else(|error| error.into_inner())
.iter()
.any(|pubkey| pubkey.eq_ignore_ascii_case(&agent_pubkey));
if !is_huddle_agent {
return Err("agent is not in this huddle".to_string());
}

(
huddle
.ephemeral_channel_id
.clone()
.ok_or("no ephemeral channel")?,
huddle.huddle_generation,
)
};

let ephemeral_channel_uuid =
Uuid::parse_str(&ephemeral_channel_id).map_err(|error| error.to_string())?;
submit_event(
events::build_remove_member(ephemeral_channel_uuid, &agent_pubkey)?,
&state,
)
.await?;

let (roster_changed, tts_pipeline) = {
let mut huddle = state.huddle()?;
if !huddle.is_current_huddle(&ephemeral_channel_id, huddle_generation) {
(false, None)
} else {
let mut agent_pubkeys = huddle
.agent_pubkeys
.lock()
.unwrap_or_else(|error| error.into_inner());
let initial_count = agent_pubkeys.len();
agent_pubkeys.retain(|pubkey| !pubkey.eq_ignore_ascii_case(&agent_pubkey));
let changed = agent_pubkeys.len() != initial_count;
drop(agent_pubkeys);

if changed {
huddle
.participants
.retain(|pubkey| !pubkey.eq_ignore_ascii_case(&agent_pubkey));
if let Some(settings_pubkey) = huddle
.agent_voice_settings
.keys()
.find(|pubkey| pubkey.eq_ignore_ascii_case(&agent_pubkey))
.cloned()
{
huddle.agent_voice_settings.remove(&settings_pubkey);
}
}
let tts_pipeline = changed
.then_some(huddle.tts_pipeline.as_ref())
.flatten()
.map(Arc::clone);
(changed, tts_pipeline)
}
};

if let Some(tts_pipeline) = tts_pipeline {
tts_pipeline.cancel_speaker(&agent_pubkey);
}
if roster_changed {
Comment thread
klopez4212 marked this conversation as resolved.
state.emit_huddle_state_changed();
}

Ok(())
}
30 changes: 28 additions & 2 deletions desktop/src-tauri/src/huddle/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ mod agent_tts_routing;
pub mod agent_voice;
pub mod agents;
pub mod audio_output;
mod commands;
pub mod jitter;
pub mod models;
pub mod pipeline;
Expand Down Expand Up @@ -67,6 +68,9 @@ pub(super) fn drain_until_shutdown<T>(

// ── Re-exports ────────────────────────────────────────────────────────────────

pub use commands::{
interrupt_huddle_speech, remove_agent_from_huddle, set_huddle_manual_mic_unmuted,
};
pub use state::{HuddleJoinInfo, HuddlePhase, HuddleState, VoiceInputMode};
pub use transcription::{set_huddle_transcription_enabled, start_stt_pipeline};
pub use tts_settings::set_tts_enabled;
Expand Down Expand Up @@ -868,19 +872,41 @@ pub async fn speak_agent_message(

let sender = {
let hs = state.huddle()?;
let agent_is_present = hs
.agent_pubkeys
.lock()
.unwrap_or_else(|error| error.into_inner())
.iter()
.any(|pubkey| pubkey.eq_ignore_ascii_case(&speaker_pubkey));
if !agent_is_present {
eprintln!(
"buzz-desktop: tts stage=queue status=dropped reason=speaker_removed route_id={route_id}"
);
return Ok(());
Comment on lines +881 to +885

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid dropping relay-verified newly added agents

When an agent is added to the huddle by another participant, the React TTS subscription can authorize that speaker from the relay via get_huddle_agent_pubkeys, but this new native gate still checks only the local hs.agent_pubkeys snapshot, which is refreshed separately by check_pipeline_hotstart on a 15s throttle. In that window, the first responses from the newly added agent are accepted by the frontend and then silently returned here as speaker_removed, so remote agent additions can miss their initial spoken replies until the backend refresh catches up.

Useful? React with 👍 / 👎.

}
hs.tts_pipeline
.as_ref()
.map(|pipeline| pipeline.text_sender())
.map(|sender| {
let speaker_generation = sender.speaker_generation(&speaker_pubkey);
(sender, speaker_generation)
})
};
let Some(sender) = sender else {
let Some((sender, speaker_generation)) = sender else {
eprintln!(
"buzz-desktop: tts stage=invoke status=failed reason=unavailable route_id={route_id}"
);
return Err("Agent text to speech is enabled but its audio pipeline is unavailable".into());
};
enqueue_agent_tts_text(route_id, text, move |route_id, text| {
sender
.send(route_id, speaker_pubkey, voice_reference, text)
.send(
route_id,
speaker_pubkey,
speaker_generation,
voice_reference,
text,
)
.map_err(|error| format!("TTS queue closed while waiting to enqueue: {error}"))
})
.await
Expand Down
14 changes: 13 additions & 1 deletion desktop/src-tauri/src/huddle/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ pub(crate) async fn maybe_start_stt_pipeline(
expected_generation,
stt_starting,
ptt_active_for_stt,
manual_mic_unmuted_for_stt,
old_stt,
) = {
let mut hs = state.huddle()?;
Expand All @@ -338,21 +339,32 @@ pub(crate) async fn maybe_start_stt_pipeline(
} else {
None
};
let manual_mic_unmuted = if hs.voice_input_mode == VoiceInputMode::PushToTalk {
Some(Arc::clone(&hs.manual_mic_unmuted))
} else {
None
};
(
Arc::clone(&hs.tts_active),
Arc::clone(&hs.agent_pubkeys),
Arc::clone(&hs.session_generation),
hs.session_generation.load(Ordering::Acquire),
stt_starting,
ptt,
manual_mic_unmuted,
old,
)
};
// Drop the old pipeline OUTSIDE the lock — thread join happens here.
drop(old_stt);

let constructed = tokio::task::spawn_blocking(move || {
stt::SttPipeline::new(model_dir, tts_active, ptt_active_for_stt)
stt::SttPipeline::new(
model_dir,
tts_active,
ptt_active_for_stt,
manual_mic_unmuted_for_stt,
)
})
.await;
let (pipeline, text_rx) = match constructed {
Expand Down
17 changes: 15 additions & 2 deletions desktop/src-tauri/src/huddle/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use super::{stt, tts};

/// Voice input mode: push-to-talk (PTT) or voice-activity detection (VAD).
///
/// PTT: mic is gated by a global shortcut (Ctrl+Space). Pressing the key sets
/// PTT (the default): mic is gated by a global shortcut (Ctrl+Space). Pressing the key sets
/// `ptt_active` and immediately cancels any playing TTS. Releasing the key
/// (after a 200 ms delay) stops mic capture and flushes the utterance.
///
Expand All @@ -26,8 +26,8 @@ use super::{stt, tts};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum VoiceInputMode {
PushToTalk,
#[default]
PushToTalk,
VoiceActivity,
}

Expand Down Expand Up @@ -135,6 +135,10 @@ pub struct HuddleState {
/// Shared with the STT pipeline for mic gating.
#[serde(skip)]
pub ptt_active: Arc<AtomicBool>,
/// True while the clickable microphone control is manually unmuted.
/// In PTT mode, either this flag or `ptt_active` opens the STT gate.
#[serde(skip)]
pub manual_mic_unmuted: Arc<AtomicBool>,
}

fn serialize_agent_pubkeys<S>(v: &Arc<Mutex<Vec<String>>>, s: S) -> Result<S::Ok, S::Error>
Expand Down Expand Up @@ -190,6 +194,7 @@ impl Clone for HuddleState {
session_generation: Arc::clone(&self.session_generation),
voice_input_mode: self.voice_input_mode.clone(),
ptt_active: Arc::clone(&self.ptt_active),
manual_mic_unmuted: Arc::clone(&self.manual_mic_unmuted),
}
}
}
Expand Down Expand Up @@ -221,6 +226,7 @@ impl Default for HuddleState {
session_generation: Arc::new(AtomicU64::new(0)),
voice_input_mode: VoiceInputMode::default(),
ptt_active: Arc::new(AtomicBool::new(false)),
manual_mic_unmuted: Arc::new(AtomicBool::new(true)),
}
}
}
Expand Down Expand Up @@ -332,6 +338,13 @@ mod tests {
assert!(!state.maybe_auto_enable_transcription_for_agents());
}

#[test]
fn defaults_to_push_to_talk_with_an_open_microphone() {
let state = HuddleState::default();
assert_eq!(state.voice_input_mode, super::VoiceInputMode::PushToTalk);
assert!(state.manual_mic_unmuted.load(Ordering::Acquire));
}

#[test]
fn explicit_user_disable_is_not_undone_by_agent_presence() {
let mut state = HuddleState::default();
Expand Down
Loading
Loading