host/audio: emit initial app_audio "lost" at strict capture start (A23 P2/F1)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 22:09:58 -04:00
parent ff7daee34e
commit 646f35d3eb
3 changed files with 37 additions and 1 deletions
+1 -1
View File
@@ -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,
+20
View File
@@ -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<crate::common::output::AppAudioState> {
(opts.app.is_some() && opts.strict_audio).then_some(crate::common::output::AppAudioState::Lost)
}
// ──────────────────────────────────────────────────────────────────────
// App enumeration (interactive picker source)
// ──────────────────────────────────────────────────────────────────────
+16
View File
@@ -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);
}
}