Polish Huddle voice controls (#4694)

## Summary

- add a visible Stop control for interrupting agent speech
- make push-to-talk available by default while preserving manual mute
controls
- refine agent management, muted audio states, drawer layering, and
return navigation
- suppress duplicate notification sounds for Huddle messages

## Why

Huddles could trap users behind long agent speech, hide useful agent
controls, and leave temporary Huddle state visible after the call. The
drawer also regressed when the terminal substrate began painting behind
the rounded app surface.

## Validation

- `just desktop-ci`
- focused Huddle Playwright coverage for the drawer, speech
interruption, agent picker, and leave navigation

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-08-04 21:07:50 +01:00
committed by GitHub
parent 5179726737
commit ce3cf3cd25
34 changed files with 2196 additions and 546 deletions

View File

@@ -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(())
}

View File

@@ -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,11 +872,27 @@ 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(());
}
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}"
);
@@ -880,7 +900,13 @@ pub async fn speak_agent_message(
};
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

View File

@@ -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()?;
@@ -338,6 +339,11 @@ 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),
@@ -345,6 +351,7 @@ pub(crate) async fn maybe_start_stt_pipeline(
hs.session_generation.load(Ordering::Acquire),
stt_starting,
ptt,
manual_mic_unmuted,
old,
)
};
@@ -352,7 +359,12 @@ pub(crate) async fn maybe_start_stt_pipeline(
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 {

View File

@@ -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.
///
@@ -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,
}
@@ -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>
@@ -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),
}
}
}
@@ -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)),
}
}
}
@@ -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();

View File

