host/audio: per-stream routing via libpipewire (session 3 of 4)

When opts.app is set, a dedicated OS thread runs a libpipewire
MainLoop, subscribes to the registry, and writes target.object to
the "default" metadata so WirePlumber reroutes matching streams to
our per-PID null-sink. Activation is now opts.app.is_some() OR the
existing PIXELPASS_AUDIO_VIA_NULL_SINK env var (kept for
no-filter dogfooding).

Threading: tokio side spawns a std::thread; the two sides bridge via
pipewire::channel for cmd→thread (Shutdown) and tokio::sync::mpsc
for event→tokio (FirstRoutedStream). Cross-thread quit goes through
the libpipewire channel so MainLoop is only mutated from its own
thread. Shutdown clears target.object on every routed stream before
quitting so WirePlumber doesn't log orphans.

Routing decisions:
- Filter is case-insensitive equality on application.name (predictable;
  no surprise matches from substring).
- target.object is written as Spa:Id with the sink's object.serial.
- Default-sink loopback stays loaded until the first stream is
  actually routed — avoids viewer silence if the user picks an app
  that isn't producing sound yet. On first route, the event task
  takes() the loopback module ID and unloads it.

Session 2 picker explainer + (app pick saved: ...) banner softening
both removed; banner is back to plain app-audio=NAME.

Verified end-to-end cross-machine: desktop host with Strawberry
selected, laptop viewer over mpv. Strawberry audible on the laptop;
YouTube playback started on the desktop was NOT audible on the
laptop. Routing isolates the filtered app.

Session 4 still open: recreate loopback when the last filtered stream
disappears (avoid silence), handle app-disappears-mid-session,
multi-instance, --repair coupling for orphan sink cleanup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 15:56:44 -04:00
parent 339a9d49e4
commit a144665f41
5 changed files with 343 additions and 57 deletions
-7
View File
@@ -40,11 +40,6 @@ pub async fn run(cli: Cli) -> Result<()> {
/// producing audio (deduped by `application.name`); user picks one or
/// the "all system audio" default. Bypassed when `--app NAME` was given
/// on the CLI.
///
/// Per-stream routing isn't wired yet — session 3 owns activation. For
/// now the picker plumbs the choice into `HostOpts.app` and the host
/// banner records it, but audio capture is still system-wide. The
/// explainer beneath the prompt sets that expectation honestly.
fn pick_app(theme: &ColorfulTheme) -> Result<Option<String>> {
let apps = match host::audio::list_playing_apps() {
Ok(a) => a,
@@ -61,8 +56,6 @@ fn pick_app(theme: &ColorfulTheme) -> Result<Option<String>> {
eprintln!("No other apps are currently producing audio.");
eprintln!("Start your game / music / call first if you want to pick it specifically.");
}
eprintln!("Note: per-app routing isn't live yet — all choices currently capture");
eprintln!("system audio. The selection is saved for when routing ships.");
eprintln!();
let mut items = vec!["Capture all system audio (default)".to_string()];