From 362e21af7c82f263adf55eea12d9622702c1fa03 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 22 Aug 2026 18:21:03 -0400 Subject: [PATCH] feat(desktop): polish huddle audio controls Preserve typed relay admission codes through the audio connection boundary so UI copy and reconnect policy can act on production handshake failures. Move all active huddle playback consumers when the output route changes: remote peers, agent TTS, and STT acoustic-coupling policy. Co-authored-by: Max Signed-off-by: Max --- desktop/src-tauri/src/huddle/audio_output.rs | 9 +- desktop/src-tauri/src/huddle/latency_bench.rs | 6 +- desktop/src-tauri/src/huddle/pipeline.rs | 18 +--- desktop/src-tauri/src/huddle/playout.rs | 22 +++- desktop/src-tauri/src/huddle/reconnect.rs | 18 +++- desktop/src-tauri/src/huddle/relay_api.rs | 102 ++++++++++++++++-- desktop/src-tauri/src/huddle/stt.rs | 8 +- desktop/src-tauri/src/huddle/tts.rs | 56 ++++++---- desktop/src-tauri/src/huddle/tts_playback.rs | 49 +++++++++ desktop/src-tauri/src/huddle/tts_settings.rs | 4 +- .../features/huddle/components/HuddleBar.tsx | 24 ++++- .../huddle/components/MicControls.tsx | 17 ++- .../features/huddle/lib/huddleError.test.mjs | 23 ++++ .../src/features/huddle/lib/huddleError.ts | 47 ++++++-- 14 files changed, 335 insertions(+), 68 deletions(-) diff --git a/desktop/src-tauri/src/huddle/audio_output.rs b/desktop/src-tauri/src/huddle/audio_output.rs index 383a7e8210..6335100dd4 100644 --- a/desktop/src-tauri/src/huddle/audio_output.rs +++ b/desktop/src-tauri/src/huddle/audio_output.rs @@ -35,7 +35,7 @@ fn list_audio_output_devices_blocking() -> Result, String } /// Set the preferred audio output device by name. Empty string = system default. -/// Takes effect on the next huddle start/join (does not change a live stream). +/// An active huddle moves subsequent playout to the selected route immediately. #[tauri::command] pub fn set_audio_output_device(name: String, state: State<'_, AppState>) -> Result<(), String> { let mut guard = state @@ -43,7 +43,12 @@ pub fn set_audio_output_device(name: String, state: State<'_, AppState>) -> Resu .output_device .lock() .map_err(|e| e.to_string())?; - *guard = if name.is_empty() { None } else { Some(name) }; + let selected = if name.is_empty() { None } else { Some(name) }; + *guard = selected.clone(); + state + .huddle_audio + .output_device_changes + .send_replace(selected); Ok(()) } diff --git a/desktop/src-tauri/src/huddle/latency_bench.rs b/desktop/src-tauri/src/huddle/latency_bench.rs index f928ddbce0..2f7787df65 100644 --- a/desktop/src-tauri/src/huddle/latency_bench.rs +++ b/desktop/src-tauri/src/huddle/latency_bench.rs @@ -143,8 +143,8 @@ fn baseline_stt_fake_llm_tts_first_audio() { Arc::clone(&tts_cancel), super::human_floor::HumanFloor::new(), "eve", - None, // default output device - None, // no Tauri app handle + tokio::sync::watch::channel(None).1, // default output device + None, // no Tauri app handle ) .expect("tts pipeline"); eprintln!( @@ -158,7 +158,7 @@ fn baseline_stt_fake_llm_tts_first_audio() { None, None, super::human_floor::HumanFloor::new(), - None, + tokio::sync::watch::channel(None).1, ) .expect("stt pipeline"); // Recognizer loads inside the worker thread; give it time, then verify diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index 47d4aeb43d..4254bbc729 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -250,7 +250,7 @@ pub(crate) async fn post_connect_setup( } return Ok(PostConnectOutcome::Stale); } - let (cancel, pcm_tx) = audio_result?; + let (cancel, pcm_tx) = audio_result.map_err(|error| error.to_string())?; hs.audio_ws_cancel = Some(cancel); hs.audio_relay_pcm_tx = Some(pcm_tx); } @@ -349,12 +349,7 @@ pub(crate) async fn maybe_start_stt_pipeline( ptt, manual_mic_unmuted, hs.human_floor.clone(), - state - .huddle_audio - .output_device - .lock() - .unwrap_or_else(|e| e.into_inner()) - .clone(), + state.huddle_audio.output_device_changes.subscribe(), old, ) }; @@ -447,12 +442,7 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result Result, ws_tx_for_pongs: Arc>>, - sink_handle: rodio::MixerDeviceSink, + mut sink_handle: rodio::MixerDeviceSink, cancel: CancellationToken, app_handle: Option, initial_peers: Vec<(u8, String, u8)>, @@ -276,6 +276,7 @@ pub(crate) async fn run_playout_recv_loop( remote_stt_pipeline: Arc>>>, agent_pubkeys: Arc>>, human_floor: HumanFloor, + mut output_device_changes: tokio::sync::watch::Receiver>, ) { use rodio::buffer::SamplesBuffer; use std::num::NonZero; @@ -321,6 +322,25 @@ pub(crate) async fn run_playout_recv_loop( tokio::select! { biased; _ = cancel.cancelled() => break, + changed = output_device_changes.changed() => { + if changed.is_err() { + break; + } + let selected = output_device_changes.borrow_and_update().clone(); + match super::audio_output::open_output_sink_by_name(selected.as_deref()) { + Ok(next_sink) => { + sink_handle = next_sink; + let mixer = sink_handle.mixer().clone(); + for slot in peers.values_mut() { + slot.player = rodio::Player::connect_new(&mixer); + slot.recovering_playout = false; + } + } + Err(error) => { + eprintln!("buzz-desktop: live output device switch failed: {error}"); + } + } + } _ = playout_tick.tick() => { // Drain one 10 ms frame from each *active* peer's NetEq into // its Player. NetEq always emits a frame (Expand/silence when diff --git a/desktop/src-tauri/src/huddle/reconnect.rs b/desktop/src-tauri/src/huddle/reconnect.rs index 6996776f1c..3950ba5b64 100644 --- a/desktop/src-tauri/src/huddle/reconnect.rs +++ b/desktop/src-tauri/src/huddle/reconnect.rs @@ -31,9 +31,21 @@ pub async fn reconnect_huddle_audio(state: State<'_, AppState>) -> Result<(), St ) }; - let (cancel, pcm_tx) = - relay_api::connect_audio_relay(&ephemeral_channel_id, parent_channel_id.as_deref(), &state) - .await?; + let (cancel, pcm_tx) = match relay_api::connect_audio_relay( + &ephemeral_channel_id, + parent_channel_id.as_deref(), + &state, + ) + .await + { + Ok(connection) => connection, + Err(error) => { + if error.code() == Some("huddle_relay_draining") { + eprintln!("buzz-desktop: huddle reconnect deferred while relay is draining"); + } + return Err(error.to_string()); + } + }; let mut hs = state.huddle()?; let still_current = !matches!(hs.phase, HuddlePhase::Idle | HuddlePhase::Leaving) diff --git a/desktop/src-tauri/src/huddle/relay_api.rs b/desktop/src-tauri/src/huddle/relay_api.rs index d5b2f8f8ed..1cc14f4cd4 100644 --- a/desktop/src-tauri/src/huddle/relay_api.rs +++ b/desktop/src-tauri/src/huddle/relay_api.rs @@ -69,13 +69,67 @@ fn build_audio_auth_event( .map_err(|e| format!("sign: {e}")) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AudioRelayConnectError { + code: Option, + message: String, +} + +impl AudioRelayConnectError { + pub(crate) fn code(&self) -> Option<&str> { + self.code.as_deref() + } + + fn from_relay_payload(value: &serde_json::Value) -> Self { + Self { + code: value["code"].as_str().map(str::to_string), + message: value["message"] + .as_str() + .unwrap_or("unknown relay error") + .to_string(), + } + } +} + +impl From for AudioRelayConnectError { + fn from(message: String) -> Self { + Self { + code: None, + message, + } + } +} + +impl From<&str> for AudioRelayConnectError { + fn from(message: &str) -> Self { + message.to_string().into() + } +} + +impl std::fmt::Display for AudioRelayConnectError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.code.as_deref() { + Some(code) => write!( + formatter, + "audio relay auth error [{code}]: {}", + self.message + ), + None => formatter.write_str(&self.message), + } + } +} + +fn format_audio_relay_error(value: &serde_json::Value) -> AudioRelayConnectError { + AudioRelayConnectError::from_relay_payload(value) +} + async fn connect_authenticated_audio_socket( channel_id: &str, parent_channel_id: Option<&str>, relay_url: &str, keys: &nostr::Keys, auth_tag_json: Option<&str>, -) -> Result<(WsSink, WsReceiver, u8, Vec<(u8, String, u8)>), String> { +) -> Result<(WsSink, WsReceiver, u8, Vec<(u8, String, u8)>), AudioRelayConnectError> { use nostr::JsonUtil; let ws_url = format!("{relay_url}/huddle/{channel_id}/audio"); @@ -153,7 +207,7 @@ async fn connect_authenticated_audio_socket( break Ok((peer_index, peers)); } Some("error") => { - break Err(format!("audio relay auth error: {}", value["message"])); + break Err(format_audio_relay_error(&value)); } _ => continue, } @@ -179,7 +233,7 @@ pub(crate) async fn connect_audio_relay( channel_id: &str, parent_channel_id: Option<&str>, state: &AppState, -) -> Result<(CancellationToken, tokio::sync::mpsc::Sender>), String> { +) -> Result<(CancellationToken, tokio::sync::mpsc::Sender>), AudioRelayConnectError> { let relay_url = crate::relay::relay_ws_url_with_override(state); let keys = state.keys.lock().map_err(|e| e.to_string())?.clone(); @@ -212,12 +266,8 @@ pub(crate) async fn connect_audio_relay( let cancel = CancellationToken::new(); let cancel_clone = cancel.clone(); let (pcm_tx, pcm_rx) = tokio::sync::mpsc::channel::>(50); - let output_device_name = state - .huddle_audio - .output_device - .lock() - .unwrap_or_else(|e| e.into_inner()) - .clone(); + let output_device_changes = state.huddle_audio.output_device_changes.subscribe(); + let output_device_name = output_device_changes.borrow().clone(); tokio::spawn(async move { if let Err(e) = audio_relay_pipeline(AudioRelayPipelineArgs { @@ -234,6 +284,7 @@ pub(crate) async fn connect_audio_relay( agent_pubkeys, human_floor, output_device_name, + output_device_changes, }) .await { @@ -395,7 +446,8 @@ pub(crate) async fn connect_tts_audio_publisher( keys, auth_tag_json, ) - .await?; + .await + .map_err(|error| error.to_string())?; let cancel = CancellationToken::new(); let publisher_cancel = cancel.clone(); @@ -528,6 +580,7 @@ struct AudioRelayPipelineArgs { agent_pubkeys: Arc>>, human_floor: super::human_floor::HumanFloor, output_device_name: Option, + output_device_changes: tokio::sync::watch::Receiver>, } async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String> { @@ -545,6 +598,7 @@ async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String agent_pubkeys, human_floor, output_device_name, + output_device_changes, } = args; let mut encoder = opus::Encoder::new(48000, opus::Channels::Mono, opus::Application::Voip) @@ -658,6 +712,7 @@ async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String remote_stt_pipeline, agent_pubkeys, human_floor, + output_device_changes, )); // Any pipeline task ending tears down the other two. The encoder never @@ -750,6 +805,33 @@ pub(crate) async fn count_human_members( mod tests { use super::*; + #[test] + fn relay_auth_errors_preserve_stable_codes_for_ui_mapping() { + for (code, message) in [ + ("room_full", "room participant capacity reached"), + ("room_ended", "huddle has ended"), + ("huddle_relay_draining", "relay is draining; reconnect"), + ( + "huddle_owner_unreachable", + "could not reach the huddle owner", + ), + ("unsupported_version", "unsupported audio protocol version"), + ("upgrade_required", "audio protocol upgrade required"), + ] { + let payload = serde_json::json!({ + "type": "error", + "code": code, + "message": message, + }); + let error = format_audio_relay_error(&payload); + assert_eq!(error.code(), Some(code)); + assert_eq!( + error.to_string(), + format!("audio relay auth error [{code}]: {message}") + ); + } + } + #[test] fn audio_send_queue_drops_oldest_frame_when_full() { let queue = AudioSendQueue::default(); diff --git a/desktop/src-tauri/src/huddle/stt.rs b/desktop/src-tauri/src/huddle/stt.rs index c27bf38b64..3d6c35db2b 100644 --- a/desktop/src-tauri/src/huddle/stt.rs +++ b/desktop/src-tauri/src/huddle/stt.rs @@ -100,7 +100,7 @@ impl SttPipeline { ptt_active: Option>, manual_mic_unmuted: Option>, human_floor: HumanFloor, - output_device: Option, + output_device_changes: tokio::sync::watch::Receiver>, ) -> Result<(Self, tokio_mpsc::Receiver), String> { let (audio_tx, audio_rx) = mpsc::sync_channel::(AUDIO_QUEUE_DEPTH); let (text_tx, text_rx) = tokio_mpsc::channel::(64); @@ -120,7 +120,7 @@ impl SttPipeline { ptt_active_worker, manual_mic_unmuted_worker, human_floor, - output_device, + output_device_changes, ) }) .map_err(|e| format!("failed to spawn stt-worker thread: {e}"))?; @@ -449,7 +449,7 @@ fn stt_worker( ptt_active: Option>, manual_mic_unmuted: Option>, human_floor: HumanFloor, - output_device: Option, + output_device_changes: tokio::sync::watch::Receiver>, ) { // ── 1. Initialise sherpa-onnx recognizer ───────────────────────────────── // @@ -566,7 +566,7 @@ fn stt_worker( manual_gate, &human_floor, local_barge_in_state, - output_device.as_deref(), + output_device_changes.borrow().as_deref(), track_local_floor, ); } diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index 3f12f883ba..a1ca712e71 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -200,7 +200,7 @@ impl TtsPipeline { cancel: Arc, human_floor: HumanFloor, voice: &str, - output_device: Option, + output_device_changes: tokio::sync::watch::Receiver>, activity_app: Option, ) -> Result { let (text_tx, text_rx) = mpsc::sync_channel::(TEXT_QUEUE_DEPTH); @@ -255,7 +255,7 @@ impl TtsPipeline { worker_playback_probe, worker_broadcasters, ), - output_device, + output_device_changes, activity_app, startup_tx, ) @@ -318,6 +318,19 @@ fn authorize_or_defer_queued_text( } } +pub(super) fn apply_pending_output_device_change( + output_device_changes: &mut tokio::sync::watch::Receiver>, + mut apply: impl FnMut(Option<&str>) -> Result<(), String>, +) { + if !output_device_changes.has_changed().unwrap_or(false) { + return; + } + let selected = output_device_changes.borrow_and_update().clone(); + if let Err(error) = apply(selected.as_deref()) { + eprintln!("buzz-desktop: live TTS output device switch failed: {error}"); + } +} + #[allow(clippy::too_many_arguments)] fn tts_worker( model_dir: PathBuf, @@ -325,7 +338,7 @@ fn tts_worker( text_rx: mpsc::Receiver, human_floor: HumanFloor, control_state: WorkerControlState, - output_device: Option, + mut output_device_changes: tokio::sync::watch::Receiver>, activity_app: Option, startup_tx: mpsc::SyncSender>, ) { @@ -397,16 +410,17 @@ fn tts_worker( // ── 3. Initialise rodio output device ───────────────────────────────────── use rodio::buffer::SamplesBuffer; - let sink_handle = match super::audio_output::open_output_sink_by_name(output_device.as_deref()) - { - Ok(h) => h, - Err(e) => { - let error = format!("TTS audio output initialization failed: {e}"); - eprintln!("buzz-desktop: tts stage=startup status=failed reason=output_open"); - let _ = startup_tx.send(Err(error)); - return; - } - }; + let initial_output_device = output_device_changes.borrow().clone(); + let mut sink_handle = + match super::audio_output::open_output_sink_by_name(initial_output_device.as_deref()) { + Ok(h) => h, + Err(e) => { + let error = format!("TTS audio output initialization failed: {e}"); + eprintln!("buzz-desktop: tts stage=startup status=failed reason=output_open"); + let _ = startup_tx.send(Err(error)); + return; + } + }; let channels = match NonZero::new(1u16) { Some(c) => c, @@ -505,11 +519,17 @@ fn tts_worker( channels, rate, }; - let append_audio = |prepared: PreparedModelAudio, - route_id: u64, - speaker_pubkey: Option<&str>, - speaker_generation: u64, - floor_epoch: u64| { + let mut append_audio = |prepared: PreparedModelAudio, + route_id: u64, + speaker_pubkey: Option<&str>, + speaker_generation: u64, + floor_epoch: u64| { + apply_pending_output_device_change(&mut output_device_changes, |selected| { + let next_sink = super::audio_output::open_output_sink_by_name(selected)?; + playback.replace_output_mixer(next_sink.mixer()); + sink_handle = next_sink; + Ok(()) + }); let broadcast_samples = speaker_pubkey.map(|_| prepared.buffer.clone()); append_worker_audio( &append_context, diff --git a/desktop/src-tauri/src/huddle/tts_playback.rs b/desktop/src-tauri/src/huddle/tts_playback.rs index 8a90c01899..deac955f73 100644 --- a/desktop/src-tauri/src/huddle/tts_playback.rs +++ b/desktop/src-tauri/src/huddle/tts_playback.rs @@ -122,6 +122,17 @@ impl PlaybackCoordinator { } } + pub(super) fn replace_output_mixer(&self, mixer: &Mixer) { + *self.mixer.lock().unwrap_or_else(PoisonError::into_inner) = Some(mixer.clone()); + let old_player = { + let mut state = self.lock(); + state.first_append = true; + state.output_lease.begin_hangover(Instant::now()); + state.player.replace(Player::connect_new(mixer)) + }; + drop(old_player); + } + fn lock(&self) -> MutexGuard<'_, PlaybackState> { self.state.lock().unwrap_or_else(PoisonError::into_inner) } @@ -422,6 +433,44 @@ mod tests { ) } + #[test] + fn pending_output_watch_change_replaces_live_playback_before_next_append() { + let (playback, _old_source) = coordinator(); + append_second(&playback); + assert!(!playback.empty()); + + let (output_tx, mut output_rx) = tokio::sync::watch::channel(None); + output_tx.send_replace(Some("new route".to_string())); + let channels = NonZero::new(1).expect("nonzero channels"); + let rate = NonZero::new(24_000).expect("nonzero rate"); + let (next_mixer, _next_source) = rodio::mixer::mixer(channels, rate); + crate::huddle::tts::apply_pending_output_device_change(&mut output_rx, |selected| { + assert_eq!(selected, Some("new route")); + playback.replace_output_mixer(&next_mixer); + Ok(()) + }); + + assert!(playback.empty(), "the old-route queue must be dropped"); + append_second(&playback); + assert!(!playback.empty(), "subsequent audio must use the new mixer"); + } + + #[test] + fn replacing_output_mixer_drops_old_route_and_accepts_new_audio() { + let (playback, _old_source) = coordinator(); + append_second(&playback); + assert!(!playback.empty()); + + let channels = NonZero::new(1).expect("nonzero channels"); + let rate = NonZero::new(24_000).expect("nonzero rate"); + let (next_mixer, _next_source) = rodio::mixer::mixer(channels, rate); + playback.replace_output_mixer(&next_mixer); + + assert!(playback.empty()); + append_second(&playback); + assert!(!playback.empty()); + } + #[test] fn floor_authorized_append_does_not_reenter_the_coordinator_lock() { let (playback, _unpulled_source) = coordinator(); diff --git a/desktop/src-tauri/src/huddle/tts_settings.rs b/desktop/src-tauri/src/huddle/tts_settings.rs index 75cfef26e5..1e96cd6f09 100644 --- a/desktop/src-tauri/src/huddle/tts_settings.rs +++ b/desktop/src-tauri/src/huddle/tts_settings.rs @@ -45,6 +45,8 @@ pub struct HuddleAudioSettingsState { pub tts_transition: tokio::sync::Mutex<()>, /// Selected huddle output device. `None` uses the system default. pub output_device: Mutex>, + /// Live output-route changes for an active huddle playout loop. + pub output_device_changes: tokio::sync::watch::Sender>, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] @@ -624,7 +626,7 @@ pub async fn preview_pocket_voice( cancel, super::human_floor::HumanFloor::new(), &voice_name, - output_device, + tokio::sync::watch::channel(output_device).1, None, )?; pipeline.speak("Hello! This is how I’ll read agent responses.".to_string())?; diff --git a/desktop/src/features/huddle/components/HuddleBar.tsx b/desktop/src/features/huddle/components/HuddleBar.tsx index d5a0423cf7..bd811f3db6 100644 --- a/desktop/src/features/huddle/components/HuddleBar.tsx +++ b/desktop/src/features/huddle/components/HuddleBar.tsx @@ -22,6 +22,7 @@ import type { RelayEvent } from "@/shared/api/types"; import { KIND_HUDDLE_REACTION } from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { getStorageItem, setStorageItem } from "@/shared/lib/safeStorage"; import { useDocumentVisible } from "@/shared/lib/useDocumentVisible"; import { Button } from "@/shared/ui/button"; import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider"; @@ -71,9 +72,14 @@ const HUDDLE_STATE_FALLBACK_INTERVAL_MS = 30_000; const HUDDLE_MODEL_STATUS_INTERVAL_MS = 10_000; const HUDDLE_REACTION_NAME_MAX = 48; const HEADPHONES_HINT_SEEN_STORAGE_KEY = "buzz.huddle.headphones-hint-seen"; +const PTT_HINT_SEEN_STORAGE_KEY = "buzz.huddle.ptt-hint-seen"; function hasSeenHeadphonesHint() { - return window.localStorage.getItem(HEADPHONES_HINT_SEEN_STORAGE_KEY) === "1"; + return getStorageItem(HEADPHONES_HINT_SEEN_STORAGE_KEY) === "1"; +} + +function hasSeenPttHint() { + return getStorageItem(PTT_HINT_SEEN_STORAGE_KEY) === "1"; } function isVisibleHuddleState(state: HuddleState | null) { @@ -188,6 +194,8 @@ export function HuddleBar({ const [headphonesHintDismissed, setHeadphonesHintDismissed] = React.useState( hasSeenHeadphonesHint, ); + const [pttHintDismissed, setPttHintDismissed] = + React.useState(hasSeenPttHint); const [isLeaving, setIsLeaving] = React.useState(false); const [showAddAgent, setShowAddAgent] = React.useState(false); const [agentAddError, setAgentAddError] = React.useState(null); @@ -330,9 +338,13 @@ export function HuddleBar({ const mainHadActiveHuddleRef = React.useRef(false); const dismissHeadphonesHint = React.useCallback(() => { - window.localStorage.setItem(HEADPHONES_HINT_SEEN_STORAGE_KEY, "1"); + setStorageItem(HEADPHONES_HINT_SEEN_STORAGE_KEY, "1"); setHeadphonesHintDismissed(true); }, []); + const dismissPttHint = React.useCallback(() => { + setStorageItem(PTT_HINT_SEEN_STORAGE_KEY, "1"); + setPttHintDismissed(true); + }, []); React.useEffect(() => { onVisibilityChange?.(isHuddleVisible); @@ -660,6 +672,14 @@ export function HuddleBar({
void; isMuted: boolean; onToggleMute: () => void; isPttMode: boolean; @@ -93,6 +95,8 @@ function usePrefersReducedMotion(): boolean { export function MicControls({ compact = false, + showPttHint = false, + onPttHintDismiss, isMuted, onToggleMute, isPttMode, @@ -146,7 +150,7 @@ export function MicControls({ "overflow-hidden border border-sidebar-border/80 bg-transparent text-sidebar-foreground/70 shadow-none", )} > - + - {isPttMode && !micUnavailable && isEffectivelyMuted ? ( + {showPttHint ? ( + + Hold + + {pushToTalkShortcut} + + to talk — click to switch to open mic + + ) : isPttMode && !micUnavailable && isEffectivelyMuted ? ( Click to unmute or hold diff --git a/desktop/src/features/huddle/lib/huddleError.test.mjs b/desktop/src/features/huddle/lib/huddleError.test.mjs index ec61b83467..fcea5ea6e7 100644 --- a/desktop/src/features/huddle/lib/huddleError.test.mjs +++ b/desktop/src/features/huddle/lib/huddleError.test.mjs @@ -44,3 +44,26 @@ test("uses action-specific fallback copy for unknown errors", () => { "Couldn’t start the huddle.", ); }); + +test("maps relay room, drain, owner, and protocol errors to useful copy", () => { + const cases = [ + ["room_full", "This huddle is full."], + ["room_ended", "This huddle has ended."], + ["huddle_relay_draining", "The huddle relay is restarting. Reconnecting…"], + [ + "huddle_owner_unreachable", + "The huddle relay can’t be reached. Try again in a moment.", + ], + ["unsupported_version", "Update Buzz to join this huddle."], + [ + "upgrade_required", + "This huddle uses a newer audio version. Update Buzz, then try again.", + ], + ]; + for (const [code, expected] of cases) { + assert.equal( + formatHuddleActionError(`audio relay error: ${code}`, "join"), + expected, + ); + } +}); diff --git a/desktop/src/features/huddle/lib/huddleError.ts b/desktop/src/features/huddle/lib/huddleError.ts index 7ee47b2133..02b5c258fb 100644 --- a/desktop/src/features/huddle/lib/huddleError.ts +++ b/desktop/src/features/huddle/lib/huddleError.ts @@ -1,7 +1,40 @@ export type HuddleAction = "join" | "start"; -const HUDDLE_AUDIO_UNAVAILABLE_MESSAGE = - "Huddle audio isn’t available on this server. Ask an administrator to turn it on."; +const HUDDLE_ERROR_MESSAGES: ReadonlyArray<{ + codes: readonly string[]; + message: string; +}> = [ + { + codes: [ + "huddle_audio_unavailable", + "huddle audio unavailable in this deployment", + ], + message: + "Huddle audio isn’t available on this server. Ask an administrator to turn it on.", + }, + { codes: ["room_full"], message: "This huddle is full." }, + { + codes: ["room_ended", "channel is archived"], + message: "This huddle has ended.", + }, + { + codes: ["huddle_relay_draining"], + message: "The huddle relay is restarting. Reconnecting…", + }, + { + codes: ["huddle_owner_unreachable"], + message: "The huddle relay can’t be reached. Try again in a moment.", + }, + { + codes: ["unsupported_version"], + message: "Update Buzz to join this huddle.", + }, + { + codes: ["upgrade_required"], + message: + "This huddle uses a newer audio version. Update Buzz, then try again.", + }, +]; function rawErrorMessage(error: unknown): string | null { if (error instanceof Error) { @@ -20,12 +53,10 @@ export function formatHuddleActionError( const message = rawErrorMessage(error)?.trim(); const normalized = message?.toLowerCase(); - if ( - normalized?.includes("huddle_audio_unavailable") || - normalized?.includes("huddle audio unavailable in this deployment") - ) { - return HUDDLE_AUDIO_UNAVAILABLE_MESSAGE; - } + const mapped = HUDDLE_ERROR_MESSAGES.find(({ codes }) => + codes.some((code) => normalized?.includes(code)), + ); + if (mapped) return mapped.message; if (message) { return message;