feat(audio): add --strict-audio + app_audio route-status events

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 17:44:02 -04:00
parent cfc480044f
commit 85fdebeb66
5 changed files with 142 additions and 14 deletions
+16
View File
@@ -27,6 +27,17 @@ pub struct Cli {
#[arg(long, value_name = "NAME")] #[arg(long, value_name = "NAME")]
pub app: Option<String>, pub app: Option<String>,
/// 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. /// Override display server autodetection.
#[arg(long, value_enum)] #[arg(long, value_enum)]
pub display_server: Option<DisplayServerArg>, pub display_server: Option<DisplayServerArg>,
@@ -135,6 +146,10 @@ pub enum Quality {
pub struct HostOpts { pub struct HostOpts {
pub window: bool, pub window: bool,
pub app: Option<String>, pub app: Option<String>,
/// 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<DisplayServerArg>, pub display_server: Option<DisplayServerArg>,
/// Chosen preset (Auto = derive at startup). Defaults to Auto. /// Chosen preset (Auto = derive at startup). Defaults to Auto.
pub quality: Quality, pub quality: Quality,
@@ -164,6 +179,7 @@ impl Cli {
HostOpts { HostOpts {
window: self.window, window: self.window,
app: self.app, app: self.app,
strict_audio: self.strict_audio,
display_server: self.display_server, display_server: self.display_server,
// No `--quality` and nothing picked interactively → the documented // No `--quality` and nothing picked interactively → the documented
// default, Auto. // default, Auto.
+34
View File
@@ -58,6 +58,11 @@ pub enum Event<'a> {
ViewerRefused { reason: &'a str }, ViewerRefused { reason: &'a str },
/// Viewer-side: the local player URL is ready to open. /// Viewer-side: the local player URL is ready to open.
Connected { url: &'a str }, 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)] #[derive(Serialize)]
@@ -67,6 +72,13 @@ pub enum CaptureState {
Stopped, 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 /// 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 /// output was enabled with [`set_json`], so call sites can sprinkle these
/// unconditionally without branching. /// unconditionally without branching.
@@ -85,3 +97,25 @@ pub fn emit(event: Event) {
Err(e) => tracing::warn!("failed to serialize event: {e}"), 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"}"#);
}
}
+48 -13
View File
@@ -55,24 +55,38 @@ impl Routing {
let sink_module = load_module(&["module-null-sink", &format!("sink_name={sink_name}")]) let sink_module = load_module(&["module-null-sink", &format!("sink_name={sink_name}")])
.context("failed to load module-null-sink")?; .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 // 20ms loopback latency keeps the mirrored audio tight; pactl's
// default of 200ms is enough to be perceptible. // default of 200ms is enough to be perceptible.
let loopback_module = load_module(&[ let strict_app = opts.app.is_some() && opts.strict_audio;
"module-loopback", let loopback_module = if strict_app {
"source=@DEFAULT_SINK@.monitor", None
&format!("sink={sink_name}"), } else {
"latency_msec=20", Some(
]) load_module(&[
.context("failed to load module-loopback (null-sink will be cleaned up on Drop)")?; "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!( tracing::info!(
sink_module, sink_module,
loopback_module, ?loopback_module,
strict_app,
%sink_name, %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 { let mut routing = Self {
sink_module: Some(sink_module), sink_module: Some(sink_module),
loopback_module: Arc::clone(&loopback_arc), 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 (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?;
let loopback_for_task = Arc::clone(&loopback_arc); let loopback_for_task = Arc::clone(&loopback_arc);
let sink_name_for_task = sink_name.clone(); let sink_name_for_task = sink_name.clone();
let strict = opts.strict_audio;
let event_task = tokio::spawn(async move { let event_task = tokio::spawn(async move {
use crate::common::output::{self, AppAudioState};
while let Some(ev) = event_rx.recv().await { while let Some(ev) = event_rx.recv().await {
match ev { match ev {
Event::FirstRoutedStream => { Event::FirstRoutedStream => {
@@ -96,11 +112,30 @@ impl Routing {
); );
unload_module(id); unload_module(id);
} }
// Tell the front-end the chosen app's audio is live.
output::emit(output::Event::AppAudio {
state: AppAudioState::Routed,
});
} }
Event::LastRoutedStreamGone => { Event::LastRoutedStreamGone => {
// Routed app exited mid-session. Restore the // Routed app exited/paused mid-session. Notify the
// default-sink loopback so the viewer hears // front-end either way; the recovery differs by mode.
// system audio again instead of silence. 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() { if loopback_for_task.lock().unwrap().is_some() {
continue; continue;
} }
+43 -1
View File
@@ -488,9 +488,51 @@ fn copy_to_clipboard(text: &str) -> bool {
fn capture_summary(opts: &HostOpts) -> String { fn capture_summary(opts: &HostOpts) -> String {
let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()]; let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()];
if let Some(app) = &opts.app { 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 { } else {
bits.push("system-audio".to_string()); bits.push("system-audio".to_string());
} }
bits.join(" + ") 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");
}
}
+1
View File
@@ -205,6 +205,7 @@ mod tests {
HostOpts { HostOpts {
window: false, window: false,
app: None, app: None,
strict_audio: false,
display_server: None::<DisplayServerArg>, display_server: None::<DisplayServerArg>,
quality, quality,
bitrate: None, bitrate: None,