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:
+16
@@ -27,6 +27,17 @@ pub struct Cli {
|
||||
#[arg(long, value_name = "NAME")]
|
||||
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.
|
||||
#[arg(long, value_enum)]
|
||||
pub display_server: Option<DisplayServerArg>,
|
||||
@@ -135,6 +146,10 @@ pub enum Quality {
|
||||
pub struct HostOpts {
|
||||
pub window: bool,
|
||||
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>,
|
||||
/// 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.
|
||||
|
||||
@@ -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"}"#);
|
||||
}
|
||||
}
|
||||
|
||||
+48
-13
@@ -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;
|
||||
}
|
||||
|
||||
+43
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +205,7 @@ mod tests {
|
||||
HostOpts {
|
||||
window: false,
|
||||
app: None,
|
||||
strict_audio: false,
|
||||
display_server: None::<DisplayServerArg>,
|
||||
quality,
|
||||
bitrate: None,
|
||||
|
||||
Reference in New Issue
Block a user