From 85fdebeb66ba71b0107d7ac327962a6c571bae1a Mon Sep 17 00:00:00 2001 From: Mollusk Date: Fri, 26 Jun 2026 17:44:02 -0400 Subject: [PATCH] feat(audio): add --strict-audio + app_audio route-status events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With --app, pixelpass mirrors the default-sink monitor (whole desktop) until the chosen app's streams route, and restores that loopback if the app's audio later stops. That fallback captures everything playing — including a voice call the sharer is in — so a caller watching the share can hear themselves echoed back (peerspeak bug A23: the per-app pick alone is best-effort, not a guarantee). - New --strict-audio flag (HostOpts.strict_audio): with --app, never load the default-sink loopback (not at startup, not on LastRoutedStreamGone). The viewer hears only the chosen app, and silence when it's quiet — never the rest of the desktop. No effect without --app; standalone best-effort behavior is unchanged. - New app_audio JSON event ({"event":"app_audio","state":"routed"|"lost"}), emitted whenever --app is set, so a front-end (peerspeak) can tell when the chosen app's audio is actually live vs. dropped and warn accordingly. - Banner capture summary shows "(strict)" when active. Unknown-event-tolerant: pixelpass's own --gui child parser skips lines it can't deserialize, so app_audio doesn't disturb it. 10 tests (+2: wire-shape + banner), clippy --all-targets clean. Co-Authored-By: Claude Opus 4.8 --- src/cli.rs | 16 ++++++++++++ src/common/output.rs | 34 ++++++++++++++++++++++++ src/host/audio.rs | 61 ++++++++++++++++++++++++++++++++++---------- src/host/mod.rs | 44 +++++++++++++++++++++++++++++++- src/host/quality.rs | 1 + 5 files changed, 142 insertions(+), 14 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 7103324..34280d5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -27,6 +27,17 @@ pub struct Cli { #[arg(long, value_name = "NAME")] pub app: Option, + /// With `--app`, never fall back to whole-desktop audio. By default an + /// app-filtered host mirrors the default sink's monitor until (and again + /// after) the chosen app's streams route, so the viewer isn't left in + /// silence. That fallback also captures everything else playing — including + /// a voice call the sharer is in — so a caller can hear themselves echoed. + /// `--strict-audio` suppresses the fallback entirely: the viewer hears only + /// the chosen app, and silence when it isn't producing audio. Ignored + /// without `--app`. + #[arg(long)] + pub strict_audio: bool, + /// Override display server autodetection. #[arg(long, value_enum)] pub display_server: Option, @@ -135,6 +146,10 @@ pub enum Quality { pub struct HostOpts { pub window: bool, pub app: Option, + /// With `app` set, suppress the whole-desktop loopback fallback so the + /// viewer only ever hears the chosen app (silence when it's quiet). No + /// effect when `app` is None. + pub strict_audio: bool, pub display_server: Option, /// Chosen preset (Auto = derive at startup). Defaults to Auto. pub quality: Quality, @@ -164,6 +179,7 @@ impl Cli { HostOpts { window: self.window, app: self.app, + strict_audio: self.strict_audio, display_server: self.display_server, // No `--quality` and nothing picked interactively → the documented // default, Auto. diff --git a/src/common/output.rs b/src/common/output.rs index 511e69c..87129da 100644 --- a/src/common/output.rs +++ b/src/common/output.rs @@ -58,6 +58,11 @@ pub enum Event<'a> { ViewerRefused { reason: &'a str }, /// Viewer-side: the local player URL is ready to open. Connected { url: &'a str }, + /// Per-app audio routing state (only emitted when `--app` is set). `routed` + /// = the chosen app's audio is now reaching viewers; `lost` = its last + /// stream went away. Under `--strict-audio`, `lost` means viewers currently + /// hear silence; without it, viewers fall back to whole-desktop audio. + AppAudio { state: AppAudioState }, } #[derive(Serialize)] @@ -67,6 +72,13 @@ pub enum CaptureState { Stopped, } +#[derive(Serialize, Clone, Copy)] +#[serde(rename_all = "snake_case")] +pub enum AppAudioState { + Routed, + Lost, +} + /// Emit one event as a JSON line on stdout, flushed. No-op unless JSON /// output was enabled with [`set_json`], so call sites can sprinkle these /// unconditionally without branching. @@ -85,3 +97,25 @@ pub fn emit(event: Event) { Err(e) => tracing::warn!("failed to serialize event: {e}"), } } + +#[cfg(test)] +mod tests { + use super::*; + + // The app_audio event is the wire contract peerspeak parses to drive its + // echo warning; pin the exact shape so a rename here is caught here. + #[test] + fn app_audio_event_wire_shape() { + let routed = serde_json::to_string(&Event::AppAudio { + state: AppAudioState::Routed, + }) + .unwrap(); + assert_eq!(routed, r#"{"event":"app_audio","state":"routed"}"#); + + let lost = serde_json::to_string(&Event::AppAudio { + state: AppAudioState::Lost, + }) + .unwrap(); + assert_eq!(lost, r#"{"event":"app_audio","state":"lost"}"#); + } +} diff --git a/src/host/audio.rs b/src/host/audio.rs index d1ba789..2dde6f6 100644 --- a/src/host/audio.rs +++ b/src/host/audio.rs @@ -55,24 +55,38 @@ impl Routing { let sink_module = load_module(&["module-null-sink", &format!("sink_name={sink_name}")]) .context("failed to load module-null-sink")?; + // In strict per-app mode we never mirror the default sink: the viewer + // must hear *only* the chosen app, never the whole desktop (which would + // leak e.g. a voice call the sharer is in back to viewers — the echo + // bug A23). Without strict mode (whole-desktop share, or best-effort + // app filtering) we load the monitor loopback so the viewer hears + // system audio immediately and during any gap before the app routes. // 20ms loopback latency keeps the mirrored audio tight; pactl's // default of 200ms is enough to be perceptible. - let loopback_module = load_module(&[ - "module-loopback", - "source=@DEFAULT_SINK@.monitor", - &format!("sink={sink_name}"), - "latency_msec=20", - ]) - .context("failed to load module-loopback (null-sink will be cleaned up on Drop)")?; + let strict_app = opts.app.is_some() && opts.strict_audio; + let loopback_module = if strict_app { + None + } else { + Some( + load_module(&[ + "module-loopback", + "source=@DEFAULT_SINK@.monitor", + &format!("sink={sink_name}"), + "latency_msec=20", + ]) + .context("failed to load module-loopback (null-sink cleaned up on Drop)")?, + ) + }; tracing::info!( sink_module, - loopback_module, + ?loopback_module, + strict_app, %sink_name, - "audio routing: null-sink + loopback ready" + "audio routing: null-sink ready (loopback skipped in strict app mode)" ); - let loopback_arc = Arc::new(Mutex::new(Some(loopback_module))); + let loopback_arc = Arc::new(Mutex::new(loopback_module)); let mut routing = Self { sink_module: Some(sink_module), loopback_module: Arc::clone(&loopback_arc), @@ -85,7 +99,9 @@ impl Routing { let (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?; let loopback_for_task = Arc::clone(&loopback_arc); let sink_name_for_task = sink_name.clone(); + let strict = opts.strict_audio; let event_task = tokio::spawn(async move { + use crate::common::output::{self, AppAudioState}; while let Some(ev) = event_rx.recv().await { match ev { Event::FirstRoutedStream => { @@ -96,11 +112,30 @@ impl Routing { ); unload_module(id); } + // Tell the front-end the chosen app's audio is live. + output::emit(output::Event::AppAudio { + state: AppAudioState::Routed, + }); } Event::LastRoutedStreamGone => { - // Routed app exited mid-session. Restore the - // default-sink loopback so the viewer hears - // system audio again instead of silence. + // Routed app exited/paused mid-session. Notify the + // front-end either way; the recovery differs by mode. + output::emit(output::Event::AppAudio { + state: AppAudioState::Lost, + }); + if strict { + // Strict mode: do NOT restore the whole-desktop + // loopback. Viewers hear silence until the app + // produces audio again — never the rest of the + // desktop (call included). + tracing::info!( + "audio routing: strict mode — last routed stream gone, leaving viewers silent" + ); + continue; + } + // Best-effort mode: restore the default-sink loopback + // so the viewer hears system audio again instead of + // silence. if loopback_for_task.lock().unwrap().is_some() { continue; } diff --git a/src/host/mod.rs b/src/host/mod.rs index f18b458..d9bd478 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -488,9 +488,51 @@ fn copy_to_clipboard(text: &str) -> bool { fn capture_summary(opts: &HostOpts) -> String { let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()]; if let Some(app) = &opts.app { - bits.push(format!("app-audio={app}")); + if opts.strict_audio { + bits.push(format!("app-audio={app} (strict)")); + } else { + bits.push(format!("app-audio={app}")); + } } else { bits.push("system-audio".to_string()); } bits.join(" + ") } + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::Quality; + + fn opts(app: Option<&str>, strict_audio: bool) -> HostOpts { + HostOpts { + window: false, + app: app.map(str::to_string), + strict_audio, + display_server: None, + quality: Quality::Auto, + bitrate: None, + framerate: None, + max_height: None, + no_hwencode: false, + max_viewers: None, + interactive: false, + relay: None, + } + } + + #[test] + fn capture_summary_reflects_audio_mode() { + assert_eq!(capture_summary(&opts(None, false)), "fullscreen + system-audio"); + assert_eq!( + capture_summary(&opts(Some("Firefox"), false)), + "fullscreen + app-audio=Firefox" + ); + // strict only shows when an app is selected. + assert_eq!( + capture_summary(&opts(Some("Firefox"), true)), + "fullscreen + app-audio=Firefox (strict)" + ); + assert_eq!(capture_summary(&opts(None, true)), "fullscreen + system-audio"); + } +} diff --git a/src/host/quality.rs b/src/host/quality.rs index ec5615c..a744d7f 100644 --- a/src/host/quality.rs +++ b/src/host/quality.rs @@ -205,6 +205,7 @@ mod tests { HostOpts { window: false, app: None, + strict_audio: false, display_server: None::, quality, bitrate: None,