From 339a9d49e4e667396bff6e956a027a40422b5521 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Fri, 22 May 2026 15:32:00 -0400 Subject: [PATCH] host/audio: app enumeration + interactive picker (session 2 of 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_playing_apps() shells out to `pactl -f json list sink-inputs`, parses with serde_json, dedupes by application.name (BTreeMap for stable ordering), returns Vec. Picker fires in interactive::run after preflight, before host::run. Bypassed when --app NAME is on the CLI. Shows the apps with a "per-app routing isn't live yet" explainer so users aren't surprised that audio still captures system-wide. Empty-list path shows the default + a "start your app first" hint so the feature stays discoverable. Banner softened to `system-audio (app pick saved: )` when opts.app is set — keeps the choice visible without lying about what gets captured. Routing activation still gated on the PIXELPASS_AUDIO_VIA_NULL_SINK env var (session 1's locked decision #2); --app flips to that activation in session 3 once per-stream filtering exists. Verified end-to-end interactively: Strawberry shows up in the picker during music playback, both default and app-pick paths advance into the portal handshake, banner matches choice. Co-Authored-By: Claude Opus 4.7 --- src/host/audio.rs | 56 ++++++++++++++++++++++++++++++++++++++++++++++ src/host/mod.rs | 7 ++++-- src/interactive.rs | 55 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/host/audio.rs b/src/host/audio.rs index 2e5b35a..35a51d2 100644 --- a/src/host/audio.rs +++ b/src/host/audio.rs @@ -15,6 +15,7 @@ //! needs it for stream events. use anyhow::{Context, Result, bail}; +use std::collections::BTreeMap; use std::process::Command; /// Owns the pactl module IDs for the null-sink and its loopback. Drop @@ -92,6 +93,61 @@ impl Drop for Routing { } } +/// One deduplicated app currently producing audio. The picker in +/// interactive mode shows these as the per-app capture choices. +#[derive(Debug, Clone)] +pub struct App { + pub name: String, + pub stream_count: u32, +} + +/// Enumerate apps currently sending audio to any sink, deduplicated by +/// `application.name`. Returns an empty Vec if nothing is playing. +/// Per-stream routing isn't wired yet — session 2 just plumbs the +/// selection through `HostOpts.app`; routing lands in session 3. +pub fn list_playing_apps() -> Result> { + let output = Command::new("pactl") + .args(["-f", "json", "list", "sink-inputs"]) + .output() + .context("failed to run `pactl -f json list sink-inputs`")?; + if !output.status.success() { + bail!( + "pactl list sink-inputs failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + parse_sink_inputs(&output.stdout) +} + +fn parse_sink_inputs(stdout: &[u8]) -> Result> { + let entries: Vec = + serde_json::from_slice(stdout).context("pactl returned unparseable JSON")?; + let mut counts: BTreeMap = BTreeMap::new(); + for entry in entries { + let Some(name) = entry.properties.application_name else { continue }; + let trimmed = name.trim(); + if trimmed.is_empty() { + continue; + } + *counts.entry(trimmed.to_string()).or_insert(0) += 1; + } + Ok(counts + .into_iter() + .map(|(name, stream_count)| App { name, stream_count }) + .collect()) +} + +#[derive(serde::Deserialize)] +struct SinkInput { + properties: SinkInputProperties, +} + +#[derive(serde::Deserialize)] +struct SinkInputProperties { + #[serde(rename = "application.name")] + application_name: Option, +} + fn load_module(args: &[&str]) -> Result { let output = Command::new("pactl") .arg("load-module") diff --git a/src/host/mod.rs b/src/host/mod.rs index 0a02665..653caad 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -1,4 +1,4 @@ -mod audio; +pub mod audio; mod capture; mod serve; mod wayland; @@ -317,8 +317,11 @@ fn copy_to_clipboard(text: &str) -> bool { fn capture_summary(opts: &HostOpts) -> String { let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()]; + // Per-stream routing isn't wired yet (session 3 owns activation), so + // even when an app is selected the audio capture is system-wide. Keep + // the choice visible in the banner but label it honestly. if let Some(app) = &opts.app { - bits.push(format!("app-audio={app}")); + bits.push(format!("system-audio (app pick saved: {app})")); } else { bits.push("system-audio".to_string()); } diff --git a/src/interactive.rs b/src/interactive.rs index 56a6149..16b00cc 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -23,6 +23,10 @@ pub async fn run(cli: Cli) -> Result<()> { match choice { 0 => { preflight_if_needed(&theme).await; + let mut cli = cli; + if cli.app.is_none() { + cli.app = pick_app(&theme)?; + } host::run(cli.into_host_opts(true)).await } _ => { @@ -32,6 +36,57 @@ pub async fn run(cli: Cli) -> Result<()> { } } +/// Picker for the per-app audio capture choice. Lists apps currently +/// 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> { + let apps = match host::audio::list_playing_apps() { + Ok(a) => a, + Err(e) => { + tracing::warn!("could not enumerate playing apps: {e:#}"); + return Ok(None); + } + }; + + eprintln!(); + eprintln!("Audio capture"); + eprintln!("─────────────"); + if apps.is_empty() { + 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()]; + for app in &apps { + items.push(if app.stream_count == 1 { + app.name.clone() + } else { + format!("{} ({} streams)", app.name, app.stream_count) + }); + } + + let choice = Select::with_theme(theme) + .with_prompt("What audio should the viewer hear?") + .items(&items) + .default(0) + .interact()?; + + if choice == 0 { + Ok(None) + } else { + Ok(Some(apps[choice - 1].name.clone())) + } +} + /// `pixelpass --reconfigure` entry point: unconditionally re-run the /// bandwidth pre-flight test, save the result, and return. Used to /// refresh a stale measurement (e.g. user moved house, changed ISP).