diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 1a2101a2d..3efe04ea6 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -48,18 +48,13 @@ pub struct AppState { pub channel_templates_store_lock: Mutex<()>, pub managed_agent_processes: Mutex>, pub huddle_state: Mutex, - pub tts_settings: Mutex, - pub tts_settings_load_error: Mutex>, - pub tts_settings_transition: tokio::sync::Mutex<()>, + pub huddle_audio: crate::huddle::tts_settings::HuddleAudioSettingsState, /// Tauri app handle — stored after setup so huddle commands can emit /// `huddle-state-changed` events without needing the handle threaded /// through every call site. /// /// Set once during `setup()` in `lib.rs`; never cleared. pub app_handle: Mutex>, - /// Selected audio output device name. `None` = system default. - /// Used by `connect_audio_relay` and TTS pipeline when opening sinks. - pub audio_output_device: Mutex>, /// Port of the localhost media streaming proxy (set during setup). pub media_proxy_port: AtomicU16, /// Set when identity resolution detected a "keyring-locked" state: the @@ -216,11 +211,8 @@ pub fn build_app_state() -> AppState { managed_agent_processes: Mutex::new(HashMap::new()), session_config_cache: Mutex::new(HashMap::new()), huddle_state: Mutex::new(HuddleState::default()), - tts_settings: Mutex::new(Default::default()), - tts_settings_load_error: Mutex::new(None), - tts_settings_transition: tokio::sync::Mutex::new(()), + huddle_audio: Default::default(), app_handle: Mutex::new(None), - audio_output_device: Mutex::new(None), media_proxy_port: AtomicU16::new(0), prevent_sleep: Arc::new(Mutex::new( crate::prevent_sleep::PreventSleepState::default(), diff --git a/desktop/src-tauri/src/huddle/audio_output.rs b/desktop/src-tauri/src/huddle/audio_output.rs index dbd09353d..34dec5309 100644 --- a/desktop/src-tauri/src/huddle/audio_output.rs +++ b/desktop/src-tauri/src/huddle/audio_output.rs @@ -39,7 +39,8 @@ fn list_audio_output_devices_blocking() -> Result, String #[tauri::command] pub fn set_audio_output_device(name: String, state: State<'_, AppState>) -> Result<(), String> { let mut guard = state - .audio_output_device + .huddle_audio + .output_device .lock() .map_err(|e| e.to_string())?; *guard = if name.is_empty() { None } else { Some(name) }; @@ -50,7 +51,8 @@ pub fn set_audio_output_device(name: String, state: State<'_, AppState>) -> Resu #[tauri::command] pub fn get_audio_output_device(state: State<'_, AppState>) -> Result { let guard = state - .audio_output_device + .huddle_audio + .output_device .lock() .map_err(|e| e.to_string())?; Ok(guard.clone().unwrap_or_default()) diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index daf0c89b4..852e84287 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -220,12 +220,14 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result>(50); let output_device_name = state - .audio_output_device + .huddle_audio + .output_device .lock() .unwrap_or_else(|e| e.into_inner()) .clone(); diff --git a/desktop/src-tauri/src/huddle/tts_settings.rs b/desktop/src-tauri/src/huddle/tts_settings.rs index 3ddc75b76..a3e931dff 100644 --- a/desktop/src-tauri/src/huddle/tts_settings.rs +++ b/desktop/src-tauri/src/huddle/tts_settings.rs @@ -8,7 +8,7 @@ use std::{ path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, Mutex}, time::Duration, }; @@ -37,6 +37,16 @@ type VoiceChangeWait = ( const VOICE_AVAILABILITY_BUNDLED: &str = "bundled"; const VOICE_AVAILABILITY_INSTALLED: &str = "installed"; +/// Installation-global huddle audio and speech preferences. +#[derive(Default)] +pub struct HuddleAudioSettingsState { + pub tts: Mutex, + pub tts_load_error: Mutex>, + pub tts_transition: tokio::sync::Mutex<()>, + /// Selected huddle output device. `None` uses the system default. + pub output_device: Mutex>, +} + #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct VoiceRegistryEntry { @@ -261,7 +271,8 @@ pub fn load_for_app(app: &AppHandle) -> (TtsSettings, Option) { #[tauri::command] pub fn get_tts_settings(state: State<'_, AppState>) -> Result { if let Some(error) = state - .tts_settings_load_error + .huddle_audio + .tts_load_error .lock() .map_err(|lock_error| format!("text-to-speech settings lock poisoned: {lock_error}"))? .clone() @@ -271,7 +282,8 @@ pub fn get_tts_settings(state: State<'_, AppState>) -> Result Vec { fn ensure_settings_writable(state: &AppState) -> Result<(), String> { if let Some(error) = state - .tts_settings_load_error + .huddle_audio + .tts_load_error .lock() .map_err(|lock_error| format!("text-to-speech settings lock poisoned: {lock_error}"))? .as_ref() @@ -321,7 +334,8 @@ fn disable_tts_runtime(state: &AppState) -> Result<(), String> { fn commit_effective_off(state: &AppState) -> Result<(), String> { state - .tts_settings + .huddle_audio + .tts .lock() .map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))? .agent_text_to_speech = false; @@ -370,7 +384,8 @@ async fn apply_tts_settings( save_to_path(&settings_path(app)?, &settings)?; *state - .tts_settings + .huddle_audio + .tts .lock() .map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))? = settings.clone(); @@ -399,7 +414,8 @@ async fn apply_tts_settings( fn current_settings(state: &AppState) -> Result { state - .tts_settings + .huddle_audio + .tts .lock() .map_err(|error| format!("text-to-speech settings lock poisoned: {error}")) .map(|settings| settings.clone()) @@ -448,9 +464,10 @@ pub async fn set_tts_enabled( app: AppHandle, state: State<'_, AppState>, ) -> Result { - let transition = state.tts_settings_transition.lock().await; + let transition = state.huddle_audio.tts_transition.lock().await; let mut settings = state - .tts_settings + .huddle_audio + .tts .lock() .map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))? .clone(); @@ -491,9 +508,10 @@ pub async fn set_pocket_voice( app: AppHandle, state: State<'_, AppState>, ) -> Result { - let transition = state.tts_settings_transition.lock().await; + let transition = state.huddle_audio.tts_transition.lock().await; let settings = state - .tts_settings + .huddle_audio + .tts .lock() .map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))? .clone(); @@ -525,7 +543,8 @@ pub async fn preview_pocket_voice( } let model_dir = models::tts_model_dir().ok_or("Pocket voice files are unavailable")?; let output_device = state - .audio_output_device + .huddle_audio + .output_device .lock() .unwrap_or_else(|error| error.into_inner()) .clone(); @@ -785,7 +804,7 @@ mod tests { // This models the next command after the OFF save fails: it must merge // from effective memory state, not the stale last-persisted ON value. - let current = state.tts_settings.lock().expect("settings").clone(); + let current = state.huddle_audio.tts.lock().expect("settings").clone(); let voice_update = settings_with_pocket_voice(current, EVE_VOICE_KEY).expect("available voice"); assert!(!voice_update.agent_text_to_speech); @@ -795,16 +814,17 @@ mod tests { fn failed_disabled_voice_save_does_not_change_the_remembered_voice() { let state = crate::app_state::build_app_state(); state - .tts_settings + .huddle_audio + .tts .lock() .expect("settings") .agent_text_to_speech = false; - let current = state.tts_settings.lock().expect("settings").clone(); + let current = state.huddle_audio.tts.lock().expect("settings").clone(); let unsaved = settings_with_pocket_voice(current, EVE_VOICE_KEY).expect("available voice"); // This is the only pre-persistence mutation for an OFF candidate. commit_effective_off(&state).expect("commit effective OFF state"); - let remembered = state.tts_settings.lock().expect("settings").clone(); + let remembered = state.huddle_audio.tts.lock().expect("settings").clone(); assert_eq!(remembered.voice_preferences, vec![MARY_VOICE_KEY]); assert_eq!(unsaved.voice_preferences, vec![EVE_VOICE_KEY]); } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 303463a2f..88f4cb7a0 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -454,10 +454,10 @@ pub fn run() { let (tts_settings, tts_settings_load_error) = huddle::tts_settings::load_for_app(&app_handle); - if let Ok(mut guard) = state.tts_settings.lock() { + if let Ok(mut guard) = state.huddle_audio.tts.lock() { *guard = tts_settings.clone(); } - if let Ok(mut guard) = state.tts_settings_load_error.lock() { + if let Ok(mut guard) = state.huddle_audio.tts_load_error.lock() { *guard = tts_settings_load_error; } if let Ok(mut huddle) = state.huddle_state.lock() { diff --git a/desktop/src/shared/api/readOnlyRelayClient.ts b/desktop/src/shared/api/readOnlyRelayClient.ts index c7446f4e7..121b00c80 100644 --- a/desktop/src/shared/api/readOnlyRelayClient.ts +++ b/desktop/src/shared/api/readOnlyRelayClient.ts @@ -12,7 +12,7 @@ import { AUTH_TIMEOUT_MS, HISTORY_TIMEOUT_MS, PUBLISH_TIMEOUT_MS, -} from "@/shared/api/relayClientSession"; +} from "@/shared/api/relayClientTimings"; type PendingHistory = { events: RelayEvent[]; diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 6be7fe04a..e89874b2e 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -51,34 +51,19 @@ import { shouldScheduleReconnect, } from "@/shared/api/relayReconnectPolicy"; import { RelayStallWatchdog } from "@/shared/api/relayStallWatchdog"; +import { + AUTH_TIMEOUT_MS, + BACKOFF_RESET_STABLE_MS, + EVENT_BATCH_MS, + HISTORY_TIMEOUT_MS, + PUBLISH_TIMEOUT_MS, + RECONNECT_BASE_DELAY_MS, + RECONNECT_MAX_DELAY_MS, + STALL_CHECK_INTERVAL_MS, + STALL_IDLE_TIMEOUT_MS, +} from "@/shared/api/relayClientTimings"; import { closeWebSocket } from "@/shared/api/relayWebSocketClose"; import { buildThreadReferenceTags } from "@/features/messages/lib/threading"; -const RECONNECT_BASE_DELAY_MS = 1_000, - RECONNECT_MAX_DELAY_MS = 30_000, - EVENT_BATCH_MS = 16; - -/** - * Op-level timeout constants. Raised from 8 s to 25 s to survive degraded - * networks where TLS handshakes and DNS resolution can take 3–10 s. - */ -export const AUTH_TIMEOUT_MS = 25_000; -export const HISTORY_TIMEOUT_MS = 25_000; -export const PUBLISH_TIMEOUT_MS = 25_000; - -/** - * The connection must remain stable for this long after a successful AUTH - * before the reconnect backoff delay resets to its base value. Stability- - * gated reset prevents repeated fast reconnects (flapping) from erasing the - * backoff that throttles them. - */ -export const BACKOFF_RESET_STABLE_MS = 60_000; - -/** - * Passive liveness check. The relay sends heartbeat pings every 30s; if no - * inbound frame arrives for two heartbeat windows, treat the socket as stalled. - */ -const STALL_CHECK_INTERVAL_MS = 10_000; -const STALL_IDLE_TIMEOUT_MS = 60_000; export class RelayClient { private wsId: number | null = null; diff --git a/desktop/src/shared/api/relayClientTimings.ts b/desktop/src/shared/api/relayClientTimings.ts new file mode 100644 index 000000000..dbe85a835 --- /dev/null +++ b/desktop/src/shared/api/relayClientTimings.ts @@ -0,0 +1,20 @@ +export const RECONNECT_BASE_DELAY_MS = 1_000; +export const RECONNECT_MAX_DELAY_MS = 30_000; +export const EVENT_BATCH_MS = 16; + +/** + * Op-level timeouts tolerate degraded networks where TLS handshakes and DNS + * resolution can take several seconds. + */ +export const AUTH_TIMEOUT_MS = 25_000; +export const HISTORY_TIMEOUT_MS = 25_000; +export const PUBLISH_TIMEOUT_MS = 25_000; + +/** + * A stability-gated reset prevents reconnect flapping from erasing backoff. + */ +export const BACKOFF_RESET_STABLE_MS = 60_000; + +/** Passive liveness thresholds for the relay heartbeat stream. */ +export const STALL_CHECK_INTERVAL_MS = 10_000; +export const STALL_IDLE_TIMEOUT_MS = 60_000;