host/audio: per-PID PipeWire null-sink + loopback scaffolding

Session 1 of the per-app audio routing feature. Adds host/audio.rs
with a Routing struct that owns the lifecycle of two pactl-loaded
modules: a per-PID null-sink (pixelpass_capture_<pid>) and a loopback
mirroring @DEFAULT_SINK@.monitor into it at 20ms latency. Activated
by PIXELPASS_AUDIO_VIA_NULL_SINK=1 — kept hidden behind an env var
because without per-stream filtering (session 3) the user-facing
behavior of --app foo would be identical to no flag, which would
mislead users about what the flag does.

When the env var is set, wayland::start substitutes the gst pulsesrc
device from {DEFAULT_SINK}.monitor to pixelpass_capture_<pid>.monitor;
audio still works end-to-end via the loopback. CaptureHandle owns the
Routing alongside gst and serve; teardown order is gst → audio → serve
so streams unlink from the null-sink before the sink is destroyed.

Lifecycle is via pactl shell-outs rather than pipewire-rs. Null-sink
+ loopback are one-shot graph mutations with no event subscription;
the libpipewire route would mean dragging a MainLoop thread in for no
benefit until session 3 needs stream events.

Known cosmetic: the null-sink appears in Plasma's audio mixer as a
user-facing volume slider. Pactl's sink_properties= quoting is fiddly
enough that the device.hidden=true fix is parked for a follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 05:11:48 -04:00
parent 9625511c65
commit 8d32ded412
3 changed files with 164 additions and 5 deletions
+27 -5
View File
@@ -19,11 +19,13 @@ use std::time::Duration;
use tokio::process::{Child, Command};
use tokio::time::timeout;
use super::audio::Routing;
use super::serve::Serve;
use crate::cli::HostOpts;
pub struct CaptureHandle {
gst: Option<Child>,
audio: Option<Routing>,
serve: Option<Serve>,
}
@@ -36,8 +38,9 @@ impl CaptureHandle {
}
/// Graceful teardown: SIGTERM gst, give it ~1s to exit, then SIGKILL,
/// then tear down the serve layer. The serve reader will see EOF on
/// gst stdout and exit on its own; serve.shutdown() is the backstop.
/// unload audio routing (if any), then tear down the serve layer.
/// The serve reader will see EOF on gst stdout and exit on its own;
/// serve.shutdown() is the backstop.
pub async fn shutdown(mut self) {
if let Some(child) = self.gst.as_mut()
&& let Some(pid) = child.id()
@@ -48,6 +51,9 @@ impl CaptureHandle {
let _ = timeout(Duration::from_millis(1000), child.wait()).await;
let _ = child.start_kill();
}
if let Some(audio) = self.audio.take() {
audio.shutdown();
}
if let Some(serve) = self.serve.take() {
serve.shutdown().await;
}
@@ -59,7 +65,7 @@ impl Drop for CaptureHandle {
if let Some(child) = self.gst.as_mut() {
let _ = child.start_kill();
}
// Serve's own Drop aborts its tasks.
// Routing's and Serve's own Drop impls handle the rest.
}
}
@@ -112,8 +118,23 @@ pub async fn start(opts: &HostOpts) -> Result<CaptureHandle> {
// no codec assumptions.
let key_interval = (opts.framerate * 2).to_string();
let bitrate = opts.bitrate.to_string();
let audio_monitor = default_audio_monitor().await?;
let audio_device = format!("device={audio_monitor}");
// PIXELPASS_AUDIO_VIA_NULL_SINK=1 routes audio through a per-PID
// PipeWire null-sink + default-sink loopback instead of tapping the
// default sink's monitor directly. Same audio captured either way at
// this stage; the null-sink path is groundwork for per-app filtering
// in a follow-up.
let audio_routing = if std::env::var_os("PIXELPASS_AUDIO_VIA_NULL_SINK").is_some() {
Some(Routing::start().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}")
};
let mut gst_cmd = Command::new("gst-launch-1.0");
gst_cmd
.args([
@@ -196,6 +217,7 @@ pub async fn start(opts: &HostOpts) -> Result<CaptureHandle> {
Ok(CaptureHandle {
gst: Some(gst),
audio: audio_routing,
serve: Some(serve),
})
}