From 646f35d3eb54eddb93bec247e56da5a9d768e5dd Mon Sep 17 00:00:00 2001 From: Mollusk Date: Fri, 26 Jun 2026 22:09:58 -0400 Subject: [PATCH] host/audio: emit initial app_audio "lost" at strict capture start (A23 P2/F1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In strict per-app mode the default-sink loopback is suppressed, so until the chosen app's first stream routes the viewer hears silence. Previously no event fired for an app that never routed (`lost` only fires on an N→0 transition after a prior route), so peerspeak couldn't warn — the share looked normal but was silent. Emit a `lost` at capture start (lazy, on first viewer) when, and only when, `--app` + `--strict-audio` are both set; whole-desktop and best-effort modes keep audio flowing via the loopback and emit nothing. Factored the emit decision into the pure, unit-tested `initial_app_audio_state`; derive Debug/PartialEq/Eq on AppAudioState so it can be asserted on. Co-Authored-By: Claude Opus 4.8 --- src/common/output.rs | 2 +- src/host/audio.rs | 20 ++++++++++++++++++++ src/host/mod.rs | 16 ++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/common/output.rs b/src/common/output.rs index 87129da..2431d00 100644 --- a/src/common/output.rs +++ b/src/common/output.rs @@ -72,7 +72,7 @@ pub enum CaptureState { Stopped, } -#[derive(Serialize, Clone, Copy)] +#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AppAudioState { Routed, diff --git a/src/host/audio.rs b/src/host/audio.rs index 2dde6f6..214e9b5 100644 --- a/src/host/audio.rs +++ b/src/host/audio.rs @@ -165,6 +165,16 @@ impl Routing { routing.event_task = Some(event_task); } + // Strict per-app mode suppresses the default-sink loopback, so until the + // chosen app's first stream routes the viewer hears *silence*. Emit an + // initial `lost` at capture start (capture is lazy — this runs on the + // first viewer) so the front-end can warn from the outset rather than + // only after an app that *was* routed later stops (audit A23 P2/F1): + // `LastRoutedStreamGone`→`lost` never fires for an app that never routed. + if let Some(state) = initial_app_audio_state(opts) { + crate::common::output::emit(crate::common::output::Event::AppAudio { state }); + } + Ok(routing) } @@ -206,6 +216,16 @@ impl Drop for Routing { } } +/// The app-audio state to announce at capture start, if any. Only strict per-app +/// mode warrants one: there the loopback is suppressed, so the viewer hears +/// silence until the chosen app's first stream routes — surface that as an +/// initial `lost`. In every other mode (whole-desktop, or best-effort app +/// filtering) the loopback keeps audio flowing from the outset, so there is no +/// initial gap to report. Pure: no I/O, so the emit decision is unit-testable. +pub(super) fn initial_app_audio_state(opts: &HostOpts) -> Option { + (opts.app.is_some() && opts.strict_audio).then_some(crate::common::output::AppAudioState::Lost) +} + // ────────────────────────────────────────────────────────────────────── // App enumeration (interactive picker source) // ────────────────────────────────────────────────────────────────────── diff --git a/src/host/mod.rs b/src/host/mod.rs index d9bd478..ddaeaa0 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -535,4 +535,20 @@ mod tests { ); assert_eq!(capture_summary(&opts(None, true)), "fullscreen + system-audio"); } + + #[test] + fn initial_app_audio_is_lost_only_in_strict_app_mode() { + use crate::common::output::AppAudioState; + use crate::host::audio::initial_app_audio_state; + // Strict + app: announce silence up front (loopback suppressed). + assert_eq!( + initial_app_audio_state(&opts(Some("Firefox"), true)), + Some(AppAudioState::Lost) + ); + // Best-effort app (no strict): loopback covers the gap → no initial event. + assert_eq!(initial_app_audio_state(&opts(Some("Firefox"), false)), None); + // Whole-desktop (strict is ignored without --app): no per-app events. + assert_eq!(initial_app_audio_state(&opts(None, true)), None); + assert_eq!(initial_app_audio_state(&opts(None, false)), None); + } }