feat(host): build desktop audio exclusion foundation

This commit is contained in:
2026-08-21 15:39:07 -04:00
parent 5d3da8b006
commit 781defcd84
16 changed files with 2586 additions and 564 deletions
+66 -65
View File
@@ -5,7 +5,7 @@
//! the [`Serve`] fanout binding, and the [`CaptureHandle`] lifecycle — is shared
//! and lives here. Backends call [`spawn`] with just their source-element args.
use anyhow::{Context, Result, bail};
use anyhow::{Context, Result};
use nix::sys::signal::Signal;
use std::process::Stdio;
use std::sync::Arc;
@@ -14,7 +14,7 @@ use std::time::Duration;
use tokio::process::{Child, Command};
use tokio::time::timeout;
use super::audio::Routing;
use super::audio_plan::CapturePlan;
use super::health;
use super::quality::EffectiveQuality;
use super::serve::Serve;
@@ -119,7 +119,7 @@ impl Drop for CaptureProcess {
pub(super) struct CaptureHandle {
gst: Option<CaptureProcess>,
audio: Option<Routing>,
audio: Option<CapturePlan>,
serve: Option<Serve>,
stopping: Arc<AtomicBool>,
}
@@ -142,8 +142,8 @@ impl CaptureHandle {
if let Some(mut gst) = self.gst.take() {
gst.shutdown().await;
}
if let Some(audio) = self.audio.take() {
audio.shutdown().await;
if let Some(audio_plan) = self.audio.take() {
audio_plan.shutdown().await;
}
if let Some(serve) = self.serve.take() {
serve.shutdown().await;
@@ -155,7 +155,7 @@ impl Drop for CaptureHandle {
fn drop(&mut self) {
self.stopping.store(true, Ordering::Release);
// CaptureProcess kills the whole process group and poisons the host;
// Routing's and Serve's own Drop impls handle their respective layers.
// the typed plan's inner owner and Serve handle their own Drop layers.
}
}
@@ -176,8 +176,8 @@ pub(super) async fn spawn(
health: health::Reporter,
after_spawn: impl FnOnce(),
) -> Result<CaptureHandle> {
let (audio_routing, audio_device) = setup_audio(opts, health.clone()).await?;
let args = build_args(&source_args, &audio_device, opts, quality, source_dims);
let audio_plan = CapturePlan::start(opts, health.clone()).await?;
let args = build_args(&source_args, &audio_plan, opts, quality, source_dims);
let mut gst_cmd = Command::new("gst-launch-1.0");
gst_cmd
@@ -207,42 +207,12 @@ pub(super) async fn spawn(
Ok(CaptureHandle {
gst: Some(gst),
audio: audio_routing,
audio: Some(audio_plan),
serve: Some(serve),
stopping,
})
}
/// Decide whether per-app audio routing is active and produce the `device=…`
/// argument for `pulsesrc`. Routing activates when either `--app` is set
/// (per-stream rerouting to a per-PID null-sink) or `PIXELPASS_AUDIO_VIA_NULL_SINK=1`
/// is set (no app filter — captures everything via the null-sink, used for
/// dogfooding the loopback path). Otherwise we capture the default sink's
/// monitor (system audio out), not the default source (the mic).
async fn setup_audio(
opts: &HostOpts,
health: health::Reporter,
) -> Result<(Option<Routing>, String)> {
let routing_requested =
opts.app.is_some() || std::env::var_os("PIXELPASS_AUDIO_VIA_NULL_SINK").is_some();
let audio_routing = if routing_requested {
Some(
Routing::start(opts, health)
.await
.context("audio routing setup failed")?,
)
} else {
None
};
let audio_device = if let Some(r) = &audio_routing {
format!("device={}.monitor", r.sink_name())
} else {
let default = default_audio_monitor().await?;
format!("device={default}")
};
Ok((audio_routing, audio_device))
}
/// Build the full gst-launch argument vector: MPEG-TS mux + fdsink, then the
/// video branch (caller's `source` → videorate cap → optional downscale →
/// encoder → h264parse → mux.), then the audio branch (pulsesrc → AAC → mux.).
@@ -252,7 +222,7 @@ async fn setup_audio(
/// wants I420).
fn build_args(
source: &[String],
audio_device: &str,
audio_plan: &CapturePlan,
opts: &HostOpts,
quality: &EffectiveQuality,
source_dims: Option<(u32, u32)>,
@@ -406,7 +376,7 @@ fn build_args(
// not the default source (which is the mic).
args.extend([
"pulsesrc".into(),
audio_device.to_string(),
audio_plan.gst_device_arg(),
"do-timestamp=true".into(),
"!".into(),
"queue".into(),
@@ -428,35 +398,66 @@ fn build_args(
args
}
async fn default_audio_monitor() -> Result<String> {
let output = Command::new("pactl")
.arg("get-default-sink")
.output()
.await
.context(
"failed to run `pactl get-default-sink` (install pulseaudio-utils or pipewire-pulse)",
)?;
if !output.status.success() {
bail!(
"pactl get-default-sink failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let sink = String::from_utf8(output.stdout)
.context("default sink name was not UTF-8")?
.trim()
.to_string();
if sink.is_empty() {
bail!("pactl get-default-sink returned no name (is a sound server running?)");
}
Ok(format!("{sink}.monitor"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::{CaptureMode, Quality};
use std::time::{Duration, Instant};
fn legacy_opts() -> HostOpts {
HostOpts {
window: false,
app: None,
strict_audio: false,
display_server: None,
quality: Quality::Source,
bitrate: None,
framerate: None,
max_height: None,
no_hwencode: false,
max_viewers: None,
interactive: false,
capture_mode: CaptureMode::Legacy,
legacy_null_sink: false,
relay: None,
}
}
#[test]
fn legacy_desktop_audio_tail_is_byte_identical() {
let opts = legacy_opts();
let quality = super::super::quality::resolve(&opts, 1);
let plan = CapturePlan::legacy_fixture("alsa_output.fixture.monitor");
let args = build_args(&["ximagesrc".to_string()], &plan, &opts, &quality, None);
let audio_start = args
.iter()
.position(|arg| arg == "pulsesrc")
.expect("pipeline has an audio branch");
assert_eq!(
&args[audio_start..],
[
"pulsesrc",
"device=alsa_output.fixture.monitor",
"do-timestamp=true",
"!",
"queue",
"!",
"audioconvert",
"!",
"audioresample",
"!",
"audio/x-raw,rate=48000,channels=2",
"!",
"avenc_aac",
"bitrate=128000",
"!",
"aacparse",
"!",
"mux.",
]
);
}
fn process_is_running(pid: u32) -> bool {
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
return false;