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 <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
This commit is contained in:
Max
2026-08-22 18:21:03 -04:00
parent 78e0eef7f7
commit 362e21af7c
14 changed files with 335 additions and 68 deletions

View File

@@ -35,7 +35,7 @@ fn list_audio_output_devices_blocking() -> Result<Vec<AudioOutputDevice>, 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(())
}

View File

@@ -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

View File

@@ -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<bool, S
// Resolve all fallible construction inputs before claiming the sentinel so
// an unreadable optional voice registry cannot wedge future start attempts.
let output_device = 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 app = state
.app_handle
.lock()
@@ -502,7 +492,7 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result<bool, S
tts_cancel,
human_floor,
&initial_voice,
output_device,
output_device_changes,
app,
)
})

View File

@@ -266,7 +266,7 @@ impl PeerSlot {
pub(crate) async fn run_playout_recv_loop(
mut ws_rx: futures_util::stream::SplitStream<WsStream>,
ws_tx_for_pongs: Arc<tokio::sync::Mutex<futures_util::stream::SplitSink<WsStream, WsMsg>>>,
sink_handle: rodio::MixerDeviceSink,
mut sink_handle: rodio::MixerDeviceSink,
cancel: CancellationToken,
app_handle: Option<tauri::AppHandle>,
initial_peers: Vec<(u8, String, u8)>,
@@ -276,6 +276,7 @@ pub(crate) async fn run_playout_recv_loop(
remote_stt_pipeline: Arc<std::sync::Mutex<Option<std::sync::Weak<super::stt::SttPipeline>>>>,
agent_pubkeys: Arc<std::sync::Mutex<Vec<String>>>,
human_floor: HumanFloor,
mut output_device_changes: tokio::sync::watch::Receiver<Option<String>>,
) {
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

View File

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

View File

@@ -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<String>,
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<String> 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<Vec<u8>>), String> {
) -> Result<(CancellationToken, tokio::sync::mpsc::Sender<Vec<u8>>), 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::<Vec<u8>>(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<std::sync::Mutex<Vec<String>>>,
human_floor: super::human_floor::HumanFloor,
output_device_name: Option<String>,
output_device_changes: tokio::sync::watch::Receiver<Option<String>>,
}
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();

View File

@@ -100,7 +100,7 @@ impl SttPipeline {
ptt_active: Option<Arc<AtomicBool>>,
manual_mic_unmuted: Option<Arc<AtomicBool>>,
human_floor: HumanFloor,
output_device: Option<String>,
output_device_changes: tokio::sync::watch::Receiver<Option<String>>,
) -> Result<(Self, tokio_mpsc::Receiver<String>), String> {
let (audio_tx, audio_rx) = mpsc::sync_channel::<SttAudioInput>(AUDIO_QUEUE_DEPTH);
let (text_tx, text_rx) = tokio_mpsc::channel::<String>(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<Arc<AtomicBool>>,
manual_mic_unmuted: Option<Arc<AtomicBool>>,
human_floor: HumanFloor,
output_device: Option<String>,
output_device_changes: tokio::sync::watch::Receiver<Option<String>>,
) {
// ── 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,
);
}

View File

@@ -200,7 +200,7 @@ impl TtsPipeline {
cancel: Arc<AtomicBool>,
human_floor: HumanFloor,
voice: &str,
output_device: Option<String>,
output_device_changes: tokio::sync::watch::Receiver<Option<String>>,
activity_app: Option<tauri::AppHandle>,
) -> Result<Self, String> {
let (text_tx, text_rx) = mpsc::sync_channel::<QueuedText>(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<Option<String>>,
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<QueuedText>,
human_floor: HumanFloor,
control_state: WorkerControlState,
output_device: Option<String>,
mut output_device_changes: tokio::sync::watch::Receiver<Option<String>>,
activity_app: Option<tauri::AppHandle>,
startup_tx: mpsc::SyncSender<Result<(), String>>,
) {
@@ -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,

View File

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

View File

@@ -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<Option<String>>,
/// Live output-route changes for an active huddle playout loop.
pub output_device_changes: tokio::sync::watch::Sender<Option<String>>,
}
#[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())?;

View File

@@ -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<string | null>(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({
<div className="flex shrink-0 items-center gap-2">
<MicControls
showPttHint={
mode === "main" &&
isPttMode &&
isMuted &&
!pttHintDismissed &&
!isDrawerClosing
}
onPttHintDismiss={dismissPttHint}
isMuted={isMuted}
onToggleMute={toggleMute}
isPttMode={isPttMode}

View File

@@ -20,6 +20,8 @@ type VoiceInputMode = "push_to_talk" | "voice_activity";
type MicControlsProps = {
/** Compact split-button treatment for the sidebar huddle card. */
compact?: boolean;
showPttHint?: boolean;
onPttHintDismiss?: () => 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",
)}
>
<Tooltip>
<Tooltip open={showPttHint ? true : undefined}>
<TooltipTrigger asChild>
<Button
aria-disabled={micUnavailable}
@@ -161,6 +165,7 @@ export function MicControls({
)}
onClick={() => {
if (!micConnected) return;
if (showPttHint) onPttHintDismiss?.();
onToggleMute();
}}
size="icon"
@@ -178,7 +183,15 @@ export function MicControls({
</Button>
</TooltipTrigger>
<TooltipContent className="buzz-huddle-tooltip" side="top">
{isPttMode && !micUnavailable && isEffectivelyMuted ? (
{showPttHint ? (
<span className="flex max-w-64 items-center gap-1.5">
<span>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>to talk — click to switch to open mic</span>
</span>
) : 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">

View File

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

View File

@@ -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;