@@ -71,9 +71,10 @@ impl SttPipeline {
/// therefore never cancel TTS. Push-to-talk and remote participant speech
/// remain explicit, reliable barge-in paths.
///
/// `ptt_active` (optional) is the push-to-talk flag. When `Some`, the STT
/// pipeline only accumulates speech while the flag is true (key held).
/// When `None`, the pipeline runs in continuous VAD mode.
/// `ptt_active` and `manual_mic_unmuted` are present when the PTT shortcut
/// is enabled. The pipeline accepts speech while either input path is open;
/// manual unmute uses normal VAD flushing while a shortcut hold is grouped
/// into one utterance.
///
/// Returns `Err` only if the thread cannot be spawned (OS error).
/// If model files are missing, the worker logs and exits cleanly —
@@ -87,6 +88,7 @@ impl SttPipeline {
model_dir: PathBuf,
tts_active: Arc<AtomicBool>,
ptt_active: Option<Arc<AtomicBool>>,
manual_mic_unmuted: Option<Arc<AtomicBool>>,
) -> Result<(Self, tokio_mpsc::Receiver<String>), String> {
let (audio_tx, audio_rx) = mpsc::sync_channel::<Vec<u8>>(AUDIO_QUEUE_DEPTH);
let (text_tx, text_rx) = tokio_mpsc::channel::<String>(64);
@@ -94,6 +96,7 @@ impl SttPipeline {
let shutdown_worker = Arc::clone(&shutdown);
let ptt_active_worker = ptt_active.as_ref().map(Arc::clone);
let manual_mic_unmuted_worker = manual_mic_unmuted.as_ref().map(Arc::clone);
let handle = thread::Builder::new()
.name("stt-worker".into())
.spawn(move || {
@@ -104,6 +107,7 @@ impl SttPipeline {
shutdown_worker,
tts_active,
ptt_active_worker,
manual_mic_unmuted_worker,
)
})
.map_err(|e| format!("failed to spawn stt-worker thread: {e}"))?;
@@ -203,6 +207,7 @@ fn stt_worker(
shutdown: Arc<AtomicBool>,
tts_active: Arc<AtomicBool>,
ptt_active: Option<Arc<AtomicBool>>,
manual_mic_unmuted: Option<Arc<AtomicBool>>,
) {
// ── 1. Initialise rubato resampler (48 kHz → 16 kHz, mono) ───────────────
use rubato::{Fft, FixedSync, Resampler};
@@ -275,9 +280,12 @@ fn stt_worker(
// ── 5. Main loop ──────────────────────────────────────────────────────────
let mut tts_was_active = false;
let mut ptt_was_active = ptt_active
let mut transmit_was_active = ptt_active
.as_ref()
.is_some_and(|p| p.load(Ordering::Acquire));
.is_some_and(|ptt| ptt.load(Ordering::Acquire))
|| manual_mic_unmuted
.as_ref()
.is_some_and(|manual| manual.load(Ordering::Acquire));
loop {
// Check shutdown flag before blocking.
if shutdown.load(Ordering::Acquire) {
@@ -292,20 +300,22 @@ fn stt_worker(
}
tts_was_active = tts_now;
// Track PTT transitions — flush accumulated speech when key is released.
// The worklet stops sending frames when PTT is inactive, so the normal
// silence-accumulation flush path never runs. We must flush here on the
// active→inactive edge to avoid buffering speech across PTT presses.
// Track the combined manual/PTT transmission edge. When both paths
// close, the worklet stops sending frames, so flush here rather than
// waiting for silence that will never arrive.
if let Some(ref ptt) = ptt_active {
let ptt_now = ptt.load(Ordering::Acquire);
if ptt_was_active && !ptt_now && in_speech && !speech_buf.is_empty() {
let transmit_now = ptt.load(Ordering::Acquire)
|| manual_mic_unmuted
.as_ref()
.is_some_and(|manual| manual.load(Ordering::Acquire));
if transmit_was_active && !transmit_now && in_speech && !speech_buf.is_empty() {
flush_to_stt(&speech_buf, voiced_frames, &recognizer, &text_tx);
speech_buf.clear();
silence_frames = 0;
in_speech = false;
voiced_frames = 0;
}
ptt_was_active = ptt_now;
transmit_was_active = transmit_now;
}
// Use recv_timeout so we can periodically check the shutdown flag.
@@ -343,6 +353,7 @@ fn stt_worker(
&tts_active,
&mut tts_stopped_at,
ptt_active.as_ref(),
manual_mic_unmuted.as_ref(),
);
}
}
@@ -385,11 +396,9 @@ fn resample_chunk(resampler: &mut rubato::Fft<f32>, chunk_48k: &[f32]) -> Vec<f3
/// - In PTT mode, the shortcut handler remains the explicit cancellation path.
/// - After TTS stops, a cooldown prevents tail audio from being transcribed.
///
/// When `ptt_active` is `Some`:
/// - VAD `is_speech` is ANDed with the PTT flag — when the key is released,
/// `is_speech` becomes false, silence_frames accumulates, and the existing
/// flush logic kicks in naturally. The 200 ms release delay + ~300 ms
/// silence flush gives a natural utterance tail.
/// When `ptt_active` is `Some`, input is accepted while either the shortcut is
/// held or the microphone is manually unmuted. Manual-open input keeps normal
/// VAD pause flushing; shortcut-only input flushes when the shortcut closes.
#[allow(clippy::too_many_arguments)]
fn process_16k_samples(
samples: &[f32],
@@ -404,6 +413,7 @@ fn process_16k_samples(
tts_active: &Arc<AtomicBool>,
tts_stopped_at: &mut Option<std::time::Instant>,
ptt_active: Option<&Arc<AtomicBool>>,
manual_mic_unmuted: Option<&Arc<AtomicBool>>,
) {
leftover.extend_from_slice(samples);
@@ -413,13 +423,11 @@ fn process_16k_samples(
let prob = vad.predict_f32(&clamped);
let is_speech = prob > VAD_THRESHOLD;
// PTT gating: when PTT key is not held, treat as silence.
// This causes natural flush when the key is released — silence_frames
// accumulates and the existing flush logic kicks in after
// SILENCE_FLUSH_FRAMES. The 200 ms release delay + ~300 ms silence
// flush gives a natural utterance tail.
let manually_open = manual_mic_unmuted.is_some_and(|manual| manual.load(Ordering::Acquire));
// Shortcut-enabled mode accepts input from either the held shortcut or
// a manually open microphone.
let is_speech = if let Some(ptt) = ptt_active {
is_speech && ptt.load(Ordering::Acquire)
is_speech && (ptt.load(Ordering::Acquire) || manually_open)
} else {
is_speech
};
@@ -478,11 +486,9 @@ fn process_16k_samples(
speech_buf.extend_from_slice(&frame);
*silence_frames += 1;
// In PTT mode, don't flush on silence — accumulate the entire
// key-hold as one utterance. The PTT release edge in the main
// loop handles the flush. In VAD mode, flush after the silence
// threshold so each natural pause becomes a separate message.
if ptt_active.is_none() && *silence_frames >= SILENCE_FLUSH_FRAMES {
// A manually open microphone behaves like normal VAD. A
// shortcut-only transmission stays grouped until key release.
if (ptt_active.is_none() || manually_open) && *silence_frames >= SILENCE_FLUSH_FRAMES {
// End of utterance — transcribe.
flush_to_stt(speech_buf, *voiced_frames, recognizer, text_tx);
speech_buf.clear();

View File

@@ -64,6 +64,11 @@ use audio::*;
#[path = "tts_activity.rs"]
mod activity;
use activity::*;
#[path = "tts_pipeline_controls.rs"]
mod pipeline_controls;
#[path = "tts_speaker_cancellation.rs"]
mod speaker_cancellation;
use speaker_cancellation::*;
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -126,7 +131,15 @@ const MAX_CHUNK_CHARS: usize = 200;
/// Injected as a silent buffer between each synthesized sentence chunk.
const INTER_SENTENCE_SILENCE: f32 = 0.1;
type WorkerControlState = (Arc<AtomicBool>, Arc<AtomicBool>, WorkerCancelSignals);
type WorkerControlState = (
Arc<AtomicBool>,
Arc<AtomicBool>,
WorkerCancelSignals,
SpeakerGenerations,
ActiveSpeaker,
SpeakerCancellation,
PlaybackProbe,
);
// ── Public pipeline handle ────────────────────────────────────────────────────
@@ -154,6 +167,15 @@ pub struct TtsPipeline {
voice: Arc<Mutex<String>>,
/// Tags messages so a voice change drops only pre-change queue entries.
voice_generation: Arc<AtomicU64>,
/// Per-agent generations let removal invalidate that agent's queued and
/// in-flight text without poisoning speech queued after the agent rejoins.
speaker_generations: SpeakerGenerations,
/// Speaker whose audio currently owns the shared player queue.
active_speaker: ActiveSpeaker,
/// Targeted cancellation used when an agent leaves the huddle.
speaker_cancel: SpeakerCancellation,
/// Shared player handle used to reject Stop clicks after playback drains.
playback_probe: PlaybackProbe,
/// Completed after the worker drains pre-change text and installs the new style.
voice_change_ack: VoiceChangeAck,
/// Worker thread handle — taken on drop to join cleanly.
@@ -187,6 +209,14 @@ impl TtsPipeline {
let voice_worker = Arc::clone(&voice);
let voice_generation = Arc::new(AtomicU64::new(1));
let worker_voice_generation = Arc::clone(&voice_generation);
let speaker_generations = Arc::new(Mutex::new(HashMap::new()));
let worker_speaker_generations = Arc::clone(&speaker_generations);
let active_speaker = Arc::new(Mutex::new(None));
let worker_active_speaker = Arc::clone(&active_speaker);
let speaker_cancel = Arc::new(Mutex::new(None));
let worker_speaker_cancel = Arc::clone(&speaker_cancel);
let playback_probe = PlaybackProbe::new();
let worker_playback_probe = playback_probe.clone();
let voice_change_ack = Arc::new(Mutex::new(None));
let worker_voice_change_ack = Arc::clone(&voice_change_ack);
let model_dir_worker = model_dir.clone();
@@ -207,6 +237,10 @@ impl TtsPipeline {
tts_active_worker,
shutdown_worker,
(cancel_worker, worker_voice_cancel),
worker_speaker_generations,
worker_active_speaker,
worker_speaker_cancel,
worker_playback_probe,
),
output_device,
activity_app,
@@ -224,79 +258,14 @@ impl TtsPipeline {
voice_cancel,
voice,
voice_generation,
speaker_generations,
active_speaker,
speaker_cancel,
playback_probe,
voice_change_ack,
thread: Some(handle),
})
}
/// Queue `text` for TTS synthesis and playback.
///
/// Non-blocking. Returns `Err` if the queue is full (bounded at
/// `TEXT_QUEUE_DEPTH`) — caller may log and discard.
pub fn speak(&self, text: String) -> Result<(), String> {
self.text_tx
.try_send(QueuedText {
generation: self.voice_generation.load(Ordering::Acquire),
route_id: 0,
speaker_pubkey: None,
voice_reference: None,
text,
})
.map_err(|e| {
eprintln!("buzz-desktop: TTS queue saturated, dropping message: {e}");
format!("TTS queue full, dropping: {e}")
})
}
/// Clone the bounded queue sender so callers can apply backpressure without
/// holding the huddle mutex. Disabling TTS drops the receiver and unblocks
/// any waiting sender while the shared cancellation flag stops playback.
pub(crate) fn text_sender(&self) -> TtsTextSender {
TtsTextSender {
text_tx: self.text_tx.clone(),
generation: self.voice_generation.load(Ordering::Acquire),
}
}
/// Select a bundled Pocket voice for subsequent speech.
///
/// Current playback and queued text are cancelled immediately so content
/// cannot continue in the old voice. The worker keeps its warmed inference
/// engine and reloads only the reference style before the next utterance.
pub fn select_voice(&self, voice: &str) -> Option<tokio::sync::oneshot::Receiver<()>> {
let acknowledged = begin_voice_change(
&self.voice,
&self.voice_generation,
&self.voice_cancel,
&self.voice_change_ack,
voice,
);
if acknowledged.is_some() {
eprintln!("buzz-desktop: tts stage=cancellation reason=voice_switch route_id=0");
}
acknowledged
}
/// Reconcile the voice of a pipeline that has not been published yet.
///
/// No caller can enqueue text before publication, so raising the shared
/// cancellation flag here would create a race that could discard the first
/// message queued immediately after installation.
pub(crate) fn select_voice_before_publish(&self, voice: &str) {
*self.voice.lock().unwrap_or_else(|error| error.into_inner()) = voice.to_string();
}
/// Signal the worker thread to stop.
pub fn shutdown(&self) {
eprintln!("buzz-desktop: tts stage=cancellation reason=shutdown route_id=0");
self.shutdown.store(true, Ordering::Release);
}
/// Returns `true` if the worker thread has exited (init failure, crash, or normal exit).
/// Used by hot-start to detect dead pipelines and clear them for retry.
pub fn is_finished(&self) -> bool {
self.thread.as_ref().is_none_or(|h| h.is_finished())
}
}
impl Drop for TtsPipeline {
@@ -322,7 +291,15 @@ fn tts_worker(
startup_tx: mpsc::SyncSender<Result<(), String>>,
) {
let (selected_voice, voice_generation, voice_change_ack) = voice_state;
let (tts_active, shutdown, cancel_signals) = control_state;
let (
tts_active,
shutdown,
cancel_signals,
speaker_generations,
active_speaker,
speaker_cancel,
playback_probe,
) = control_state;
let (cancel, voice_cancel) = cancel_signals;
// ── 1. Initialise TTS engine ──────────────────────────────────────────────
let model_dir_str = model_dir.to_string_lossy().to_string();
@@ -415,6 +392,7 @@ fn tts_worker(
// Shared (Arc) with the barge-in monitor thread below, which needs to
// silence it while this thread is blocked inside `synth_chunk`.
let player = Arc::new(Player::connect_new(sink_handle.mixer()));
playback_probe.install(Arc::clone(&player));
// Prime the audio output stream with a short silent buffer.
// On macOS, CoreAudio initializes the output device lazily on first use.
@@ -443,103 +421,21 @@ fn tts_worker(
}
eprintln!("buzz-desktop: tts stage=startup status=ready");
// ── 3b. Barge-in monitor thread ───────────────────────────────────────────
//
// The worker loop only observes `cancel` between sentences — while it is
// blocked inside `synth_chunk` (hundreds of ms for a long sentence),
// nothing would silence the audio that is already playing. The monitor
// closes that gap: every MONITOR_TICK it checks the flag and, while set,
// silences the player and releases the mic gate. It does NOT consume the
// flag — the worker still owns that (drain queue, reset lead-in), so the
// monitor keeps re-clearing until the worker catches up, which also
// covers a sentence appended in the race window after the worker's own
// post-synthesis cancel check.
//
// `player_ops` closes the converse race (found in review): the monitor
// loads `cancel == true`, is preempted, the worker consumes the cancel
// and appends a fresh post-cancel utterance, then the monitor resumes
// from its stale branch and deletes audio that was meant to play. All
// worker player mutations (appends and cancel/shutdown clears) hold this
// lock, and the monitor re-checks `cancel` *while holding it* — so its
// clear either runs before fresh audio can be appended, or observes
// `cancel == false` and no-ops. The lock is uncontended except during an
// actual barge-in, so the hot path is unaffected.
let player_ops = Arc::new(Mutex::new(()));
let player_ops = Arc::clone(&playback_probe.player_ops);
let activity_frames = Arc::new(Mutex::new(VecDeque::<TtsSpeakerActivityFrame>::new()));
let monitor_stop = Arc::new(AtomicBool::new(false));
let monitor = {
let player = Arc::clone(&player);
let cancel = Arc::clone(&cancel);
let voice_cancel = Arc::clone(&voice_cancel);
let tts_active = Arc::clone(&tts_active);
let stop = Arc::clone(&monitor_stop);
let player_ops = Arc::clone(&player_ops);
let activity_frames = Arc::clone(&activity_frames);
thread::Builder::new()
.name("tts-barge-in-monitor".into())
.spawn(move || {
let mut last_activity_pubkey: Option<String> = None;
let mut next_activity_tick = Instant::now();
while !stop.load(Ordering::Acquire) {
if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) {
let _ops = lock_player_ops(&player_ops);
// Re-check under the lock: the worker may have
// consumed this cancel (and appended fresh audio)
// between the load above and the lock acquisition.
if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) {
// clear() pauses the persistent player; play()
// un-pauses (see handle_cancel_or_shutdown).
// Idempotent — safe to repeat every tick until
// the worker consumes the flag.
player.clear();
player.play();
tts_active.store(false, Ordering::Release);
}
}
if let Some(ref app) = activity_app {
if tts_active.load(Ordering::Acquire) {
let now = Instant::now();
if now >= next_activity_tick {
let frame = activity_frames
.lock()
.unwrap_or_else(|error| error.into_inner())
.pop_front();
if let Some(frame) = frame {
use tauri::Emitter;
let _ = app.emit(
"huddle-tts-speaker-level",
TtsSpeakerActivityPayload {
pubkey: Some(frame.pubkey.clone()),
level: frame.level,
},
);
last_activity_pubkey = Some(frame.pubkey);
}
next_activity_tick = now + SPEAKER_ACTIVITY_TICK;
}
} else {
let had_activity = last_activity_pubkey.take().is_some();
activity_frames
.lock()
.unwrap_or_else(|error| error.into_inner())
.clear();
if had_activity {
use tauri::Emitter;
let _ = app.emit(
"huddle-tts-speaker-level",
TtsSpeakerActivityPayload {
pubkey: None,
level: 0.0,
},
);
}
next_activity_tick = Instant::now();
}
}
thread::sleep(MONITOR_TICK);
}
})
};
let monitor = spawn_tts_monitor(TtsMonitorState {
player: Arc::clone(&player),
cancel: Arc::clone(&cancel),
voice_cancel: Arc::clone(&voice_cancel),
tts_active: Arc::clone(&tts_active),
stop: Arc::clone(&monitor_stop),
player_ops: Arc::clone(&player_ops),
activity_frames: Arc::clone(&activity_frames),
active_speaker: Arc::clone(&active_speaker),
speaker_cancel: Arc::clone(&speaker_cancel),
activity_app,
});
if let Err(ref e) = monitor {
// Degraded but functional: barge-in still works between sentences
// via the worker's own checks, just not mid-synthesis.
@@ -564,7 +460,8 @@ fn tts_worker(
let mut deferred_text = VecDeque::new();
let append_audio = |prepared: PreparedModelAudio,
route_id: u64,
speaker_pubkey: Option<&str>| {
speaker_pubkey: Option<&str>,
speaker_generation: u64| {
let _ops = lock_player_ops(&player_ops);
if cancel.load(Ordering::Acquire)
|| voice_cancel.load(Ordering::Acquire)
@@ -582,6 +479,30 @@ fn tts_worker(
);
return false;
}
let speaker_is_current = speaker_pubkey.is_none_or(|pubkey| {
current_speaker_generation(&speaker_generations, pubkey) == speaker_generation
});
if !speaker_is_current {
eprintln!(
"buzz-desktop: tts stage=synthesis status=cancelled reason=speaker_removed route_id={route_id}"
);
return false;
}
if let Some(pubkey) = speaker_pubkey {
let mut active = active_speaker
.lock()
.unwrap_or_else(|error| error.into_inner());
if player.empty() {
active.take();
}
if active
.as_deref()
.is_some_and(|current| !current.eq_ignore_ascii_case(pubkey))
{
return false;
}
active.get_or_insert_with(|| pubkey.to_ascii_lowercase());
}
if let Some(pubkey) = speaker_pubkey {
activity_frames
.lock()
@@ -604,6 +525,17 @@ fn tts_worker(
loop {
let mut no_current_text = None;
if consume_speaker_cancel(
&speaker_cancel,
&active_speaker,
&speaker_generations,
&tts_active,
(&text_rx, &mut deferred_text, &mut no_current_text),
Some((&player, &player_ops)),
) {
first_append = true;
continue;
}
if handle_cancel_or_shutdown(
(&cancel, &voice_cancel),
&shutdown,
@@ -647,6 +579,10 @@ fn tts_worker(
// lead-in so the next utterance gets a fresh cushion.
if player.empty() && !first_append {
tts_active.store(false, Ordering::Release);
active_speaker
.lock()
.unwrap_or_else(|error| error.into_inner())
.take();
eprintln!(
"buzz-desktop: tts stage=player status=drained route_id={last_route_id}"
);
@@ -679,6 +615,13 @@ fn tts_worker(
let Some(queued_text) = queued_text else {
continue;
};
if !queued_speaker_is_current(&speaker_generations, &queued_text) {
eprintln!(
"buzz-desktop: tts stage=queue status=dropped reason=speaker_removed route_id={}",
queued_text.route_id
);
continue;
}
if queued_text.generation < voice_generation.load(Ordering::Acquire) {
eprintln!(
"buzz-desktop: tts stage=queue status=dropped reason=voice_switch route_id={}",
@@ -686,6 +629,22 @@ fn tts_worker(
);
continue;
}
if !player.empty()
&& queued_text
.speaker_pubkey
.as_deref()
.is_some_and(|speaker| {
active_speaker
.lock()
.unwrap_or_else(|error| error.into_inner())
.as_deref()
.is_some_and(|active| !active.eq_ignore_ascii_case(speaker))
})
{
deferred_text.push_front(queued_text);
thread::sleep(RECV_TIMEOUT);
continue;
}
let requested_voice = queued_text.voice_reference.unwrap_or_else(|| {
selected_voice
.lock()
@@ -694,9 +653,32 @@ fn tts_worker(
});
let raw_text = queued_text.text;
let speaker_pubkey = queued_text.speaker_pubkey;
let speaker_generation = queued_text.speaker_generation;
let route_id = queued_text.route_id;
eprintln!("buzz-desktop: tts stage=synthesis status=started route_id={route_id}");
// If playback already drained while we were waiting for this item,
// release stale ownership before doing any potentially slow voice or
// synthesis work. Serialize the drain decision with Stop and append so
// those paths observe one coherent utterance boundary.
{
let _ops = lock_player_ops(&player_ops);
if player.empty() && !first_append {
tts_active.store(false, Ordering::Release);
active_speaker
.lock()
.unwrap_or_else(|error| error.into_inner())
.take();
eprintln!("buzz-desktop: tts stage=player status=drained route_id={last_route_id}");
first_append = true;
}
}
// From this point until the item finishes, an empty player can mean a
// voice-preparation or synthesis gap rather than a drained utterance.
// Stop must remain able to invalidate the in-flight speaker generation.
let _synthesis_flight = playback_probe.begin_synthesis();
// The selected per-agent voice travels with the queue item, preserving
// message order while allowing one warmed Pocket engine to alternate
// between cached reference styles.
@@ -714,20 +696,6 @@ fn tts_worker(
continue;
}
// If playback already drained while we were waiting for this item,
// the agent is silent — release the mic gate BEFORE preprocessing/
// synthesis. Without this, an item arriving inside the recv timeout
// window would run the whole synthesis pass with `tts_active` stuck
// true and nothing playing, making STT discard human speech as
// "echo" during a silent window. (Pipelining is unaffected: when
// audio is still draining, `player.empty()` is false and the flag
// stays set across items.)
if player.empty() && !first_append {
tts_active.store(false, Ordering::Release);
eprintln!("buzz-desktop: tts stage=player status=drained route_id={last_route_id}");
first_append = true;
}
// Preprocess text.
let text = preprocess_for_tts(&raw_text);
if text.is_empty() {
@@ -846,7 +814,12 @@ fn tts_worker(
silence_buf_len,
player.empty(),
) {
if !append_audio(prepared, route_id, speaker_pubkey.as_deref()) {
if !append_audio(
prepared,
route_id,
speaker_pubkey.as_deref(),
speaker_generation,
) {
first_append = true;
synthesis_outcome = "cancelled";
break 'playback_chunks;
@@ -872,7 +845,12 @@ fn tts_worker(
if let Some(prepared) =
playback_audio.finish(&mut first_append, silence_buf_len, player.empty())
{
if !append_audio(prepared, route_id, speaker_pubkey.as_deref()) {
if !append_audio(
prepared,
route_id,
speaker_pubkey.as_deref(),
speaker_generation,
) {
first_append = true;
synthesis_outcome = "cancelled";
break 'playback_chunks;

View File

@@ -0,0 +1,100 @@
use super::*;
impl TtsPipeline {
/// Queue `text` for TTS synthesis and playback.
///
/// Non-blocking. Returns `Err` if the queue is full (bounded at
/// `TEXT_QUEUE_DEPTH`) — caller may log and discard.
pub fn speak(&self, text: String) -> Result<(), String> {
self.text_tx
.try_send(QueuedText {
generation: self.voice_generation.load(Ordering::Acquire),
route_id: 0,
speaker_pubkey: None,
speaker_generation: 0,
voice_reference: None,
text,
})
.map_err(|e| {
eprintln!("buzz-desktop: TTS queue saturated, dropping message: {e}");
format!("TTS queue full, dropping: {e}")
})
}
/// Clone the bounded queue sender so callers can apply backpressure without
/// holding the huddle mutex. Disabling TTS drops the receiver and unblocks
/// any waiting sender while the shared cancellation flag stops playback.
pub(crate) fn text_sender(&self) -> TtsTextSender {
TtsTextSender {
text_tx: self.text_tx.clone(),
generation: self.voice_generation.load(Ordering::Acquire),
speaker_generations: Arc::clone(&self.speaker_generations),
}
}
/// Invalidate speech queued for one agent and cancel the player only when
/// that same agent currently owns it.
pub(crate) fn cancel_speaker(&self, speaker_pubkey: &str) {
request_speaker_cancel(
&self.speaker_generations,
&self.active_speaker,
&self.speaker_cancel,
speaker_pubkey,
);
}
/// Cancel exactly the speaker utterance currently owning playback.
///
/// The speaker generation is advanced while ownership is locked, so a
/// stale Stop click cannot cancel a later utterance that starts after the
/// observed one drains.
pub(crate) fn cancel_active_speaker(&self, expected_speaker_pubkey: &str) -> bool {
request_active_speaker_cancel(
&self.speaker_generations,
&self.active_speaker,
&self.speaker_cancel,
&self.playback_probe,
expected_speaker_pubkey,
)
}
/// Select a bundled Pocket voice for subsequent speech.
///
/// Current playback and queued text are cancelled immediately so content
/// cannot continue in the old voice. The worker keeps its warmed inference
/// engine and reloads only the reference style before the next utterance.
pub fn select_voice(&self, voice: &str) -> Option<tokio::sync::oneshot::Receiver<()>> {
let acknowledged = begin_voice_change(
&self.voice,
&self.voice_generation,
&self.voice_cancel,
&self.voice_change_ack,
voice,
);
if acknowledged.is_some() {
eprintln!("buzz-desktop: tts stage=cancellation reason=voice_switch route_id=0");
}
acknowledged
}
/// Reconcile the voice of a pipeline that has not been published yet.
///
/// No caller can enqueue text before publication, so raising the shared
/// cancellation flag here would create a race that could discard the first
/// message queued immediately after installation.
pub(crate) fn select_voice_before_publish(&self, voice: &str) {
*self.voice.lock().unwrap_or_else(|error| error.into_inner()) = voice.to_string();
}
/// Signal the worker thread to stop.
pub fn shutdown(&self) {
eprintln!("buzz-desktop: tts stage=cancellation reason=shutdown route_id=0");
self.shutdown.store(true, Ordering::Release);
}
/// Returns `true` if the worker thread has exited (init failure, crash, or normal exit).
/// Used by hot-start to detect dead pipelines and clear them for retry.
pub fn is_finished(&self) -> bool {
self.thread.as_ref().is_none_or(|h| h.is_finished())
}
}

View File

@@ -0,0 +1,177 @@
use super::*;
pub(super) struct TtsMonitorState {
pub(super) player: Arc<rodio::Player>,
pub(super) cancel: Arc<AtomicBool>,
pub(super) voice_cancel: Arc<AtomicBool>,
pub(super) tts_active: Arc<AtomicBool>,
pub(super) stop: Arc<AtomicBool>,
pub(super) player_ops: Arc<Mutex<()>>,
pub(super) activity_frames: Arc<Mutex<VecDeque<TtsSpeakerActivityFrame>>>,
pub(super) active_speaker: ActiveSpeaker,
pub(super) speaker_cancel: SpeakerCancellation,
pub(super) activity_app: Option<tauri::AppHandle>,
}
pub(super) fn spawn_tts_monitor(state: TtsMonitorState) -> std::io::Result<thread::JoinHandle<()>> {
thread::Builder::new()
.name("tts-barge-in-monitor".into())
.spawn(move || {
let mut last_activity_pubkey: Option<String> = None;
let mut next_activity_tick = Instant::now();
while !state.stop.load(Ordering::Acquire) {
if state.cancel.load(Ordering::Acquire)
|| state.voice_cancel.load(Ordering::Acquire)
{
let _ops = lock_player_ops(&state.player_ops);
if state.cancel.load(Ordering::Acquire)
|| state.voice_cancel.load(Ordering::Acquire)
{
state.player.clear();
state.player.play();
state
.active_speaker
.lock()
.unwrap_or_else(|error| error.into_inner())
.take();
state.tts_active.store(false, Ordering::Release);
}
}
silence_cancelled_speaker(
&state.speaker_cancel,
&state.active_speaker,
&state.player,
&state.player_ops,
&state.tts_active,
);
if let Some(ref app) = state.activity_app {
if state.tts_active.load(Ordering::Acquire) {
let now = Instant::now();
if now >= next_activity_tick {
let frame = state
.activity_frames
.lock()
.unwrap_or_else(|error| error.into_inner())
.pop_front();
if let Some(frame) = frame {
use tauri::Emitter;
let _ = app.emit(
"huddle-tts-speaker-level",
TtsSpeakerActivityPayload {
pubkey: Some(frame.pubkey.clone()),
level: frame.level,
},
);
last_activity_pubkey = Some(frame.pubkey);
}
next_activity_tick = now + SPEAKER_ACTIVITY_TICK;
}
} else {
let had_activity = last_activity_pubkey.take().is_some();
state
.activity_frames
.lock()
.unwrap_or_else(|error| error.into_inner())
.clear();
if had_activity {
use tauri::Emitter;
let _ = app.emit(
"huddle-tts-speaker-level",
TtsSpeakerActivityPayload {
pubkey: None,
level: 0.0,
},
);
}
next_activity_tick = Instant::now();
}
}
thread::sleep(MONITOR_TICK);
}
})
}
pub(super) fn silence_cancelled_speaker(
cancellation: &SpeakerCancellation,
active_speaker: &ActiveSpeaker,
player: &rodio::Player,
player_ops: &Mutex<()>,
tts_active: &AtomicBool,
) {
let Some(cancelled) = cancellation
.lock()
.unwrap_or_else(|error| error.into_inner())
.clone()
else {
return;
};
let _ops = lock_player_ops(player_ops);
if take_cancelled_active_speaker(&cancelled, active_speaker) {
player.clear();
player.play();
tts_active.store(false, Ordering::Release);
}
}
fn take_cancelled_active_speaker(cancelled: &str, active_speaker: &ActiveSpeaker) -> bool {
let mut active = active_speaker
.lock()
.unwrap_or_else(|error| error.into_inner());
if !active
.as_deref()
.is_some_and(|speaker| speaker.eq_ignore_ascii_case(cancelled))
{
return false;
}
active.take();
true
}
pub(super) fn consume_speaker_cancel(
cancellation: &SpeakerCancellation,
active_speaker: &ActiveSpeaker,
generations: &SpeakerGenerations,
tts_active: &AtomicBool,
text_state: CancelTextState<'_>,
player: Option<(&rodio::Player, &Mutex<()>)>,
) -> bool {
let Some(cancelled) = cancellation
.lock()
.unwrap_or_else(|error| error.into_inner())
.take()
else {
return false;
};
let (text_rx, deferred_text, current_text) = text_state;
retain_current_speaker_text(generations, deferred_text, current_text, text_rx);
let mut cleared_player = false;
if let Some((player, player_ops)) = player {
let _ops = lock_player_ops(player_ops);
if take_cancelled_active_speaker(&cancelled, active_speaker) {
player.clear();
player.play();
tts_active.store(false, Ordering::Release);
cleared_player = true;
}
}
// The monitor may already have cleared the cancelled speaker while the
// worker was blocked. If another speaker has since claimed the player,
// preserve that speaker's activity flag and lead-in state.
cleared_player
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stale_targeted_cancel_does_not_release_the_next_speaker() {
let active_speaker = Arc::new(Mutex::new(Some("bob".to_string())));
assert!(!take_cancelled_active_speaker("alice", &active_speaker));
assert_eq!(
active_speaker.lock().expect("active speaker").as_deref(),
Some("bob")
);
}
}

View File

@@ -19,6 +19,10 @@ fn inert_pipeline(cancel: Arc<AtomicBool>) -> TtsPipeline {
voice_cancel: Arc::new(AtomicBool::new(false)),
voice: Arc::new(std::sync::Mutex::new("reference_sample".to_string())),
voice_generation: Arc::new(AtomicU64::new(1)),
speaker_generations: Arc::new(std::sync::Mutex::new(HashMap::new())),
active_speaker: Arc::new(std::sync::Mutex::new(None)),
speaker_cancel: Arc::new(std::sync::Mutex::new(None)),
playback_probe: PlaybackProbe::new(),
voice_change_ack: Arc::new(std::sync::Mutex::new(None)),
thread: Some(thread),
}
@@ -168,6 +172,7 @@ fn an_in_hand_post_change_message_survives_cancellation() {
generation: voice_generation.load(Ordering::Acquire),
route_id: 1,
speaker_pubkey: None,
speaker_generation: 0,
voice_reference: None,
text: "new message".to_string(),
})
@@ -181,6 +186,7 @@ fn an_in_hand_post_change_message_survives_cancellation() {
generation: 1,
route_id: 2,
speaker_pubkey: None,
speaker_generation: 0,
voice_reference: None,
text: "old message".to_string(),
},
@@ -188,6 +194,7 @@ fn an_in_hand_post_change_message_survives_cancellation() {
generation: voice_generation.load(Ordering::Acquire),
route_id: 3,
speaker_pubkey: None,
speaker_generation: 0,
voice_reference: None,
text: "later new message".to_string(),
},
@@ -246,6 +253,7 @@ fn superseding_voice_change_removes_earlier_deferred_messages() {
generation: voice_generation.load(Ordering::Acquire),
route_id: 4,
speaker_pubkey: None,
speaker_generation: 0,
voice_reference: None,
text: "message for Eve".to_string(),
});
@@ -294,6 +302,7 @@ fn barge_in_clears_deferred_voice_change_messages() {
generation: 2,
route_id: 5,
speaker_pubkey: None,
speaker_generation: 0,
voice_reference: None,
text: "deferred message".to_string(),
}]);
@@ -337,6 +346,7 @@ fn barge_in_during_a_voice_change_clears_post_change_messages() {
generation: voice_generation.load(Ordering::Acquire),
route_id: 6,
speaker_pubkey: None,
speaker_generation: 0,
voice_reference: None,
text: "post-change message".to_string(),
});
@@ -365,6 +375,7 @@ fn a_sender_captured_before_voice_change_is_stale_even_if_it_sends_after_drain()
let old_sender = TtsTextSender {
text_tx,
generation: voice_generation.load(Ordering::Acquire),
speaker_generations: Arc::new(std::sync::Mutex::new(HashMap::new())),
};
let shutdown = AtomicBool::new(false);
let active = AtomicBool::new(true);
@@ -392,6 +403,7 @@ fn a_sender_captured_before_voice_change_is_stale_even_if_it_sends_after_drain()
.send(
7,
"agent".to_string(),
0,
"reference_sample".to_string(),
"late old message".to_string(),
)

View File

@@ -1,5 +1,6 @@
use std::{
collections::{HashMap, VecDeque},
fmt,
path::Path,
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
@@ -19,6 +20,9 @@ pub(super) struct PendingVoiceChange {
pub(super) type VoiceChangeAck = Arc<Mutex<Option<PendingVoiceChange>>>;
pub(super) type WorkerVoiceState = (Arc<Mutex<String>>, Arc<AtomicU64>, VoiceChangeAck);
pub(super) type WorkerCancelSignals = (Arc<AtomicBool>, Arc<AtomicBool>);
pub(super) type SpeakerGenerations = Arc<Mutex<HashMap<String, u64>>>;
pub(super) type ActiveSpeaker = Arc<Mutex<Option<String>>>;
pub(super) type SpeakerCancellation = Arc<Mutex<Option<String>>>;
pub(super) type CancelTextState<'a> = (
&'a mpsc::Receiver<QueuedText>,
&'a mut VecDeque<QueuedText>,
@@ -26,11 +30,73 @@ pub(super) type CancelTextState<'a> = (
);
pub(super) type CancelSignals<'a> = (&'a AtomicBool, &'a AtomicBool);
#[derive(Clone)]
pub(super) struct PlaybackProbe {
player: Arc<Mutex<Option<Arc<rodio::Player>>>>,
pub(super) player_ops: Arc<Mutex<()>>,
synthesis_in_flight: Arc<AtomicBool>,
}
pub(super) struct SynthesisFlightGuard {
playback_probe: PlaybackProbe,
}
impl Drop for SynthesisFlightGuard {
fn drop(&mut self) {
self.playback_probe.set_synthesis_in_flight(false);
}
}
impl PlaybackProbe {
pub(super) fn new() -> Self {
Self {
player: Arc::new(Mutex::new(None)),
player_ops: Arc::new(Mutex::new(())),
synthesis_in_flight: Arc::new(AtomicBool::new(false)),
}
}
pub(super) fn install(&self, player: Arc<rodio::Player>) {
self.player
.lock()
.unwrap_or_else(|error| error.into_inner())
.replace(player);
}
pub(super) fn set_synthesis_in_flight(&self, in_flight: bool) {
let _ops = super::lock_player_ops(&self.player_ops);
self.synthesis_in_flight.store(in_flight, Ordering::Release);
}
pub(super) fn begin_synthesis(&self) -> SynthesisFlightGuard {
self.set_synthesis_in_flight(true);
SynthesisFlightGuard {
playback_probe: self.clone(),
}
}
fn player(&self) -> Option<Arc<rodio::Player>> {
self.player
.lock()
.unwrap_or_else(|error| error.into_inner())
.clone()
}
}
impl fmt::Debug for PlaybackProbe {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PlaybackProbe")
.finish_non_exhaustive()
}
}
#[derive(Debug)]
pub(super) struct QueuedText {
pub(super) generation: u64,
pub(super) route_id: u64,
pub(super) speaker_pubkey: Option<String>,
pub(super) speaker_generation: u64,
pub(super) voice_reference: Option<String>,
pub(super) text: String,
}
@@ -39,6 +105,7 @@ pub(super) struct QueuedText {
pub(crate) struct TtsTextSender {
pub(super) text_tx: SyncSender<QueuedText>,
pub(super) generation: u64,
pub(super) speaker_generations: SpeakerGenerations,
}
impl TtsTextSender {
@@ -46,6 +113,7 @@ impl TtsTextSender {
&self,
route_id: u64,
speaker_pubkey: String,
speaker_generation: u64,
voice_reference: String,
text: String,
) -> Result<(), String> {
@@ -54,11 +122,156 @@ impl TtsTextSender {
generation: self.generation,
route_id,
speaker_pubkey: Some(speaker_pubkey),
speaker_generation,
voice_reference: Some(voice_reference),
text,
})
.map_err(|error| error.to_string())
}
pub(crate) fn speaker_generation(&self, speaker_pubkey: &str) -> u64 {
current_speaker_generation(&self.speaker_generations, speaker_pubkey)
}
}
pub(super) fn current_speaker_generation(
generations: &SpeakerGenerations,
speaker_pubkey: &str,
) -> u64 {
generations
.lock()
.unwrap_or_else(|error| error.into_inner())
.get(&speaker_pubkey.to_ascii_lowercase())
.copied()
.unwrap_or(0)
}
pub(super) fn advance_speaker_generation(
generations: &SpeakerGenerations,
speaker_pubkey: &str,
) -> u64 {
let mut generations = generations
.lock()
.unwrap_or_else(|error| error.into_inner());
let generation = generations
.entry(speaker_pubkey.to_ascii_lowercase())
.or_default();
*generation = generation.saturating_add(1);
*generation
}
pub(super) fn queued_speaker_is_current(
generations: &SpeakerGenerations,
queued: &QueuedText,
) -> bool {
queued
.speaker_pubkey
.as_deref()
.is_none_or(|speaker_pubkey| {
current_speaker_generation(generations, speaker_pubkey) == queued.speaker_generation
})
}
pub(super) fn request_speaker_cancel(
generations: &SpeakerGenerations,
active_speaker: &ActiveSpeaker,
cancellation: &SpeakerCancellation,
speaker_pubkey: &str,
) {
advance_speaker_generation(generations, speaker_pubkey);
let owns_player = active_speaker
.lock()
.unwrap_or_else(|error| error.into_inner())
.as_deref()
.is_some_and(|active| active.eq_ignore_ascii_case(speaker_pubkey));
if owns_player {
cancellation
.lock()
.unwrap_or_else(|error| error.into_inner())
.replace(speaker_pubkey.to_ascii_lowercase());
}
}
pub(super) fn request_active_speaker_cancel(
generations: &SpeakerGenerations,
active_speaker: &ActiveSpeaker,
cancellation: &SpeakerCancellation,
playback_probe: &PlaybackProbe,
expected_speaker_pubkey: &str,
) -> bool {
let Some(player) = playback_probe.player() else {
return false;
};
let _ops = super::lock_player_ops(&playback_probe.player_ops);
let playback_live =
!player.empty() || playback_probe.synthesis_in_flight.load(Ordering::Acquire);
request_active_speaker_cancel_while_locked(
generations,
active_speaker,
cancellation,
playback_live,
expected_speaker_pubkey,
)
}
fn request_active_speaker_cancel_while_locked(
generations: &SpeakerGenerations,
active_speaker: &ActiveSpeaker,
cancellation: &SpeakerCancellation,
playback_live: bool,
expected_speaker_pubkey: &str,
) -> bool {
if !playback_live {
return false;
}
let active = active_speaker
.lock()
.unwrap_or_else(|error| error.into_inner());
let Some(speaker_pubkey) = active.as_deref() else {
return false;
};
if !speaker_pubkey.eq_ignore_ascii_case(expected_speaker_pubkey) {
return false;
}
// Keep ownership locked until the generation and cancellation request are
// committed. The drain path takes the same lock, so the request is bound
// to the utterance the Stop action actually observed.
let mut cancellation = cancellation
.lock()
.unwrap_or_else(|error| error.into_inner());
if cancellation
.as_deref()
.is_some_and(|pending| pending.eq_ignore_ascii_case(speaker_pubkey))
{
return false;
}
advance_speaker_generation(generations, speaker_pubkey);
cancellation.replace(speaker_pubkey.to_ascii_lowercase());
true
}
pub(super) fn retain_current_speaker_text(
generations: &SpeakerGenerations,
deferred_text: &mut VecDeque<QueuedText>,
current_text: &mut Option<QueuedText>,
text_rx: &mpsc::Receiver<QueuedText>,
) {
deferred_text.retain(|text| queued_speaker_is_current(generations, text));
if let Some(text) = current_text.take() {
if queued_speaker_is_current(generations, &text) {
deferred_text.push_front(text);
} else {
log_cancelled_route(text.route_id, "speaker_removed");
}
}
while let Ok(text) = text_rx.try_recv() {
if queued_speaker_is_current(generations, &text) {
deferred_text.push_back(text);
} else {
log_cancelled_route(text.route_id, "speaker_removed");
}
}
}
pub(super) fn has_pending_voice_change(voice_change_ack: &VoiceChangeAck) -> bool {
@@ -258,3 +471,227 @@ pub(super) fn retain_cancelled_text(
fn log_cancelled_route(route_id: u64, reason: &str) {
eprintln!("buzz-desktop: tts stage=queue status=dropped reason={reason} route_id={route_id}");
}
#[cfg(test)]
mod speaker_generation_tests {
use super::*;
fn playback_probe(playback_live: bool) -> PlaybackProbe {
let channels = std::num::NonZero::new(1).expect("non-zero channels");
let sample_rate = std::num::NonZero::new(24_000).expect("non-zero sample rate");
let (mixer, _mixer_source) = rodio::mixer::mixer(channels, sample_rate);
let player = Arc::new(rodio::Player::connect_new(&mixer));
if playback_live {
player.append(rodio::buffer::SamplesBuffer::new(
channels,
sample_rate,
vec![0.0; 24_000],
));
}
let probe = PlaybackProbe::new();
probe.install(player);
probe
}
fn queued_speech(speaker_pubkey: &str, speaker_generation: u64) -> QueuedText {
QueuedText {
generation: 1,
route_id: 1,
speaker_pubkey: Some(speaker_pubkey.to_string()),
speaker_generation,
voice_reference: Some("pocket:mary".to_string()),
text: "Hello".to_string(),
}
}
#[test]
fn removing_a_speaker_invalidates_only_that_speakers_queued_text() {
let generations = Arc::new(Mutex::new(HashMap::new()));
let alice = queued_speech("ALICE", current_speaker_generation(&generations, "alice"));
let bob = queued_speech("bob", current_speaker_generation(&generations, "bob"));
advance_speaker_generation(&generations, "alice");
assert!(!queued_speaker_is_current(&generations, &alice));
assert!(queued_speaker_is_current(&generations, &bob));
let rejoined_alice =
queued_speech("alice", current_speaker_generation(&generations, "alice"));
assert!(queued_speaker_is_current(&generations, &rejoined_alice));
}
#[test]
fn removing_a_silent_speaker_does_not_cancel_the_active_speaker() {
let generations = Arc::new(Mutex::new(HashMap::new()));
let active_speaker = Arc::new(Mutex::new(Some("alice".to_string())));
let cancellation = Arc::new(Mutex::new(None));
request_speaker_cancel(&generations, &active_speaker, &cancellation, "bob");
assert!(cancellation.lock().expect("cancellation").is_none());
assert_eq!(
active_speaker.lock().expect("active speaker").as_deref(),
Some("alice")
);
}
#[test]
fn targeted_cancellation_preserves_other_speakers_queue_entries() {
let generations = Arc::new(Mutex::new(HashMap::new()));
let active_speaker = Arc::new(Mutex::new(Some("alice".to_string())));
let cancellation = Arc::new(Mutex::new(None));
let alice = queued_speech("alice", 0);
let bob = queued_speech("bob", 0);
let (_text_tx, text_rx) = mpsc::sync_channel(1);
let mut deferred = VecDeque::from([alice, bob]);
let mut current = None;
request_speaker_cancel(&generations, &active_speaker, &cancellation, "alice");
retain_current_speaker_text(&generations, &mut deferred, &mut current, &text_rx);
assert_eq!(deferred.len(), 1);
assert_eq!(deferred[0].speaker_pubkey.as_deref(), Some("bob"));
}
#[test]
fn stop_request_is_bound_to_the_observed_speaker_generation() {
let generations = Arc::new(Mutex::new(HashMap::new()));
let active_speaker = Arc::new(Mutex::new(Some("alice".to_string())));
let cancellation = Arc::new(Mutex::new(None));
assert!(request_active_speaker_cancel(
&generations,
&active_speaker,
&cancellation,
&playback_probe(true),
"alice",
));
assert_eq!(current_speaker_generation(&generations, "alice"), 1);
assert_eq!(
cancellation.lock().expect("cancellation").as_deref(),
Some("alice")
);
active_speaker.lock().expect("active speaker").take();
cancellation.lock().expect("cancellation").take();
assert!(!request_active_speaker_cancel(
&generations,
&active_speaker,
&cancellation,
&playback_probe(true),
"alice",
));
let next_utterance =
queued_speech("alice", current_speaker_generation(&generations, "alice"));
assert!(queued_speaker_is_current(&generations, &next_utterance));
assert!(cancellation.lock().expect("cancellation").is_none());
}
#[test]
fn stop_request_does_not_cancel_a_different_active_speaker() {
let generations = Arc::new(Mutex::new(HashMap::new()));
let active_speaker = Arc::new(Mutex::new(Some("bob".to_string())));
let cancellation = Arc::new(Mutex::new(None));
assert!(!request_active_speaker_cancel(
&generations,
&active_speaker,
&cancellation,
&playback_probe(true),
"alice",
));
assert_eq!(current_speaker_generation(&generations, "alice"), 0);
assert_eq!(current_speaker_generation(&generations, "bob"), 0);
assert!(cancellation.lock().expect("cancellation").is_none());
assert_eq!(
active_speaker.lock().expect("active speaker").as_deref(),
Some("bob"),
);
}
#[test]
fn stop_request_during_empty_synthesis_gap_cancels_in_flight_speech() {
let generations = Arc::new(Mutex::new(HashMap::new()));
let active_speaker = Arc::new(Mutex::new(Some("alice".to_string())));
let cancellation = Arc::new(Mutex::new(None));
let next_chunk = queued_speech("alice", 0);
let probe = playback_probe(false);
let _synthesis_flight = probe.begin_synthesis();
assert!(request_active_speaker_cancel(
&generations,
&active_speaker,
&cancellation,
&probe,
"alice",
));
assert_eq!(current_speaker_generation(&generations, "alice"), 1);
assert!(!queued_speaker_is_current(&generations, &next_chunk));
assert_eq!(
cancellation.lock().expect("cancellation").as_deref(),
Some("alice"),
);
}
#[test]
fn repeated_stop_for_same_in_flight_utterance_is_idempotent() {
let generations = Arc::new(Mutex::new(HashMap::new()));
let active_speaker = Arc::new(Mutex::new(Some("alice".to_string())));
let cancellation = Arc::new(Mutex::new(None));
let probe = playback_probe(false);
let _synthesis_flight = probe.begin_synthesis();
assert!(request_active_speaker_cancel(
&generations,
&active_speaker,
&cancellation,
&probe,
"alice",
));
let speech_queued_after_first_stop = queued_speech("alice", 1);
assert!(!request_active_speaker_cancel(
&generations,
&active_speaker,
&cancellation,
&probe,
"alice",
));
assert_eq!(current_speaker_generation(&generations, "alice"), 1);
assert!(queued_speaker_is_current(
&generations,
&speech_queued_after_first_stop,
));
assert_eq!(
cancellation.lock().expect("cancellation").as_deref(),
Some("alice"),
);
}
#[test]
fn stop_request_after_playback_drains_preserves_queued_speech() {
let generations = Arc::new(Mutex::new(HashMap::new()));
let active_speaker = Arc::new(Mutex::new(Some("alice".to_string())));
let cancellation = Arc::new(Mutex::new(None));
let next_utterance = queued_speech("alice", 0);
assert!(!request_active_speaker_cancel(
&generations,
&active_speaker,
&cancellation,
&playback_probe(false),
"alice",
));
assert_eq!(current_speaker_generation(&generations, "alice"), 0);
assert!(queued_speaker_is_current(&generations, &next_utterance));
assert!(cancellation.lock().expect("cancellation").is_none());
assert_eq!(
active_speaker.lock().expect("active speaker").as_deref(),
Some("alice"),
);
}
}

View File

@@ -56,9 +56,10 @@ use huddle::reconnect::reconnect_huddle_audio;
use huddle::{
add_agent_to_huddle, check_pipeline_hotstart, close_huddle_companion, confirm_huddle_active,
download_voice_models, end_huddle, get_huddle_agent_pubkeys, get_huddle_state,
get_model_status, get_voice_input_mode, join_huddle, leave_huddle, open_huddle_window,
push_audio_pcm, set_huddle_transcription_enabled, set_tts_enabled, set_voice_input_mode,
speak_agent_message, start_huddle, start_stt_pipeline, HuddlePhase,
get_model_status, get_voice_input_mode, interrupt_huddle_speech, join_huddle, leave_huddle,
open_huddle_window, push_audio_pcm, remove_agent_from_huddle, set_huddle_manual_mic_unmuted,
set_huddle_transcription_enabled, set_tts_enabled, set_voice_input_mode, speak_agent_message,
start_huddle, start_stt_pipeline, HuddlePhase,
};
use initial_window::*;
use managed_agents::{
@@ -851,7 +852,9 @@ pub fn run() {
huddle::agent_voice::set_huddle_agent_tts_enabled,
huddle::agent_voice::set_huddle_agent_voice,
speak_agent_message,
interrupt_huddle_speech,
add_agent_to_huddle,
remove_agent_from_huddle,
huddle::agents::sync_agents_to_active_huddle,
check_pipeline_hotstart,
confirm_huddle_active,
@@ -859,6 +862,7 @@ pub fn run() {
get_huddle_agent_pubkeys,
set_voice_input_mode,
get_voice_input_mode,
set_huddle_manual_mic_unmuted,
list_audio_output_devices,
set_audio_output_device,
get_audio_output_device,

View File

@@ -48,6 +48,13 @@ export function AppHuddleShell({
data-huddle-open={isDrawerOpen}
data-huddle-window={isRoom}
>
<div
aria-hidden="true"
className={cn(
"buzz-huddle-drawer-backdrop",
isDrawerOpen && "buzz-huddle-drawer-backdrop-open",
)}
/>
<div
className={cn(
"buzz-huddle-app-surface z-10 flex min-h-0 flex-row overflow-hidden bg-background",
@@ -61,7 +68,7 @@ export function AppHuddleShell({
{children}
</div>
{isRoom || !isCompanionOpen ? (
<div className="absolute inset-x-0 bottom-0 z-0 h-(--buzz-huddle-drawer-height)">
<div className="absolute inset-x-0 bottom-0 z-[2] h-(--buzz-huddle-drawer-height)">
<AppHuddleBar
mode={isRoom ? "room" : "main"}
onOpenHuddleWindow={isRoom ? undefined : onCompanionOpen}

View File

@@ -333,6 +333,7 @@ export function AppShell() {
notificationSettings: notificationSettings.settings,
openSearchHit,
pubkey: identityQuery.data?.pubkey,
silentChannelIds: huddleBackingChannelIds,
});
const {
followedRootIds,
@@ -420,7 +421,6 @@ export function AppShell() {
unreadThreadFeedItems,
]);
// Badge count consumes the shared NIP-RS read-state from useUnreadChannels.
const { homeBadgeCount, homeBadgeCountExcludingHighPriority } =
useHomeFeedNotificationState(
homeFeedQuery.data,
@@ -439,8 +439,8 @@ export function AppShell() {
getThreadReadAt,
getMessageReadAt,
channels,
huddleBackingChannelIds,
);
const dueReminderBadge = useDueReminderBadgeCount(
identityQuery.data?.pubkey,
notificationSettings.settings.homeBadgeEnabled,

View File

@@ -20,6 +20,7 @@ import {
import {
playNotificationSound,
resolveSlotSound,
shouldPlayNotificationSound,
} from "@/features/notifications/lib/sound";
import type { Channel, RelayEvent } from "@/shared/api/types";
@@ -31,6 +32,7 @@ export function useAppShellDesktopNotifications({
notificationSettings,
openSearchHit,
pubkey,
silentChannelIds,
}: {
channels: Channel[];
enabled: boolean;
@@ -41,6 +43,7 @@ export function useAppShellDesktopNotifications({
hit: import("@/shared/api/types").SearchHit,
) => Promise<unknown>;
pubkey?: string;
silentChannelIds?: ReadonlySet<string>;
}) {
const handleChannelNotification = React.useEffectEvent(
(_channelId: string, event: RelayEvent) => {
@@ -80,7 +83,9 @@ export function useAppShellDesktopNotifications({
},
}).then((didSend) => {
if (!didSend) return;
playNotificationSound(resolveSlotSound(notificationSettings, "dm"));
if (shouldPlayNotificationSound(channel.id, silentChannelIds)) {
playNotificationSound(resolveSlotSound(notificationSettings, "dm"));
}
void requestDockBounce();
});
},
@@ -126,9 +131,11 @@ export function useAppShellDesktopNotifications({
},
}).then((didSend) => {
if (!didSend) return;
playNotificationSound(
resolveSlotSound(notificationSettings, "thread_reply"),
);
if (shouldPlayNotificationSound(channelId, silentChannelIds)) {
playNotificationSound(
resolveSlotSound(notificationSettings, "thread_reply"),
);
}
void requestDockBounce();
});
},

View File

@@ -202,6 +202,18 @@ export function useHuddlePresentation() {
},
[goChannel, location.pathname],
);
const returnToHuddleParentAfterEnd = React.useCallback(
(ephemeralChannelId: string | null, parentChannelId: string | null) => {
if (
ephemeralChannelId &&
parentChannelId &&
location.pathname === `/channels/${ephemeralChannelId}`
) {
void goChannel(parentChannelId, { replace: true });
}
},
[goChannel, location.pathname],
);
const handleHuddleCompanionOpen = React.useCallback(() => {
const ephemeralChannelId = activeHuddleChannelIdRef.current;
huddleCompanionDismissedChannelIdRef.current = null;
@@ -315,15 +327,20 @@ export function useHuddlePresentation() {
(ephemeralChannelId: string | null) => {
const endedChannelId =
ephemeralChannelId ?? activeHuddleChannelIdRef.current;
returnToHuddleParentAfterEnd(
endedChannelId,
activeHuddleParentChannelIdRef.current,
);
hideHuddleChannel(endedChannelId);
activeHuddleChannelIdRef.current = null;
activeHuddleParentChannelIdRef.current = null;
huddleCompanionChannelIdRef.current = null;
huddleCompanionDismissedChannelIdRef.current = null;
huddleCompanionOpenPromiseRef.current = null;
setIsHuddleCompanionOpen(false);
void queryClient.invalidateQueries({ queryKey: channelsQueryKey });
},
[hideHuddleChannel, queryClient],
[hideHuddleChannel, queryClient, returnToHuddleParentAfterEnd],
);
React.useEffect(() => {
@@ -397,7 +414,10 @@ export function useHuddlePresentation() {
);
}
if (event.payload.phase === "idle") {
hideHuddleChannel(activeHuddleChannelIdRef.current);
const endedChannelId = activeHuddleChannelIdRef.current;
const parentChannelId = activeHuddleParentChannelIdRef.current;
returnToHuddleParentAfterEnd(endedChannelId, parentChannelId);
hideHuddleChannel(endedChannelId);
activeHuddleChannelIdRef.current = null;
activeHuddleParentChannelIdRef.current = null;
huddleCompanionChannelIdRef.current = null;
@@ -421,6 +441,7 @@ export function useHuddlePresentation() {
isHuddleRoom,
openHuddleCompanion,
queryClient,
returnToHuddleParentAfterEnd,
trackHuddleBackingChannel,
]);

View File

@@ -65,6 +65,10 @@ function clamp01(value: number): number {
return Math.min(1, Math.max(0, value));
}
function interruptAgentSpeech(agentPubkey: string) {
return invoke<void>("interrupt_huddle_speech", { agentPubkey });
}
const HuddleContext = React.createContext<HuddleContextValue | null>(null);
export function HuddleProvider({
@@ -99,10 +103,10 @@ export function HuddleProvider({
const clearHuddleError = React.useCallback(() => setHuddleError(null), []);
const [micConnected, setMicConnected] = React.useState(false);
const [isMuted, setIsMuted] = React.useState(false);
const micConnectedRef = React.useRef(micConnected);
micConnectedRef.current = micConnected;
const isMutedRef = React.useRef(isMuted);
isMutedRef.current = isMuted;
const micConnectedRef = React.useRef(micConnected);
micConnectedRef.current = micConnected;
const [mirroredAudioState, setMirroredAudioState] =
React.useState<HuddleAudioMirrorState | null>(null);
const [mirroredMicLevel, setMirroredMicLevel] = React.useState(0);
@@ -113,6 +117,12 @@ export function HuddleProvider({
setVoiceInputModeState,
voiceInputMode,
} = useHuddlePttState(micConnected);
// Manual mute remains independently controllable in every input mode. The
// PTT shortcut temporarily opens a manually muted microphone while held.
const locallyMuted =
isMuted && !(voiceInputMode === "push_to_talk" && pttActive);
const locallyMutedRef = React.useRef(locallyMuted);
locallyMutedRef.current = locallyMuted;
/** Ephemeral channel ID — set after start_huddle/join_huddle, used for TTS subscription */
const [ephemeralChannelId, setEphemeralChannelId] = React.useState<
string | null
@@ -140,6 +150,9 @@ export function HuddleProvider({
const effectiveVoiceInputMode = ownsAudioSession
? voiceInputMode
: (mirroredAudioState?.voiceInputMode ?? voiceInputMode);
const effectiveIsMuted = ownsAudioSession
? locallyMuted
: (mirroredAudioState?.isMuted ?? true);
const setSelectedDeviceId = React.useCallback(
(deviceId: string) => {
if (ownsAudioSession) {
@@ -237,13 +250,20 @@ export function HuddleProvider({
[ownsAudioSession, setVoiceInputModeState],
);
// Ref-track the current audio track so disconnectMedia is stable (no
// dependency on localAudioTrack state). This prevents the unmount-cleanup
// effect from re-firing mid-startup when setLocalAudioTrack triggers a
// leaveHuddle dependency chain update.
// Keep disconnectMedia stable so setting the track cannot re-fire the
// unmount cleanup during startup.
const audioTrackRef = React.useRef<MediaStreamTrack | null>(null);
audioTrackRef.current = localAudioTrack;
// Keep the browser track and worklet aligned with the combined manual/PTT
// state. The worklet tracks the manual state separately so a PTT release
// does not remute a microphone the user explicitly left open.
React.useEffect(() => {
if (!ownsAudioSession || !audioTrackRef.current) return;
audioTrackRef.current.enabled = !locallyMuted;
workletRef.current?.setTransmitting(!isMuted);
}, [isMuted, locallyMuted, ownsAudioSession]);
const toggleMute = React.useCallback(() => {
if (!ownsAudioSession) {
const nextMuted = !(mirroredAudioState?.isMuted ?? false);
@@ -265,12 +285,19 @@ export function HuddleProvider({
return;
}
setIsMuted((previous) => {
const next = !previous;
if (audioTrackRef.current) audioTrackRef.current.enabled = !next;
return next;
// Set the effective state promised by the button instead of inverting the
// hidden manual preference, which can differ while PTT is held.
const requestedMuted = !locallyMuted;
setIsMuted(requestedMuted);
void invoke("set_huddle_manual_mic_unmuted", {
enabled: !requestedMuted,
});
}, [mirroredAudioState?.isMuted, ownsAudioSession, voiceInputMode]);
}, [
locallyMuted,
mirroredAudioState?.isMuted,
ownsAudioSession,
voiceInputMode,
]);
React.useEffect(() => {
let cancelled = false;
@@ -321,7 +348,7 @@ export function HuddleProvider({
if (!ownsAudioSession) return;
const state: HuddleAudioMirrorState = {
isMuted,
isMuted: locallyMuted,
micConnected,
audioDevices: localAudioDevices,
selectedDeviceId: localSelectedDeviceId,
@@ -337,9 +364,9 @@ export function HuddleProvider({
if (event.payload.type === "set-muted") {
const requestedMuted = event.payload.isMuted;
setIsMuted(() => {
if (audioTrackRef.current) {
audioTrackRef.current.enabled = !requestedMuted;
}
void invoke("set_huddle_manual_mic_unmuted", {
enabled: !requestedMuted,
});
return requestedMuted;
});
return;
@@ -358,7 +385,7 @@ export function HuddleProvider({
return;
}
void emit(HUDDLE_AUDIO_STATE_EVENT, {
isMuted: isMutedRef.current,
isMuted: locallyMutedRef.current,
micConnected: micConnectedRef.current,
audioDevices: localAudioDevices,
selectedDeviceId: localSelectedDeviceId,
@@ -375,7 +402,7 @@ export function HuddleProvider({
unlisten?.();
};
}, [
isMuted,
locallyMuted,
localAudioDevices,
localMicGain,
localSelectedDeviceId,
@@ -570,10 +597,11 @@ export function HuddleProvider({
setMicConnected(true);
// Setup AudioWorklet — PCM goes to Rust via push_audio_pcm
const initialTransmitting = getVoiceInputMode() !== "push_to_talk";
audioTrack.enabled = !locallyMutedRef.current;
const worklet = await setupAudioWorklet(
audioTrack,
initialTransmitting,
getVoiceInputMode(),
!isMutedRef.current,
);
worklet.setGain(micGainRef.current);
@@ -612,6 +640,8 @@ export function HuddleProvider({
tokenRef.current += 1;
const myToken = tokenRef.current;
isMutedRef.current = false;
setIsMuted(false);
setHuddleError(null);
setIsStarting(true);
onHuddleStartPendingChange?.(true);
@@ -685,6 +715,8 @@ export function HuddleProvider({
busyRef.current = true;
tokenRef.current += 1;
const myToken = tokenRef.current;
isMutedRef.current = false;
setIsMuted(false);
setHuddleError(null);
setIsStarting(true);
@@ -928,10 +960,9 @@ export function HuddleProvider({
micConnected: ownsAudioSession
? micConnected
: (mirroredAudioState?.micConnected ?? false),
isMuted: ownsAudioSession
? isMuted
: (mirroredAudioState?.isMuted ?? false),
isMuted: effectiveIsMuted,
toggleMute,
interruptAgentSpeech,
micLevel: ownsAudioSession ? micLevel : mirroredMicLevel,
pttActive,
voiceInputMode: effectiveVoiceInputMode,

View File

@@ -9,6 +9,8 @@ export interface HuddleContextValue {
micConnected: boolean;
isMuted: boolean;
toggleMute: () => void;
/** Interrupt this agent only if it still owns the active utterance. */
interruptAgentSpeech: (agentPubkey: string) => Promise<void>;
micLevel: number;
pttActive: boolean;
voiceInputMode: VoiceInputMode;

View File

@@ -1,20 +1,18 @@
import { invoke } from "@tauri-apps/api/core";
import { Bot } from "lucide-react";
import { LoaderCircle } from "lucide-react";
import * as React from "react";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { Dialog } from "@/shared/ui/dialog";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import type { ManagedAgentBackend } from "@/shared/api/types";
type ManagedAgentSummary = {
pubkey: string;
name: string;
status: string;
avatar_url: string | null;
backend: ManagedAgentBackend;
};
type AgentAddResult = {
@@ -24,12 +22,14 @@ type AgentAddResult = {
};
type AddAgentDialogProps = {
open: boolean;
onClose: () => void;
onAdd: (pubkey: string) => Promise<AgentAddResult>;
currentAgentPubkeys: string[];
};
export function AddAgentDialog({
open,
onClose,
onAdd,
currentAgentPubkeys,
@@ -41,27 +41,68 @@ export function AddAgentDialog({
const [warning, setWarning] = React.useState<string | null>(null);
React.useEffect(() => {
if (!open) return;
let cancelled = false;
setAgents([]);
setLoading(true);
setAdding(null);
setError(null);
setWarning(null);
invoke<ManagedAgentSummary[]>("list_managed_agents")
.then(setAgents)
.then((nextAgents) => {
if (!cancelled) setAgents(nextAgents);
})
.catch((e: unknown) => {
if (cancelled) return;
console.error("Failed to load agents:", e);
setError("Could not load agents.");
})
.finally(() => setLoading(false));
}, []);
.finally(() => {
if (!cancelled) setLoading(false);
});
// Only show running agents that aren't already in the huddle.
const runningAgents = agents.filter(
(a) => a.status === "running" && !currentAgentPubkeys.includes(a.pubkey),
return () => {
cancelled = true;
};
}, [open]);
const availableAgents = agents.filter(
(agent) =>
!currentAgentPubkeys.some(
(pubkey) => pubkey.toLowerCase() === agent.pubkey.toLowerCase(),
),
);
async function handleAdd(pubkey: string) {
async function handleAdd(agent: ManagedAgentSummary) {
if (adding) return;
setAdding(pubkey);
setAdding(agent.pubkey);
setError(null);
setWarning(null);
let startedForAdd = false;
try {
const result = await onAdd(pubkey);
const isLocal = agent.backend.type === "local";
const needsStart = isLocal
? agent.status !== "running"
: agent.status !== "deployed";
if (needsStart && isLocal) {
await invoke("start_managed_agent", { pubkey: agent.pubkey });
startedForAdd = true;
}
const result = await onAdd(agent.pubkey);
if (needsStart && !isLocal) {
try {
await invoke("start_managed_agent", { pubkey: agent.pubkey });
} catch (startError: unknown) {
const msg =
startError instanceof Error
? startError.message
: String(startError);
setWarning(`Added to huddle, but could not start agent: ${msg}`);
console.error("Failed to start agent after huddle add:", startError);
return;
}
}
if (result.parent_error) {
// Agent was added to the ephemeral channel but parent channel add failed.
// Show as a warning — don't close the dialog so the user can see it.
@@ -72,6 +113,16 @@ export function AddAgentDialog({
onClose();
}
} catch (e: unknown) {
if (startedForAdd) {
try {
await invoke("stop_managed_agent", { pubkey: agent.pubkey });
} catch (rollbackError: unknown) {
console.error(
"Failed to stop agent after huddle add failed:",
rollbackError,
);
}
}
const msg = e instanceof Error ? e.message : String(e);
setError(`Failed to add agent: ${msg}`);
console.error("Failed to add agent to huddle:", e);
@@ -85,76 +136,68 @@ export function AddAgentDialog({
onOpenChange={(open) => {
if (!open) onClose();
}}
open
open={open}
>
<DialogContent className="flex max-h-[60vh] max-w-sm flex-col gap-0 p-0">
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>Add Agent to Huddle</DialogTitle>
<DialogDescription>
Select a running agent to join the huddle.
</DialogDescription>
</DialogHeader>
<ChooserDialogContent
className="max-w-xl"
data-testid="add-huddle-agent-dialog"
headerSubtitle="Choose an agent to join this huddle."
scrollAreaClassName="space-y-5"
title="Add agents"
>
{error ? (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</p>
) : null}
<div className="flex-1 overflow-y-auto px-6 py-4">
{error && (
<p className="mb-3 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
{warning ? (
<p className="rounded-lg border border-warning/30 bg-warning-bg px-4 py-3 text-sm text-warning">
{warning}
</p>
) : null}
{warning && (
<div className="mb-3 flex items-start justify-between gap-2 rounded-md bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-400">
<span>{warning}</span>
<button
className="shrink-0 font-medium underline-offset-2 hover:underline"
onClick={onClose}
type="button"
>
Dismiss
</button>
</div>
)}
{loading ? (
<p className="py-4 text-center text-sm text-muted-foreground">
Loading agents…
</p>
) : runningAgents.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
{agents.filter((a) => a.status === "running").length > 0
? "All running agents are already in this huddle."
: "No running agents found."}
</p>
) : (
<ul className="flex flex-col gap-1">
{runningAgents.map((agent) => (
{loading ? (
<p className="py-4 text-center text-sm text-muted-foreground">
Loading agents…
</p>
) : availableAgents.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
All available agents are already in this huddle.
</p>
) : (
<ul className="flex flex-col gap-1">
{availableAgents.map((agent) => {
const isAdding = adding === agent.pubkey;
return (
<li key={agent.pubkey}>
<button
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
disabled={adding === agent.pubkey}
onClick={() => void handleAdd(agent.pubkey)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
disabled={adding !== null}
onClick={() => void handleAdd(agent)}
type="button"
>
<Bot className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate font-medium">
<ProfileAvatar
avatarUrl={agent.avatar_url}
className="h-9 w-9 shrink-0 text-xs"
label={agent.name}
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{agent.name}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{agent.status}
</span>
{isAdding ? (
<LoaderCircle
aria-label={`Adding ${agent.name}`}
className="h-4 w-4 shrink-0 animate-spin text-muted-foreground"
/>
) : null}
</button>
</li>
))}
</ul>
)}
</div>
<div className="border-t px-6 py-4">
<Button className="w-full" onClick={onClose} variant="outline">
Cancel
</Button>
</div>
</DialogContent>
);
})}
</ul>
)}
</ChooserDialogContent>
</Dialog>
);
}

View File

@@ -7,6 +7,7 @@ import {
voicesForBackend,
} from "@/features/settings/ui/voiceSettingsLogic";
import { invokeTauri } from "@/shared/api/tauri";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
@@ -26,18 +27,28 @@ export type HuddleAgentVoiceSettings = {
type AgentVoiceMenuProps = {
agentPubkey: string;
contentAlign?: React.ComponentProps<typeof PopoverContent>["align"];
contentClassName?: string;
contentSide?: React.ComponentProps<typeof PopoverContent>["side"];
displayName: string;
onRemoveAgent?: () => void;
registry: VoiceRegistryEntry[];
settings: HuddleAgentVoiceSettings | undefined;
onSettingsChange: (settings: HuddleAgentVoiceSettings) => void;
trigger?: React.ReactElement;
};
export function AgentVoiceMenu({
agentPubkey,
contentAlign = "end",
contentClassName,
contentSide,
displayName,
onRemoveAgent,
registry,
settings,
onSettingsChange,
trigger,
}: AgentVoiceMenuProps) {
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
@@ -74,18 +85,26 @@ export function AgentVoiceMenu({
return (
<Popover>
<PopoverTrigger asChild>
<Button
aria-label={`Voice settings for ${displayName}`}
className="absolute right-1.5 top-1.5 z-20 h-7 w-7 rounded-md bg-transparent text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground"
data-testid="huddle-agent-voice-menu-trigger"
size="icon"
type="button"
variant="ghost"
>
<MoreVertical className="h-4 w-4" />
</Button>
{trigger ?? (
<Button
aria-label={`Voice settings for ${displayName}`}
className="absolute right-1.5 top-1.5 z-20 h-7 w-7 rounded-md bg-transparent text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground"
data-testid="huddle-agent-voice-menu-trigger"
size="icon"
type="button"
variant="ghost"
>
<MoreVertical className="h-4 w-4" />
</Button>
)}
</PopoverTrigger>
<PopoverContent align="end" className="w-64 space-y-3 p-3" sideOffset={8}>
<PopoverContent
align={contentAlign}
className={cn("w-64 space-y-3 p-3", contentClassName)}
data-testid="huddle-agent-voice-menu-content"
side={contentSide}
sideOffset={8}
>
<div className="flex items-center justify-between gap-3">
<label
className="text-sm font-medium"
@@ -105,43 +124,58 @@ export function AgentVoiceMenu({
</div>
{settings?.enabled ? (
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">Agent voice</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Choose agent voice"
className="h-8 shrink-0 gap-1.5 px-2 text-sm text-muted-foreground"
data-testid="huddle-agent-voice-selector"
disabled={busy || voices.length === 0}
size="sm"
type="button"
variant="ghost"
>
{selectedVoice
? voiceOptionLabel(selectedVoice, voices)
: "Unavailable"}
<ChevronDown aria-hidden="true" className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-h-72 min-w-40 overflow-y-auto"
>
<DropdownMenuRadioGroup
onValueChange={(voiceKey) => {
void update("set_huddle_agent_voice", { voiceKey });
}}
value={selectedVoice?.key}
>
{voices.map((voice) => (
<DropdownMenuRadioItem key={voice.key} value={voice.key}>
{voiceOptionLabel(voice, voices)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
) : null}
{onRemoveAgent ? (
<>
<Separator />
<div className="space-y-2">
<span className="text-sm font-medium">Agent voice</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="w-full justify-between"
data-testid="huddle-agent-voice-selector"
disabled={busy || voices.length === 0}
variant="outline"
>
{selectedVoice
? voiceOptionLabel(selectedVoice, voices)
: "Unavailable"}
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-h-72 overflow-y-auto"
>
<DropdownMenuRadioGroup
onValueChange={(voiceKey) => {
void update("set_huddle_agent_voice", { voiceKey });
}}
value={selectedVoice?.key}
>
{voices.map((voice) => (
<DropdownMenuRadioItem key={voice.key} value={voice.key}>
{voiceOptionLabel(voice, voices)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button
aria-label={`Remove ${displayName} from huddle`}
className="w-full justify-start text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={onRemoveAgent}
type="button"
variant="ghost"
>
Remove from huddle
</Button>
</>
) : null}

View File

@@ -156,7 +156,6 @@ export function HuddleBar({
isMuted,
toggleMute,
micLevel,
pttActive,
voiceInputMode,
setVoiceInputMode,
activeSpeakers,
@@ -485,7 +484,6 @@ export function HuddleBar({
const hasAvailableMic = micConnected;
const ttsEnabled = barState.tts_enabled;
const transcriptionEnabled = barState.transcription_enabled;
// Self-removing detection: remote-peer audio plays through native rodio
// today (outside the WebView render graph), so the browser's AEC has no
// far-end reference. The AEC follow-up PR flips this constant in the
@@ -619,36 +617,34 @@ export function HuddleBar({
</span>
)}
{showAddAgent && (
<AddAgentDialog
currentAgentPubkeys={barState.agent_pubkeys}
onClose={() => setShowAddAgent(false)}
onAdd={async (pubkey: string): Promise<AgentAddResult> => {
setAgentAddError(null);
try {
const result = await invoke<AgentAddResult>(
"add_agent_to_huddle",
{ agentPubkey: pubkey },
);
// Refresh huddle state so the participant list updates immediately.
const s = await invoke<HuddleState>("get_huddle_state");
setState(s);
return result;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setAgentAddError(`Failed to add agent: ${msg}`);
throw e; // Re-throw so AddAgentDialog shows its inline error.
}
}}
/>
)}
<AddAgentDialog
currentAgentPubkeys={barState.agent_pubkeys}
onClose={() => setShowAddAgent(false)}
onAdd={async (pubkey: string): Promise<AgentAddResult> => {
setAgentAddError(null);
try {
const result = await invoke<AgentAddResult>(
"add_agent_to_huddle",
{ agentPubkey: pubkey },
);
// Refresh huddle state so the participant list updates immediately.
const s = await invoke<HuddleState>("get_huddle_state");
setState(s);
return result;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setAgentAddError(`Failed to add agent: ${msg}`);
throw e; // Re-throw so AddAgentDialog shows its inline error.
}
}}
open={showAddAgent}
/>
<div className="flex shrink-0 items-center gap-2">
<MicControls
isMuted={isMuted}
onToggleMute={toggleMute}
isPttMode={isPttMode}
pttActive={pttActive}
micConnected={hasAvailableMic}
micLevel={micLevel}
onSelectVoiceInputMode={setVoiceInputMode}
@@ -701,15 +697,13 @@ export function HuddleBar({
pubkey: currentPubkey,
}}
onRemoveAgent={async (pubkey) => {
if (!barState.ephemeral_channel_id) return;
const confirmed = window.confirm(
"Remove this agent from the huddle?",
);
if (!confirmed) return;
try {
await invoke("remove_channel_member", {
channelId: barState.ephemeral_channel_id,
pubkey,
await invoke("remove_agent_from_huddle", {
agentPubkey: pubkey,
});
setState((prev) => {
if (!prev) return prev;

View File

@@ -43,7 +43,6 @@ export function HuddleProfileControl({
micConnected,
micGain,
micLevel,
pttActive,
selectedDeviceId,
setMicGain,
setSelectedDeviceId,
@@ -156,7 +155,6 @@ export function HuddleProfileControl({
onSelectDevice={setSelectedDeviceId}
onSelectVoiceInputMode={setVoiceInputMode}
onToggleMute={toggleMute}
pttActive={pttActive}
selectedDeviceId={selectedDeviceId}
/>
<Button

View File

@@ -28,8 +28,14 @@ function isVisible(state: HuddleRosterState | null) {
/** Larger, persistent roster for the companion huddle room window. */
export function HuddleRoomHeader() {
const { activeSpeakers, isMuted, micConnected, micLevel, speakerLevels } =
useHuddle();
const {
activeSpeakers,
interruptAgentSpeech,
isMuted,
micConnected,
micLevel,
speakerLevels,
} = useHuddle();
const identityQuery = useIdentityQuery();
const profileQuery = useProfileQuery();
const selfProfileCache = useSelfProfileCache();
@@ -43,34 +49,29 @@ export function HuddleRoomHeader() {
}
return levels;
}, [currentPubkey, isMuted, micConnected, micLevel, speakerLevels]);
const handleRemoveAgent = React.useCallback(
async (pubkey: string) => {
if (!state?.ephemeral_channel_id) return;
if (!window.confirm("Remove this agent from the huddle?")) return;
try {
await invoke("remove_channel_member", {
channelId: state.ephemeral_channel_id,
pubkey,
});
setState((current) =>
current
? {
...current,
participants: current.participants.filter(
(member) => member !== pubkey,
),
agent_pubkeys: current.agent_pubkeys.filter(
(agent) => agent !== pubkey,
),
}
: current,
);
} catch (error) {
console.error("Failed to remove agent from huddle:", error);
}
},
[state?.ephemeral_channel_id],
);
const handleRemoveAgent = React.useCallback(async (pubkey: string) => {
if (!window.confirm("Remove this agent from the huddle?")) return;
try {
await invoke("remove_agent_from_huddle", {
agentPubkey: pubkey,
});
setState((current) =>
current
? {
...current,
participants: current.participants.filter(
(member) => member !== pubkey,
),
agent_pubkeys: current.agent_pubkeys.filter(
(agent) => agent !== pubkey,
),
}
: current,
);
} catch (error) {
console.error("Failed to remove agent from huddle:", error);
}
}, []);
React.useEffect(() => {
let disposed = false;
@@ -107,6 +108,9 @@ export function HuddleRoomHeader() {
agentPubkeys={state.agent_pubkeys}
agentVoiceSettings={state.agent_voice_settings}
appearance="room"
onInterruptAgentSpeech={(agentPubkey) =>
void interruptAgentSpeech(agentPubkey)
}
onRemoveAgent={handleRemoveAgent}
participants={state.participants}
selfProfile={{

View File

@@ -23,7 +23,6 @@ type MicControlsProps = {
isMuted: boolean;
onToggleMute: () => void;
isPttMode: boolean;
pttActive: boolean;
micConnected: boolean;
micLevel: number;
onSelectVoiceInputMode: (mode: VoiceInputMode) => void | Promise<void>;
@@ -37,6 +36,10 @@ type MicControlsProps = {
const splitIconButtonClass = "h-12 w-auto shrink-0 rounded-r-none px-4 py-4";
const splitChevronButtonClass =
"buzz-huddle-split-chevron group h-12 w-auto shrink-0 rounded-l-none px-2 py-4";
const mutedHuddleControlClass =
"bg-destructive/35 text-destructive shadow-none hover:bg-destructive/45 hover:text-destructive";
const compactMutedHuddleControlClass =
"bg-destructive/15 text-destructive shadow-none hover:bg-destructive/20 hover:text-destructive";
const MIC_PERMISSION_SETTINGS_URL =
"x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone";
@@ -93,7 +96,6 @@ export function MicControls({
isMuted,
onToggleMute,
isPttMode,
pttActive,
micConnected,
micLevel,
onSelectVoiceInputMode,
@@ -104,10 +106,11 @@ export function MicControls({
onGainChange,
}: MicControlsProps) {
const micUnavailable = !micConnected;
const showMicMeter = micConnected && !isMuted;
const isMac = isMacPlatform();
const prefersReducedMotion = usePrefersReducedMotion();
const pushToTalkShortcut = isMac ? "⌃Space" : "Ctrl+Space";
const isEffectivelyMuted = isMuted;
const showMicMeter = micConnected && !isEffectivelyMuted;
const barHeights: [number, number, number] = prefersReducedMotion
? MIC_METER_IDLE_HEIGHTS
: micMeterHeights(showMicMeter ? micLevel : 0);
@@ -115,20 +118,23 @@ export function MicControls({
const micButtonLabel = micUnavailable
? "Microphone unavailable"
: isMuted
: isEffectivelyMuted
? "Unmute microphone"
: isPttMode
? "Force mute (overrides PTT)"
: "Mute microphone";
: "Mute microphone";
const micTooltip = micUnavailable
? "Microphone unavailable. Check app permissions or input device."
: micButtonLabel;
const iconButtonClass = compact
? "h-8 w-8 shrink-0 rounded-l-md rounded-r-none px-0 py-0 text-sidebar-foreground/70 !shadow-none hover:bg-sidebar-foreground/5 hover:text-sidebar-foreground/70"
: splitIconButtonClass;
const chevronButtonClass = compact
? "buzz-huddle-split-chevron group h-8 w-5 shrink-0 rounded-l-none rounded-r-md border-l border-sidebar-border/80 px-0.5 py-0 text-sidebar-foreground/70 !shadow-none hover:bg-sidebar-foreground/5 hover:text-sidebar-foreground/70"
: splitChevronButtonClass;
const compactMutedClass =
compact && (isMuted || micUnavailable)
? "bg-destructive text-white hover:bg-[color-mix(in_srgb,hsl(var(--destructive))_92%,white)] hover:text-white"
const mutedMicClass =
isEffectivelyMuted || micUnavailable
? compact
? compactMutedHuddleControlClass
: mutedHuddleControlClass
: null;
return (
@@ -138,11 +144,6 @@ export function MicControls({
"flex items-center rounded-md",
compact &&
"overflow-hidden border border-sidebar-border/80 bg-transparent text-sidebar-foreground/70 shadow-none",
isPttMode &&
pttActive &&
!isMuted &&
micConnected &&
"ring-2 ring-green-500 ring-offset-1 ring-offset-background",
)}
>
<Tooltip>
@@ -150,11 +151,13 @@ export function MicControls({
<Button
aria-disabled={micUnavailable}
aria-label={micButtonLabel}
aria-pressed={micConnected ? isMuted : true}
aria-pressed={micConnected ? isEffectivelyMuted : true}
className={cn(
iconButtonClass,
compactMutedClass,
!isMuted && !micUnavailable && "buzz-huddle-split-main",
mutedMicClass,
!isEffectivelyMuted &&
!micUnavailable &&
"buzz-huddle-split-main",
)}
onClick={() => {
if (!micConnected) return;
@@ -162,14 +165,12 @@ export function MicControls({
}}
size="icon"
variant={
compact
compact || isEffectivelyMuted || micUnavailable
? "ghost"
: isMuted || micUnavailable
? "destructive"
: "secondary"
: "secondary"
}
>
{isMuted || micUnavailable ? (
{isEffectivelyMuted || micUnavailable ? (
<MicOff className="h-4 w-4" />
) : (
<Mic className="h-4 w-4" />
@@ -177,9 +178,16 @@ export function MicControls({
</Button>
</TooltipTrigger>
<TooltipContent className="buzz-huddle-tooltip" side="top">
{micUnavailable
? "Microphone unavailable. Check app permissions or input device."
: micButtonLabel}
{isPttMode && !micUnavailable && isEffectivelyMuted ? (
<span className="flex items-center gap-1.5">
<span>Click to unmute or hold</span>
<kbd className="rounded border border-border/70 bg-muted/70 px-1.5 py-0.5 text-2xs text-muted-foreground">
{pushToTalkShortcut}
</kbd>
</span>
) : (
micTooltip
)}
</TooltipContent>
</Tooltip>
<PopoverTrigger asChild>
@@ -240,7 +248,7 @@ export function MicControls({
className={cn("h-3 w-3 shrink-0", !isPttMode && "invisible")}
/>
<span className="font-medium">Push to Talk</span>
<kbd className="ml-auto rounded border border-foreground/10 px-1.5 py-0.5 text-2xs font-medium text-foreground/60">
<kbd className="ml-auto rounded border border-border/70 bg-muted/70 px-1.5 py-0.5 text-2xs font-medium text-muted-foreground">
{pushToTalkShortcut}
</kbd>
</button>
@@ -390,11 +398,11 @@ export function SpeakerControls({
aria-pressed={!ttsEnabled}
className={cn(
splitIconButtonClass,
ttsEnabled && "buzz-huddle-split-main",
ttsEnabled ? "buzz-huddle-split-main" : mutedHuddleControlClass,
)}
onClick={onToggleTts}
size="icon"
variant={ttsEnabled ? "secondary" : "destructive"}
variant={ttsEnabled ? "secondary" : "ghost"}
>
{ttsEnabled ? (
<Volume2 className="h-4 w-4" />

View File

@@ -1,4 +1,4 @@
import { MoreHorizontal, X } from "lucide-react";
import { X } from "lucide-react";
import * as React from "react";
import {
@@ -32,6 +32,8 @@ type ParticipantListProps = {
agentVoiceSettings?: Record<string, HuddleAgentVoiceSettings>;
/** Called when the user clicks the remove button on an agent avatar. */
onRemoveAgent?: (pubkey: string) => void;
/** Interrupts local TTS playback while this agent is speaking. */
onInterruptAgentSpeech?: (pubkey: string) => void;
/** The locally loaded profile is authoritative for the current participant. */
selfProfile?: {
avatarUrl: string | null;
@@ -134,6 +136,7 @@ export function HuddleParticipantsControl({
agentPubkeys,
agentVoiceSettings,
onRemoveAgent,
onInterruptAgentSpeech,
selfProfile,
appearance = "bar",
className,
@@ -306,6 +309,11 @@ export function HuddleParticipantsControl({
<AgentVoiceMenu
agentPubkey={participant.pubkey}
displayName={participant.displayName}
onRemoveAgent={
onRemoveAgent
? () => void onRemoveAgent(participant.pubkey)
: undefined
}
onSettingsChange={(settings) => {
setResolvedAgentVoiceSettings((current) => ({
...current,
@@ -317,10 +325,62 @@ export function HuddleParticipantsControl({
/>
) : null}
<ParticipantAvatar participant={participant} size="room" />
<span className="w-full truncate text-center text-xs font-medium text-foreground/80">
{participant.displayName}
</span>
<div
className="-mb-2 mt-1 flex h-5 w-full shrink-0 items-center justify-center"
data-testid="huddle-participant-label-slot"
>
{participant.isAgent &&
participant.isActive &&
onInterruptAgentSpeech ? (
<Button
aria-label={`Stop ${participant.displayName} speaking`}
className="h-5 min-h-0 w-full rounded-sm bg-destructive/15 px-0 py-0 text-xs font-medium leading-none text-destructive hover:bg-destructive/20 hover:text-destructive"
onClick={() => onInterruptAgentSpeech(participant.pubkey)}
size="sm"
type="button"
variant="ghost"
>
Stop
</Button>
) : (
<span className="w-full truncate text-center text-xs font-medium leading-none text-foreground/80">
{participant.displayName}
</span>
)}
</div>
</div>
) : participant.isAgent ? (
<AgentVoiceMenu
agentPubkey={participant.pubkey}
contentAlign="start"
contentClassName="buzz-huddle-drawer buzz-huddle-popover text-foreground"
contentSide="top"
displayName={participant.displayName}
key={participant.pubkey}
onRemoveAgent={
onRemoveAgent
? () => void onRemoveAgent(participant.pubkey)
: undefined
}
onSettingsChange={(settings) => {
setResolvedAgentVoiceSettings((current) => ({
...current,
[participant.pubkey]: settings,
}));
}}
registry={voiceRegistry}
settings={resolvedAgentVoiceSettings[participant.pubkey]}
trigger={
<button
aria-label={`Voice settings for ${participant.displayName}`}
className="inline-flex shrink-0 cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-black"
data-testid="huddle-agent-voice-menu-trigger"
type="button"
>
<ParticipantAvatar participant={participant} size="bar" />
</button>
}
/>
) : (
<Tooltip key={participant.pubkey}>
<TooltipTrigger asChild>
@@ -350,26 +410,6 @@ export function HuddleParticipantsControl({
+{hiddenParticipantCount}
</Button>
</PopoverTrigger>
) : onRemoveAgent ? (
<Popover>
<PopoverTrigger asChild>
<Button
aria-label="Manage huddle participants"
className={cn(
"relative z-10 shrink-0 px-1 shadow-none",
appearance === "room"
? "buzz-huddle-participant-tile min-h-[6.375rem] min-w-28 rounded-xl border border-border/70 bg-muted/45 text-foreground/70 hover:bg-muted/65 hover:text-foreground"
: "h-9 min-w-9 rounded-full border-2 border-black bg-white/15 text-white hover:bg-white/25 hover:text-white",
)}
size="icon"
type="button"
variant="ghost"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</PopoverTrigger>
{participantDetails}
</Popover>
) : null}
</div>
);

View File

@@ -19,10 +19,9 @@ function invokeRawBinary(cmd: string, payload: Uint8Array): Promise<unknown> {
/** Return type for setupAudioWorklet — stop + mode control. */
export type AudioWorkletHandle = {
stop: () => void;
/** Send PTT state to the worklet processor. */
/** Update whether the microphone is manually unmuted. */
setTransmitting: (active: boolean) => void;
/** Switch voice input mode. In VAD mode, always transmitting (PTT events ignored).
* In PTT mode, gated by Ctrl+Space. */
/** Switch whether the push-to-talk shortcut participates in transmission. */
setMode: (mode: "push_to_talk" | "voice_activity") => void;
/** Set mic input gain (0–1). Adjusts the GainNode between source and worklet. */
setGain: (value: number) => void;
@@ -40,17 +39,19 @@ export type AudioWorkletHandle = {
* → invokeRawBinary("push_audio_pcm", bytes)
* Rust: SttPipeline::push_audio → bounded sync_channel
*
* PTT gating:
* Main thread listens for Tauri "ptt-state" events (from Rust global shortcut)
* and forwards them to the worklet via port.postMessage({ type: 'ptt', active }).
* The worklet discards audio frames when transmitting=false.
* Transmission gating:
* Main thread combines manual mute with Tauri "ptt-state" events from the
* global shortcut. The worklet sends audio while either path is open and
* discards frames only when both are closed.
*
* @param audioTrack - Mic track from LiveKit
* @param initialTransmitting - Initial PTT state. true=open mic (VAD), false=muted until PTT press.
* @param initialMode - Whether the push-to-talk shortcut is enabled.
* @param initiallyManuallyUnmuted - Initial state of the clickable mic control.
*/
export async function setupAudioWorklet(
audioTrack: MediaStreamTrack,
initialTransmitting = true,
initialMode: "push_to_talk" | "voice_activity" = "voice_activity",
initiallyManuallyUnmuted = true,
): Promise<AudioWorkletHandle> {
const audioContext = new AudioContext({ sampleRate: 48000 });
@@ -74,11 +75,17 @@ export async function setupAudioWorklet(
source.connect(gainNode);
gainNode.connect(workletNode);
// Set initial PTT state (worklet defaults to transmitting=true).
// In PTT mode, immediately gate audio until the user presses the key.
if (!initialTransmitting) {
workletNode.port.postMessage({ type: "ptt", active: false });
}
let currentMode = initialMode;
let shortcutActive = false;
let manuallyUnmuted = initiallyManuallyUnmuted;
const syncTransmission = () => {
workletNode.port.postMessage({
type: "ptt",
active:
manuallyUnmuted || (currentMode === "push_to_talk" && shortcutActive),
});
};
syncTransmission();
// Forward PCM batches to Rust via raw binary invoke.
// Direction: worklet→main (receives PCM data from worklet processor).
@@ -96,22 +103,16 @@ export async function setupAudioWorklet(
});
};
// Track the current mode so PTT events are only forwarded in PTT mode.
// In VAD mode, the worklet stays in transmitting=true regardless of
// Ctrl+Space presses — prevents accidental muting. (Crossfire fix I1.)
let currentMode: "push_to_talk" | "voice_activity" = initialTransmitting
? "voice_activity"
: "push_to_talk";
// Listen for PTT state from Rust global shortcut (Ctrl+Space press/release).
// Direction: Rust→main→worklet. The Tauri event carries a boolean payload.
let pttUnlisten: UnlistenFn | null = null;
try {
pttUnlisten = await listen<boolean>("ptt-state", (event) => {
// Only forward PTT events to the worklet when in PTT mode.
// In VAD mode, Ctrl+Space is ignored — the worklet stays open.
// Manual unmute remains independent from the shortcut state.
if (currentMode === "push_to_talk") {
workletNode.port.postMessage({ type: "ptt", active: event.payload });
shortcutActive = event.payload;
syncTransmission();
}
});
} catch {
@@ -130,16 +131,13 @@ export async function setupAudioWorklet(
void audioContext.close();
},
setTransmitting: (active: boolean) => {
workletNode.port.postMessage({ type: "ptt", active });
manuallyUnmuted = active;
syncTransmission();
},
setMode: (mode: "push_to_talk" | "voice_activity") => {
currentMode = mode;
// When switching to VAD, immediately open the mic.
// When switching to PTT, immediately gate until key press.
workletNode.port.postMessage({
type: "ptt",
active: mode === "voice_activity",
});
shortcutActive = false;
syncTransmission();
},
setGain: (value: number) => {
gainNode.gain.value = value;

View File

@@ -7,8 +7,8 @@ export type VoiceInputMode = "push_to_talk" | "voice_activity";
export function useHuddlePttState(micConnected: boolean) {
const [pttActive, setPttActive] = React.useState(false);
const [voiceInputMode, setVoiceInputModeState] =
React.useState<VoiceInputMode>("voice_activity");
const voiceInputModeRef = React.useRef<VoiceInputMode>("voice_activity");
React.useState<VoiceInputMode>("push_to_talk");
const voiceInputModeRef = React.useRef<VoiceInputMode>("push_to_talk");
voiceInputModeRef.current = voiceInputMode;
const getVoiceInputMode = React.useCallback(
() => voiceInputModeRef.current,
@@ -20,7 +20,7 @@ export function useHuddlePttState(micConnected: boolean) {
invoke<VoiceInputMode>("get_voice_input_mode")
.then(setVoiceInputModeState)
.catch(() => {
/* best-effort — default is voice_activity */
/* best-effort — default is push_to_talk */
});
}, []);
@@ -29,8 +29,10 @@ export function useHuddlePttState(micConnected: boolean) {
let unlisten: (() => void) | null = null;
listen<boolean>("ptt-state", (event) => {
if (cancelled) return;
setPttActive(event.payload);
if (!micConnected) return;
const acceptsPtt =
micConnected && voiceInputModeRef.current === "push_to_talk";
setPttActive(acceptsPtt ? event.payload : false);
if (!acceptsPtt) return;
try {
if (
!pttAudioCtxRef.current ||
@@ -64,6 +66,12 @@ export function useHuddlePttState(micConnected: boolean) {
};
}, [micConnected]);
React.useEffect(() => {
if (!micConnected || voiceInputMode !== "push_to_talk") {
setPttActive(false);
}
}, [micConnected, voiceInputMode]);
return {
getVoiceInputMode,
pttActive,

View File

@@ -378,6 +378,7 @@ export function useHomeFeedNotificationState(
// has not been advanced by opening Home.
getMessageReadAt: (messageId: string) => number | null = () => null,
channels: ReadonlyArray<Pick<Channel, "id" | "name" | "channelType">> = [],
silentChannelIds?: ReadonlySet<string>,
) {
useFeedDesktopNotifications(
feed,
@@ -388,6 +389,7 @@ export function useHomeFeedNotificationState(
profiles,
mutedChannelIds,
channels,
silentChannelIds,
);
const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
const [seenFeedIds, setSeenFeedIds] = React.useState<string[]>(() =>

View File

@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import { shouldPlayNotificationSound } from "./sound.ts";
test("silences notifications from Huddle backing channels", () => {
const silentChannelIds = new Set(["active-huddle"]);
assert.equal(
shouldPlayNotificationSound("active-huddle", silentChannelIds),
false,
);
assert.equal(
shouldPlayNotificationSound("ordinary-channel", silentChannelIds),
true,
);
assert.equal(shouldPlayNotificationSound(null, silentChannelIds), true);
});

View File

@@ -128,6 +128,13 @@ export function slotForFeedKind(
return "needs_action";
}
export function shouldPlayNotificationSound(
channelId: string | null | undefined,
silentChannelIds?: ReadonlySet<string>,
): boolean {
return !channelId || !silentChannelIds?.has(channelId);
}
const cache = new Map<SoundName, HTMLAudioElement>();
function getAudio(name: SoundName): HTMLAudioElement {

View File

@@ -22,6 +22,7 @@ import {
import {
playNotificationSound,
resolveSlotSound,
shouldPlayNotificationSound,
slotForFeedKind,
} from "./lib/sound";
import type { NotificationSettings } from "./hooks";
@@ -77,6 +78,7 @@ export function useFeedDesktopNotifications(
profiles?: UserProfileLookup,
mutedChannelIds?: ReadonlySet<string>,
channels: readonly NotificationChannel[] = [],
silentChannelIds?: ReadonlySet<string>,
) {
const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
const seenItemIdsRef = React.useRef<Set<string>>(
@@ -126,7 +128,10 @@ export function useFeedDesktopNotifications(
title: notificationTitle(item, senderName),
});
if (didSend) {
if (
didSend &&
shouldPlayNotificationSound(item.channelId, silentChannelIds)
) {
const slot = slotForFeedKind(item.kind, item.category);
playNotificationSound(resolveSlotSound(settings, slot));
}

View File

@@ -10,10 +10,9 @@
--buzz-huddle-drawer-radius: 24px;
--buzz-huddle-drawer-ease: cubic-bezier(0.32, 0.72, 0, 1);
--buzz-huddle-drawer-surface: var(--huddle-drawer-surface, 0 0% 0%);
/* The lifted app surface reveals this shell at its rounded corners. The
* Buzz theme paints its gradient here; the drawer remains responsible for
* its own dark surface below. */
background: transparent;
/* Keep the drawer surface behind the lifted app so the dark background
* remains visible through its rounded lower corners. */
background: hsl(var(--buzz-huddle-drawer-surface));
}
.buzz-huddle-app-surface {
@@ -61,6 +60,26 @@
opacity: 1;
}
.buzz-huddle-drawer-backdrop {
background: hsl(var(--buzz-huddle-drawer-surface));
bottom: 0;
height: calc(
var(--buzz-huddle-drawer-height) +
var(--buzz-huddle-drawer-radius)
);
left: 0;
opacity: 0;
pointer-events: none;
position: absolute;
right: 0;
transition: opacity 260ms var(--buzz-huddle-drawer-ease);
z-index: 1;
}
.buzz-huddle-drawer-backdrop-open {
opacity: 1;
}
/* The dedicated companion keeps room for the controls without making the
* transcript look like a raised card. */
.buzz-huddle-app-surface-room-open {
@@ -199,6 +218,10 @@
transition: none;
}
.buzz-huddle-drawer-backdrop {
transition: none;
}
.buzz-huddle-mic-meter-bar {
transition: none;
}

View File

@@ -2,7 +2,12 @@ import * as React from "react";
import { cn } from "@/shared/lib/cn";
import { DialogContent, DialogHeader, DialogTitle } from "./dialog";
import {
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./dialog";
type ChooserDialogContentProps = React.ComponentPropsWithoutRef<
typeof DialogContent
@@ -13,6 +18,7 @@ type ChooserDialogContentProps = React.ComponentPropsWithoutRef<
footerTestId?: string;
contentClassName?: string;
headerClassName?: string;
headerSubtitle?: React.ReactNode;
headerTestId?: string;
scrollAreaClassName?: string;
scrollAreaTestId?: string;
@@ -33,6 +39,7 @@ export const ChooserDialogContent = React.forwardRef<
footerClassName,
footerTestId,
headerClassName,
headerSubtitle,
headerTestId,
scrollAreaClassName,
scrollAreaTestId,
@@ -56,6 +63,9 @@ export const ChooserDialogContent = React.forwardRef<
data-testid={headerTestId}
>
<DialogTitle>{title}</DialogTitle>
{headerSubtitle ? (
<DialogDescription>{headerSubtitle}</DialogDescription>
) : null}
</DialogHeader>
<div

View File

@@ -266,6 +266,8 @@ type E2eConfig = {
parent_added: boolean;
parent_error: string | null;
};
/** When set, the mocked `add_agent_to_huddle` command throws. */
addAgentToHuddleError?: string;
/** Delay an invocation-time huddle snapshot to exercise hydration ordering. */
huddleStateReadDelayMs?: number;
/** Delay companion creation to expose the newly-started huddle handoff state. */
@@ -3043,7 +3045,7 @@ type MockHuddleState = {
tts_enabled: boolean;
transcription_enabled: boolean;
is_creator: boolean;
voice_input_mode: "voice_activity";
voice_input_mode: "push_to_talk" | "voice_activity";
};
type PersistedMockHuddle = {
@@ -3158,7 +3160,7 @@ function initializeMockHuddle(
tts_enabled: seed.ttsEnabled ?? false,
transcription_enabled: seed.transcriptionEnabled ?? false,
is_creator: seed.isCreator ?? true,
voice_input_mode: "voice_activity",
voice_input_mode: "push_to_talk",
},
};
}
@@ -10208,6 +10210,21 @@ export function maybeInstallE2eTauriMocks() {
}
return snapshot;
}
case "get_voice_input_mode":
return mockHuddle?.state.voice_input_mode ?? "push_to_talk";
case "set_voice_input_mode": {
if (!mockHuddle) throw new Error("No active mock huddle.");
const mode = (payload as { mode?: unknown }).mode;
if (mode !== "push_to_talk" && mode !== "voice_activity") {
throw new Error("Missing voice input mode.");
}
mockHuddle.state.voice_input_mode = mode;
persistMockHuddle();
await emitMockHuddleState();
return null;
}
case "set_huddle_manual_mic_unmuted":
return null;
case "get_huddle_agent_pubkeys":
if (!mockHuddle) return [];
return [...mockHuddle.state.agent_pubkeys];
@@ -10239,7 +10256,7 @@ export function maybeInstallE2eTauriMocks() {
tts_enabled: false,
transcription_enabled: false,
is_creator: true,
voice_input_mode: "voice_activity",
voice_input_mode: "push_to_talk",
},
};
refreshMockHuddleMembership(activeConfig);
@@ -10333,7 +10350,7 @@ export function maybeInstallE2eTauriMocks() {
tts_enabled: false,
transcription_enabled: false,
is_creator: false,
voice_input_mode: "voice_activity",
voice_input_mode: "push_to_talk",
});
return null;
case "set_huddle_transcription_enabled":
@@ -10414,12 +10431,33 @@ export function maybeInstallE2eTauriMocks() {
return { matched_active_huddle: true, added };
}
case "add_agent_to_huddle": {
const error = activeConfig?.mock?.addAgentToHuddleError;
if (error) {
throw new Error(error);
}
const result = activeConfig?.mock?.addAgentToHuddleResult;
if (!result) {
throw new Error("No mock add-agent result configured.");
}
return structuredClone(result);
}
case "remove_agent_from_huddle": {
const agentPubkey = (payload as { agentPubkey?: string })?.agentPubkey;
if (!mockHuddle || !agentPubkey) {
throw new Error("No active huddle agent to remove.");
}
const initialCount = mockHuddle.members.length;
mockHuddle.members = mockHuddle.members.filter(
(member) => member.role !== "bot" || member.pubkey !== agentPubkey,
);
if (mockHuddle.members.length === initialCount) {
throw new Error("Agent is not in this huddle.");
}
refreshMockHuddleMembership(activeConfig);
persistMockHuddle();
await emitMockHuddleState();
return;
}
case "get_model_status":
return { stt: "ready", tts: "ready" };
case "get_tts_settings":
@@ -10552,6 +10590,8 @@ export function maybeInstallE2eTauriMocks() {
}
return settings;
}
case "interrupt_huddle_speech":
return;
case "set_pocket_voice": {
const voiceKey = (payload as { voiceKey?: string })?.voiceKey;
if (!voiceKey) throw new Error("Missing Pocket voice key");

View File

@@ -134,6 +134,47 @@ test("keeps the drawer open until the huddle is expanded", async ({ page }) => {
name: "Stop transcript",
});
await expect(transcriptButton).toBeVisible();
const huddleShell = page.locator(".buzz-huddle-shell");
await expect(huddleShell).toHaveAttribute("data-huddle-open", "true");
const huddleBackdrop = page.locator(".buzz-huddle-drawer-backdrop");
await expect(huddleBackdrop).toHaveClass(/buzz-huddle-drawer-backdrop-open/);
const [huddleBackdropColor, huddleDrawerColor] = await Promise.all([
huddleBackdrop.evaluate(
(element) => getComputedStyle(element).backgroundColor,
),
page
.locator(".buzz-huddle-drawer")
.first()
.evaluate((element) => getComputedStyle(element).backgroundColor),
]);
expect(huddleBackdropColor).toBe(huddleDrawerColor);
await expect
.poll(() =>
huddleBackdrop.evaluate((element) => {
const shell = element.parentElement;
const appSurface = shell?.querySelector(".buzz-huddle-app-surface");
if (!appSurface) return false;
const backdropStyle = getComputedStyle(element);
const appStyle = getComputedStyle(appSurface);
return Number(backdropStyle.zIndex) < Number(appStyle.zIndex);
}),
)
.toBe(true);
const drawerAgentMenu = page.getByRole("button", {
name: "Voice settings for alice",
});
await expect(
drawerAgentMenu.getByTestId("huddle-participant-avatar"),
).toBeVisible();
await drawerAgentMenu.click();
await expect(
page.getByText("Agent text-to-speech", { exact: true }),
).toBeVisible();
await expect(page.getByText("Agent voice", { exact: true })).toBeVisible();
await expect(
page.getByRole("button", { name: "Remove alice from huddle" }),
).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("profile-huddle-control")).toHaveCount(0);
await page
.getByRole("button", { name: "Open huddle in a new window" })
@@ -170,6 +211,11 @@ test("keeps the drawer open until the huddle is expanded", async ({ page }) => {
name: /microphone/i,
});
await expect(compactMicButton).toBeVisible();
await expect(compactMicButton).toHaveClass(/bg-destructive\/15/);
await expect(compactMicButton).toHaveClass(/text-destructive/);
await expect(
huddleControl.getByRole("button", { name: "Audio settings" }),
).not.toHaveClass(/bg-destructive\/15/);
const [micContainerRadius, micHoverRadius] = await Promise.all([
compactMicButton
.locator("..")
@@ -516,6 +562,89 @@ test("animates the responding agent with the shared speaker ring", async ({
.toBe("0");
});
test("stops the speaking agent from the huddle controls", async ({ page }) => {
await installMockBridge(page, {
windowLabel: `huddle-${HUDDLE_CHANNEL_ID}`,
huddle: {
parentChannelId: HUDDLE_PARENT_ID,
ephemeralChannelId: HUDDLE_CHANNEL_ID,
members: [
{ pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" },
{ pubkey: TEST_IDENTITIES.alice.pubkey, role: "bot" },
],
},
});
await page.goto("/");
const roomMicButton = page.getByRole("button", {
name: "Microphone unavailable",
});
await expect(roomMicButton).toHaveClass(/bg-destructive\/35/);
await expect(roomMicButton).toHaveClass(/text-destructive/);
await expect(
page.getByRole("button", { name: "Audio settings" }),
).not.toHaveClass(/bg-destructive\/35/);
const roomSpeakerButton = page.getByRole("button", {
name: "Unmute agent speech",
});
await expect(roomSpeakerButton).toHaveClass(/bg-destructive\/35/);
await expect(roomSpeakerButton).toHaveClass(/text-destructive/);
await expect(
page.getByRole("button", { name: "Speaker settings" }),
).not.toHaveClass(/bg-destructive\/35/);
await waitForAnimations(page);
const agentTile = page
.getByTestId("huddle-participant-strip")
.getByTestId("huddle-participant-tile")
.nth(1);
const agentAvatar = page
.getByTestId("huddle-participant-strip")
.locator(".buzz-huddle-speaking-avatar")
.nth(1);
const labelSlots = page.getByTestId("huddle-participant-label-slot");
await expect(agentAvatar).toBeVisible();
await waitForAnimations(page);
const idleTileHeight = await agentTile.evaluate(
(element) => element.offsetHeight,
);
const idleAgentLabelBox = await labelSlots.nth(1).boundingBox();
const humanLabelBox = await labelSlots.nth(0).boundingBox();
expect(humanLabelBox?.y).toBe(idleAgentLabelBox?.y);
expect(humanLabelBox?.height).toBe(idleAgentLabelBox?.height);
expect(humanLabelBox?.width).toBe(idleAgentLabelBox?.width);
await page.evaluate(async (pubkey) => {
await window.__BUZZ_E2E_EMIT_MOCK_HUDDLE_TTS_SPEAKER__?.({
pubkey,
level: 0.8,
});
}, TEST_IDENTITIES.alice.pubkey);
await expect
.poll(() =>
agentAvatar.evaluate((element) =>
element.style.getPropertyValue("--buzz-huddle-speaker-opacity"),
),
)
.toBe("0.890");
const stopButton = page.getByRole("button", { name: /^Stop .* speaking$/ });
await expect(stopButton).toBeVisible();
await expect(stopButton).toHaveClass(/bg-destructive\/15/);
expect(await labelSlots.nth(1).boundingBox()).toEqual(idleAgentLabelBox);
expect(await agentTile.evaluate((element) => element.offsetHeight)).toBe(
idleTileHeight,
);
await stopButton.click();
await expect
.poll(() =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).findLast(
(entry) => entry.command === "interrupt_huddle_speech",
)?.payload ?? null,
),
)
.toEqual({ agentPubkey: TEST_IDENTITIES.alice.pubkey });
});
test("assigns distinct agent voices and exposes compact per-agent controls", async ({
page,
}) => {
@@ -571,21 +700,30 @@ test("assigns distinct agent voices and exposes compact per-agent controls", asy
});
expect(new Set(assignedVoices).size).toBe(2);
await voiceMenus.first().click();
await page.getByRole("button", { name: "Voice settings for alice" }).click();
await waitForAnimations(page);
await expect(
page.getByText("Agent text-to-speech", { exact: true }),
).toBeVisible();
await expect(page.getByTestId("huddle-agent-tts-toggle")).toBeChecked();
await expect(page.getByTestId("huddle-agent-voice-selector")).toContainText(
"Vera",
const voiceMenu = page.locator(
'[data-testid="huddle-agent-voice-menu-content"][data-state="open"]',
);
await expect(voiceMenu).toBeVisible();
await expect(
voiceMenu.getByText("Agent text-to-speech", { exact: true }),
).toBeVisible();
const ttsToggle = voiceMenu.getByTestId("huddle-agent-tts-toggle");
await expect(ttsToggle).toBeChecked();
await expect(
voiceMenu.getByTestId("huddle-agent-voice-selector"),
).toContainText("Vera");
await page.getByTestId("huddle-agent-tts-toggle").click();
await expect(page.getByTestId("huddle-agent-voice-selector")).toHaveCount(0);
await page.getByTestId("huddle-agent-tts-toggle").click();
await expect(page.getByTestId("huddle-agent-tts-toggle")).toBeChecked();
await page.getByTestId("huddle-agent-voice-selector").click();
await ttsToggle.click();
await expect(
voiceMenu.getByTestId("huddle-agent-voice-selector"),
).toHaveCount(0);
await ttsToggle.click();
await expect(ttsToggle).toBeChecked();
const voiceSelector = voiceMenu.getByTestId("huddle-agent-voice-selector");
await expect(voiceSelector).toBeEnabled();
await voiceSelector.click();
await page.getByRole("menuitemradio", { name: "Jane" }).click();
await expect
.poll(() =>
@@ -790,6 +928,39 @@ test("returns the companion transcript to the same channel in the main app", asy
).toHaveCount(0);
});
test("returns to the parent channel when leaving a huddle channel in view", async ({
page,
}) => {
await installMockBridge(page, {
huddle: {
parentChannelId: HUDDLE_PARENT_ID,
ephemeralChannelId: HUDDLE_CHANNEL_ID,
members: [{ pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" }],
},
});
await page.goto("/");
await expect(page.locator(".buzz-huddle-shell")).toHaveAttribute(
"data-huddle-open",
"true",
);
await page.evaluate(async () => {
await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("close_huddle_companion");
});
await expect(page).toHaveURL(
new RegExp(`/channels/${HUDDLE_CHANNEL_ID.replaceAll("-", "\\-")}`),
);
await page.getByRole("button", { name: "Leave huddle" }).click();
await expect(page).toHaveURL(
new RegExp(`/channels/${HUDDLE_PARENT_ID.replaceAll("-", "\\-")}`),
);
await expect(
page.locator(`[data-channel-id="${HUDDLE_CHANNEL_ID}"]`),
).toHaveCount(0);
});
test("keeps the huddle avatar strip compact and exposes the full roster", async ({
page,
}) => {
@@ -827,6 +998,48 @@ test("keeps the huddle avatar strip compact and exposes the full roster", async
).toHaveCount(1);
});
test("removes an agent from its menu without showing an extra participant control", async ({
page,
}) => {
await installMockBridge(page, {
windowLabel: `huddle-${HUDDLE_CHANNEL_ID}`,
huddle: {
parentChannelId: HUDDLE_PARENT_ID,
ephemeralChannelId: HUDDLE_CHANNEL_ID,
members: [
{ pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" },
{ pubkey: TEST_IDENTITIES.alice.pubkey, role: "bot" },
],
},
});
await page.goto("/");
await expect(
page.getByRole("button", { name: "Manage huddle participants" }),
).toHaveCount(0);
await page.getByTestId("huddle-agent-voice-menu-trigger").click();
const removeButton = page.getByRole("button", {
name: "Remove alice from huddle",
});
await expect(removeButton).toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
await removeButton.click({ force: true });
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).some(
(entry) => entry.command === "remove_agent_from_huddle",
),
),
)
.toBe(true);
await expect(page.getByTestId("huddle-agent-voice-menu-trigger")).toHaveCount(
0,
);
});
test("keeps a newer huddle event over a delayed hydration snapshot", async ({
page,
}) => {
@@ -947,6 +1160,100 @@ test("keeps a starting huddle in the drawer after its companion closes", async (
).toBeVisible();
});
test("starts unmuted with Push to Talk while preserving manual microphone control", async ({
page,
}) => {
await installFakeHuddleMicrophone(page);
await installMockBridge(page, {
openHuddleWindowDelayMs: 10_000,
startHuddleReturnDelayMs: 1_500,
});
await page.goto("/");
await page.getByTestId("channel-alice-tyler").click();
await page.getByTestId("channel-start-huddle-trigger").click();
const muteButton = page.getByRole("button", { name: "Mute microphone" });
await expect(muteButton).toBeVisible();
await expect(muteButton).not.toHaveClass(/bg-destructive\/15/);
await expect(
page.getByRole("button", { name: "Audio settings" }),
).not.toHaveClass(/bg-destructive\/15/);
await muteButton.click();
const unmuteButton = page.getByRole("button", {
name: "Unmute microphone",
});
await expect(unmuteButton).toBeVisible();
await expect(unmuteButton).toHaveClass(/bg-destructive\/15/);
await expect(unmuteButton).toHaveClass(/text-destructive/);
await expect
.poll(() =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).findLast(
(entry) => entry.command === "set_huddle_manual_mic_unmuted",
)?.payload,
),
)
.toEqual({ enabled: false });
await page.mouse.move(0, 0);
await unmuteButton.hover();
await expect(page.getByRole("tooltip")).toHaveAccessibleName(
/Click to unmute or hold .*Space/,
);
await page.evaluate(async () => {
await window.__BUZZ_E2E_EMIT_TAURI_EVENT__?.("ptt-state", true);
});
await expect(muteButton).toBeVisible();
await muteButton.click();
await expect
.poll(() =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).findLast(
(entry) => entry.command === "set_huddle_manual_mic_unmuted",
)?.payload,
),
)
.toEqual({ enabled: false });
await page.evaluate(async () => {
await window.__BUZZ_E2E_EMIT_TAURI_EVENT__?.("ptt-state", false);
});
await expect(unmuteButton).toBeVisible();
await page.getByRole("button", { name: "Audio settings" }).click();
await page.getByRole("button", { name: "Turn off Push to Talk" }).click();
await page.evaluate(async () => {
await window.__BUZZ_E2E_EMIT_TAURI_EVENT__?.("ptt-state", true);
});
await expect(unmuteButton).toBeVisible();
await page.getByRole("button", { name: "Turn on Push to Talk" }).click();
await expect(unmuteButton).toBeVisible();
await page.evaluate(async () => {
await window.__BUZZ_E2E_EMIT_TAURI_EVENT__?.("ptt-state", true);
});
await expect(muteButton).toBeVisible();
await page.evaluate(async () => {
await window.__BUZZ_E2E_EMIT_TAURI_EVENT__?.("ptt-state", false);
});
await expect(unmuteButton).toBeVisible();
await unmuteButton.click();
await expect(muteButton).toBeVisible();
await expect
.poll(() =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).findLast(
(entry) => entry.command === "set_huddle_manual_mic_unmuted",
)?.payload,
),
)
.toEqual({ enabled: true });
});
test("starts an agent DM huddle and hides its backing channel after it ends", async ({
page,
}) => {
@@ -1160,7 +1467,7 @@ test("closes add-agent dialog when parent membership is already satisfied", asyn
await page.goto("/");
await page.getByRole("button", { name: "Add agent to huddle" }).click();
const dialog = page.getByRole("dialog", { name: "Add Agent to Huddle" });
const dialog = page.getByRole("dialog", { name: "Add agents" });
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: /bob/i }).click();
@@ -1169,3 +1476,149 @@ test("closes add-agent dialog when parent membership is already satisfied", asyn
page.getByText("Added to huddle, but parent channel failed:"),
).toHaveCount(0);
});
test("starts an available stopped agent before adding it to the huddle", async ({
page,
}) => {
await installMockBridge(page, {
addAgentToHuddleResult: {
ephemeral_added: true,
parent_added: true,
parent_error: null,
},
huddle: {
parentChannelId: HUDDLE_PARENT_ID,
ephemeralChannelId: HUDDLE_CHANNEL_ID,
members: [{ pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" }],
},
managedAgents: [
{
avatarUrl: "https://example.com/bob.png",
pubkey: TEST_IDENTITIES.bob.pubkey,
name: "bob",
status: "stopped",
},
],
});
await page.goto("/");
await page.getByRole("button", { name: "Add agent to huddle" }).click();
const dialog = page.getByRole("dialog", { name: "Add agents" });
await expect(dialog).toBeVisible();
await expect(dialog).toHaveClass(/data-\[state=open\]:animate-in/);
await expect(dialog).toHaveClass(/data-\[state=open\]:fade-in-0/);
await expect(dialog).toHaveClass(/data-\[state=open\]:zoom-in-95/);
await expect(dialog).toHaveClass(/duration-200/);
await expect(dialog).toHaveClass(/motion-reduce:animate-none/);
await expect(
dialog.getByText("Choose an agent to join this huddle.", { exact: true }),
).toBeVisible();
await expect(dialog.getByText("stopped", { exact: true })).toHaveCount(0);
await dialog.getByRole("button", { name: /bob/i }).click();
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? [])
.filter(
(entry) =>
entry.command === "start_managed_agent" ||
entry.command === "add_agent_to_huddle",
)
.map((entry) => entry.command),
),
)
.toEqual(["start_managed_agent", "add_agent_to_huddle"]);
await expect(dialog).toHaveCount(0);
});
test("stops an agent started solely for a failed huddle add", async ({
page,
}) => {
await installMockBridge(page, {
addAgentToHuddleError: "membership publish rejected",
huddle: {
parentChannelId: HUDDLE_PARENT_ID,
ephemeralChannelId: HUDDLE_CHANNEL_ID,
members: [{ pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" }],
},
managedAgents: [
{
avatarUrl: "https://example.com/bob.png",
pubkey: TEST_IDENTITIES.bob.pubkey,
name: "bob",
status: "stopped",
},
],
});
await page.goto("/");
await page.getByRole("button", { name: "Add agent to huddle" }).click();
const dialog = page.getByRole("dialog", { name: "Add agents" });
await dialog.getByRole("button", { name: /bob/i }).click();
await expect(dialog.getByText(/Failed to add agent:/)).toBeVisible();
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? [])
.filter((entry) =>
[
"start_managed_agent",
"add_agent_to_huddle",
"stop_managed_agent",
].includes(entry.command),
)
.map((entry) => entry.command),
),
)
.toEqual([
"start_managed_agent",
"add_agent_to_huddle",
"stop_managed_agent",
]);
});
test("does not deploy a provider agent when its huddle add fails", async ({
page,
}) => {
await installMockBridge(page, {
addAgentToHuddleError: "membership publish rejected",
huddle: {
parentChannelId: HUDDLE_PARENT_ID,
ephemeralChannelId: HUDDLE_CHANNEL_ID,
members: [{ pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" }],
},
managedAgents: [
{
avatarUrl: "https://example.com/bob.png",
backend: { type: "provider", id: "mock", config: {} },
pubkey: TEST_IDENTITIES.bob.pubkey,
name: "bob",
status: "not_deployed",
},
],
});
await page.goto("/");
await page.getByRole("button", { name: "Add agent to huddle" }).click();
const dialog = page.getByRole("dialog", { name: "Add agents" });
await dialog.getByRole("button", { name: /bob/i }).click();
await expect(dialog.getByText(/Failed to add agent:/)).toBeVisible();
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? [])
.filter((entry) =>
[
"start_managed_agent",
"add_agent_to_huddle",
"stop_managed_agent",
].includes(entry.command),
)
.map((entry) => entry.command),
),
)
.toEqual(["add_agent_to_huddle"]);
});