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
+136
View File
@@ -0,0 +1,136 @@
//! Per-app audio routing scaffolding.
//!
//! Session 1: owns a per-PID PipeWire null-sink and a loopback that
//! mirrors the default sink's monitor into it, so the gst pipeline can
//! `pulsesrc` from `pixelpass_capture_<pid>.monitor` and still hear all
//! system audio. No per-stream filtering yet — that lands in session 3,
//! at which point the loopback gets dropped once any stream is being
//! routed exclusively.
//!
//! Module lifecycle is via `pactl load-module` / `pactl unload-module`
//! shell-outs. PipeWire offers a Rust binding (already in deps) but
//! null-sink + loopback are one-shot graph mutations with no event
//! subscription; the shell-out is simpler and avoids dragging a
//! pipewire MainLoop thread into the picture until session 3 actually
//! needs it for stream events.
use anyhow::{Context, Result, bail};
use std::process::Command;
/// Owns the pactl module IDs for the null-sink and its loopback. Drop
/// unloads both as a backstop; prefer calling [`Routing::shutdown`]
/// explicitly so failures get logged.
pub struct Routing {
sink_module: Option<u32>,
loopback_module: Option<u32>,
sink_name: String,
}
impl Routing {
/// Create a per-PID null-sink and mirror the default sink's monitor
/// into it. Returns once both modules are loaded.
pub fn start() -> Result<Self> {
let pid = std::process::id();
let sink_name = format!("pixelpass_capture_{pid}");
let sink_module = load_module(&[
"module-null-sink",
&format!("sink_name={sink_name}"),
])
.context("failed to load module-null-sink")?;
let mut routing = Self {
sink_module: Some(sink_module),
loopback_module: None,
sink_name: sink_name.clone(),
};
// 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)")?;
routing.loopback_module = Some(loopback_module);
tracing::info!(
sink_module,
loopback_module,
%sink_name,
"audio routing: null-sink + loopback ready"
);
Ok(routing)
}
pub fn sink_name(&self) -> &str {
&self.sink_name
}
/// Unload loopback first, then the null-sink. Order matters: PipeWire
/// can leave zombie links if you destroy a sink with active inputs.
pub fn shutdown(mut self) {
if let Some(id) = self.loopback_module.take() {
unload_module(id);
}
if let Some(id) = self.sink_module.take() {
unload_module(id);
}
}
}
impl Drop for Routing {
fn drop(&mut self) {
if let Some(id) = self.loopback_module.take() {
unload_module(id);
}
if let Some(id) = self.sink_module.take() {
unload_module(id);
}
}
}
fn load_module(args: &[&str]) -> Result<u32> {
let output = Command::new("pactl")
.arg("load-module")
.args(args)
.output()
.context("failed to run pactl load-module")?;
if !output.status.success() {
bail!(
"pactl load-module failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let id_str = String::from_utf8(output.stdout)
.context("pactl returned non-UTF-8")?
.trim()
.to_string();
id_str
.parse::<u32>()
.with_context(|| format!("pactl returned unexpected module ID: {id_str:?}"))
}
fn unload_module(id: u32) {
let result = Command::new("pactl")
.arg("unload-module")
.arg(id.to_string())
.output();
match result {
Ok(output) if output.status.success() => {
tracing::info!(module = id, "audio routing: unloaded pactl module");
}
Ok(output) => {
tracing::warn!(
module = id,
stderr = %String::from_utf8_lossy(&output.stderr).trim(),
"audio routing: pactl unload-module exited non-zero"
);
}
Err(e) => {
tracing::warn!(module = id, "audio routing: failed to run pactl unload-module: {e}");
}
}
}