feat(host): build desktop audio exclusion foundation
This commit is contained in:
+66
-65
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user