-
Notifications
You must be signed in to change notification settings - Fork 2.5k
Polish Huddle voice controls #4694
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5d71925
d35b326
53d4e07
52f8db3
1015ade
80258a5
6e9fbbd
05c7623
0aa4cbb
241d57f
3309279
b4942d8
b30f430
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 { | ||
| state.emit_huddle_state_changed(); | ||
| } | ||
|
|
||
| Ok(()) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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; | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an agent is added to the huddle by another participant, the React TTS subscription can authorize that speaker from the relay via 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 | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.