From e19f398397feb10918da81f73aab9e84b060745f Mon Sep 17 00:00:00 2001 From: Overlord Date: Tue, 11 Aug 2026 13:28:33 +0200 Subject: [PATCH 01/10] Introduce initial implementation of the `vmic` CLI with PipeWire integration - Added `Cargo.toml` to define dependencies and project metadata. - Implemented core CLI functionality for managing virtual microphones (`create`, `edit`, `delete`, `list`, `route`, and `wipe` commands) using `clap`. - Integrated `rusqlite` for persistent state storage and `pipewire` to manage PipeWire nodes. - Ensured graceful handling of feedback loops and system defaults during virtual mic creation and deletion. - Added error handling via `thiserror` for cleaner error definitions. --- Cargo.toml | 27 ++++ README.md | 79 +++++++++- src/cli.rs | 90 +++++++++++ src/commands/create.rs | 77 ++++++++++ src/commands/delete.rs | 31 ++++ src/commands/edit.rs | 109 +++++++++++++ src/commands/list.rs | 62 ++++++++ src/commands/mod.rs | 35 +++++ src/commands/route.rs | 102 ++++++++++++ src/commands/wipe.rs | 103 +++++++++++++ src/error.rs | 45 ++++++ src/main.rs | 194 +++++++++++++++++++++++ src/pw/graph.rs | 342 +++++++++++++++++++++++++++++++++++++++++ src/pw/loopback.rs | 326 +++++++++++++++++++++++++++++++++++++++ src/pw/mod.rs | 28 ++++ src/pw/text.rs | 256 ++++++++++++++++++++++++++++++ src/state.rs | 187 ++++++++++++++++++++++ src/ui.rs | 59 +++++++ 18 files changed, 2151 insertions(+), 1 deletion(-) create mode 100644 Cargo.toml create mode 100644 src/cli.rs create mode 100644 src/commands/create.rs create mode 100644 src/commands/delete.rs create mode 100644 src/commands/edit.rs create mode 100644 src/commands/list.rs create mode 100644 src/commands/mod.rs create mode 100644 src/commands/route.rs create mode 100644 src/commands/wipe.rs create mode 100644 src/error.rs create mode 100644 src/main.rs create mode 100644 src/pw/graph.rs create mode 100644 src/pw/loopback.rs create mode 100644 src/pw/mod.rs create mode 100644 src/pw/text.rs create mode 100644 src/state.rs create mode 100644 src/ui.rs diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..75bf3ea --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "vmic" +version = "0.1.0" +edition = "2021" +description = "Create and manage PipeWire virtual microphones." +license = "AGPL-3+" + +[[bin]] +name = "vmic" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +clap_complete = "4" + +thiserror = "^2" +dirs = "6" + +rusqlite = { version = "^0", features = ["bundled"] } +pipewire = "=0.8.0" +libc = "0.2" + +# Keep panics from corrupting the sqlite connection mid-transaction; abort +# is fine for a CLI as there is no long-lived state to unwind for. +[profile.release] +panic = "abort" +lto = true diff --git a/README.md b/README.md index f4df5f1..287eec6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,80 @@ # vmic -Create and manage PipeWire virtual microphones easily. \ No newline at end of file +Create and manage PipeWire virtual microphones easily, from the command line. + +## What it does + +A vmic is two chained loopback stages: + +``` +apps -> vmic_X_sink =(stage 1)=> vmic_X_mid -> vmic_X_mix =(stage 2)=> vmic_X_mic -> recorders + ^ hardware mic joins here (route -s) +``` + +Point an app's output device at `vmic_X_sink`, and a recording app's input device at +`vmic_X_mic`. Optionally mix in a real microphone downstream, and optionally hear your +own output via a self-monitor loopback (which never carries the mixed-in mic). + +## Requirements + +- PipeWire + pipewire-pulse (`pactl`, `pw-loopback`, `pw-link` on `PATH`) +- Rust (edition 2021) + +## Build + +``` +cargo build --release +``` + +If the build fails inside `libspa-sys` (e.g. `no field 'data' on type 'spa_pod_builder'`), +your system clang is newer than the pinned `bindgen` version supports. Point `LIBCLANG_PATH` +at an older libclang - a quick fix is a pip-vendored one: + +``` +python3 -m venv ~/.local/share/vmic-build/libclang-venv +~/.local/share/vmic-build/libclang-venv/bin/pip install libclang +``` + +then add to `.cargo/config.toml`: + +```toml +[env] +LIBCLANG_PATH = "/home//.local/share/vmic-build/libclang-venv/lib/python3.*/site-packages/clang/native" +``` + +## Usage + +``` +vmic create [-l] # create a vmic, optionally with self-monitor +vmic route -i # move an app's playback into the vmic +vmic route -o # move an app's recording off the vmic +vmic route -s # mix a hardware mic in, or remove it +vmic edit -l # toggle the self-monitor loopback +vmic edit -v <0.8|80> # self-monitor loopback volume +vmic edit -sv <0.8|80> # mixed-in source volume +vmic delete # tear down one vmic +vmic list # show all vmics and their status +vmic wipe # tear down every vmic, tracked or not +``` + +Aliases: `create` = `mk`/`make`, `delete` = `rm`/`remove`, `list` = `ls`, `wipe` = `reset`. + +Shell completions: `vmic completions `. + +## Example + +``` +vmic create podcast -l +# set your app's output device to vmic_podcast_sink +# set your recorder's input device to vmic_podcast_mic +vmic route podcast -s "USB Microphone" +``` + +## State + +Tracked in a SQLite database at `~/.config/vmic/vmic.db` (override the directory with +`VMIC_STATE_DIR_OVERRIDE`). + +## License + +APGL-3 diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..e168579 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,90 @@ +use clap::{Args, Parser, Subcommand}; +use clap_complete::Shell; + +/// vmic - create and manage PipeWire virtual microphones. +#[derive(Parser)] +#[command(name = "vmic", version, about)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Create a new virtual microphone. + #[command(visible_alias = "mk", visible_alias = "make")] + Create(CreateArgs), + + /// Move app streams and mix a hardware source into a vmic. + Route(RouteArgs), + + /// Change loopback and volume settings on a vmic. + Edit(EditArgs), + + /// Delete a virtual microphone. + #[command(visible_alias = "rm", visible_alias = "remove")] + Delete(DeleteArgs), + + /// List all virtual microphones. + #[command(visible_alias = "ls")] + List, + + /// Tear down every vmic, tracked or not. + #[command(visible_alias = "reset")] + Wipe, + + /// Generate a shell completion script. + Completions { shell: Shell }, +} + +#[derive(Args)] +pub struct CreateArgs { + /// Name for the new vmic. + pub name: String, + + /// Enable a self-monitor loopback. + #[arg(short, long)] + pub loopback: bool, +} + +#[derive(Args)] +pub struct RouteArgs { + /// Name of the vmic to route into. + pub name: String, + + /// Move matching sink-inputs into this vmic's sink. + #[arg(short, long = "input", value_name = "APP[:MEDIA]")] + pub inputs: Vec, + + /// Move matching source-outputs off this vmic's source. + #[arg(short, long = "output", value_name = "APP[:MEDIA]")] + pub outputs: Vec, + + /// Mix a hardware source into the vmic, or "off" to remove it. + #[arg(short, long = "source", value_name = "SOURCE|off")] + pub source: Option, +} + +#[derive(Args)] +pub struct EditArgs { + /// Name of the vmic to edit. + pub name: String, + + /// Enable or disable the self-monitor loopback. + #[arg(short, long)] + pub loopback: Option, + + /// Loopback volume: fraction (0.8) or percent (80). + #[arg(short, long)] + pub volume: Option, + + /// Mixed-in source volume: fraction (0.8) or percent (80). + #[arg(long = "source-volume", visible_alias = "sv")] + pub source_volume: Option, +} + +#[derive(Args)] +pub struct DeleteArgs { + /// Name of the vmic to delete. + pub name: String, +} diff --git a/src/commands/create.rs b/src/commands/create.rs new file mode 100644 index 0000000..d08217e --- /dev/null +++ b/src/commands/create.rs @@ -0,0 +1,77 @@ +use crate::cli::CreateArgs; +use crate::error::{Result, VmicError}; +use crate::pw::{loopback, PwGraph}; +use crate::state::{self, VmicState}; +use crate::ui; +use std::process::Command; + +pub fn run(args: CreateArgs) -> Result<()> { + state::require_valid_name(&args.name)?; + let name = state::normalize(&args.name); + let conn = state::open_db()?; + + if VmicState::exists(&conn, &name)? { + return Err(VmicError::AlreadyExists(name)); + } + + let names = loopback::NodeNames::for_vmic(&name); + let graph = PwGraph::connect()?; + + // pw-loopback may steal the default sink/source; restore them afterward. + let orig_sink = default_output("get-default-sink"); + let orig_source = default_output("get-default-source"); + + let stages = loopback::create_stages(&graph, &names)?; + + if let Some(sink) = &orig_sink { + let _ = Command::new("pactl").args(["set-default-sink", sink]).status(); + } + if let Some(source) = &orig_source { + let _ = Command::new("pactl").args(["set-default-source", source]).status(); + } + + // Insurance: a sink monitor feeding any vmic node is a feedback ring; + // it should never exist, but tear it down if one appears anyway. + let prefix = format!("vmic_{name}_"); + let removed = graph.break_feedback_rings(&prefix)?; + if removed > 0 { + ui::warn(&format!("removed {removed} unexpected monitor feedback link(s).")); + } + + let mut state = VmicState { + name: name.clone(), + sink_name: names.sink.clone(), + mid_name: names.mid, + mix_name: names.mix, + source_name: names.source.clone(), + sink_node_id: Some(stages.sink_node_id), + mix_node_id: Some(stages.mix_node_id), + sink_pid: Some(stages.sink_pid as i64), + mix_pid: Some(stages.mix_pid as i64), + ..Default::default() + }; + + if args.loopback { + let loop_id = loopback::enable_self_monitor(&graph, &names.sink)?; + state.loopback_id = Some(loop_id); + } + + state.save(&conn)?; + + ui::ok(&format!("Created virtual microphone '{name}'.")); + if let (Some(sink), Some(source)) = (&orig_sink, &orig_source) { + ui::info(&format!(" Default Output/Input kept intact ('{sink}' / '{source}').")); + } + println!(" In the source app, set output device to:"); + println!(" '{}'", ui::blue(&names.sink)); + println!(" In the receiving app, set input/mic device to:"); + println!(" '{}'", ui::blue(&names.source)); + + Ok(()) +} + +fn default_output(subcommand: &str) -> Option { + let out = Command::new("pactl").arg(subcommand).output().ok()?; + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + (!s.is_empty()).then_some(s) +} diff --git a/src/commands/delete.rs b/src/commands/delete.rs new file mode 100644 index 0000000..05a7384 --- /dev/null +++ b/src/commands/delete.rs @@ -0,0 +1,31 @@ +use crate::cli::DeleteArgs; +use crate::error::Result; +use crate::pw::{loopback, PwGraph}; +use crate::state::{self, VmicState}; +use crate::ui; + +pub fn run(args: DeleteArgs) -> Result<()> { + let conn = state::open_db()?; + let name = state::normalize(&args.name); + let mut state = VmicState::load(&conn, &name)?; + let graph = PwGraph::connect()?; + + if let Some(loopback_id) = state.loopback_id { + loopback::unload_pulse_module(loopback_id)?; + } + + // Unlink a mixed-in hardware source explicitly; the links would also + // die with the nodes below. + super::unsource(&graph, &mut state)?; + + if let Some(pid) = state.mix_pid { + loopback::terminate_stage(pid, &state.mix_name)?; + } + if let Some(pid) = state.sink_pid { + loopback::terminate_stage(pid, &state.sink_name)?; + } + + VmicState::delete(&conn, &name)?; + ui::ok(&format!("Deleted virtual mic '{name}'.")); + Ok(()) +} diff --git a/src/commands/edit.rs b/src/commands/edit.rs new file mode 100644 index 0000000..3182703 --- /dev/null +++ b/src/commands/edit.rs @@ -0,0 +1,109 @@ +use crate::cli::EditArgs; +use crate::commands::to_pct; +use crate::error::{Result, VmicError}; +use crate::pw::{loopback, PwGraph}; +use crate::state::{self, VmicState}; +use crate::ui; + +pub fn run(args: EditArgs) -> Result<()> { + if args.loopback.is_none() && args.volume.is_none() && args.source_volume.is_none() { + return Err(VmicError::NothingToDo( + "pass --loopback, --volume and/or --source-volume.".into(), + )); + } + + let conn = state::open_db()?; + let name = state::normalize(&args.name); + let mut state = VmicState::load(&conn, &name)?; + let graph = PwGraph::connect()?; + + if let Some(enable) = args.loopback { + edit_loopback(&graph, &mut state, enable)?; + } + if let Some(volume) = args.volume { + edit_volume(&graph, &mut state, volume)?; + } + if let Some(source_volume) = args.source_volume { + edit_source_volume(&mut state, source_volume)?; + } + + state.save(&conn) +} + +fn edit_loopback(graph: &PwGraph, state: &mut VmicState, enable: bool) -> Result<()> { + match (enable, state.loopback_id) { + (true, Some(id)) => { + ui::warn(&format!( + "self-monitor loopback already enabled for '{}' (module {id}).", + state.name + )); + } + (true, None) => { + let id = loopback::enable_self_monitor(graph, &state.sink_name)?; + state.loopback_id = Some(id); + ui::ok(&format!("Self-monitor loopback enabled (module {id}).")); + + // Re-apply a previously stored loopback volume, if any. A + // failure here is a warning, not a hard error - the loopback + // itself was still created successfully. + if let Some(pct) = state.volume_pct { + if loopback::set_loopback_volume(graph, id, pct).is_ok() { + ui::info(&format!("Loopback volume restored to {pct}%.")); + } else { + ui::warn(&format!("could not apply stored loopback volume ({pct}%).")); + } + } + } + (false, None) => { + ui::warn(&format!("self-monitor loopback is not enabled for '{}'.", state.name)); + } + (false, Some(id)) => { + // A failed unload propagates as a real error rather than being + // silently swallowed. + loopback::unload_pulse_module(id)?; + state.loopback_id = None; + ui::ok(&format!("Self-monitor loopback disabled (module {id} unloaded).")); + } + } + Ok(()) +} + +fn edit_volume(graph: &PwGraph, state: &mut VmicState, value: f32) -> Result<()> { + let pct = to_pct(value).ok_or_else(|| VmicError::InvalidVolume(value.to_string()))?; + state.volume_pct = Some(pct); + + let Some(id) = state.loopback_id else { + ui::ok(&format!( + "Loopback volume for '{}' saved ({pct}%); applies when the loopback is enabled.", + state.name + )); + return Ok(()); + }; + + if loopback::set_loopback_volume(graph, id, pct).is_ok() { + ui::ok(&format!("Loopback volume for '{}' set to {pct}%.", state.name)); + } else { + ui::warn(&format!("could not find the loopback playback stream (module {id}).")); + } + Ok(()) +} + +fn edit_source_volume(state: &mut VmicState, value: f32) -> Result<()> { + let pct = to_pct(value).ok_or_else(|| VmicError::InvalidVolume(value.to_string()))?; + state.mic_volume_pct = Some(pct); + + let Some(mic) = state.mic_source.clone() else { + ui::ok(&format!("Source volume for '{}' saved ({pct}%).", state.name)); + return Ok(()); + }; + + let status = std::process::Command::new("pactl") + .args(["set-source-volume", &mic, &format!("{pct}%")]) + .status()?; + if status.success() { + ui::ok(&format!("Source volume for '{}' set to {pct}% ('{mic}').", state.name)); + } else { + ui::warn(&format!("could not set volume on '{mic}'.")); + } + Ok(()) +} diff --git a/src/commands/list.rs b/src/commands/list.rs new file mode 100644 index 0000000..aab50f6 --- /dev/null +++ b/src/commands/list.rs @@ -0,0 +1,62 @@ +use crate::error::Result; +use crate::pw::PwGraph; +use crate::state::{self, VmicState}; +use crate::ui; + +pub fn run() -> Result<()> { + let conn = state::open_db()?; + let states = VmicState::list_all(&conn)?; + if states.is_empty() { + ui::info("No virtual mics created."); + return Ok(()); + } + + let graph = PwGraph::connect().ok(); + + for (i, state) in states.iter().enumerate() { + let status = liveness(graph.as_ref(), state); + let status_colored = if status == "active" { ui::green(&status) } else { ui::yellow(&status) }; + + println!("{}", ui::blue(&state.name)); + println!(" output device: {}", state.sink_name); + println!(" mic device: {}", state.source_name); + println!(" status: {status_colored}"); + println!( + " self-monitor loopback: {}", + state + .loopback_id + .map(|id| format!("yes (module {id})")) + .unwrap_or_else(|| "no".into()) + ); + if let Some(pct) = state.volume_pct { + println!(" loopback volume: {}", ui::blue(&format!("{pct}%"))); + } + if let Some(mic) = &state.mic_source { + println!(" mixed source: {mic}"); + if let Some(pct) = state.mic_volume_pct { + println!(" source volume: {}", ui::blue(&format!("{pct}%"))); + } + } + if i + 1 < states.len() { + println!(); + } + } + + Ok(()) +} + +/// Checks `sink_node_id`/`mix_node_id` against the live graph snapshot. +fn liveness(graph: Option<&PwGraph>, state: &VmicState) -> String { + let Some(graph) = graph else { + return "unknown (could not connect to PipeWire)".to_string(); + }; + let nodes = graph.nodes(); + let sink_alive = state.sink_node_id.is_some_and(|id| nodes.iter().any(|n| n.id == id)); + let mix_alive = state.mix_node_id.is_some_and(|id| nodes.iter().any(|n| n.id == id)); + match (sink_alive, mix_alive) { + (true, true) => "active".to_string(), + (true, false) => "degraded (mix loopback process not found)".to_string(), + (false, true) => "degraded (sink loopback process not found)".to_string(), + (false, false) => "stale (matching pw-loopback process not found)".to_string(), + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..2bb746d --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,35 @@ +pub mod create; +pub mod delete; +pub mod edit; +pub mod list; +pub mod route; +pub mod wipe; + +use crate::error::Result; +use crate::pw::{loopback, PwGraph}; +use crate::state::VmicState; +use crate::ui; + +/// Normalizes a fraction (0.8) or percentage (80) to an integer percent, +/// clamped to the storable range (0..=255 - `volume_pct`/`mic_volume_pct` +/// are `u8`). Warns rather than silently dropping precision when clamped. +pub fn to_pct(value: f32) -> Option { + if !value.is_finite() || value < 0.0 { + return None; + } + let pct = if value <= 1.0 { value * 100.0 } else { value }; + if pct > 255.0 { + ui::warn(&format!("volume {}% exceeds the maximum of 255%; clamped.", pct.round())); + } + Some(pct.round().clamp(0.0, u8::MAX as f32) as u8) +} + +/// Unlinks a mixed-in hardware source and clears `state.mic_source`. +/// Shared by `route -s off` and `delete`. +pub(crate) fn unsource(graph: &PwGraph, state: &mut VmicState) -> Result<()> { + let Some(mic) = state.mic_source.take() else { + return Ok(()); + }; + loopback::unlink_stage(graph, &mic, &state.mix_name)?; + Ok(()) +} diff --git a/src/commands/route.rs b/src/commands/route.rs new file mode 100644 index 0000000..75d1bae --- /dev/null +++ b/src/commands/route.rs @@ -0,0 +1,102 @@ +use crate::cli::RouteArgs; +use crate::error::{Result, VmicError}; +use crate::pw::{loopback, text, PwGraph}; +use crate::state::{self, VmicState}; +use crate::ui; +use std::time::Duration; + +/// Shorter than stage-creation's timeout since the source already exists - +/// this is just retrying while its ports enumerate. +const SOURCE_LINK_TIMEOUT: Duration = Duration::from_secs(2); + +pub fn run(args: RouteArgs) -> Result<()> { + let conn = state::open_db()?; + let name = state::normalize(&args.name); + let mut state = VmicState::load(&conn, &name)?; + + if args.inputs.is_empty() && args.outputs.is_empty() && args.source.is_none() { + return Err(VmicError::NothingToDo( + "pass --input, --output and/or --source.".into(), + )); + } + + let graph = PwGraph::connect()?; + + for filter in &args.inputs { + move_stream("sink-inputs", "move-sink-input", &state.sink_name, filter)?; + } + for filter in &args.outputs { + move_stream("source-outputs", "move-source-output", &state.source_name, filter)?; + } + if let Some(source) = &args.source { + route_source(&graph, &mut state, source)?; + state.save(&conn)?; + } + + Ok(()) +} + +/// Matches streams via `pw::text` (name/description matching - see that +/// module for why this stays text-based) then moves each with `pactl`. +fn move_stream(kind: &str, move_cmd: &str, target: &str, filter: &str) -> Result<()> { + let matches = text::match_streams(kind, filter)?; + if matches.is_empty() { + ui::warn(&format!("'{filter}': matched 0 streams, nothing moved.")); + return Ok(()); + } + for m in &matches { + std::process::Command::new("pactl") + .args([move_cmd, &m.id, target]) + .status()?; + } + ui::info(&format!("'{filter}': matched {} stream(s) -> moved to '{target}'.", matches.len())); + Ok(()) +} + +fn route_source(graph: &PwGraph, state: &mut VmicState, value: &str) -> Result<()> { + let lowered = value.to_lowercase(); + if matches!(lowered.as_str(), "off" | "none" | "disable") { + let Some(mic) = state.mic_source.clone() else { + ui::warn(&format!("no source is mixed into '{}'.", state.name)); + return Ok(()); + }; + super::unsource(graph, state)?; + ui::ok(&format!("Removed source mix '{mic}' from '{}'.", state.name)); + return Ok(()); + } + + let matches = text::match_source(value)?; + match matches.len() { + 0 => return Err(VmicError::NoMatchingSource(value.to_string())), + 1 => {} + _ => { + let names: Vec<_> = matches.iter().map(|m| m.name.clone()).collect(); + return Err(VmicError::AmbiguousSource { + value: value.to_string(), + matches: names.join(", "), + }); + } + } + let mic = matches[0].name.clone(); + + // Replace a different currently-mixed source first. + if state.mic_source.as_deref().is_some_and(|old| old != mic) { + super::unsource(graph, state)?; + } + + let linked = loopback::link_stage(graph, &mic, &state.mix_name, SOURCE_LINK_TIMEOUT)?; + state.mic_source = Some(mic.clone()); + + // Insurance: make sure the new links did not create a monitor ring. + let prefix = format!("vmic_{}_", state.name.to_lowercase()); + let rings = graph.break_feedback_rings(&prefix)?; + if rings > 0 { + ui::warn(&format!("removed {rings} unexpected monitor feedback link(s).")); + } + + ui::ok(&format!( + "Mixed '{mic}' into the mix stage of '{}' ({linked} link(s); not audible in self-monitor).", + state.name + )); + Ok(()) +} diff --git a/src/commands/wipe.rs b/src/commands/wipe.rs new file mode 100644 index 0000000..887bf30 --- /dev/null +++ b/src/commands/wipe.rs @@ -0,0 +1,103 @@ +use crate::error::Result; +use crate::pw::{loopback, text, PwGraph}; +use crate::state; +use crate::ui; +use std::process::Command; + +pub fn run() -> Result<()> { + let graph = PwGraph::connect()?; + + let mods_removed = wipe_loopback_modules(&graph)?; + let links_removed = graph.unlink_all_into_prefix("vmic_")?; + let procs_killed = wipe_pw_loopback_processes(); + + let conn = state::open_db()?; + let rows_removed = conn.execute("DELETE FROM vmics", [])? as u32; + + restore_defaults(); + + if mods_removed + links_removed + procs_killed + rows_removed == 0 { + ui::info("Nothing to wipe: no virtual mics or vmic loopbacks found."); + } else { + ui::ok("Wiped all virtual mics:"); + println!(" loopback modules unloaded: {mods_removed}"); + println!(" links removed: {links_removed}"); + println!(" pw-loopback processes killed: {procs_killed}"); + println!(" state rows removed: {rows_removed}"); + } + + Ok(()) +} + +/// Unloads every loopback module referencing a vmic_* node, tracked or not. +fn wipe_loopback_modules(_graph: &PwGraph) -> Result { + let ids = text::list_vmic_loopback_module_ids()?; + let mut removed = 0; + for id in ids { + if loopback::unload_pulse_module(id).is_ok() { + removed += 1; + } + } + Ok(removed) +} + +/// Kills every pw-loopback process whose cmdline mentions a vmic_* node, +/// tracked or not. SIGTERM only - no SIGKILL escalation, unlike +/// `terminate_stage`. +fn wipe_pw_loopback_processes() -> u32 { + let mut killed = 0; + let Ok(entries) = std::fs::read_dir("/proc") else { + return 0; + }; + for entry in entries.flatten() { + let Ok(pid) = entry.file_name().to_string_lossy().parse::() else { + continue; + }; + let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).unwrap_or_default(); + if comm.trim() != "pw-loopback" { + continue; + } + let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else { + continue; + }; + if String::from_utf8_lossy(&cmdline).contains("vmic_") + && unsafe { libc::kill(pid, libc::SIGTERM) } == 0 + { + killed += 1; + } + } + killed +} + +/// If the default sink/source pointed at a vmic node that just got wiped, +/// point it back at the first non-vmic device instead of leaving it +/// dangling. +fn restore_defaults() { + if default_output("get-default-sink").is_some_and(|s| s.starts_with("vmic_")) { + if let Some(new_sink) = first_non_vmic("sinks", false) { + let _ = Command::new("pactl").args(["set-default-sink", &new_sink]).status(); + ui::info(&format!("Default sink restored to '{new_sink}'.")); + } + } + if default_output("get-default-source").is_some_and(|s| s.starts_with("vmic_")) { + if let Some(new_source) = first_non_vmic("sources", true) { + let _ = Command::new("pactl").args(["set-default-source", &new_source]).status(); + ui::info(&format!("Default source restored to '{new_source}'.")); + } + } +} + +fn default_output(subcommand: &str) -> Option { + let out = Command::new("pactl").arg(subcommand).output().ok()?; + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + (!s.is_empty()).then_some(s) +} + +fn first_non_vmic(kind: &str, exclude_monitors: bool) -> Option { + let out = Command::new("pactl").args(["list", "short", kind]).output().ok()?; + String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|l| l.split_whitespace().nth(1)) + .find(|name| !name.starts_with("vmic_") && !(exclude_monitors && name.ends_with(".monitor"))) + .map(str::to_string) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..096ca4e --- /dev/null +++ b/src/error.rs @@ -0,0 +1,45 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum VmicError { + #[error( + "invalid name '{0}' (use 1-64 chars: letters, digits, '_' or '-'; \ + must start with a letter or digit)" + )] + InvalidName(String), + + #[error("no vmic named '{0}'")] + NotFound(String), + + #[error("vmic '{0}' already exists")] + AlreadyExists(String), + + #[error("default output is the vmic sink itself; self-monitor would create a feedback loop")] + WouldFeedback, + + #[error("could not resolve ports for '{from}' -> '{to}'")] + PortResolution { from: String, to: String }, + + #[error("-s '{0}': no matching source (monitors and vmic nodes are excluded)")] + NoMatchingSource(String), + + #[error("-s '{value}': ambiguous, matches: {matches}")] + AmbiguousSource { value: String, matches: String }, + + #[error("invalid volume '{0}' (expected 0..1 or a percentage, e.g. 0.8 or 80)")] + InvalidVolume(String), + + #[error("nothing to do: {0}")] + NothingToDo(String), + + #[error("PipeWire graph error: {0}")] + PipeWire(String), + + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error(transparent)] + Sqlite(#[from] rusqlite::Error), +} + +pub type Result = std::result::Result; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..7017510 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,194 @@ +mod cli; +mod commands; +mod error; +mod pw; +mod state; +mod ui; + +use clap::{CommandFactory, Parser}; +use clap_complete::{generate, Shell}; +use cli::{Cli, Commands}; + +fn main() { + let args = normalize_args(std::env::args().collect()); + + // Plain `vmic`, `-h`/`--help`, or `help` at the top level: show every + // subcommand's own flags inline instead of making the user drill into + // each one with its own `--help`. + if wants_top_level_help(&args) { + print_full_help(); + std::process::exit(if args.len() <= 1 { 2 } else { 0 }); + } + + let cli = match Cli::try_parse_from(&args) { + Ok(cli) => cli, + Err(e) => e.exit(), + }; + + let result = match cli.command { + Commands::Create(args) => commands::create::run(args), + Commands::Route(args) => commands::route::run(args), + Commands::Edit(args) => commands::edit::run(args), + Commands::Delete(args) => commands::delete::run(args), + Commands::List => commands::list::run(), + Commands::Wipe => commands::wipe::run(), + Commands::Completions { shell } => { + print_completions(shell); + Ok(()) + } + }; + + if let Err(e) = result { + ui::err(&e.to_string()); + std::process::exit(1); + } +} + +/// clap's `#[arg(short)]` only supports single-character short flags, so +/// `-sv` can't be a real short flag. Rewrite it to `--source-volume` before +/// clap ever sees it. +fn normalize_args(args: Vec) -> Vec { + args.into_iter() + .map(|a| if a == "-sv" { "--source-volume".to_string() } else { a }) + .collect() +} + +fn print_completions(shell: Shell) { + let mut cmd = Cli::command(); + let name = cmd.get_name().to_string(); + generate(shell, &mut cmd, name, &mut std::io::stdout()); +} + +/// True for a bare `vmic` invocation, or top-level `-h`/`--help`/`help` - +/// i.e. anything that should show the expanded help rather than being +/// handled (or rejected) by a specific subcommand. +fn wants_top_level_help(args: &[String]) -> bool { + match args.get(1..) { + Some([]) => true, + Some([a]) => a == "-h" || a == "--help" || a == "help", + _ => false, + } +} + +/// Prints one screen of help: every subcommand's positional args and flags +/// summarized inline (e.g. ` [-l/--loopback]`), so nothing requires +/// drilling into a subcommand's own `--help` just to see what it takes. +fn print_full_help() { + let mut cmd = Cli::command(); + cmd.build(); // resolve default value names etc. before introspecting + + if let Some(about) = cmd.get_about() { + println!("{about}"); + println!(); + } + println!("{} vmic {} {}", ui::blue("Usage:"), ui::cyan(""), ui::yellow("[ARGS]")); + println!(); + + println!("{}", ui::blue("Commands:")); + let rows: Vec<(String, String, String, String)> = cmd + .get_subcommands() + .filter(|s| s.get_name() != "help") + .map(|s| { + let aliases = s.get_visible_aliases().collect::>().join("/"); + let about = s.get_about().map(|a| a.to_string()).unwrap_or_default(); + (s.get_name().to_string(), compact_args(s), about, aliases) + }) + .collect(); + print_table(&rows); + println!(); + + println!("{}", ui::blue("Options:")); + let opt_rows: Vec<(String, String)> = cmd + .get_arguments() + .filter(|a| !a.is_positional()) + .filter_map(|a| Some((flag_names(a)?, a.get_help().map(|h| h.to_string()).unwrap_or_default()))) + .collect(); + let flag_w = opt_rows.iter().map(|(f, _)| f.len()).max().unwrap_or(0); + for (flag, help) in &opt_rows { + println!(" {flag:flag_w$} {help}"); + } +} + +/// `` for each positional, `[-x/--long ]` or `[-x/--long]` for +/// each flag, in one space-joined line - a compact stand-in for the +/// `[OPTIONS] ` a normal usage line would collapse this to. +/// +/// Colors match the original fish script's `vmic_usage_line`: positional +/// `<...>` args in cyan, optional `[...]` flag groups (whole bracket, +/// including any value placeholder inside) in yellow. +fn compact_args(cmd: &clap::Command) -> String { + let mut parts: Vec = cmd + .get_positionals() + .map(|a| ui::cyan(&format!("<{}>", a.get_id().as_str()))) + .collect(); + + for arg in cmd.get_arguments() { + if arg.is_positional() || arg.get_id().as_str() == "help" { + continue; + } + let Some(flag) = flag_names(arg) else { continue }; + let group = if matches!(arg.get_action(), clap::ArgAction::Set | clap::ArgAction::Append) { + let value = arg + .get_value_names() + .and_then(|v| v.first()) + .map(|v| v.to_string()) + .unwrap_or_else(|| arg.get_id().as_str().to_uppercase()); + format!("[{flag} <{value}>]") + } else { + format!("[{flag}]") + }; + parts.push(ui::yellow(&group)); + } + parts.join(" ") +} + +/// `-x, --long` / `-x` / `--long` for a non-positional arg, or `None` for +/// one with no visible flag at all. +fn flag_names(arg: &clap::Arg) -> Option { + match (arg.get_short(), arg.get_long()) { + (Some(s), Some(l)) => Some(format!("-{s}/--{l}")), + (Some(s), None) => Some(format!("-{s}")), + (None, Some(l)) => Some(format!("--{l}")), + (None, None) => None, + } +} + +/// Prints `(name, args, about, aliases)` rows with the name and args +/// columns padded to their widest entry, so the `about` column lines up. +/// `args` carries ANSI color codes (see `compact_args`), so padding by +/// `.len()`/`{:width$}` would count invisible escape bytes as columns and +/// misalign everything - `pad_visual` pads by visible width instead. +/// `aliases`, if non-empty, trails after `| `. +fn print_table(rows: &[(String, String, String, String)]) { + let name_w = rows.iter().map(|(n, ..)| n.len()).max().unwrap_or(0); + let args_w = rows.iter().map(|(_, a, ..)| visual_width(a)).max().unwrap_or(0); + for (name, args, about, aliases) in rows { + print!(" {name:name_w$} {} {about}", pad_visual(args, args_w)); + if !aliases.is_empty() { + print!(" | {aliases}"); + } + println!(); + } +} + +/// Number of visible columns in `s`, skipping any `\x1b[...m` ANSI SGR +/// escape sequences it contains. +fn visual_width(s: &str) -> usize { + let mut width = 0; + let mut in_escape = false; + for c in s.chars() { + if in_escape { + in_escape = c != 'm'; + } else if c == '\x1b' { + in_escape = true; + } else { + width += 1; + } + } + width +} + +/// Right-pads `s` with spaces to `width` visible columns, per `visual_width`. +fn pad_visual(s: &str, width: usize) -> String { + format!("{s}{}", " ".repeat(width.saturating_sub(visual_width(s)))) +} diff --git a/src/pw/graph.rs b/src/pw/graph.rs new file mode 100644 index 0000000..07752ed --- /dev/null +++ b/src/pw/graph.rs @@ -0,0 +1,342 @@ +use crate::error::{Result, VmicError}; +use pipewire as pw; +use pw::types::ObjectType; +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +/// A node in the live PipeWire graph, as seen via the registry. +#[derive(Debug, Clone)] +pub struct Node { + pub id: u32, + pub name: String, // always one of this program's own ASCII vmic_* names + #[allow(dead_code)] + pub media_class: String, // "Audio/Sink", "Audio/Source", ... +} + +/// A single input/output port on a node. +#[derive(Debug, Clone)] +pub struct Port { + pub id: u32, + pub node_id: u32, + pub name: String, // e.g. "capture_FL", "playback_FL", "monitor_FL" + pub is_input: bool, +} + +/// A live link between two ports. +#[derive(Debug, Clone)] +pub struct Link { + pub id: u32, + pub output_port: u32, + pub input_port: u32, +} + +/// The standard core-registered factory that creates `PipeWire:Interface:Link` +/// objects. Confirmed via `pw-dump` (`factory.name = "link-factory"`) - the +/// same name the real `pw-link` CLI hardcodes, not something that varies +/// per system. +const LINK_FACTORY: &str = "link-factory"; + +const ROUNDTRIP_TIMEOUT: Duration = Duration::from_secs(5); + +/// Bound on one polling tick while waiting for a freshly spawned node/port +/// to appear. Short and retried, unlike `ROUNDTRIP_TIMEOUT`, which is a +/// hard failure. +const POLL_TICK: Duration = Duration::from_millis(200); + +#[derive(Default)] +struct GraphState { + nodes: HashMap, + ports: HashMap, + links: HashMap, +} + +/// Handle to a running PipeWire main loop + registry snapshot, used only for +/// operations on this program's own nodes (known ASCII names, numeric ids). +/// Anything involving third-party device names/descriptions goes through +/// `pw::text` instead. +/// +/// Field order matters: Rust drops struct fields in declaration order, and +/// `_listener`/`registry` must be torn down before `core`, which must be +/// torn down before `_context`, which must be torn down before `mainloop`. +pub struct PwGraph { + _listener: pw::registry::Listener, + registry: pw::registry::Registry, + core: pw::core::Core, + _context: pw::context::Context, + mainloop: pw::main_loop::MainLoop, + state: Rc>, +} + +impl PwGraph { + pub fn connect() -> Result { + let mainloop = pw::main_loop::MainLoop::new(None) + .map_err(|e| VmicError::PipeWire(format!("main loop: {e}")))?; + let context = pw::context::Context::new(&mainloop) + .map_err(|e| VmicError::PipeWire(format!("context: {e}")))?; + let core = context + .connect(None) + .map_err(|e| VmicError::PipeWire(format!("connect: {e} (is PipeWire running?)")))?; + let registry = core + .get_registry() + .map_err(|e| VmicError::PipeWire(format!("get registry: {e}")))?; + + let state = Rc::new(RefCell::new(GraphState::default())); + let state_add = state.clone(); + let state_rm = state.clone(); + + let listener = registry + .add_listener_local() + .global(move |g| { + let mut s = state_add.borrow_mut(); + match &g.type_ { + ObjectType::Node => { + let name = g + .props + .and_then(|p| p.get(*pw::keys::NODE_NAME)) + .unwrap_or_default() + .to_string(); + let media_class = g + .props + .and_then(|p| p.get(*pw::keys::MEDIA_CLASS)) + .unwrap_or_default() + .to_string(); + s.nodes.insert(g.id, Node { id: g.id, name, media_class }); + } + ObjectType::Port => { + let node_id = g + .props + .and_then(|p| p.get(*pw::keys::NODE_ID)) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let name = g + .props + .and_then(|p| p.get(*pw::keys::PORT_NAME)) + .unwrap_or_default() + .to_string(); + let is_input = + g.props.and_then(|p| p.get(*pw::keys::PORT_DIRECTION)) == Some("in"); + s.ports.insert(g.id, Port { id: g.id, node_id, name, is_input }); + } + ObjectType::Link => { + let output_port = g + .props + .and_then(|p| p.get(*pw::keys::LINK_OUTPUT_PORT)) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let input_port = g + .props + .and_then(|p| p.get(*pw::keys::LINK_INPUT_PORT)) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + s.links.insert(g.id, Link { id: g.id, output_port, input_port }); + } + _ => {} + } + }) + .global_remove(move |id| { + let mut s = state_rm.borrow_mut(); + s.nodes.remove(&id); + s.ports.remove(&id); + s.links.remove(&id); + }) + .register(); + + do_roundtrip(&mainloop, &core, ROUNDTRIP_TIMEOUT)?; + + Ok(Self { _listener: listener, registry, core, _context: context, mainloop, state }) + } + + fn roundtrip(&self) -> Result<()> { + do_roundtrip(&self.mainloop, &self.core, ROUNDTRIP_TIMEOUT) + } + + pub(crate) fn poll_tick(&self) { + let _ = do_roundtrip(&self.mainloop, &self.core, POLL_TICK); + } + + pub fn nodes(&self) -> Vec { + self.state.borrow().nodes.values().cloned().collect() + } + + pub fn ports_for(&self, node_name: &str) -> Vec { + let s = self.state.borrow(); + let Some(node) = s.nodes.values().find(|n| n.name == node_name) else { + return Vec::new(); + }; + s.ports.values().filter(|p| p.node_id == node.id).cloned().collect() + } + + pub fn links(&self) -> Vec { + self.state.borrow().links.values().cloned().collect() + } + + /// Links two ports if not already linked (idempotent). + pub fn link_ports(&self, output_port: u32, input_port: u32) -> Result<()> { + if self.is_linked(output_port, input_port) { + return Ok(()); + } + + let props = pw::properties::properties! { + *pw::keys::LINK_OUTPUT_PORT => output_port.to_string(), + *pw::keys::LINK_INPUT_PORT => input_port.to_string(), + // Don't destroy the link on the remote when our local proxy for + // it is dropped at the end of this function - links should + // outlive this one-shot CLI invocation. + "object.linger" => "1", + }; + + if let Ok(link) = self.core.create_object::(LINK_FACTORY, &props) { + let _ = self.roundtrip(); + drop(link); + } + + // Defensive re-check: a create_object error doesn't necessarily mean + // no link exists. + if self.is_linked(output_port, input_port) { + Ok(()) + } else { + Err(VmicError::PipeWire(format!("failed to link ports {output_port} -> {input_port}"))) + } + } + + pub fn unlink_ports(&self, output_port: u32, input_port: u32) -> Result<()> { + let id = self + .links() + .into_iter() + .find(|l| l.output_port == output_port && l.input_port == input_port) + .map(|l| l.id); + let Some(id) = id else { + return Ok(()); // already gone + }; + self.registry + .destroy_global(id) + .into_result() + .map_err(|e| VmicError::PipeWire(format!("unlink {output_port}->{input_port}: {e}")))?; + self.roundtrip() + } + + fn is_linked(&self, output_port: u32, input_port: u32) -> bool { + self.links() + .iter() + .any(|l| l.output_port == output_port && l.input_port == input_port) + } + + /// Collects (output_port, input_port) pairs for links whose endpoint + /// node/port names satisfy `pred(out_node, out_port, in_node, in_port)`. + fn matching_link_ports( + &self, + pred: impl Fn(&str, &str, &str, &str) -> bool, + ) -> Vec<(u32, u32)> { + let s = self.state.borrow(); + let mut out = Vec::new(); + for link in s.links.values() { + let (Some(out_port), Some(in_port)) = + (s.ports.get(&link.output_port), s.ports.get(&link.input_port)) + else { + continue; + }; + let (Some(out_node), Some(in_node)) = + (s.nodes.get(&out_port.node_id), s.nodes.get(&in_port.node_id)) + else { + continue; + }; + if pred(&out_node.name, &out_port.name, &in_node.name, &in_port.name) { + out.push((link.output_port, link.input_port)); + } + } + out + } + + fn unlink_all(&self, pairs: Vec<(u32, u32)>) -> Result { + let mut removed = 0; + for (o, i) in pairs { + if self.unlink_ports(o, i).is_ok() { + removed += 1; + } + } + Ok(removed) + } + + /// Destroys any link from a vmic sink's monitor back into a vmic node + /// (a feedback ring). Returns the number removed. + pub fn break_feedback_rings(&self, prefix: &str) -> Result { + let sink_name = format!("{prefix}sink"); + let pairs = self.matching_link_ports(|out_node, out_port, in_node, _in_port| { + out_node == sink_name && out_port.starts_with("monitor") && in_node.starts_with(prefix) + }); + self.unlink_all(pairs) + } + + /// Destroys every link whose input lands on a node under `prefix`, + /// regardless of the output side - broader than `break_feedback_rings`, + /// used when tearing down everything. + pub fn unlink_all_into_prefix(&self, prefix: &str) -> Result { + let pairs = self.matching_link_ports(|_out_node, _out_port, in_node, _in_port| { + in_node.starts_with(prefix) + }); + self.unlink_all(pairs) + } + + /// Waits (bounded) for a node to appear in the graph. + pub fn wait_for_node(&self, name: &str, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + loop { + if let Some(n) = self.nodes().into_iter().find(|n| n.name == name) { + return Ok(n); + } + if Instant::now() >= deadline { + return Err(VmicError::PipeWire(format!( + "node '{name}' did not appear within {timeout:?}" + ))); + } + self.poll_tick(); + } + } +} + +/// Triggers `core.sync` and pumps the main loop until the server +/// acknowledges it (delivering any pending `global`/`global_remove` events +/// along the way), or until `timeout` elapses (a watchdog, so a dead +/// daemon can't hang the CLI). +fn do_roundtrip(mainloop: &pw::main_loop::MainLoop, core: &pw::core::Core, timeout: Duration) -> Result<()> { + let done = Rc::new(Cell::new(false)); + let done_clone = done.clone(); + let loop_done = mainloop.clone(); + + let pending = core + .sync(0) + .map_err(|e| VmicError::PipeWire(format!("sync: {e}")))?; + + let _core_listener = core + .add_listener_local() + .done(move |id, seq| { + if id == pw::core::PW_ID_CORE && seq == pending { + done_clone.set(true); + loop_done.quit(); + } + }) + .register(); + + let timed_out = Rc::new(Cell::new(false)); + let timed_out_clone = timed_out.clone(); + let loop_timeout = mainloop.clone(); + let timer = mainloop.loop_().add_timer(move |_expirations| { + timed_out_clone.set(true); + loop_timeout.quit(); + }); + timer + .update_timer(Some(timeout), None) + .into_result() + .map_err(|e| VmicError::PipeWire(format!("arm watchdog timer: {e}")))?; + + while !done.get() && !timed_out.get() { + mainloop.run(); + } + + if !done.get() { + return Err(VmicError::PipeWire("timed out waiting for the PipeWire daemon".into())); + } + Ok(()) +} diff --git a/src/pw/loopback.rs b/src/pw/loopback.rs new file mode 100644 index 0000000..7c4e5ed --- /dev/null +++ b/src/pw/loopback.rs @@ -0,0 +1,326 @@ +use crate::error::{Result, VmicError}; +use crate::pw::graph::{Port, PwGraph}; +use crate::pw::text; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +pub const LATENCY_MS: u32 = 32; +pub const SAMPLE_RATE: u32 = 48_000; + +const NODE_WAIT_TIMEOUT: Duration = Duration::from_secs(3); +const LINK_RETRY_TIMEOUT: Duration = Duration::from_secs(3); +const STAGE_STARTUP_CHECK: Duration = Duration::from_millis(500); + +/// Node names derived for a vmic. Always ASCII, since they come from a name +/// that already passed `state::valid_name`. +pub struct NodeNames { + pub sink: String, // stage 1 capture (apps play into this) + pub mid: String, // stage 1 playback (internal) + pub mix: String, // stage 2 capture (internal, hw mic joins here) + pub source: String, // stage 2 playback (recorders read from this) + pub sink_desc: String, + pub mid_desc: String, + pub mix_desc: String, + pub source_desc: String, +} + +impl NodeNames { + pub fn for_vmic(name: &str) -> Self { + let lower = name.to_lowercase(); + let upper = name.to_uppercase(); + Self { + sink: format!("vmic_{lower}_sink"), + mid: format!("vmic_{lower}_mid"), + mix: format!("vmic_{lower}_mix"), + source: format!("vmic_{lower}_mic"), + sink_desc: format!("VIRTUAL_MIC_{upper}_OUTPUT"), + mid_desc: format!("VIRTUAL_MIC_{upper}_MID_INTERNAL"), + mix_desc: format!("VIRTUAL_MIC_{upper}_MIX_INTERNAL"), + source_desc: format!("VIRTUAL_MIC_{upper}_INPUT"), + } + } +} + +/// OS pids and PipeWire node ids produced by [`create_stages`], persisted in +/// `VmicState` so later commands can terminate the processes and check +/// liveness against the live graph. +pub struct StageHandles { + pub sink_pid: u32, + pub mix_pid: u32, + pub sink_node_id: u32, + pub mix_node_id: u32, +} + +/// Creates the two chained loopback stages as detached `pw-loopback` +/// subprocesses (stdin is `/dev/null`, never waited on, so the OS +/// reparents them to init on exit), then links stage 1 into stage 2 +/// (mid -> mix). +/// +/// apps -> sink =(stage1)=> mid -> mix =(stage2)=> source -> recorders +/// ^ hw mic joins here (route --source) +pub fn create_stages(graph: &PwGraph, names: &NodeNames) -> Result { + let fmt = format!("audio.rate={SAMPLE_RATE} audio.position=[FL FR]"); + let log_path = std::env::temp_dir().join(format!( + "vmic_{}.log", + names.sink.trim_start_matches("vmic_").trim_end_matches("_sink") + )); + let log_file = std::fs::OpenOptions::new().create(true).append(true).open(&log_path)?; + + let mut sink_child = spawn_loopback_stage( + &format!( + "media.class=Audio/Sink node.name={} node.description={} node.virtual=false {fmt}", + names.sink, names.sink_desc + ), + &format!( + "media.class=Audio/Source node.name={} node.description={} node.virtual=false {fmt}", + names.mid, names.mid_desc + ), + &log_file, + )?; + + let mut mix_child = spawn_loopback_stage( + &format!( + "media.class=Audio/Sink node.name={} node.description={} node.virtual=false {fmt}", + names.mix, names.mix_desc + ), + &format!( + "media.class=Audio/Source node.name={} node.description={} node.virtual=false {fmt}", + names.source, names.source_desc + ), + &log_file, + )?; + + // Give both processes a moment to fail fast (e.g. a name collision) + // before trusting them. + std::thread::sleep(STAGE_STARTUP_CHECK); + if sink_child.try_wait()?.is_some() || mix_child.try_wait()?.is_some() { + let _ = sink_child.kill(); + let _ = mix_child.kill(); + return Err(VmicError::PipeWire(format!( + "pw-loopback exited immediately, check '{}'.", + log_path.display() + ))); + } + + let sink_pid = sink_child.id(); + let mix_pid = mix_child.id(); + + let stage_ids = (|| -> Result<(u32, u32)> { + let sink_node = graph.wait_for_node(&names.sink, NODE_WAIT_TIMEOUT)?; + let mix_node = graph.wait_for_node(&names.mix, NODE_WAIT_TIMEOUT)?; + link_stage(graph, &names.mid, &names.mix, LINK_RETRY_TIMEOUT)?; + Ok((sink_node.id, mix_node.id)) + })(); + + let (sink_node_id, mix_node_id) = match stage_ids { + Ok(ids) => ids, + Err(e) => { + let _ = sink_child.kill(); + let _ = mix_child.kill(); + return Err(e); + } + }; + + Ok(StageHandles { sink_pid, mix_pid, sink_node_id, mix_node_id }) +} + +fn spawn_loopback_stage(capture_props: &str, playback_props: &str, log_file: &std::fs::File) -> Result { + Command::new("pw-loopback") + .arg(format!("--latency={LATENCY_MS}")) + .args(["-m", "[ FL FR ]"]) + .arg(format!("--capture-props={capture_props}")) + .arg(format!("--playback-props={playback_props}")) + .stdin(Stdio::null()) + .stdout(log_file.try_clone()?) + .stderr(log_file.try_clone()?) + .spawn() + .map_err(VmicError::from) +} + +/// Resolves and links `from_node`'s output ports into `target_node`'s input +/// ports, retrying briefly while ports may still be appearing. Shared by +/// stage creation and `route -s`. +pub fn link_stage(graph: &PwGraph, from_node: &str, target_node: &str, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + loop { + let outs: Vec = graph.ports_for(from_node).into_iter().filter(|p| !p.is_input).collect(); + let ins: Vec = graph.ports_for(target_node).into_iter().filter(|p| p.is_input).collect(); + + if let Some(pairs) = resolve_link_pairs(&outs, &ins) { + let linked = pairs.iter().filter(|(o, i)| graph.link_ports(*o, *i).is_ok()).count() as u32; + if linked > 0 { + return Ok(linked); + } + } + + if Instant::now() >= deadline { + return Err(VmicError::PortResolution { + from: from_node.to_string(), + to: target_node.to_string(), + }); + } + graph.poll_tick(); + } +} + +/// Resolves the same port pairs `link_stage` would use and unlinks them, +/// best-effort. Used to tear down a mixed-in hardware source. +pub fn unlink_stage(graph: &PwGraph, from_node: &str, target_node: &str) -> Result { + let outs: Vec = graph.ports_for(from_node).into_iter().filter(|p| !p.is_input).collect(); + let ins: Vec = graph.ports_for(target_node).into_iter().filter(|p| p.is_input).collect(); + + let Some(pairs) = resolve_link_pairs(&outs, &ins) else { + return Ok(0); + }; + + let mut removed = 0; + for (o, i) in pairs { + if graph.unlink_ports(o, i).is_ok() { + removed += 1; + } + } + Ok(removed) +} + +fn find_port<'a>(ports: &'a [Port], name: &str) -> Option<&'a Port> { + ports.iter().find(|p| p.name == name) +} + +/// Resolves link pairs for feeding `outs` into `ins`. A mono source is +/// linked into both input channels. +fn resolve_link_pairs(outs: &[Port], ins: &[Port]) -> Option> { + let mut fl = find_port(outs, "capture_FL"); + let mut fr = find_port(outs, "capture_FR"); + // Sinks name their input ports playback_*; some name them input_*. + let mut ifl = find_port(ins, "input_FL").or_else(|| find_port(ins, "playback_FL")); + let mut ifr = find_port(ins, "input_FR").or_else(|| find_port(ins, "playback_FR")); + + // Fall back to first/second port for exotic port names. + if fl.is_none() { + fl = outs.first(); + } + if fr.is_none() { + fr = outs.get(1); + } + if ifl.is_none() { + ifl = ins.first(); + } + if ifr.is_none() { + ifr = ins.get(1); + } + + let fl = fl?; + let ifl = ifl?; + + let mut pairs = vec![(fl.id, ifl.id)]; + match (fr, ifr) { + (Some(fr), Some(ifr)) => pairs.push((fr.id, ifr.id)), + (None, Some(ifr)) => pairs.push((fl.id, ifr.id)), // mono source: feed both channels + _ => {} + } + Some(pairs) +} + +/// SIGTERM a verified pw-loopback pid, escalating to SIGKILL if it lingers. +/// `expected_name_fragment` must appear in the process's cmdline first, as +/// insurance against a stale/reused pid. +pub fn terminate_stage(pid: i64, expected_name_fragment: &str) -> Result<()> { + let pid = pid as libc::pid_t; + + if !pid_matches(pid, expected_name_fragment) { + return Ok(()); + } + + unsafe { libc::kill(pid, libc::SIGTERM) }; + + for _ in 0..10 { + if !process_alive(pid) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(100)); + } + + if process_alive(pid) && pid_matches(pid, expected_name_fragment) { + unsafe { libc::kill(pid, libc::SIGKILL) }; + } + Ok(()) +} + +fn process_alive(pid: libc::pid_t) -> bool { + unsafe { libc::kill(pid, 0) == 0 } +} + +/// True only if `pid` is a live `pw-loopback` process whose cmdline +/// mentions `expected_name_fragment`. +fn pid_matches(pid: libc::pid_t, expected_name_fragment: &str) -> bool { + let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else { + return false; + }; + let text = String::from_utf8_lossy(&cmdline); + text.contains("pw-loopback") && text.contains(expected_name_fragment) +} + +/// Unloads a pactl-hosted module (the self-monitor loopback). +pub fn unload_pulse_module(module_id: u32) -> Result<()> { + let status = Command::new("pactl") + .args(["unload-module", &module_id.to_string()]) + .status()?; + if !status.success() { + return Err(VmicError::PipeWire(format!("failed to unload module {module_id}"))); + } + Ok(()) +} + +/// Loads a self-monitor loopback: sink.monitor -> default sink. `graph` is +/// unused (module loading goes through `pactl`, see `pw/mod.rs`) but kept +/// so the signature matches its call sites. +pub fn enable_self_monitor(_graph: &PwGraph, sink_name: &str) -> Result { + let default_sink = String::from_utf8_lossy(&Command::new("pactl").arg("get-default-sink").output()?.stdout) + .trim() + .to_string(); + + if default_sink == sink_name { + return Err(VmicError::WouldFeedback); + } + + let output = Command::new("pactl") + .args([ + "load-module", + "module-loopback", + &format!("source={sink_name}.monitor"), + "sink=@DEFAULT_SINK@", + ]) + .output()?; + + if !output.status.success() { + return Err(VmicError::PipeWire("failed to create self-monitor loopback".into())); + } + String::from_utf8_lossy(&output.stdout) + .trim() + .parse() + .map_err(|_| VmicError::PipeWire("failed to create self-monitor loopback".into())) +} + +/// Sets the volume of the self-monitor loopback's playback stream, +/// retrying briefly while it may still be appearing. +pub fn set_loopback_volume(_graph: &PwGraph, loopback_id: u32, pct: u8) -> Result<()> { + let mut sink_input_id = None; + for _ in 0..5 { + if let Some(found) = text::find_sink_input_for_module(loopback_id)? { + sink_input_id = Some(found); + break; + } + std::thread::sleep(Duration::from_millis(200)); + } + let Some(sink_input_id) = sink_input_id else { + return Err(VmicError::PipeWire("loopback playback stream not found".into())); + }; + + let status = Command::new("pactl") + .args(["set-sink-input-volume", &sink_input_id.to_string(), &format!("{pct}%")]) + .status()?; + if !status.success() { + return Err(VmicError::PipeWire("pactl set-sink-input-volume failed".into())); + } + Ok(()) +} diff --git a/src/pw/mod.rs b/src/pw/mod.rs new file mode 100644 index 0000000..ad2e948 --- /dev/null +++ b/src/pw/mod.rs @@ -0,0 +1,28 @@ +//! PipeWire control, split into three modules: +//! +//! - `graph` - live graph control (link/unlink ports, break feedback +//! rings, node/port/link introspection) via `pipewire-rs`, +//! using only numeric object ids and this program's own +//! ASCII node names. +//! - `loopback` - creates/destroys the two chained loopback stages and the +//! self-monitor loopback, built on top of `graph`. +//! - `text` - byte-safe text matching (`pactl` subprocess output) for +//! anything that has to match against human-authored, +//! possibly-multibyte device names/descriptions. +//! +//! Module *lifecycle* (spawning/loading the loopback stages and the +//! self-monitor) can't go through `graph`: `pw_context_load_module` loads a +//! module into the *calling process's own* context, which then has to keep +//! running its main loop forever for the module's nodes to keep producing +//! audio - that's what `pw-loopback` itself is. A one-shot CLI invocation +//! can't be that persistent host, so the two chained stages stay +//! `pw-loopback` subprocesses (tracked by pid), and the self-monitor stays a +//! `pactl load-module`/`unload-module` call. Everything downstream of +//! that - linking, unlinking, ring-breaking, liveness - is native +//! `pipewire-rs` via `graph`. + +pub mod graph; +pub mod loopback; +pub mod text; + +pub use graph::PwGraph; diff --git a/src/pw/text.rs b/src/pw/text.rs new file mode 100644 index 0000000..a75e52c --- /dev/null +++ b/src/pw/text.rs @@ -0,0 +1,256 @@ +//! Byte-safe text scraping for anything that has to match against +//! human-authored device names/descriptions. +//! +//! PipeWire's structured JSON dump (`pw-dump --json`) has had real problems +//! serializing certain multibyte UTF-8 sequences in node/device properties - +//! a hardware mic whose description contains a "TM"-style trademark symbol +//! is a documented case that can produce JSON a strict deserializer refuses +//! to parse. This module shells out to `pactl`'s plain-text listings +//! instead, and reads output with `String::from_utf8_lossy`, so a stray or +//! unexpected byte sequence degrades to `\u{FFFD}` instead of aborting the +//! whole match or corrupting a JSON parse. +//! +//! This module owns ONLY name/description matching. Actually creating and +//! linking nodes goes through `pw::graph`'s numeric-id API once a match is +//! resolved to a concrete node name. + +use crate::error::Result; +use std::process::Command; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceMatch { + pub name: String, + pub description: String, +} + +/// Runs `pactl list sources` and parses Name/Description pairs. +pub fn list_sources() -> Result> { + let output = Command::new("pactl").args(["list", "sources"]).output()?; + Ok(parse_pactl_sources(&String::from_utf8_lossy(&output.stdout))) +} + +fn parse_pactl_sources(text: &str) -> Vec { + let mut sources = Vec::new(); + let mut current_name: Option = None; + + for line in text.lines() { + let trimmed = line.trim_start(); + if let Some(rest) = trimmed.strip_prefix("Name: ") { + current_name = Some(rest.trim().to_string()); + } else if let Some(rest) = trimmed.strip_prefix("Description: ") { + if let Some(name) = current_name.take() { + sources.push(SourceMatch { + name, + description: rest.trim().to_string(), + }); + } + } + } + sources +} + +/// Excludes monitor sources and vmic's own nodes (a vmic node routed as its +/// own hardware source would be an unguarded feedback loop - see +/// `PwGraph::break_feedback_rings`, which doesn't catch this case since it +/// only watches for rings through a sink's monitor port). Case-insensitive +/// substring match against "name description", or an explicit numeric +/// index into `pactl list short sources` (same exclusions apply there too). +pub fn match_source(filter: &str) -> Result> { + if let Ok(idx) = filter.parse::() { + let output = Command::new("pactl") + .args(["list", "short", "sources"]) + .output()?; + let text = String::from_utf8_lossy(&output.stdout); + return Ok(text + .lines() + .filter_map(|l| { + let mut cols = l.split_whitespace(); + let id = cols.next()?; + let name = cols.next()?; + (id == idx.to_string()).then(|| SourceMatch { + name: name.to_string(), + description: String::new(), + }) + }) + .filter(|s| !s.name.ends_with(".monitor") && !s.name.starts_with("vmic_")) + .collect()); + } + + let needle = filter.to_lowercase(); + Ok(list_sources()? + .into_iter() + .filter(|s| !s.name.ends_with(".monitor") && !s.name.starts_with("vmic_")) + .filter(|s| format!("{} {}", s.name, s.description).to_lowercase().contains(&needle)) + .collect()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StreamMatch { + pub id: String, + pub application: String, + pub media_name: String, +} + +/// Matches a numeric id, "app", or "app:media" (case-insensitive +/// substring) against sink-inputs or source-outputs. +pub fn match_streams(kind: &str, filter: &str) -> Result> { + let output = Command::new("pactl").args(["list", kind]).output()?; + let text = String::from_utf8_lossy(&output.stdout); + let streams = parse_pactl_streams(&text, kind); + + if let Ok(_) = filter.parse::() { + return Ok(streams.into_iter().filter(|s| s.id == filter).collect()); + } + + let mut parts = filter.splitn(2, ':'); + let app = parts.next().unwrap_or("").to_lowercase(); + let media = parts.next().unwrap_or("").to_lowercase(); + + Ok(streams + .into_iter() + .filter(|s| app.is_empty() || s.application.to_lowercase().contains(&app)) + .filter(|s| media.is_empty() || s.media_name.to_lowercase().contains(&media)) + .collect()) +} + +fn parse_pactl_streams(text: &str, kind: &str) -> Vec { + let header_prefix = if kind == "sink-inputs" { + "Sink Input #" + } else { + "Source Output #" + }; + + let mut streams = Vec::new(); + let mut id = String::new(); + let mut application = String::new(); + let mut media_name = String::new(); + let mut started = false; + + let flush = |streams: &mut Vec, id: &str, application: &str, media_name: &str| { + if !id.is_empty() { + streams.push(StreamMatch { + id: id.to_string(), + application: application.to_string(), + media_name: media_name.to_string(), + }); + } + }; + + for line in text.lines() { + if let Some(rest) = line.strip_prefix(header_prefix) { + flush(&mut streams, &id, &application, &media_name); + id = rest.trim_start_matches('#').trim().to_string(); + application.clear(); + media_name.clear(); + started = true; + continue; + } + if !started { + continue; + } + let trimmed = line.trim_start(); + if let Some(rest) = trimmed.strip_prefix("application.name = ") { + application = unquote(rest); + } else if let Some(rest) = trimmed.strip_prefix("media.name = ") { + media_name = unquote(rest); + } + } + flush(&mut streams, &id, &application, &media_name); + streams +} + +fn unquote(s: &str) -> String { + s.trim_matches('"').to_string() +} + +/// The sink-input (playback stream) owned by the given `module-loopback` +/// id, if it has appeared yet. Callers retry this briefly - see +/// `pw::loopback::set_loopback_volume`. +pub fn find_sink_input_for_module(module_id: u32) -> Result> { + let output = Command::new("pactl").args(["list", "sink-inputs"]).output()?; + Ok(parse_sink_input_for_module(&String::from_utf8_lossy(&output.stdout), module_id)) +} + +fn parse_sink_input_for_module(text: &str, module_id: u32) -> Option { + let mut current_id: Option = None; + for line in text.lines() { + if let Some(rest) = line.strip_prefix("Sink Input #") { + current_id = rest.trim().parse().ok(); + continue; + } + let trimmed = line.trim_start(); + if let Some(rest) = trimmed.strip_prefix("Owner Module: ") { + if rest.trim().parse::().ok() == Some(module_id) { + return current_id; + } + } + } + None +} + +/// Every `module-loopback` whose `Argument:` line mentions a `vmic_` node, +/// tracked in state or not. +pub fn list_vmic_loopback_module_ids() -> Result> { + let output = Command::new("pactl").args(["list", "modules"]).output()?; + Ok(parse_vmic_loopback_module_ids(&String::from_utf8_lossy(&output.stdout))) +} + +fn parse_vmic_loopback_module_ids(text: &str) -> Vec { + let mut ids = Vec::new(); + let mut current_id: Option = None; + let mut is_loopback = false; + + for line in text.lines() { + if let Some(rest) = line.strip_prefix("Module #") { + current_id = rest.trim().parse().ok(); + is_loopback = false; + continue; + } + let trimmed = line.trim_start(); + if let Some(rest) = trimmed.strip_prefix("Name: ") { + is_loopback = rest.trim() == "module-loopback"; + } else if let Some(rest) = trimmed.strip_prefix("Argument:") { + if is_loopback && rest.contains("vmic_") { + if let Some(id) = current_id { + ids.push(id); + } + } + } + } + ids +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_sink_input_owned_by_module() { + let text = "\ +Sink Input #10 +\tOwner Module: n/a +Sink Input #11 +\tOwner Module: 42 +Sink Input #12 +\tOwner Module: 7 +"; + assert_eq!(parse_sink_input_for_module(text, 42), Some(11)); + assert_eq!(parse_sink_input_for_module(text, 99), None); + } + + #[test] + fn lists_only_vmic_loopback_modules() { + let text = "\ +Module #1 +\tName: module-loopback +\tArgument: source=vmic_test_sink.monitor sink=@DEFAULT_SINK@ +Module #2 +\tName: module-loopback +\tArgument: source=alsa_input.usb-mic sink=@DEFAULT_SINK@ +Module #3 +\tName: libpipewire-module-rt +\tArgument: vmic_should_not_match +"; + assert_eq!(parse_vmic_loopback_module_ids(text), vec![1]); + } +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..203df46 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,187 @@ +use crate::error::{Result, VmicError}; +use rusqlite::{params, Connection, OptionalExtension}; +use std::path::PathBuf; + +/// Persisted state for one vmic, stored in `~/.config/vmic/vmic.db`. +#[derive(Debug, Clone, Default)] +pub struct VmicState { + pub name: String, + + pub sink_name: String, + pub mid_name: String, + pub mix_name: String, + pub source_name: String, + + // PipeWire node ids, used to check liveness against the live graph. + pub sink_node_id: Option, + pub mix_node_id: Option, + + // pw-loopback pids, needed alongside the node ids: a client can't + // destroy another client's node, so teardown happens by signal instead + // (see pw::loopback::terminate_stage). + pub sink_pid: Option, + pub mix_pid: Option, + + pub loopback_id: Option, + pub volume_pct: Option, + + // May contain arbitrary multibyte text; SQLite TEXT has no encoding + // pitfalls here, unlike PipeWire's JSON dump (see pw::text). + pub mic_source: Option, + pub mic_volume_pct: Option, +} + +fn db_path() -> PathBuf { + if let Ok(dir) = std::env::var("VMIC_STATE_DIR_OVERRIDE") { + return PathBuf::from(dir).join("vmic.db"); + } + dirs::config_dir() + .expect("could not resolve config dir") + .join("vmic") + .join("vmic.db") +} + +pub fn valid_name(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { return false }; + if !first.is_ascii_alphanumeric() { + return false; + } + name.len() <= 64 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +pub fn require_valid_name(name: &str) -> Result<()> { + if valid_name(name) { + Ok(()) + } else { + Err(VmicError::InvalidName(name.to_string())) + } +} + +/// Canonical form of a vmic name, used as the state-table key and for +/// deriving PipeWire node names. Names are lowercased so e.g. "Test" and +/// "test" can't collide on the underlying (already-lowercased) node names +/// while being treated as distinct rows in the state table. +pub fn normalize(name: &str) -> String { + name.to_lowercase() +} + +/// Opens (or creates) the database and ensures the schema exists. +pub fn open_db() -> Result { + let path = db_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let conn = Connection::open(path)?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS vmics ( + name TEXT PRIMARY KEY, + sink_name TEXT NOT NULL, + mid_name TEXT NOT NULL, + mix_name TEXT NOT NULL, + source_name TEXT NOT NULL, + sink_node_id INTEGER, + mix_node_id INTEGER, + sink_pid INTEGER, + mix_pid INTEGER, + loopback_id INTEGER, + volume_pct INTEGER, + mic_source TEXT, + mic_volume_pct INTEGER + );", + )?; + Ok(conn) +} + +impl VmicState { + fn from_row(row: &rusqlite::Row) -> rusqlite::Result { + Ok(Self { + name: row.get("name")?, + sink_name: row.get("sink_name")?, + mid_name: row.get("mid_name")?, + mix_name: row.get("mix_name")?, + source_name: row.get("source_name")?, + sink_node_id: row.get("sink_node_id")?, + mix_node_id: row.get("mix_node_id")?, + sink_pid: row.get("sink_pid")?, + mix_pid: row.get("mix_pid")?, + loopback_id: row.get("loopback_id")?, + volume_pct: row.get("volume_pct")?, + mic_source: row.get("mic_source")?, + mic_volume_pct: row.get("mic_volume_pct")?, + }) + } + + pub fn load(conn: &Connection, name: &str) -> Result { + require_valid_name(name)?; + conn.query_row("SELECT * FROM vmics WHERE name = ?1", params![name], Self::from_row) + .optional()? + .ok_or_else(|| VmicError::NotFound(name.to_string())) + } + + pub fn exists(conn: &Connection, name: &str) -> Result { + Ok(conn + .query_row("SELECT 1 FROM vmics WHERE name = ?1", params![name], |_| Ok(())) + .optional()? + .is_some()) + } + + /// Upsert: replaces the row for `self.name` if it exists. + pub fn save(&self, conn: &Connection) -> Result<()> { + conn.execute( + "INSERT INTO vmics ( + name, sink_name, mid_name, mix_name, source_name, + sink_node_id, mix_node_id, sink_pid, mix_pid, loopback_id, volume_pct, + mic_source, mic_volume_pct + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) + ON CONFLICT(name) DO UPDATE SET + sink_name = excluded.sink_name, + mid_name = excluded.mid_name, + mix_name = excluded.mix_name, + source_name = excluded.source_name, + sink_node_id = excluded.sink_node_id, + mix_node_id = excluded.mix_node_id, + sink_pid = excluded.sink_pid, + mix_pid = excluded.mix_pid, + loopback_id = excluded.loopback_id, + volume_pct = excluded.volume_pct, + mic_source = excluded.mic_source, + mic_volume_pct = excluded.mic_volume_pct", + params![ + self.name, + self.sink_name, + self.mid_name, + self.mix_name, + self.source_name, + self.sink_node_id, + self.mix_node_id, + self.sink_pid, + self.mix_pid, + self.loopback_id, + self.volume_pct, + self.mic_source, + self.mic_volume_pct, + ], + )?; + Ok(()) + } + + pub fn delete(conn: &Connection, name: &str) -> Result<()> { + conn.execute("DELETE FROM vmics WHERE name = ?1", params![name])?; + Ok(()) + } + + /// Lists every persisted vmic, sorted by name. + pub fn list_all(conn: &Connection) -> Result> { + let mut stmt = conn.prepare("SELECT * FROM vmics ORDER BY name")?; + let rows = stmt.query_map([], Self::from_row)?; + let mut out = Vec::new(); + for row in rows { + out.push(row?); + } + Ok(out) + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..fcc4c84 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,59 @@ +//! Colorized status output (NO_COLOR-aware). + +use std::io::IsTerminal; +use std::sync::OnceLock; + +/// Honors NO_COLOR (), dumb terminals, and redirected output. +fn color_enabled() -> bool { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| { + std::env::var_os("NO_COLOR").is_none() + && std::env::var("TERM").map(|t| t != "dumb").unwrap_or(true) + && std::io::stdout().is_terminal() + && std::io::stderr().is_terminal() + }) +} + +fn paint(code: &str, s: &str) -> String { + if color_enabled() { + format!("\x1b[{code}m{s}\x1b[0m") + } else { + s.to_string() + } +} + +pub fn red(s: &str) -> String { + paint("31", s) +} + +pub fn yellow(s: &str) -> String { + paint("33", s) +} + +pub fn green(s: &str) -> String { + paint("32", s) +} + +pub fn blue(s: &str) -> String { + paint("34", s) +} + +pub fn cyan(s: &str) -> String { + paint("36", s) +} + +pub fn err(msg: &str) { + eprintln!("{}", red(&format!("Error: {msg}"))); +} + +pub fn warn(msg: &str) { + eprintln!("{}", yellow(&format!("Warning: {msg}"))); +} + +pub fn info(msg: &str) { + println!("{}", blue(msg)); +} + +pub fn ok(msg: &str) { + println!("{}", green(msg)); +} From b3c87b02da13546b579b9cba8b55be57c9af7f2a Mon Sep 17 00:00:00 2001 From: Overlord Date: Tue, 11 Aug 2026 13:44:20 +0200 Subject: [PATCH 02/10] Improve error warnings and command behavior for `vmic` - Updated `vmic route` to use "none" instead of "off" for source removal, standardizing terminology. - Added warnings for handling stale `pw-loopback` processes during `vmic edit` operations. - Enhanced logging for `vmic route` to provide detailed feedback on moved streams. - Ensured stable sorting of ports in PipeWire graph for consistent behavior. --- README.md | 2 +- src/cli.rs | 4 ++-- src/commands/edit.rs | 18 ++++++++++++++++++ src/commands/route.rs | 20 ++++++++++++++++++-- src/pw/graph.rs | 8 +++++++- 5 files changed, 46 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 287eec6..7fbc7b7 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ LIBCLANG_PATH = "/home//.local/share/vmic-build/libclang-venv/lib/python3.* vmic create [-l] # create a vmic, optionally with self-monitor vmic route -i # move an app's playback into the vmic vmic route -o # move an app's recording off the vmic -vmic route -s # mix a hardware mic in, or remove it +vmic route -s # mix a hardware mic in, or remove it vmic edit -l # toggle the self-monitor loopback vmic edit -v <0.8|80> # self-monitor loopback volume vmic edit -sv <0.8|80> # mixed-in source volume diff --git a/src/cli.rs b/src/cli.rs index e168579..8d69c9f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -60,8 +60,8 @@ pub struct RouteArgs { #[arg(short, long = "output", value_name = "APP[:MEDIA]")] pub outputs: Vec, - /// Mix a hardware source into the vmic, or "off" to remove it. - #[arg(short, long = "source", value_name = "SOURCE|off")] + /// Mix a hardware source into the vmic, or "none" to remove it. + #[arg(short, long = "source", value_name = "SOURCE|none")] pub source: Option, } diff --git a/src/commands/edit.rs b/src/commands/edit.rs index 3182703..2922eaa 100644 --- a/src/commands/edit.rs +++ b/src/commands/edit.rs @@ -16,6 +16,7 @@ pub fn run(args: EditArgs) -> Result<()> { let name = state::normalize(&args.name); let mut state = VmicState::load(&conn, &name)?; let graph = PwGraph::connect()?; + warn_if_stale(&graph, &state); if let Some(enable) = args.loopback { edit_loopback(&graph, &mut state, enable)?; @@ -30,6 +31,23 @@ pub fn run(args: EditArgs) -> Result<()> { state.save(&conn) } +/// Warns if the tracked pw-loopback processes aren't visible in the live +/// graph, so changes made below may silently fail to apply. +fn warn_if_stale(graph: &PwGraph, state: &VmicState) { + let alive = |id: Option| id.is_some_and(|id| graph.nodes().iter().any(|n| n.id == id)); + if !alive(state.sink_node_id) { + ui::warn(&format!( + "vmic '{}' appears stale (matching pw-loopback process not found); changes may not apply!", + state.name + )); + } else if !alive(state.mix_node_id) { + ui::warn(&format!( + "vmic '{}' mix loopback process not found; -sv changes may not apply!", + state.name + )); + } +} + fn edit_loopback(graph: &PwGraph, state: &mut VmicState, enable: bool) -> Result<()> { match (enable, state.loopback_id) { (true, Some(id)) => { diff --git a/src/commands/route.rs b/src/commands/route.rs index 75d1bae..83bf4b0 100644 --- a/src/commands/route.rs +++ b/src/commands/route.rs @@ -44,12 +44,28 @@ fn move_stream(kind: &str, move_cmd: &str, target: &str, filter: &str) -> Result ui::warn(&format!("'{filter}': matched 0 streams, nothing moved.")); return Ok(()); } + + let mut moved = 0; for m in &matches { - std::process::Command::new("pactl") + let status = std::process::Command::new("pactl") .args([move_cmd, &m.id, target]) .status()?; + if status.success() { + moved += 1; + } + } + + let total = matches.len(); + if moved == total { + ui::info(&format!("'{filter}': matched {total} stream(s) -> moved to '{target}'.")); + } else if moved == 0 { + ui::warn(&format!("'{filter}': matched {total} stream(s), but none could be moved to '{target}'.")); + } else { + ui::warn(&format!( + "'{filter}': matched {total} stream(s), moved {moved} to '{target}' ({} failed).", + total - moved + )); } - ui::info(&format!("'{filter}': matched {} stream(s) -> moved to '{target}'.", matches.len())); Ok(()) } diff --git a/src/pw/graph.rs b/src/pw/graph.rs index 07752ed..73ce47a 100644 --- a/src/pw/graph.rs +++ b/src/pw/graph.rs @@ -165,7 +165,13 @@ impl PwGraph { let Some(node) = s.nodes.values().find(|n| n.name == node_name) else { return Vec::new(); }; - s.ports.values().filter(|p| p.node_id == node.id).cloned().collect() + // Ports come out of a HashMap, whose iteration order is randomized + // per process - sort by id (assigned monotonically by PipeWire) so + // callers that fall back to "first"/"second" port (see + // `loopback::resolve_link_pairs`) get a stable, reproducible pick. + let mut ports: Vec = s.ports.values().filter(|p| p.node_id == node.id).cloned().collect(); + ports.sort_by_key(|p| p.id); + ports } pub fn links(&self) -> Vec { From be70c55728d63c3b995d5186c49d1a9e2aa88d6d Mon Sep 17 00:00:00 2001 From: Overlord Date: Tue, 11 Aug 2026 13:51:18 +0200 Subject: [PATCH 03/10] Refactor help output formatting for improved clarity and readability - Updated subcommand help to display one summary line per subcommand, followed by detailed flag descriptions. - Added individual alignment for flag columns across subcommands for consistent presentation. - Replaced compact inline summaries with a more structured format to prevent line wrapping and improve visual layout. - Enhanced flag argument handling by introducing `value --- src/cli.rs | 6 +-- src/main.rs | 130 +++++++++++++++++++++++++++------------------------- 2 files changed, 71 insertions(+), 65 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 8d69c9f..00ef73e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -71,15 +71,15 @@ pub struct EditArgs { pub name: String, /// Enable or disable the self-monitor loopback. - #[arg(short, long)] + #[arg(short, long, value_name = "BOOL")] pub loopback: Option, /// Loopback volume: fraction (0.8) or percent (80). - #[arg(short, long)] + #[arg(short, long, value_name = "PCT")] pub volume: Option, /// Mixed-in source volume: fraction (0.8) or percent (80). - #[arg(long = "source-volume", visible_alias = "sv")] + #[arg(long = "source-volume", visible_alias = "sv", value_name = "PCT")] pub source_volume: Option, } diff --git a/src/main.rs b/src/main.rs index 7017510..b823087 100644 --- a/src/main.rs +++ b/src/main.rs @@ -70,9 +70,11 @@ fn wants_top_level_help(args: &[String]) -> bool { } } -/// Prints one screen of help: every subcommand's positional args and flags -/// summarized inline (e.g. ` [-l/--loopback]`), so nothing requires -/// drilling into a subcommand's own `--help` just to see what it takes. +/// Prints one screen of help: one short summary line per subcommand (name, +/// positional args, about), followed by an indented line per flag with its +/// own help text - so nothing requires drilling into a subcommand's own +/// `--help` just to see what it takes, without cramming everything about a +/// subcommand onto a single (often terminal-wrapping) line. fn print_full_help() { let mut cmd = Cli::command(); cmd.build(); // resolve default value names etc. before introspecting @@ -85,16 +87,37 @@ fn print_full_help() { println!(); println!("{}", ui::blue("Commands:")); - let rows: Vec<(String, String, String, String)> = cmd - .get_subcommands() - .filter(|s| s.get_name() != "help") - .map(|s| { - let aliases = s.get_visible_aliases().collect::>().join("/"); - let about = s.get_about().map(|a| a.to_string()).unwrap_or_default(); - (s.get_name().to_string(), compact_args(s), about, aliases) - }) - .collect(); - print_table(&rows); + let subcommands: Vec<&clap::Command> = cmd.get_subcommands().filter(|s| s.get_name() != "help").collect(); + let flag_rows_by_cmd: Vec> = subcommands.iter().map(|s| flag_rows(s)).collect(); + + let name_w = subcommands.iter().map(|s| s.get_name().len()).max().unwrap_or(0); + let pos_w = subcommands.iter().map(|s| visual_width(&positional_args(s))).max().unwrap_or(0); + // One width across every subcommand's flags, not just its own, so the + // help-text column lines up no matter which command it's under. + let flag_w = flag_rows_by_cmd.iter().flatten().map(|(f, _)| visual_width(f)).max().unwrap_or(0); + + for (i, s) in subcommands.iter().enumerate() { + let name = s.get_name(); + let positionals = positional_args(s); + let about = s.get_about().map(|a| a.to_string()).unwrap_or_default(); + let aliases = s.get_visible_aliases().collect::>().join("/"); + + print!(" {name:name_w$} {} {about}", pad_visual(&positionals, pos_w)); + if !aliases.is_empty() { + print!(" | {aliases}"); + } + println!(); + + let rows = &flag_rows_by_cmd[i]; + for (flag, help) in rows { + println!(" {} {help}", pad_visual(flag, flag_w)); + } + // Flagless commands stay a tight single line; only the multi-line + // (flag-bearing) ones get a blank line to separate them visually. + if !rows.is_empty() && i + 1 < subcommands.len() { + println!(); + } + } println!(); println!("{}", ui::blue("Options:")); @@ -109,40 +132,41 @@ fn print_full_help() { } } -/// `` for each positional, `[-x/--long ]` or `[-x/--long]` for -/// each flag, in one space-joined line - a compact stand-in for the -/// `[OPTIONS] ` a normal usage line would collapse this to. -/// -/// Colors match the original fish script's `vmic_usage_line`: positional -/// `<...>` args in cyan, optional `[...]` flag groups (whole bracket, -/// including any value placeholder inside) in yellow. -fn compact_args(cmd: &clap::Command) -> String { - let mut parts: Vec = cmd - .get_positionals() +/// `` for each positional arg of `cmd`, space-joined and colored cyan +/// (matching the original fish script's `vmic_usage_line` convention). +fn positional_args(cmd: &clap::Command) -> String { + cmd.get_positionals() .map(|a| ui::cyan(&format!("<{}>", a.get_id().as_str()))) - .collect(); - - for arg in cmd.get_arguments() { - if arg.is_positional() || arg.get_id().as_str() == "help" { - continue; - } - let Some(flag) = flag_names(arg) else { continue }; - let group = if matches!(arg.get_action(), clap::ArgAction::Set | clap::ArgAction::Append) { - let value = arg - .get_value_names() - .and_then(|v| v.first()) - .map(|v| v.to_string()) - .unwrap_or_else(|| arg.get_id().as_str().to_uppercase()); - format!("[{flag} <{value}>]") - } else { - format!("[{flag}]") - }; - parts.push(ui::yellow(&group)); - } - parts.join(" ") + .collect::>() + .join(" ") } -/// `-x, --long` / `-x` / `--long` for a non-positional arg, or `None` for +/// `(flag display, help text)` for each of `cmd`'s non-positional, non-help +/// args, e.g. `("-i, --input ", "Move matching sink-inputs +/// into this vmic's sink")`. The flag display is colored yellow, matching +/// the original fish script's convention for optional flags. +fn flag_rows(cmd: &clap::Command) -> Vec<(String, String)> { + cmd.get_arguments() + .filter(|a| !a.is_positional() && a.get_id().as_str() != "help") + .filter_map(|a| { + let flag = flag_names(a)?; + let display = if matches!(a.get_action(), clap::ArgAction::Set | clap::ArgAction::Append) { + let value = a + .get_value_names() + .and_then(|v| v.first()) + .map(|v| v.to_string()) + .unwrap_or_else(|| a.get_id().as_str().to_uppercase()); + format!("{flag} <{value}>") + } else { + flag + }; + let help = a.get_help().map(|h| h.to_string()).unwrap_or_default(); + Some((ui::yellow(&display), help)) + }) + .collect() +} + +/// `-x/--long` / `-x` / `--long` for a non-positional arg, or `None` for /// one with no visible flag at all. fn flag_names(arg: &clap::Arg) -> Option { match (arg.get_short(), arg.get_long()) { @@ -153,24 +177,6 @@ fn flag_names(arg: &clap::Arg) -> Option { } } -/// Prints `(name, args, about, aliases)` rows with the name and args -/// columns padded to their widest entry, so the `about` column lines up. -/// `args` carries ANSI color codes (see `compact_args`), so padding by -/// `.len()`/`{:width$}` would count invisible escape bytes as columns and -/// misalign everything - `pad_visual` pads by visible width instead. -/// `aliases`, if non-empty, trails after `| `. -fn print_table(rows: &[(String, String, String, String)]) { - let name_w = rows.iter().map(|(n, ..)| n.len()).max().unwrap_or(0); - let args_w = rows.iter().map(|(_, a, ..)| visual_width(a)).max().unwrap_or(0); - for (name, args, about, aliases) in rows { - print!(" {name:name_w$} {} {about}", pad_visual(args, args_w)); - if !aliases.is_empty() { - print!(" | {aliases}"); - } - println!(); - } -} - /// Number of visible columns in `s`, skipping any `\x1b[...m` ANSI SGR /// escape sequences it contains. fn visual_width(s: &str) -> usize { From 493f91bc28b574ec1fa4d1a9f689656c08931ed9 Mon Sep 17 00:00:00 2001 From: Overlord Date: Tue, 11 Aug 2026 15:33:36 +0200 Subject: [PATCH 04/10] Add support for `--loopback-no-mix` flag and topology synchronization - Introduced the `--loopback-no-mix` flag to control 2-node vs 4-node loopback topologies. - Updated topology handling logic to recompute and migrate as needed based on loopback flags. - Enhanced database schema to include `loopback_no_mix` with automatic migration for backward compatibility. - Refactored `create`, `edit`, and `route` commands to integrate new topology management capabilities. - Improved `list` command output to include architecture details. --- src/cli.rs | 5 +++ src/commands/create.rs | 13 +++--- src/commands/edit.rs | 45 +++++++++++++++++-- src/commands/list.rs | 10 +++++ src/commands/mod.rs | 7 ++- src/commands/route.rs | 34 +++++++------- src/pw/graph.rs | 16 ++++++- src/pw/loopback.rs | 100 ++++++++++++++++++++++++++++++++++++----- src/pw/text.rs | 83 ++++++++++++++++++++++++++++++++++ src/state.rs | 37 +++++++++++++-- 10 files changed, 308 insertions(+), 42 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 00ef73e..ee5df02 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -74,6 +74,11 @@ pub struct EditArgs { #[arg(short, long, value_name = "BOOL")] pub loopback: Option, + /// Keep a mixed-in source audible in the loopback instead of isolating + /// it (2-node topology instead of 4). + #[arg(long = "loopback-no-mix", value_name = "BOOL")] + pub loopback_no_mix: Option, + /// Loopback volume: fraction (0.8) or percent (80). #[arg(short, long, value_name = "PCT")] pub volume: Option, diff --git a/src/commands/create.rs b/src/commands/create.rs index d08217e..e2c44f6 100644 --- a/src/commands/create.rs +++ b/src/commands/create.rs @@ -21,7 +21,7 @@ pub fn run(args: CreateArgs) -> Result<()> { let orig_sink = default_output("get-default-sink"); let orig_source = default_output("get-default-source"); - let stages = loopback::create_stages(&graph, &names)?; + let stage = loopback::create_simple_stage(&graph, &names, &[], &[])?; if let Some(sink) = &orig_sink { let _ = Command::new("pactl").args(["set-default-sink", sink]).status(); @@ -38,16 +38,19 @@ pub fn run(args: CreateArgs) -> Result<()> { ui::warn(&format!("removed {removed} unexpected monitor feedback link(s).")); } + // A fresh vmic always starts as Simple2Node - no source can be mixed in + // yet, so there's nothing for a 4-node split to isolate (see + // commands::topology). mid_name/mix_name are still stored even though no + // live node backs them yet: cheap, and needed if this vmic later + // upgrades. mix_node_id/mix_pid stay at their Default (None). let mut state = VmicState { name: name.clone(), sink_name: names.sink.clone(), mid_name: names.mid, mix_name: names.mix, source_name: names.source.clone(), - sink_node_id: Some(stages.sink_node_id), - mix_node_id: Some(stages.mix_node_id), - sink_pid: Some(stages.sink_pid as i64), - mix_pid: Some(stages.mix_pid as i64), + sink_node_id: Some(stage.sink_node_id), + sink_pid: Some(stage.pid as i64), ..Default::default() }; diff --git a/src/commands/edit.rs b/src/commands/edit.rs index 2922eaa..595fb8f 100644 --- a/src/commands/edit.rs +++ b/src/commands/edit.rs @@ -1,14 +1,19 @@ use crate::cli::EditArgs; use crate::commands::to_pct; +use crate::commands::topology; use crate::error::{Result, VmicError}; use crate::pw::{loopback, PwGraph}; use crate::state::{self, VmicState}; use crate::ui; pub fn run(args: EditArgs) -> Result<()> { - if args.loopback.is_none() && args.volume.is_none() && args.source_volume.is_none() { + if args.loopback.is_none() + && args.loopback_no_mix.is_none() + && args.volume.is_none() + && args.source_volume.is_none() + { return Err(VmicError::NothingToDo( - "pass --loopback, --volume and/or --source-volume.".into(), + "pass --loopback, --loopback-no-mix, --volume and/or --source-volume.".into(), )); } @@ -21,6 +26,9 @@ pub fn run(args: EditArgs) -> Result<()> { if let Some(enable) = args.loopback { edit_loopback(&graph, &mut state, enable)?; } + if let Some(no_mix) = args.loopback_no_mix { + edit_loopback_no_mix(&mut state, no_mix); + } if let Some(volume) = args.volume { edit_volume(&graph, &mut state, volume)?; } @@ -28,6 +36,12 @@ pub fn run(args: EditArgs) -> Result<()> { edit_source_volume(&mut state, source_volume)?; } + // Only --loopback/--loopback-no-mix can change the desired topology - + // recompute it and migrate if needed. + if args.loopback.is_some() || args.loopback_no_mix.is_some() { + topology::sync_topology(&graph, &mut state)?; + } + state.save(&conn) } @@ -40,7 +54,10 @@ fn warn_if_stale(graph: &PwGraph, state: &VmicState) { "vmic '{}' appears stale (matching pw-loopback process not found); changes may not apply!", state.name )); - } else if !alive(state.mix_node_id) { + } else if state.mix_pid.is_some() && !alive(state.mix_node_id) { + // A missing mix stage is only suspicious when one is supposed to + // exist (Pure4Node topology, see commands::topology) - a + // Simple2Node vmic never has one. ui::warn(&format!( "vmic '{}' mix loopback process not found; -sv changes may not apply!", state.name @@ -86,6 +103,28 @@ fn edit_loopback(graph: &PwGraph, state: &mut VmicState, enable: bool) -> Result Ok(()) } +fn edit_loopback_no_mix(state: &mut VmicState, no_mix: bool) { + if state.loopback_no_mix == no_mix { + ui::warn(&format!( + "--loopback-no-mix is already {no_mix} for '{}'.", + state.name + )); + return; + } + state.loopback_no_mix = no_mix; + if no_mix { + ui::ok(&format!( + "'{}' will keep a mixed-in source in the self-monitor loopback instead of isolating it.", + state.name + )); + } else { + ui::ok(&format!( + "'{}' will isolate a mixed-in source from the self-monitor loopback again.", + state.name + )); + } +} + fn edit_volume(graph: &PwGraph, state: &mut VmicState, value: f32) -> Result<()> { let pct = to_pct(value).ok_or_else(|| VmicError::InvalidVolume(value.to_string()))?; state.volume_pct = Some(pct); diff --git a/src/commands/list.rs b/src/commands/list.rs index aab50f6..7508d0c 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -1,3 +1,4 @@ +use crate::commands::topology; use crate::error::Result; use crate::pw::PwGraph; use crate::state::{self, VmicState}; @@ -21,6 +22,7 @@ pub fn run() -> Result<()> { println!(" output device: {}", state.sink_name); println!(" mic device: {}", state.source_name); println!(" status: {status_colored}"); + println!(" architecture: {}", topology::architecture_label(state)); println!( " self-monitor loopback: {}", state @@ -46,12 +48,20 @@ pub fn run() -> Result<()> { } /// Checks `sink_node_id`/`mix_node_id` against the live graph snapshot. +/// `mix_node_id` is only expected to be alive when this vmic is currently +/// running as `Pure4Node` (`mix_pid.is_some()`) - a `Simple2Node` vmic never +/// has one, and that's not a problem. fn liveness(graph: Option<&PwGraph>, state: &VmicState) -> String { let Some(graph) = graph else { return "unknown (could not connect to PipeWire)".to_string(); }; let nodes = graph.nodes(); let sink_alive = state.sink_node_id.is_some_and(|id| nodes.iter().any(|n| n.id == id)); + + if topology::current_topology(state) == topology::Topology::Simple2Node { + return if sink_alive { "active".to_string() } else { "stale (matching pw-loopback process not found)".to_string() }; + } + let mix_alive = state.mix_node_id.is_some_and(|id| nodes.iter().any(|n| n.id == id)); match (sink_alive, mix_alive) { (true, true) => "active".to_string(), diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 2bb746d..bb5c310 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod delete; pub mod edit; pub mod list; pub mod route; +pub mod topology; pub mod wipe; use crate::error::Result; @@ -30,6 +31,10 @@ pub(crate) fn unsource(graph: &PwGraph, state: &mut VmicState) -> Result<()> { let Some(mic) = state.mic_source.take() else { return Ok(()); }; - loopback::unlink_stage(graph, &mic, &state.mix_name)?; + // Target depends on the current topology: a Simple2Node vmic mixes the + // source straight into its sink, not a (nonexistent) mix stage. + let current = topology::current_topology(state); + let target = topology::mic_target(state, current).to_string(); + loopback::unlink_stage(graph, &mic, &target)?; Ok(()) } diff --git a/src/commands/route.rs b/src/commands/route.rs index 83bf4b0..36b7353 100644 --- a/src/commands/route.rs +++ b/src/commands/route.rs @@ -1,13 +1,9 @@ use crate::cli::RouteArgs; +use crate::commands::topology::{self, Topology}; use crate::error::{Result, VmicError}; -use crate::pw::{loopback, text, PwGraph}; +use crate::pw::{text, PwGraph}; use crate::state::{self, VmicState}; use crate::ui; -use std::time::Duration; - -/// Shorter than stage-creation's timeout since the source already exists - -/// this is just retrying while its ports enumerate. -const SOURCE_LINK_TIMEOUT: Duration = Duration::from_secs(2); pub fn run(args: RouteArgs) -> Result<()> { let conn = state::open_db()?; @@ -69,6 +65,10 @@ fn move_stream(kind: &str, move_cmd: &str, target: &str, filter: &str) -> Result Ok(()) } +/// Sets or clears the mixed-in hardware source, then hands off to +/// `topology::sync_topology` to (re)link it and, if the loopback/no-mix +/// flags call for it, upgrade or downgrade between the 2-node and 4-node +/// shapes. fn route_source(graph: &PwGraph, state: &mut VmicState, value: &str) -> Result<()> { let lowered = value.to_lowercase(); if matches!(lowered.as_str(), "off" | "none" | "disable") { @@ -77,6 +77,7 @@ fn route_source(graph: &PwGraph, state: &mut VmicState, value: &str) -> Result<( return Ok(()); }; super::unsource(graph, state)?; + topology::sync_topology(graph, state)?; ui::ok(&format!("Removed source mix '{mic}' from '{}'.", state.name)); return Ok(()); } @@ -100,19 +101,16 @@ fn route_source(graph: &PwGraph, state: &mut VmicState, value: &str) -> Result<( super::unsource(graph, state)?; } - let linked = loopback::link_stage(graph, &mic, &state.mix_name, SOURCE_LINK_TIMEOUT)?; state.mic_source = Some(mic.clone()); + topology::sync_topology(graph, state)?; - // Insurance: make sure the new links did not create a monitor ring. - let prefix = format!("vmic_{}_", state.name.to_lowercase()); - let rings = graph.break_feedback_rings(&prefix)?; - if rings > 0 { - ui::warn(&format!("removed {rings} unexpected monitor feedback link(s).")); - } - - ui::ok(&format!( - "Mixed '{mic}' into the mix stage of '{}' ({linked} link(s); not audible in self-monitor).", - state.name - )); + let note = match topology::current_topology(state) { + Topology::Pure4Node => "isolated mix stage; not audible in self-monitor", + Topology::Simple2Node if state.loopback_id.is_some() => { + "sink; also audible in self-monitor - see --loopback-no-mix" + } + Topology::Simple2Node => "sink", + }; + ui::ok(&format!("Mixed '{mic}' into '{}' ({note}).", state.name)); Ok(()) } diff --git a/src/pw/graph.rs b/src/pw/graph.rs index 73ce47a..8786c4a 100644 --- a/src/pw/graph.rs +++ b/src/pw/graph.rs @@ -160,6 +160,11 @@ impl PwGraph { self.state.borrow().nodes.values().cloned().collect() } + /// Must not be called on a node name that may transiently have two live + /// nodes (e.g. mid-migration in `commands::topology`, where an old and a + /// new node can briefly share a name) - it has no way to disambiguate + /// them. Callers in that situation must resolve the specific node id + /// first (see `wait_for_new_node`) and work with ids from then on. pub fn ports_for(&self, node_name: &str) -> Vec { let s = self.state.borrow(); let Some(node) = s.nodes.values().find(|n| n.name == node_name) else { @@ -287,9 +292,18 @@ impl PwGraph { /// Waits (bounded) for a node to appear in the graph. pub fn wait_for_node(&self, name: &str, timeout: Duration) -> Result { + self.wait_for_new_node(name, &[], timeout) + } + + /// Like `wait_for_node`, but ignores any node whose id is in `excluding`. + /// Needed during a topology migration (`commands::topology`), where a + /// freshly spawned node can transiently share a `node.name` with the + /// old one it's about to replace - a plain name match could otherwise + /// resolve to the dying node instead of the new one. + pub fn wait_for_new_node(&self, name: &str, excluding: &[u32], timeout: Duration) -> Result { let deadline = Instant::now() + timeout; loop { - if let Some(n) = self.nodes().into_iter().find(|n| n.name == name) { + if let Some(n) = self.nodes().into_iter().find(|n| n.name == name && !excluding.contains(&n.id)) { return Ok(n); } if Instant::now() >= deadline { diff --git a/src/pw/loopback.rs b/src/pw/loopback.rs index 7c4e5ed..9f2fa0e 100644 --- a/src/pw/loopback.rs +++ b/src/pw/loopback.rs @@ -51,20 +51,100 @@ pub struct StageHandles { pub mix_node_id: u32, } -/// Creates the two chained loopback stages as detached `pw-loopback` -/// subprocesses (stdin is `/dev/null`, never waited on, so the OS -/// reparents them to init on exit), then links stage 1 into stage 2 -/// (mid -> mix). -/// -/// apps -> sink =(stage1)=> mid -> mix =(stage2)=> source -> recorders -/// ^ hw mic joins here (route --source) -pub fn create_stages(graph: &PwGraph, names: &NodeNames) -> Result { - let fmt = format!("audio.rate={SAMPLE_RATE} audio.position=[FL FR]"); +/// OS pid and sink PipeWire node id produced by [`create_simple_stage`]. +pub struct SingleStageHandles { + pub pid: u32, + pub sink_node_id: u32, +} + +fn log_file_for(names: &NodeNames) -> Result<(std::path::PathBuf, std::fs::File)> { let log_path = std::env::temp_dir().join(format!( "vmic_{}.log", names.sink.trim_start_matches("vmic_").trim_end_matches("_sink") )); let log_file = std::fs::OpenOptions::new().create(true).append(true).open(&log_path)?; + Ok((log_path, log_file)) +} + +/// Creates the baseline single-stage loopback: apps -> sink =(pw-loopback)=> +/// source_name -> recorders. Used whenever a 4-node split isn't needed (see +/// `commands::topology::desired_topology`) - the common case, and the reason +/// a fresh vmic only ever costs one process, not two. +/// +/// `exclude_sink_ids`/`exclude_source_ids` matter only when this is called as +/// part of a topology migration (`commands::topology::migrate`), where a +/// same-named node from the topology being replaced may still be alive at +/// spawn time; see `PwGraph::wait_for_new_node`. Pass empty slices for a +/// brand-new vmic. +pub fn create_simple_stage( + graph: &PwGraph, + names: &NodeNames, + exclude_sink_ids: &[u32], + exclude_source_ids: &[u32], +) -> Result { + let fmt = format!("audio.rate={SAMPLE_RATE} audio.position=[FL FR]"); + let (log_path, log_file) = log_file_for(names)?; + + let mut child = spawn_loopback_stage( + &format!( + "media.class=Audio/Sink node.name={} node.description={} node.virtual=false {fmt}", + names.sink, names.sink_desc + ), + &format!( + "media.class=Audio/Source node.name={} node.description={} node.virtual=false {fmt}", + names.source, names.source_desc + ), + &log_file, + )?; + + // Give the process a moment to fail fast (e.g. a name collision) before + // trusting it. + std::thread::sleep(STAGE_STARTUP_CHECK); + if child.try_wait()?.is_some() { + let _ = child.kill(); + return Err(VmicError::PipeWire(format!( + "pw-loopback exited immediately, check '{}'.", + log_path.display() + ))); + } + + let pid = child.id(); + + // Unlike create_stages, there's no link_stage retry loop to implicitly + // wait through - both nodes must be waited for explicitly. + let sink_node_id = (|| -> Result { + let sink_node = graph.wait_for_new_node(&names.sink, exclude_sink_ids, NODE_WAIT_TIMEOUT)?; + graph.wait_for_new_node(&names.source, exclude_source_ids, NODE_WAIT_TIMEOUT)?; + Ok(sink_node.id) + })(); + + let sink_node_id = match sink_node_id { + Ok(id) => id, + Err(e) => { + let _ = child.kill(); + return Err(e); + } + }; + + Ok(SingleStageHandles { pid, sink_node_id }) +} + +/// Creates the two chained loopback stages as detached `pw-loopback` +/// subprocesses (stdin is `/dev/null`, never waited on, so the OS +/// reparents them to init on exit), then links stage 1 into stage 2 +/// (mid -> mix). Used only when a mixed-in source needs to be kept out of +/// the self-monitor loopback (see `commands::topology`). +/// +/// apps -> sink =(stage1)=> mid -> mix =(stage2)=> source -> recorders +/// ^ hw mic joins here (route --source) +/// +/// `exclude_sink_ids` is the same migration-safety mechanism as +/// `create_simple_stage`'s - `mid`/`mix` never need it, since those names are +/// exclusive to this topology and can't already exist across a migration +/// boundary. +pub fn create_stages(graph: &PwGraph, names: &NodeNames, exclude_sink_ids: &[u32]) -> Result { + let fmt = format!("audio.rate={SAMPLE_RATE} audio.position=[FL FR]"); + let (log_path, log_file) = log_file_for(names)?; let mut sink_child = spawn_loopback_stage( &format!( @@ -106,7 +186,7 @@ pub fn create_stages(graph: &PwGraph, names: &NodeNames) -> Result let mix_pid = mix_child.id(); let stage_ids = (|| -> Result<(u32, u32)> { - let sink_node = graph.wait_for_node(&names.sink, NODE_WAIT_TIMEOUT)?; + let sink_node = graph.wait_for_new_node(&names.sink, exclude_sink_ids, NODE_WAIT_TIMEOUT)?; let mix_node = graph.wait_for_node(&names.mix, NODE_WAIT_TIMEOUT)?; link_stage(graph, &names.mid, &names.mix, LINK_RETRY_TIMEOUT)?; Ok((sink_node.id, mix_node.id)) diff --git a/src/pw/text.rs b/src/pw/text.rs index a75e52c..f18c2da 100644 --- a/src/pw/text.rs +++ b/src/pw/text.rs @@ -188,6 +188,64 @@ fn parse_sink_input_for_module(text: &str, module_id: u32) -> Option { None } +/// Numeric pulse-side id for `name` within `kind` ("sinks" or "sources"), +/// via `pactl list short ` - same id/name column layout used by +/// `match_source`'s numeric branch and `wipe::first_non_vmic`. +fn resolve_pulse_id(kind: &str, name: &str) -> Result> { + let output = Command::new("pactl").args(["list", "short", kind]).output()?; + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .find_map(|l| { + let mut cols = l.split_whitespace(); + let id = cols.next()?.parse::().ok()?; + (cols.next()? == name).then_some(id) + })) +} + +/// Ids of every sink-input currently targeting `sink_name`. Used by +/// `commands::topology::migrate` to snapshot stream routing before a +/// migration recreates the sink node, so they can be moved back afterward. +pub fn sink_inputs_on(sink_name: &str) -> Result> { + let Some(id) = resolve_pulse_id("sinks", sink_name)? else { + return Ok(Vec::new()); + }; + let output = Command::new("pactl").args(["list", "sink-inputs"]).output()?; + Ok(parse_ids_matching(&String::from_utf8_lossy(&output.stdout), "Sink Input #", "Sink: ", id)) +} + +/// Ids of every source-output currently reading from `source_name`. See +/// `sink_inputs_on`. +pub fn source_outputs_on(source_name: &str) -> Result> { + let Some(id) = resolve_pulse_id("sources", source_name)? else { + return Ok(Vec::new()); + }; + let output = Command::new("pactl").args(["list", "source-outputs"]).output()?; + Ok(parse_ids_matching(&String::from_utf8_lossy(&output.stdout), "Source Output #", "Source: ", id)) +} + +/// Ids of every block (headed by `header_prefix`) whose `key_prefix` +/// property line equals `target_id`. +fn parse_ids_matching(text: &str, header_prefix: &str, key_prefix: &str, target_id: u32) -> Vec { + let mut ids = Vec::new(); + let mut current_id: Option = None; + + for line in text.lines() { + if let Some(rest) = line.strip_prefix(header_prefix) { + current_id = rest.trim().parse().ok(); + continue; + } + let trimmed = line.trim_start(); + if let Some(rest) = trimmed.strip_prefix(key_prefix) { + if rest.trim().parse::().ok() == Some(target_id) { + if let Some(id) = current_id { + ids.push(id); + } + } + } + } + ids +} + /// Every `module-loopback` whose `Argument:` line mentions a `vmic_` node, /// tracked in state or not. pub fn list_vmic_loopback_module_ids() -> Result> { @@ -238,6 +296,31 @@ Sink Input #12 assert_eq!(parse_sink_input_for_module(text, 99), None); } + #[test] + fn finds_sink_inputs_targeting_sink() { + let text = "\ +Sink Input #10 +\tSink: 65 +Sink Input #11 +\tSink: 99 +Sink Input #12 +\tSink: 65 +"; + assert_eq!(parse_ids_matching(text, "Sink Input #", "Sink: ", 65), vec![10, 12]); + assert_eq!(parse_ids_matching(text, "Sink Input #", "Sink: ", 1), Vec::::new()); + } + + #[test] + fn finds_source_outputs_targeting_source() { + let text = "\ +Source Output #20 +\tSource: 3 +Source Output #21 +\tSource: 4 +"; + assert_eq!(parse_ids_matching(text, "Source Output #", "Source: ", 3), vec![20]); + } + #[test] fn lists_only_vmic_loopback_modules() { let text = "\ diff --git a/src/state.rs b/src/state.rs index 203df46..2ccd0c4 100644 --- a/src/state.rs +++ b/src/state.rs @@ -29,6 +29,11 @@ pub struct VmicState { // pitfalls here, unlike PipeWire's JSON dump (see pw::text). pub mic_source: Option, pub mic_volume_pct: Option, + + // Opt-in: keep the 2-node topology even when a source is mixed in while + // the self-monitor loopback is on, accepting that the mix becomes + // audible in the loopback instead of paying for a 4-node split. + pub loopback_no_mix: bool, } fn db_path() -> PathBuf { @@ -90,12 +95,33 @@ pub fn open_db() -> Result { loopback_id INTEGER, volume_pct INTEGER, mic_source TEXT, - mic_volume_pct INTEGER + mic_volume_pct INTEGER, + loopback_no_mix INTEGER NOT NULL DEFAULT 0 );", )?; + migrate_add_loopback_no_mix(&conn)?; Ok(conn) } +/// `CREATE TABLE IF NOT EXISTS` above only covers fresh databases; an +/// existing `vmic.db` from before `loopback_no_mix` existed needs the column +/// added explicitly. Safe to call unconditionally - a no-op once the column +/// is present either way. +fn migrate_add_loopback_no_mix(conn: &Connection) -> Result<()> { + let has_column = conn + .prepare("PRAGMA table_info(vmics)")? + .query_map([], |row| row.get::<_, String>(1))? + .filter_map(std::result::Result::ok) + .any(|c| c == "loopback_no_mix"); + if !has_column { + conn.execute( + "ALTER TABLE vmics ADD COLUMN loopback_no_mix INTEGER NOT NULL DEFAULT 0", + [], + )?; + } + Ok(()) +} + impl VmicState { fn from_row(row: &rusqlite::Row) -> rusqlite::Result { Ok(Self { @@ -112,6 +138,7 @@ impl VmicState { volume_pct: row.get("volume_pct")?, mic_source: row.get("mic_source")?, mic_volume_pct: row.get("mic_volume_pct")?, + loopback_no_mix: row.get("loopback_no_mix")?, }) } @@ -135,8 +162,8 @@ impl VmicState { "INSERT INTO vmics ( name, sink_name, mid_name, mix_name, source_name, sink_node_id, mix_node_id, sink_pid, mix_pid, loopback_id, volume_pct, - mic_source, mic_volume_pct - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) + mic_source, mic_volume_pct, loopback_no_mix + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) ON CONFLICT(name) DO UPDATE SET sink_name = excluded.sink_name, mid_name = excluded.mid_name, @@ -149,7 +176,8 @@ impl VmicState { loopback_id = excluded.loopback_id, volume_pct = excluded.volume_pct, mic_source = excluded.mic_source, - mic_volume_pct = excluded.mic_volume_pct", + mic_volume_pct = excluded.mic_volume_pct, + loopback_no_mix = excluded.loopback_no_mix", params![ self.name, self.sink_name, @@ -164,6 +192,7 @@ impl VmicState { self.volume_pct, self.mic_source, self.mic_volume_pct, + self.loopback_no_mix, ], )?; Ok(()) From 50849540fa1b71681e649847022d6919a3609b61 Mon Sep 17 00:00:00 2001 From: Overlord Date: Tue, 11 Aug 2026 15:33:40 +0200 Subject: [PATCH 05/10] Add support for `--loopback-no-mix` flag and topology synchronization - Introduced the `--loopback-no-mix` flag to control 2-node vs 4-node loopback topologies. - Updated topology handling logic to recompute and migrate as needed based on loopback flags. - Enhanced database schema to include `loopback_no_mix` with automatic migration for backward compatibility. - Refactored `create`, `edit`, and `route` commands to integrate new topology management capabilities. - Improved `list` command output to include architecture details. --- src/commands/topology.rs | 241 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 src/commands/topology.rs diff --git a/src/commands/topology.rs b/src/commands/topology.rs new file mode 100644 index 0000000..5fdcc01 --- /dev/null +++ b/src/commands/topology.rs @@ -0,0 +1,241 @@ +//! Decides and migrates between the two node shapes a vmic can run as, and +//! is the sole place that decision is made - every command that can change +//! `loopback_id`/`mic_source`/`loopback_no_mix` calls [`sync_topology`] +//! afterward rather than deciding for itself. +//! +//! - `Simple2Node`: one `pw-loopback` process, `sink` capture straight into +//! `mic` playback. The baseline - cheapest, and correct whenever there's +//! nothing to isolate from the self-monitor loopback. +//! - `Pure4Node`: today's `sink -> mid -> mix -> mic` split. Only earns its +//! keep when a mixed-in hardware source would otherwise leak into an +//! active self-monitor loopback. +//! +//! Switching between the two necessarily recreates the nodes backing `sink`/ +//! `mic` (same names, new ids) - a `pw-loopback` process's capture/playback +//! pair share a lifecycle and can't be renamed live, and a playback node +//! can't have a second audio feed injected into it externally. `migrate` +//! spawns the new stage(s) before tearing down the old ones (so a failed +//! spawn leaves the old topology fully untouched), then best-effort +//! reconnects whatever was plugged into the old nodes and reports what +//! happened. + +use crate::error::Result; +use crate::pw::{loopback, text, PwGraph}; +use crate::state::VmicState; +use crate::ui; +use std::process::Command; +use std::time::Duration; + +const SOURCE_LINK_TIMEOUT: Duration = Duration::from_secs(2); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Topology { + Simple2Node, + Pure4Node, +} + +fn shape_label(topology: Topology) -> &'static str { + match topology { + Topology::Simple2Node => "2-node", + Topology::Pure4Node => "4-node", + } +} + +/// What's actually running right now, derived from `mix_pid` rather than +/// separately persisted - `mix_pid.is_some()` has always meant "the second +/// stage exists" (see `delete.rs`'s teardown logic). +pub fn current_topology(state: &VmicState) -> Topology { + if state.mix_pid.is_some() { + Topology::Pure4Node + } else { + Topology::Simple2Node + } +} + +/// What *should* be running, given the current flags. Isolating a mixed-in +/// source from the self-monitor is only a meaningful, achievable property +/// when both a loopback and a source actually exist - every other +/// combination gets nothing from paying for the second stage. +pub fn desired_topology(state: &VmicState) -> Topology { + if state.loopback_id.is_some() && state.mic_source.is_some() && !state.loopback_no_mix { + Topology::Pure4Node + } else { + Topology::Simple2Node + } +} + +/// The node a mixed-in hardware source should link into for `topology`: the +/// isolated mix stage in `Pure4Node`, or the sink directly in `Simple2Node` +/// (where it's also audible in any active self-monitor loopback). +pub fn mic_target(state: &VmicState, topology: Topology) -> &str { + match topology { + Topology::Pure4Node => &state.mix_name, + Topology::Simple2Node => &state.sink_name, + } +} + +/// Human-readable summary of the current shape for `vmic list`. +pub fn architecture_label(state: &VmicState) -> String { + match current_topology(state) { + Topology::Pure4Node => "4-node (source isolated from self-monitor)".to_string(), + Topology::Simple2Node if state.mic_source.is_some() && state.loopback_id.is_some() => { + "2-node (source audible in self-monitor - see --loopback-no-mix)".to_string() + } + Topology::Simple2Node => "2-node (simple)".to_string(), + } +} + +/// Recomputes the desired topology from `state`'s current flags and +/// migrates the live PipeWire nodes to match if needed; otherwise just +/// (re)links a mixed-in source into the right target as insurance (link_ports +/// is idempotent, so this is a cheap no-op most of the time). Callers still +/// need to `state.save()` afterward - this only mutates in-memory `state` +/// plus the live graph. +pub fn sync_topology(graph: &PwGraph, state: &mut VmicState) -> Result<()> { + let current = current_topology(state); + let desired = desired_topology(state); + + if current == desired { + if let Some(mic) = state.mic_source.clone() { + relink_source(graph, state, &mic, desired)?; + } + return Ok(()); + } + + migrate(graph, state, desired) +} + +fn relink_source(graph: &PwGraph, state: &VmicState, mic: &str, topology: Topology) -> Result<()> { + let target = mic_target(state, topology).to_string(); + loopback::link_stage(graph, mic, &target, SOURCE_LINK_TIMEOUT)?; + let prefix = format!("vmic_{}_", state.name); + let rings = graph.break_feedback_rings(&prefix)?; + if rings > 0 { + ui::warn(&format!("removed {rings} unexpected monitor feedback link(s).")); + } + Ok(()) +} + +fn migrate(graph: &PwGraph, state: &mut VmicState, to: Topology) -> Result<()> { + let from = current_topology(state); + ui::info(&format!( + "Rebuilding '{}' internals ({} -> {})...", + state.name, + shape_label(from), + shape_label(to) + )); + + // 1. Snapshot - best-effort throughout; a `pactl` hiccup here just means + // less gets auto-reconnected later, not a failed migration. + let live_nodes = graph.nodes(); + let exclude_sink_ids: Vec = + live_nodes.iter().filter(|n| n.name == state.sink_name).map(|n| n.id).collect(); + let exclude_source_ids: Vec = + live_nodes.iter().filter(|n| n.name == state.source_name).map(|n| n.id).collect(); + let sink_inputs = text::sink_inputs_on(&state.sink_name).unwrap_or_default(); + let source_outputs = text::source_outputs_on(&state.source_name).unwrap_or_default(); + + // 2. Spawn the new stage(s) for `to`. The only hard-fail point: on + // error, return immediately with the old topology and `state` + // completely untouched. + let names = loopback::NodeNames::for_vmic(&state.name); + let (new_sink_pid, new_sink_node_id, new_mix_pid, new_mix_node_id) = match to { + Topology::Simple2Node => { + let s = loopback::create_simple_stage(graph, &names, &exclude_sink_ids, &exclude_source_ids)?; + (s.pid as i64, s.sink_node_id, None, None) + } + Topology::Pure4Node => { + let s = loopback::create_stages(graph, &names, &exclude_sink_ids)?; + (s.sink_pid as i64, s.sink_node_id, Some(s.mix_pid as i64), Some(s.mix_node_id)) + } + }; + + // 3. Only now tear down the old stage(s) - all best-effort, never `?`, + // since by this point the new stage(s) are already confirmed alive. + if let Some(loopback_id) = state.loopback_id { + let _ = loopback::unload_pulse_module(loopback_id); + } + if let Some(pid) = state.mix_pid { + let _ = loopback::terminate_stage(pid, &state.mix_name); + } + if let Some(pid) = state.sink_pid { + let _ = loopback::terminate_stage(pid, &state.sink_name); + } + // terminate_stage only waits for the OS process to die, not for our own + // PwGraph's cached registry snapshot to catch up - without this, a + // later name-based lookup (step 7's relink_source, when downgrading to + // Simple2Node while a source stays mixed in: mic_target becomes + // `sink_name`, the exact name just torn down) could still resolve the + // dying old node instead of the new one. Flush pending removal events + // now, before anything downstream resolves ports by name again. + graph.poll_tick(); + + // 4. Update state to the new reality. + state.sink_pid = Some(new_sink_pid); + state.sink_node_id = Some(new_sink_node_id); + state.mix_pid = new_mix_pid; + state.mix_node_id = new_mix_node_id; + let had_loopback = state.loopback_id.take().is_some(); + + // 5. Reconnect streams that were plugged into the old nodes - best + // effort per id; one that vanished during the gap is simply skipped. + let total = sink_inputs.len() + source_outputs.len(); + let mut reconnected = 0; + for id in &sink_inputs { + if move_stream("move-sink-input", *id, &state.sink_name) { + reconnected += 1; + } + } + for id in &source_outputs { + if move_stream("move-source-output", *id, &state.source_name) { + reconnected += 1; + } + } + if total > 0 { + ui::info(&format!(" reconnected {reconnected}/{total} stream(s).")); + } + + // 6. Self-monitor reload - warn-only. The old nodes are already gone by + // this point, so this must never hard-fail: an `Err` here must not stop + // `state` (which is already fully correct for sink/mix) from being + // saved by the caller. + if had_loopback { + match loopback::enable_self_monitor(graph, &state.sink_name) { + Ok(id) => { + state.loopback_id = Some(id); + if let Some(pct) = state.volume_pct { + if loopback::set_loopback_volume(graph, id, pct).is_err() { + ui::warn(&format!("could not restore loopback volume ({pct}%).")); + } + } + } + Err(e) => { + ui::warn(&format!( + "could not re-enable the self-monitor loopback ({e}); run `vmic edit {} -l true` to retry.", + state.name + )); + } + } + } + + // 7. Mixed-source relink - also warn-only, same reasoning as step 6. + if let Some(mic) = state.mic_source.clone() { + if relink_source(graph, state, &mic, to).is_err() { + ui::warn(&format!( + "could not relink mixed source '{mic}'; run `vmic route {} -s '{mic}'` to retry.", + state.name + )); + } + } + + ui::ok(&format!("'{}' is now running as {}.", state.name, shape_label(to))); + Ok(()) +} + +fn move_stream(cmd: &str, id: u32, target: &str) -> bool { + Command::new("pactl") + .args([cmd, &id.to_string(), target]) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} From ab99a9da3eb141d4c3a56a1bae8b50caa2367c97 Mon Sep 17 00:00:00 2001 From: Overlord Date: Tue, 11 Aug 2026 15:43:03 +0200 Subject: [PATCH 06/10] Add live integration test suite for `vmic` - Introduced `live_test.sh` to perform end-to-end testing against a real PipeWire session. - Tests cover all major commands (`create`, `edit`, `route`, `list`, `delete`, `wipe`) and validate topology changes, stream migrations, and cleanup behavior. - Ensured safety with guarded destructive operations and thorough resource cleanup mechanisms. - Designed as an opt-in script, not intended for inclusion in automated CI workflows. --- tests/live_test.sh | 368 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100755 tests/live_test.sh diff --git a/tests/live_test.sh b/tests/live_test.sh new file mode 100755 index 0000000..2ff889f --- /dev/null +++ b/tests/live_test.sh @@ -0,0 +1,368 @@ +#!/usr/bin/env bash +# Live integration test suite for vmic. +# +# Exercises every command against a REAL PipeWire session: spawns real +# pw-loopback processes, loads/unloads real pactl loopback modules, and +# moves real sink-inputs/source-outputs. This is deliberately NOT part of +# `cargo test` (see .claude/PROJECT.md: "no integration test suite" was a +# known, deliberate gap - this fills it, but live-only, opt-in, and never +# run automatically). +# +# Usage: +# tests/live_test.sh [options] +# +# Options (env var or flag; flag wins if both given): +# --bin PATH VMIC_BIN vmic binary to test (default: target/debug/vmic) +# --name NAME VMIC_TEST_NAME test vmic name (default: vmictestsuite) +# --input-app APP VMIC_TEST_INPUT_APP `route -i` filter (default: firefox) +# --output-app APP VMIC_TEST_OUTPUT_APP `route -o` filter (default: chromium) +# --hw-source SUBSTR VMIC_TEST_HW_SOURCE `route -s` filter (default: fifine) +# --skip-wipe VMIC_TEST_SKIP_WIPE=1 skip the destructive `wipe` phase +# --no-build skip the `cargo build` preflight +# -h, --help +# +# Safety: +# - Only ever creates/deletes vmics named $NAME, ${NAME}_a, ${NAME}_b. +# - `wipe` tears down EVERY vmic system-wide by design - the wipe phase +# only runs if `vmic list` shows nothing outside those three names at +# that point, or is skipped otherwise (or via --skip-wipe). +# - Moves the given real app streams into/out of the test vmic; an EXIT +# trap always attempts full cleanup (delete test vmics, kill stray +# matching pw-loopback processes, unload stray matching pactl modules) +# even on failure or interruption. +# - Never assumes the user has zero other vmics; all existence/emptiness +# checks are scoped to the test names above. + +set -uo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +VMIC_BIN="${VMIC_BIN:-target/debug/vmic}" +NAME="${VMIC_TEST_NAME:-vmictestsuite}" +INPUT_APP="${VMIC_TEST_INPUT_APP:-firefox}" +OUTPUT_APP="${VMIC_TEST_OUTPUT_APP:-chromium}" +HW_SOURCE="${VMIC_TEST_HW_SOURCE:-fifine}" +SKIP_WIPE="${VMIC_TEST_SKIP_WIPE:-0}" +DO_BUILD=1 + +usage() { sed -n '2,/^set -uo/p' "$0" | sed '$d; s/^# \{0,1\}//'; } + +while [[ $# -gt 0 ]]; do + case "$1" in + --bin) VMIC_BIN="$2"; shift 2 ;; + --name) NAME="$2"; shift 2 ;; + --input-app) INPUT_APP="$2"; shift 2 ;; + --output-app) OUTPUT_APP="$2"; shift 2 ;; + --hw-source) HW_SOURCE="$2"; shift 2 ;; + --skip-wipe) SKIP_WIPE=1; shift ;; + --no-build) DO_BUILD=0; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done +NAME="$(tr '[:upper:]' '[:lower:]' <<<"$NAME")" + +RED=$'\e[31m'; GREEN=$'\e[32m'; YELLOW=$'\e[33m'; BLUE=$'\e[34m'; RESET=$'\e[0m' +[[ -t 1 ]] || { RED=""; GREEN=""; YELLOW=""; BLUE=""; RESET=""; } + +PASS=0; FAIL=0; SKIP=0 +section() { echo; echo "${BLUE}== $1 ==${RESET}"; } +pass() { PASS=$((PASS+1)); echo " ${GREEN}PASS${RESET} $1"; } +fail() { FAIL=$((FAIL+1)); echo " ${RED}FAIL${RESET} $1"; [[ -n "${2:-}" ]] && echo " ${2//$'\n'/$'\n '}"; } +skip() { SKIP=$((SKIP+1)); echo " ${YELLOW}SKIP${RESET} $1"; } + +LAST_OUT=""; LAST_CODE=0 +vmic_run() { LAST_OUT="$("$VMIC_BIN" "$@" 2>&1)"; LAST_CODE=$?; } + +expect_exit() { # expect_exit + if [[ "$LAST_CODE" == "$2" ]]; then pass "$1 (exit $LAST_CODE)" + else fail "$1 (expected exit $2, got $LAST_CODE)" "$LAST_OUT"; fi +} +expect_contains() { # expect_contains + if [[ "$LAST_OUT" == *"$2"* ]]; then pass "$1" + else fail "$1 (expected output to contain: $2)" "$LAST_OUT"; fi +} +expect_not_contains() { + if [[ "$LAST_OUT" != *"$2"* ]]; then pass "$1" + else fail "$1 (expected output NOT to contain: $2)" "$LAST_OUT"; fi +} +assert_eq() { if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected '$3', got '$2'"; fi; } +assert_ne() { if [[ "$2" != "$3" ]]; then pass "$1"; else fail "$1" "expected different from '$3', got same '$2'"; fi; } +assert_true() { if "${@:2}" >/dev/null 2>&1; then pass "$1"; else fail "$1"; fi; } + +pw_loopback_count() { # pw_loopback_count + local n; n=$(pgrep -c -f "pw-loopback.*vmic_${1}_" 2>/dev/null); echo "${n:-0}" +} +pulse_id_for() { # pulse_id_for + pactl list short "$1" 2>/dev/null | awk -v n="$2" '$2==n{print $1; exit}' +} +wait_pulse_id() { # wait_pulse_id -> prints id, empty on timeout + local id="" + for _ in $(seq 1 25); do + id="$(pulse_id_for "$1" "$2")" + [[ -n "$id" ]] && { echo "$id"; return 0; } + sleep 0.2 + done + echo "" +} +wait_pw_loopback_count() { # wait_pw_loopback_count + for _ in $(seq 1 25); do + [[ "$(pw_loopback_count "$1")" == "$2" ]] && return 0 + sleep 0.2 + done + return 1 +} +count_sink_inputs_on() { pactl list sink-inputs 2>/dev/null | awk -v id="$1" '$1=="Sink:"{if($2==id)c++} END{print c+0}'; } +count_source_outputs_on() { pactl list source-outputs 2>/dev/null | awk -v id="$1" '$1=="Source:"{if($2==id)c++} END{print c+0}'; } + +cleanup() { + section "Cleanup" + for n in "$NAME" "${NAME}_a" "${NAME}_b"; do + "$VMIC_BIN" delete "$n" >/dev/null 2>&1 || true + done + pkill -f "pw-loopback.*vmic_${NAME}_" 2>/dev/null || true + while read -r modid; do + [[ -n "$modid" ]] && pactl unload-module "$modid" >/dev/null 2>&1 + done < <(pactl list modules 2>/dev/null | awk -v pat="vmic_${NAME}_" ' + /^Module #/{id=$2; sub("#","",id)} /Argument:/{if (index($0, pat)) print id}') + echo " done." + echo + echo "${BLUE}== Results ==${RESET} ${GREEN}$PASS passed${RESET}, ${RED}$FAIL failed${RESET}, ${YELLOW}$SKIP skipped${RESET}" + [[ "$FAIL" -eq 0 ]] +} +trap 'cleanup; exit $(( $? ))' EXIT + +echo "vmic: $VMIC_BIN" +echo "test name: $NAME (+ ${NAME}_a, ${NAME}_b for the wipe phase)" +echo "input app: $INPUT_APP output app: $OUTPUT_APP hw source: $HW_SOURCE" + +if [[ "$DO_BUILD" == "1" ]]; then + section "Build" + if cargo build 2>&1 | tee /dev/stderr | grep -q '^error'; then + echo "build failed, aborting." >&2; exit 1 + fi +fi +[[ -x "$VMIC_BIN" ]] || { echo "binary not found/executable: $VMIC_BIN" >&2; exit 1; } + +# --------------------------------------------------------------------------- +section "Phase 1: CLI surface" +# --------------------------------------------------------------------------- +vmic_run; expect_exit "bare 'vmic' shows help" 2 +expect_contains "bare 'vmic' mentions Usage" "Usage:" +vmic_run -h; expect_exit "'vmic -h'" 0 +vmic_run help; expect_exit "'vmic help'" 0 +vmic_run --version; expect_exit "'vmic --version'" 0 +expect_contains "'--version' mentions vmic" "vmic" +vmic_run create --help; expect_exit "'vmic create --help'" 0 +vmic_run route --help; expect_exit "'vmic route --help'" 0 +vmic_run edit --help; expect_exit "'vmic edit --help'" 0 +expect_contains "'edit --help' documents --loopback-no-mix" "--loopback-no-mix" +for shell in bash zsh fish; do + vmic_run completions "$shell" + assert_eq "'vmic completions $shell' exits 0" "$LAST_CODE" "0" + [[ -n "$LAST_OUT" ]] && pass "'vmic completions $shell' produces output" || fail "'vmic completions $shell' produces output" "(empty)" +done + +# --------------------------------------------------------------------------- +section "Phase 2: error paths (pre-creation)" +# --------------------------------------------------------------------------- +"$VMIC_BIN" delete "$NAME" >/dev/null 2>&1 || true # ensure a clean slate + +vmic_run create; expect_exit "'create' with no name fails" 2 +vmic_run create "bad name!"; assert_true "'create' with an invalid name fails" [ "$LAST_CODE" -ne 0 ] +expect_contains "invalid name error message" "invalid name" +vmic_run route "$NAME" -s x; assert_true "'route' on nonexistent vmic fails" [ "$LAST_CODE" -ne 0 ] +expect_contains "nonexistent vmic error message (route)" "no vmic named" +vmic_run edit "$NAME" -l true; assert_true "'edit' on nonexistent vmic fails" [ "$LAST_CODE" -ne 0 ] +expect_contains "nonexistent vmic error message (edit)" "no vmic named" +vmic_run delete "$NAME"; assert_true "'delete' on nonexistent vmic fails" [ "$LAST_CODE" -ne 0 ] +expect_contains "nonexistent vmic error message (delete)" "no vmic named" + +# --------------------------------------------------------------------------- +section "Phase 3: create - baseline is 2-node" +# --------------------------------------------------------------------------- +vmic_run create "$NAME"; expect_exit "'create $NAME'" 0 +expect_contains "create success message" "Created virtual microphone" +assert_eq "exactly 1 pw-loopback process after create" "$(pw_loopback_count "$NAME")" "1" + +vmic_run list +expect_contains "'list' shows the new vmic" "$NAME" +expect_contains "'list' reports 2-node architecture" "architecture: 2-node (simple)" +expect_contains "'list' reports active status" "status: active" + +vmic_run create "$NAME"; assert_true "'create' twice fails" [ "$LAST_CODE" -ne 0 ] +expect_contains "duplicate create error message" "already exists" +vmic_run route "$NAME"; assert_true "'route' with no flags fails" [ "$LAST_CODE" -ne 0 ] +expect_contains "route nothing-to-do message" "nothing to do" +vmic_run edit "$NAME"; assert_true "'edit' with no flags fails" [ "$LAST_CODE" -ne 0 ] +expect_contains "edit nothing-to-do message" "nothing to do" + +# --------------------------------------------------------------------------- +section "Phase 4: route -i/-o against real streams" +# --------------------------------------------------------------------------- +vmic_run route "$NAME" -i "$INPUT_APP"; expect_exit "'route -i $INPUT_APP'" 0 +if [[ "$LAST_OUT" == *"matched 0 streams"* ]]; then + skip "no active '$INPUT_APP' sink-input right now - CLI path still ran cleanly" +else + expect_contains "'-i $INPUT_APP' reports a move" "-> moved to" + sink_id="$(wait_pulse_id sinks "vmic_${NAME}_sink")" + if [[ -n "$sink_id" ]]; then + n="$(count_sink_inputs_on "$sink_id")" + assert_true "'$INPUT_APP' stream now lands on vmic sink" [ "$n" -ge 1 ] + else + fail "resolve vmic sink's pulse id" "timed out" + fi +fi + +vmic_run route "$NAME" -o "$OUTPUT_APP"; expect_exit "'route -o $OUTPUT_APP'" 0 +if [[ "$LAST_OUT" == *"matched 0 streams"* ]]; then + skip "no active '$OUTPUT_APP' source-output right now - CLI path still ran cleanly" +else + expect_contains "'-o $OUTPUT_APP' reports a move" "-> moved to" + mic_id="$(wait_pulse_id sources "vmic_${NAME}_mic")" + if [[ -n "$mic_id" ]]; then + n="$(count_source_outputs_on "$mic_id")" + assert_true "'$OUTPUT_APP' stream now reads from vmic mic" [ "$n" -ge 1 ] + else + fail "resolve vmic mic's pulse id" "timed out" + fi +fi + +# --------------------------------------------------------------------------- +section "Phase 5: route -s with loopback OFF - stays 2-node" +# --------------------------------------------------------------------------- +sink_before="$(pulse_id_for sinks "vmic_${NAME}_sink")" +vmic_run route "$NAME" -s "$HW_SOURCE"; expect_exit "'route -s $HW_SOURCE'" 0 +expect_contains "mix message notes it's on the sink (no isolation needed)" "sink)" +assert_eq "still 1 pw-loopback process (no upgrade without loopback on)" "$(pw_loopback_count "$NAME")" "1" +vmic_run list +expect_contains "'list' shows the mixed source" "mixed source:" + +vmic_run route "$NAME" -s "$HW_SOURCE"; expect_exit "re-'route -s $HW_SOURCE' (same source, idempotent)" 0 +sink_after="$(pulse_id_for sinks "vmic_${NAME}_sink")" +assert_eq "re-linking the same source doesn't migrate anything" "$sink_after" "$sink_before" + +# --------------------------------------------------------------------------- +section "Phase 6: edit -l true with a source mixed - upgrades to 4-node" +# --------------------------------------------------------------------------- +sink_before="$(pulse_id_for sinks "vmic_${NAME}_sink")" +mic_before="$(pulse_id_for sources "vmic_${NAME}_mic")" +vmic_run edit "$NAME" -l true; expect_exit "'edit -l true'" 0 +assert_true "upgraded to 2 pw-loopback processes" wait_pw_loopback_count "$NAME" 2 +vmic_run list +expect_contains "'list' reports 4-node architecture" "architecture: 4-node (source isolated from self-monitor)" + +sink_id="$(wait_pulse_id sinks "vmic_${NAME}_sink")" +mic_id="$(wait_pulse_id sources "vmic_${NAME}_mic")" +assert_ne "sink node was actually recreated (new pulse id)" "$sink_id" "$sink_before" +assert_ne "mic node was actually recreated (new pulse id)" "$mic_id" "$mic_before" + +if [[ -n "$sink_id" ]]; then + n="$(count_sink_inputs_on "$sink_id")" + if [[ "$n" -ge 1 ]]; then pass "'$INPUT_APP' stream auto-reconnected after upgrade" + else skip "no '$INPUT_APP' stream was active to verify reconnection"; fi +fi +if [[ -n "$mic_id" ]]; then + n="$(count_source_outputs_on "$mic_id")" + if [[ "$n" -ge 1 ]]; then pass "'$OUTPUT_APP' stream auto-reconnected after upgrade" + else skip "no '$OUTPUT_APP' stream was active to verify reconnection"; fi +fi + +# --------------------------------------------------------------------------- +section "Phase 7: edit --loopback-no-mix true - downgrades to 2-node" +# --------------------------------------------------------------------------- +sink_before="$(pulse_id_for sinks "vmic_${NAME}_sink")" +vmic_run edit "$NAME" --loopback-no-mix true; expect_exit "'edit --loopback-no-mix true'" 0 +assert_true "downgraded to 1 pw-loopback process" wait_pw_loopback_count "$NAME" 1 +vmic_run list +expect_contains "'list' reports 2-node + no-mix architecture" "architecture: 2-node (source audible in self-monitor - see --loopback-no-mix)" +sink_after="$(pulse_id_for sinks "vmic_${NAME}_sink")" +assert_ne "sink node was recreated on downgrade" "$sink_after" "$sink_before" + +# --------------------------------------------------------------------------- +section "Phase 8: edit --loopback-no-mix false - re-upgrades to 4-node" +# --------------------------------------------------------------------------- +vmic_run edit "$NAME" --loopback-no-mix false; expect_exit "'edit --loopback-no-mix false'" 0 +assert_true "re-upgraded to 2 pw-loopback processes" wait_pw_loopback_count "$NAME" 2 +vmic_run list +expect_contains "'list' back to 4-node architecture" "architecture: 4-node (source isolated from self-monitor)" + +# --------------------------------------------------------------------------- +section "Phase 9: volumes" +# --------------------------------------------------------------------------- +vmic_run edit "$NAME" -v 55; expect_exit "'edit -v 55'" 0 +vmic_run list; expect_contains "loopback volume shows 55%" "loopback volume: 55%" +vmic_run edit "$NAME" -sv 66; expect_exit "'edit -sv 66'" 0 +vmic_run list; expect_contains "source volume shows 66%" "source volume: 66%" +vmic_run edit "$NAME" -v 0.8; expect_exit "'edit -v 0.8' (fraction form)" 0 +vmic_run list; expect_contains "fraction volume normalizes to 80%" "loopback volume: 80%" +vmic_run edit "$NAME" -v 999; expect_exit "'edit -v 999' still succeeds (clamped)" 0 +expect_contains "over-range volume warns about clamping" "clamped" +vmic_run list; expect_contains "clamped volume shows as 255%" "loopback volume: 255%" + +# --------------------------------------------------------------------------- +section "Phase 10: route -s none - downgrades back to 2-node" +# --------------------------------------------------------------------------- +vmic_run route "$NAME" -s none; expect_exit "'route -s none'" 0 +expect_contains "removal message" "Removed source mix" +assert_true "downgraded to 1 pw-loopback process" wait_pw_loopback_count "$NAME" 1 +vmic_run list; expect_not_contains "'list' no longer shows a mixed source" "mixed source:" + +vmic_run route "$NAME" -s none; expect_exit "re-'route -s none' with nothing mixed" 0 +expect_contains "no-op removal message" "no source is mixed" + +# --------------------------------------------------------------------------- +section "Phase 11: edit -l false with no source mixed - no-op topology" +# --------------------------------------------------------------------------- +sink_before="$(pulse_id_for sinks "vmic_${NAME}_sink")" +vmic_run edit "$NAME" -l false; expect_exit "'edit -l false'" 0 +assert_eq "still 1 pw-loopback process" "$(pw_loopback_count "$NAME")" "1" +sink_after="$(pulse_id_for sinks "vmic_${NAME}_sink")" +assert_eq "sink was NOT recreated (already Simple2Node, no-op)" "$sink_after" "$sink_before" +vmic_run list; expect_contains "'list' shows loopback disabled" "self-monitor loopback: no" + +# --------------------------------------------------------------------------- +section "Phase 12: route -s error paths" +# --------------------------------------------------------------------------- +vmic_run route "$NAME" -s zzz_definitely_not_a_real_source_zzz_12345 +assert_true "'route -s' on a nonexistent source fails" [ "$LAST_CODE" -ne 0 ] +expect_contains "no-matching-source error message" "no matching source" + +# --------------------------------------------------------------------------- +section "Phase 13: delete" +# --------------------------------------------------------------------------- +vmic_run delete "$NAME"; expect_exit "'delete $NAME'" 0 +assert_true "0 pw-loopback processes after delete" wait_pw_loopback_count "$NAME" 0 +vmic_run list; expect_not_contains "'list' no longer mentions $NAME" "$NAME" +vmic_run delete "$NAME"; assert_true "'delete' twice fails" [ "$LAST_CODE" -ne 0 ] +expect_contains "double-delete error message" "no vmic named" + +# --------------------------------------------------------------------------- +section "Phase 14: wipe (guarded - destroys ALL vmics system-wide)" +# --------------------------------------------------------------------------- +if [[ "$SKIP_WIPE" == "1" ]]; then + skip "wipe phase (--skip-wipe passed)" +else + vmic_run list + mapfile -t existing < <(echo "$LAST_OUT" | grep -v '^ ' | grep -v '^$' | grep -v '^No virtual mics created\.$' || true) + other=0 + for n in "${existing[@]:-}"; do + [[ -z "$n" ]] && continue + case "$n" in "${NAME}_a"|"${NAME}_b") ;; *) other=1 ;; esac + done + if [[ "$other" == "1" ]]; then + skip "wipe phase (other, non-test vmics exist: ${existing[*]}) - not safe to run a system-wide wipe" + else + vmic_run create "${NAME}_a"; expect_exit "create throwaway 2-node vmic for wipe test" 0 + vmic_run create "${NAME}_b"; expect_exit "create throwaway vmic" 0 + vmic_run route "${NAME}_b" -s "$HW_SOURCE"; expect_exit "mix a source into it" 0 + vmic_run edit "${NAME}_b" -l true; expect_exit "upgrade it to 4-node" 0 + assert_eq "throwaway_a is 2-node before wipe" "$(pw_loopback_count "${NAME}_a")" "1" + assert_eq "throwaway_b is 4-node before wipe" "$(pw_loopback_count "${NAME}_b")" "2" + + vmic_run wipe; expect_exit "'vmic wipe'" 0 + expect_contains "wipe reports what it did" "Wiped all virtual mics" + assert_true "0 processes left for throwaway_a" wait_pw_loopback_count "${NAME}_a" 0 + assert_true "0 processes left for throwaway_b" wait_pw_loopback_count "${NAME}_b" 0 + vmic_run list; expect_contains "'list' is empty after wipe" "No virtual mics created." + fi +fi From b9b99868331d90a5e95640cdefe95de710b69906 Mon Sep 17 00:00:00 2001 From: Overlord Date: Tue, 11 Aug 2026 15:51:26 +0200 Subject: [PATCH 07/10] Add comprehensive live integration test suite in `live_test.rs`. - Ported functionality from `live_test.sh` to Rust, enabling end-to-end testing of `vmic` against a real PipeWire session. - Tests validate all major commands (`create`, `edit`, `route`, `list`, `delete`, `wipe`), topology transitions, and stream migrations. - Implemented robust cleanup mechanisms and guarded destructive operations to ensure safety. - Designed as an opt-in test, excluded from default `cargo test` runs. --- tests/live_test.rs | 554 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 554 insertions(+) create mode 100644 tests/live_test.rs diff --git a/tests/live_test.rs b/tests/live_test.rs new file mode 100644 index 0000000..ef92fdb --- /dev/null +++ b/tests/live_test.rs @@ -0,0 +1,554 @@ +//! Live integration test suite for vmic - the Rust counterpart to +//! `tests/live_test.sh` (see that file's header for the full safety/ +//! parameter rationale; this mirrors it phase for phase, same messages +//! where practical, so the two stay easy to cross-reference). +//! +//! Exercises every command against a REAL PipeWire session: spawns real +//! pw-loopback processes, loads/unloads real pactl loopback modules, moves +//! real sink-inputs/source-outputs, and (guarded) runs the destructive +//! `wipe` command. `#[ignore]`d so a plain `cargo test` never touches a +//! live session - run it explicitly: +//! +//! cargo test --test live_test -- --ignored --nocapture +//! +//! `--nocapture` only matters for watching progress live; on failure cargo +//! prints the captured output regardless. +//! +//! Parameters (env vars, same names/defaults as the shell version): +//! VMIC_TEST_NAME test vmic name (default: vmictestsuite) +//! VMIC_TEST_INPUT_APP `route -i` filter (default: firefox) +//! VMIC_TEST_OUTPUT_APP `route -o` filter (default: chromium) +//! VMIC_TEST_HW_SOURCE `route -s` filter (default: fifine) +//! VMIC_TEST_SKIP_WIPE=1 skip the destructive `wipe` phase +//! +//! Safety: only ever creates/deletes vmics named $NAME, ${NAME}_a, +//! ${NAME}_b. `wipe` tears down every vmic system-wide by design, so that +//! phase only runs if `vmic list` shows nothing outside those three names +//! at that point (or is skipped via VMIC_TEST_SKIP_WIPE). A `Drop`-based +//! guard always attempts full cleanup, even on panic/failure. + +use std::io::IsTerminal; +use std::process::Command; +use std::time::{Duration, Instant}; + +// --------------------------------------------------------------------------- +// Running the binary under test +// --------------------------------------------------------------------------- + +struct CmdOut { + code: i32, + combined: String, +} + +fn vmic(args: &[&str]) -> CmdOut { + let out = Command::new(env!("CARGO_BIN_EXE_vmic")) + .args(args) + .output() + .expect("failed to spawn the vmic binary under test"); + let mut combined = String::from_utf8_lossy(&out.stdout).into_owned(); + combined.push_str(&String::from_utf8_lossy(&out.stderr)); + CmdOut { code: out.status.code().unwrap_or(-1), combined } +} + +// --------------------------------------------------------------------------- +// Live-system introspection (independent of vmic's own internals - this is +// a bin-only crate with no lib target, so these mirror rather than reuse +// vmic's own pactl-parsing/proc-scanning logic) +// --------------------------------------------------------------------------- + +fn pactl(args: &[&str]) -> String { + let out = Command::new("pactl").args(args).output().expect("pactl not found on PATH"); + String::from_utf8_lossy(&out.stdout).into_owned() +} + +/// Number of live `pw-loopback` processes whose cmdline mentions +/// `vmic__` - mirrors `wipe.rs`'s own `/proc` matching logic. +fn pw_loopback_count(name_fragment: &str) -> usize { + let needle = format!("vmic_{name_fragment}_"); + let Ok(entries) = std::fs::read_dir("/proc") else { return 0 }; + entries + .flatten() + .filter(|entry| { + let Ok(pid) = entry.file_name().to_string_lossy().parse::() else { return false }; + let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).unwrap_or_default(); + if comm.trim() != "pw-loopback" { + return false; + } + let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else { return false }; + String::from_utf8_lossy(&cmdline).contains(&needle) + }) + .count() +} + +fn wait_pw_loopback_count(name_fragment: &str, expected: usize, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + loop { + if pw_loopback_count(name_fragment) == expected { + return true; + } + if Instant::now() >= deadline { + return pw_loopback_count(name_fragment) == expected; + } + std::thread::sleep(Duration::from_millis(200)); + } +} + +fn pulse_id_for(kind: &str, name: &str) -> Option { + pactl(&["list", "short", kind]).lines().find_map(|l| { + let mut cols = l.split_whitespace(); + let id = cols.next()?.parse::().ok()?; + (cols.next()? == name).then_some(id) + }) +} + +fn wait_pulse_id(kind: &str, name: &str) -> Option { + for _ in 0..25 { + if let Some(id) = pulse_id_for(kind, name) { + return Some(id); + } + std::thread::sleep(Duration::from_millis(200)); + } + None +} + +fn count_sink_inputs_on(sink_id: u32) -> usize { + pactl(&["list", "sink-inputs"]) + .lines() + .filter(|l| l.trim_start().strip_prefix("Sink: ").and_then(|v| v.trim().parse::().ok()) == Some(sink_id)) + .count() +} + +fn count_source_outputs_on(source_id: u32) -> usize { + pactl(&["list", "source-outputs"]) + .lines() + .filter(|l| { + l.trim_start().strip_prefix("Source: ").and_then(|v| v.trim().parse::().ok()) == Some(source_id) + }) + .count() +} + +/// Top-level (non-indented) lines from `vmic list` output, i.e. vmic names. +fn existing_vmic_names(list_output: &str) -> Vec { + list_output + .lines() + .filter(|l| !l.starts_with(" ") && !l.is_empty() && *l != "No virtual mics created.") + .map(str::to_string) + .collect() +} + +// --------------------------------------------------------------------------- +// Reporting +// --------------------------------------------------------------------------- + +struct Colors { + red: &'static str, + green: &'static str, + yellow: &'static str, + blue: &'static str, + reset: &'static str, +} + +fn colors() -> Colors { + if std::io::stdout().is_terminal() { + Colors { red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", blue: "\x1b[34m", reset: "\x1b[0m" } + } else { + Colors { red: "", green: "", yellow: "", blue: "", reset: "" } + } +} + +struct Report { + n_pass: u32, + n_fail: u32, + n_skip: u32, + c: Colors, +} + +impl Report { + fn new() -> Self { + Self { n_pass: 0, n_fail: 0, n_skip: 0, c: colors() } + } + + fn section(&self, title: &str) { + println!("\n{}== {title} =={}", self.c.blue, self.c.reset); + } + fn pass(&mut self, desc: &str) { + self.n_pass += 1; + println!(" {}PASS{} {desc}", self.c.green, self.c.reset); + } + fn fail(&mut self, desc: &str, detail: &str) { + self.n_fail += 1; + println!(" {}FAIL{} {desc}", self.c.red, self.c.reset); + for line in detail.lines() { + println!(" {line}"); + } + } + fn skip(&mut self, desc: &str) { + self.n_skip += 1; + println!(" {}SKIP{} {desc}", self.c.yellow, self.c.reset); + } + fn check(&mut self, desc: &str, ok: bool) { + if ok { + self.pass(desc); + } else { + self.fail(desc, ""); + } + } + fn eq(&mut self, desc: &str, actual: T, expected: T) { + if actual == expected { + self.pass(desc); + } else { + self.fail(desc, &format!("expected {expected:?}, got {actual:?}")); + } + } + fn ne(&mut self, desc: &str, actual: T, unexpected: T) { + if actual != unexpected { + self.pass(desc); + } else { + self.fail(desc, &format!("expected different from {unexpected:?}, got the same value")); + } + } + fn expect_exit(&mut self, desc: &str, out: &CmdOut, expected: i32) { + if out.code == expected { + self.pass(&format!("{desc} (exit {})", out.code)); + } else { + self.fail(&format!("{desc} (expected exit {expected}, got {})", out.code), &out.combined); + } + } + fn expect_contains(&mut self, desc: &str, out: &CmdOut, needle: &str) { + if out.combined.contains(needle) { + self.pass(desc); + } else { + self.fail(desc, &format!("expected output to contain: {needle}\n{}", out.combined)); + } + } + fn expect_not_contains(&mut self, desc: &str, out: &CmdOut, needle: &str) { + if !out.combined.contains(needle) { + self.pass(desc); + } else { + self.fail(desc, &format!("expected output NOT to contain: {needle}\n{}", out.combined)); + } + } +} + +// --------------------------------------------------------------------------- +// Cleanup - always runs on scope exit, including panics (unwinding, not +// aborting, is still the default outside `[profile.release]`). +// --------------------------------------------------------------------------- + +struct CleanupGuard { + name: String, +} + +impl Drop for CleanupGuard { + fn drop(&mut self) { + println!("\n== Cleanup =="); + for n in [self.name.clone(), format!("{}_a", self.name), format!("{}_b", self.name)] { + let _ = vmic(&["delete", n.as_str()]); + } + let needle = format!("vmic_{}_", self.name); + if let Ok(entries) = std::fs::read_dir("/proc") { + for entry in entries.flatten() { + let Ok(pid) = entry.file_name().to_string_lossy().parse::() else { continue }; + let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).unwrap_or_default(); + if comm.trim() != "pw-loopback" { + continue; + } + let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else { continue }; + if String::from_utf8_lossy(&cmdline).contains(&needle) { + let _ = Command::new("kill").args(["-TERM", &pid.to_string()]).status(); + } + } + } + let mut current_id: Option = None; + for line in pactl(&["list", "modules"]).lines() { + if let Some(rest) = line.strip_prefix("Module #") { + current_id = rest.trim().parse().ok(); + } else if line.trim_start().starts_with("Argument:") && line.contains(&needle) { + if let Some(id) = current_id { + let _ = Command::new("pactl").args(["unload-module", &id.to_string()]).status(); + } + } + } + println!(" done."); + } +} + +// --------------------------------------------------------------------------- + +#[test] +#[ignore = "touches a live PipeWire session: spawns real processes, moves real streams, uses a real hardware source, and may run the destructive `wipe` command"] +fn live_suite() { + let name = std::env::var("VMIC_TEST_NAME").unwrap_or_else(|_| "vmictestsuite".into()).to_lowercase(); + let input_app = std::env::var("VMIC_TEST_INPUT_APP").unwrap_or_else(|_| "firefox".into()); + let output_app = std::env::var("VMIC_TEST_OUTPUT_APP").unwrap_or_else(|_| "chromium".into()); + let hw_source = std::env::var("VMIC_TEST_HW_SOURCE").unwrap_or_else(|_| "fifine".into()); + let skip_wipe = std::env::var("VMIC_TEST_SKIP_WIPE").as_deref() == Ok("1"); + + println!("vmic: {}", env!("CARGO_BIN_EXE_vmic")); + println!("test name: {name} (+ {name}_a, {name}_b for the wipe phase)"); + println!("input app: {input_app} output app: {output_app} hw source: {hw_source}"); + + let _cleanup = CleanupGuard { name: name.clone() }; + let _ = vmic(&["delete", name.as_str()]); // ensure a clean slate + + let mut r = Report::new(); + + // -- Phase 1: CLI surface -- + r.section("Phase 1: CLI surface"); + let out = vmic(&[]); + r.expect_exit("bare 'vmic' shows help", &out, 2); + r.expect_contains("bare 'vmic' mentions Usage", &out, "Usage:"); + let out = vmic(&["-h"]); + r.expect_exit("'vmic -h'", &out, 0); + let out = vmic(&["help"]); + r.expect_exit("'vmic help'", &out, 0); + let out = vmic(&["--version"]); + r.expect_exit("'vmic --version'", &out, 0); + r.expect_contains("'--version' mentions vmic", &out, "vmic"); + let out = vmic(&["create", "--help"]); + r.expect_exit("'vmic create --help'", &out, 0); + let out = vmic(&["route", "--help"]); + r.expect_exit("'vmic route --help'", &out, 0); + let out = vmic(&["edit", "--help"]); + r.expect_exit("'vmic edit --help'", &out, 0); + r.expect_contains("'edit --help' documents --loopback-no-mix", &out, "--loopback-no-mix"); + for shell in ["bash", "zsh", "fish"] { + let out = vmic(&["completions", shell]); + r.eq(&format!("'vmic completions {shell}' exits 0"), out.code, 0); + r.check(&format!("'vmic completions {shell}' produces output"), !out.combined.is_empty()); + } + + // -- Phase 2: error paths (pre-creation) -- + r.section("Phase 2: error paths (pre-creation)"); + let out = vmic(&["create"]); + r.expect_exit("'create' with no name fails", &out, 2); + let out = vmic(&["create", "bad name!"]); + r.check("'create' with an invalid name fails", out.code != 0); + r.expect_contains("invalid name error message", &out, "invalid name"); + let out = vmic(&["route", name.as_str(), "-s", "x"]); + r.check("'route' on nonexistent vmic fails", out.code != 0); + r.expect_contains("nonexistent vmic error message (route)", &out, "no vmic named"); + let out = vmic(&["edit", name.as_str(), "-l", "true"]); + r.check("'edit' on nonexistent vmic fails", out.code != 0); + r.expect_contains("nonexistent vmic error message (edit)", &out, "no vmic named"); + let out = vmic(&["delete", name.as_str()]); + r.check("'delete' on nonexistent vmic fails", out.code != 0); + r.expect_contains("nonexistent vmic error message (delete)", &out, "no vmic named"); + + // -- Phase 3: create - baseline is 2-node -- + r.section("Phase 3: create - baseline is 2-node"); + let out = vmic(&["create", name.as_str()]); + r.expect_exit(&format!("'create {name}'"), &out, 0); + r.expect_contains("create success message", &out, "Created virtual microphone"); + r.eq("exactly 1 pw-loopback process after create", pw_loopback_count(&name), 1); + + let out = vmic(&["list"]); + r.expect_contains("'list' shows the new vmic", &out, &name); + r.expect_contains("'list' reports 2-node architecture", &out, "architecture: 2-node (simple)"); + r.expect_contains("'list' reports active status", &out, "status: active"); + + let out = vmic(&["create", name.as_str()]); + r.check("'create' twice fails", out.code != 0); + r.expect_contains("duplicate create error message", &out, "already exists"); + let out = vmic(&["route", name.as_str()]); + r.check("'route' with no flags fails", out.code != 0); + r.expect_contains("route nothing-to-do message", &out, "nothing to do"); + let out = vmic(&["edit", name.as_str()]); + r.check("'edit' with no flags fails", out.code != 0); + r.expect_contains("edit nothing-to-do message", &out, "nothing to do"); + + // -- Phase 4: route -i/-o against real streams -- + r.section("Phase 4: route -i/-o against real streams"); + let out = vmic(&["route", name.as_str(), "-i", input_app.as_str()]); + r.expect_exit(&format!("'route -i {input_app}'"), &out, 0); + if out.combined.contains("matched 0 streams") { + r.skip(&format!("no active '{input_app}' sink-input right now - CLI path still ran cleanly")); + } else { + r.expect_contains(&format!("'-i {input_app}' reports a move"), &out, "-> moved to"); + match wait_pulse_id("sinks", &format!("vmic_{name}_sink")) { + Some(id) => r.check(&format!("'{input_app}' stream now lands on vmic sink"), count_sink_inputs_on(id) >= 1), + None => r.fail("resolve vmic sink's pulse id", "timed out"), + } + } + + let out = vmic(&["route", name.as_str(), "-o", output_app.as_str()]); + r.expect_exit(&format!("'route -o {output_app}'"), &out, 0); + if out.combined.contains("matched 0 streams") { + r.skip(&format!("no active '{output_app}' source-output right now - CLI path still ran cleanly")); + } else { + r.expect_contains(&format!("'-o {output_app}' reports a move"), &out, "-> moved to"); + match wait_pulse_id("sources", &format!("vmic_{name}_mic")) { + Some(id) => { + r.check(&format!("'{output_app}' stream now reads from vmic mic"), count_source_outputs_on(id) >= 1) + } + None => r.fail("resolve vmic mic's pulse id", "timed out"), + } + } + + // -- Phase 5: route -s with loopback OFF - stays 2-node -- + r.section("Phase 5: route -s with loopback OFF - stays 2-node"); + let sink_before = pulse_id_for("sinks", &format!("vmic_{name}_sink")); + let out = vmic(&["route", name.as_str(), "-s", hw_source.as_str()]); + r.expect_exit(&format!("'route -s {hw_source}'"), &out, 0); + r.expect_contains("mix message notes it's on the sink (no isolation needed)", &out, "sink)"); + r.eq("still 1 pw-loopback process (no upgrade without loopback on)", pw_loopback_count(&name), 1); + let out = vmic(&["list"]); + r.expect_contains("'list' shows the mixed source", &out, "mixed source:"); + + let out = vmic(&["route", name.as_str(), "-s", hw_source.as_str()]); + r.expect_exit(&format!("re-'route -s {hw_source}' (same source, idempotent)"), &out, 0); + let sink_after = pulse_id_for("sinks", &format!("vmic_{name}_sink")); + r.eq("re-linking the same source doesn't migrate anything", sink_after, sink_before); + + // -- Phase 6: edit -l true with a source mixed - upgrades to 4-node -- + r.section("Phase 6: edit -l true with a source mixed - upgrades to 4-node"); + let sink_before = pulse_id_for("sinks", &format!("vmic_{name}_sink")); + let mic_before = pulse_id_for("sources", &format!("vmic_{name}_mic")); + let out = vmic(&["edit", name.as_str(), "-l", "true"]); + r.expect_exit("'edit -l true'", &out, 0); + r.check("upgraded to 2 pw-loopback processes", wait_pw_loopback_count(&name, 2, Duration::from_secs(5))); + let out = vmic(&["list"]); + r.expect_contains("'list' reports 4-node architecture", &out, "architecture: 4-node (source isolated from self-monitor)"); + + let sink_id = wait_pulse_id("sinks", &format!("vmic_{name}_sink")); + let mic_id = wait_pulse_id("sources", &format!("vmic_{name}_mic")); + r.ne("sink node was actually recreated (new pulse id)", sink_id, sink_before); + r.ne("mic node was actually recreated (new pulse id)", mic_id, mic_before); + + match sink_id { + Some(id) if count_sink_inputs_on(id) >= 1 => r.pass(&format!("'{input_app}' stream auto-reconnected after upgrade")), + _ => r.skip(&format!("no '{input_app}' stream was active to verify reconnection")), + } + match mic_id { + Some(id) if count_source_outputs_on(id) >= 1 => { + r.pass(&format!("'{output_app}' stream auto-reconnected after upgrade")) + } + _ => r.skip(&format!("no '{output_app}' stream was active to verify reconnection")), + } + + // -- Phase 7: edit --loopback-no-mix true - downgrades to 2-node -- + r.section("Phase 7: edit --loopback-no-mix true - downgrades to 2-node"); + let sink_before = pulse_id_for("sinks", &format!("vmic_{name}_sink")); + let out = vmic(&["edit", name.as_str(), "--loopback-no-mix", "true"]); + r.expect_exit("'edit --loopback-no-mix true'", &out, 0); + r.check("downgraded to 1 pw-loopback process", wait_pw_loopback_count(&name, 1, Duration::from_secs(5))); + let out = vmic(&["list"]); + r.expect_contains( + "'list' reports 2-node + no-mix architecture", + &out, + "architecture: 2-node (source audible in self-monitor - see --loopback-no-mix)", + ); + let sink_after = pulse_id_for("sinks", &format!("vmic_{name}_sink")); + r.ne("sink node was recreated on downgrade", sink_after, sink_before); + + // -- Phase 8: edit --loopback-no-mix false - re-upgrades to 4-node -- + r.section("Phase 8: edit --loopback-no-mix false - re-upgrades to 4-node"); + let out = vmic(&["edit", name.as_str(), "--loopback-no-mix", "false"]); + r.expect_exit("'edit --loopback-no-mix false'", &out, 0); + r.check("re-upgraded to 2 pw-loopback processes", wait_pw_loopback_count(&name, 2, Duration::from_secs(5))); + let out = vmic(&["list"]); + r.expect_contains("'list' back to 4-node architecture", &out, "architecture: 4-node (source isolated from self-monitor)"); + + // -- Phase 9: volumes -- + r.section("Phase 9: volumes"); + let out = vmic(&["edit", name.as_str(), "-v", "55"]); + r.expect_exit("'edit -v 55'", &out, 0); + let out = vmic(&["list"]); + r.expect_contains("loopback volume shows 55%", &out, "loopback volume: 55%"); + let out = vmic(&["edit", name.as_str(), "-sv", "66"]); + r.expect_exit("'edit -sv 66'", &out, 0); + let out = vmic(&["list"]); + r.expect_contains("source volume shows 66%", &out, "source volume: 66%"); + let out = vmic(&["edit", name.as_str(), "-v", "0.8"]); + r.expect_exit("'edit -v 0.8' (fraction form)", &out, 0); + let out = vmic(&["list"]); + r.expect_contains("fraction volume normalizes to 80%", &out, "loopback volume: 80%"); + let out = vmic(&["edit", name.as_str(), "-v", "999"]); + r.expect_exit("'edit -v 999' still succeeds (clamped)", &out, 0); + r.expect_contains("over-range volume warns about clamping", &out, "clamped"); + let out = vmic(&["list"]); + r.expect_contains("clamped volume shows as 255%", &out, "loopback volume: 255%"); + + // -- Phase 10: route -s none - downgrades back to 2-node -- + r.section("Phase 10: route -s none - downgrades back to 2-node"); + let out = vmic(&["route", name.as_str(), "-s", "none"]); + r.expect_exit("'route -s none'", &out, 0); + r.expect_contains("removal message", &out, "Removed source mix"); + r.check("downgraded to 1 pw-loopback process", wait_pw_loopback_count(&name, 1, Duration::from_secs(5))); + let out = vmic(&["list"]); + r.expect_not_contains("'list' no longer shows a mixed source", &out, "mixed source:"); + + let out = vmic(&["route", name.as_str(), "-s", "none"]); + r.expect_exit("re-'route -s none' with nothing mixed", &out, 0); + r.expect_contains("no-op removal message", &out, "no source is mixed"); + + // -- Phase 11: edit -l false with no source mixed - no-op topology -- + r.section("Phase 11: edit -l false with no source mixed - no-op topology"); + let sink_before = pulse_id_for("sinks", &format!("vmic_{name}_sink")); + let out = vmic(&["edit", name.as_str(), "-l", "false"]); + r.expect_exit("'edit -l false'", &out, 0); + r.eq("still 1 pw-loopback process", pw_loopback_count(&name), 1); + let sink_after = pulse_id_for("sinks", &format!("vmic_{name}_sink")); + r.eq("sink was NOT recreated (already Simple2Node, no-op)", sink_after, sink_before); + let out = vmic(&["list"]); + r.expect_contains("'list' shows loopback disabled", &out, "self-monitor loopback: no"); + + // -- Phase 12: route -s error paths -- + r.section("Phase 12: route -s error paths"); + let out = vmic(&["route", name.as_str(), "-s", "zzz_definitely_not_a_real_source_zzz_12345"]); + r.check("'route -s' on a nonexistent source fails", out.code != 0); + r.expect_contains("no-matching-source error message", &out, "no matching source"); + + // -- Phase 13: delete -- + r.section("Phase 13: delete"); + let out = vmic(&["delete", name.as_str()]); + r.expect_exit(&format!("'delete {name}'"), &out, 0); + r.check("0 pw-loopback processes after delete", wait_pw_loopback_count(&name, 0, Duration::from_secs(5))); + let out = vmic(&["list"]); + r.expect_not_contains(&format!("'list' no longer mentions {name}"), &out, &name); + let out = vmic(&["delete", name.as_str()]); + r.check("'delete' twice fails", out.code != 0); + r.expect_contains("double-delete error message", &out, "no vmic named"); + + // -- Phase 14: wipe (guarded - destroys ALL vmics system-wide) -- + r.section("Phase 14: wipe (guarded - destroys ALL vmics system-wide)"); + if skip_wipe { + r.skip("wipe phase (VMIC_TEST_SKIP_WIPE=1)"); + } else { + let out = vmic(&["list"]); + let existing = existing_vmic_names(&out.combined); + let a = format!("{name}_a"); + let b = format!("{name}_b"); + let other: Vec<&String> = existing.iter().filter(|n| **n != a && **n != b).collect(); + if !other.is_empty() { + r.skip(&format!( + "wipe phase (other, non-test vmics exist: {other:?}) - not safe to run a system-wide wipe" + )); + } else { + let out = vmic(&["create", a.as_str()]); + r.expect_exit("create throwaway 2-node vmic for wipe test", &out, 0); + let out = vmic(&["create", b.as_str()]); + r.expect_exit("create throwaway vmic", &out, 0); + let out = vmic(&["route", b.as_str(), "-s", hw_source.as_str()]); + r.expect_exit("mix a source into it", &out, 0); + let out = vmic(&["edit", b.as_str(), "-l", "true"]); + r.expect_exit("upgrade it to 4-node", &out, 0); + r.eq("throwaway_a is 2-node before wipe", pw_loopback_count(&a), 1); + r.eq("throwaway_b is 4-node before wipe", pw_loopback_count(&b), 2); + + let out = vmic(&["wipe"]); + r.expect_exit("'vmic wipe'", &out, 0); + r.expect_contains("wipe reports what it did", &out, "Wiped all virtual mics"); + r.check("0 processes left for throwaway_a", wait_pw_loopback_count(&a, 0, Duration::from_secs(5))); + r.check("0 processes left for throwaway_b", wait_pw_loopback_count(&b, 0, Duration::from_secs(5))); + let out = vmic(&["list"]); + r.expect_contains("'list' is empty after wipe", &out, "No virtual mics created."); + } + } + + println!( + "\n{}== Results =={} {}{} passed{}, {}{} failed{}, {}{} skipped{}", + r.c.blue, r.c.reset, r.c.green, r.n_pass, r.c.reset, r.c.red, r.n_fail, r.c.reset, r.c.yellow, r.n_skip, r.c.reset + ); + assert_eq!(r.n_fail, 0, "{} check(s) failed - see output above", r.n_fail); +} From 45f2f13970f9918821fc6019415b66fa11db5e1f Mon Sep 17 00:00:00 2001 From: Overlord Date: Tue, 11 Aug 2026 15:56:33 +0200 Subject: [PATCH 08/10] Add support for multi-character flag aliases and enhance argument handling - Introduced `MULTI_CHAR_ALIASES` for mapping long flag names to multi-character short aliases. - Updated argument normalization to preprocess multi-character short-form flags. - Refactored help output to include alias visibility and improve alignment. - Enhanced CLI flag definition capabilities with better support for optional boolean arguments. --- src/cli.rs | 6 +++--- src/main.rs | 58 ++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index ee5df02..4597c6f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -71,12 +71,12 @@ pub struct EditArgs { pub name: String, /// Enable or disable the self-monitor loopback. - #[arg(short, long, value_name = "BOOL")] + #[arg(short, long, num_args = 0..=1, default_missing_value = "true", value_name = "BOOL")] pub loopback: Option, /// Keep a mixed-in source audible in the loopback instead of isolating /// it (2-node topology instead of 4). - #[arg(long = "loopback-no-mix", value_name = "BOOL")] + #[arg(long = "loopback-no-mix", num_args = 0..=1, default_missing_value = "true", value_name = "BOOL")] pub loopback_no_mix: Option, /// Loopback volume: fraction (0.8) or percent (80). @@ -84,7 +84,7 @@ pub struct EditArgs { pub volume: Option, /// Mixed-in source volume: fraction (0.8) or percent (80). - #[arg(long = "source-volume", visible_alias = "sv", value_name = "PCT")] + #[arg(long = "source-volume", value_name = "PCT")] pub source_volume: Option, } diff --git a/src/main.rs b/src/main.rs index b823087..4572307 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,12 +44,22 @@ fn main() { } } -/// clap's `#[arg(short)]` only supports single-character short flags, so -/// `-sv` can't be a real short flag. Rewrite it to `--source-volume` before -/// clap ever sees it. +/// `(long name, multi-char alias)` pairs for flags whose short form is more +/// than one character - clap's `#[arg(short)]` only supports single +/// characters, so these can't be real clap short flags. Rewritten to their +/// long form before clap ever sees them (`normalize_args`), and shown +/// alongside the long form in the top-level help (`flag_names`) so both +/// places stay in sync from one definition. +const MULTI_CHAR_ALIASES: &[(&str, &str)] = &[("source-volume", "sv")]; + fn normalize_args(args: Vec) -> Vec { args.into_iter() - .map(|a| if a == "-sv" { "--source-volume".to_string() } else { a }) + .map(|a| { + a.strip_prefix('-') + .and_then(|rest| MULTI_CHAR_ALIASES.iter().find(|(_, alias)| *alias == rest)) + .map(|(long, _)| format!("--{long}")) + .unwrap_or(a) + }) .collect() } @@ -100,13 +110,8 @@ fn print_full_help() { let name = s.get_name(); let positionals = positional_args(s); let about = s.get_about().map(|a| a.to_string()).unwrap_or_default(); - let aliases = s.get_visible_aliases().collect::>().join("/"); - print!(" {name:name_w$} {} {about}", pad_visual(&positionals, pos_w)); - if !aliases.is_empty() { - print!(" | {aliases}"); - } - println!(); + println!(" {name:name_w$} {} {about}", pad_visual(&positionals, pos_w)); let rows = &flag_rows_by_cmd[i]; for (flag, help) in rows { @@ -120,6 +125,22 @@ fn print_full_help() { } println!(); + let alias_rows: Vec<(String, String)> = subcommands + .iter() + .filter_map(|s| { + let aliases = s.get_visible_aliases().collect::>().join(", "); + (!aliases.is_empty()).then(|| (s.get_name().to_string(), aliases)) + }) + .collect(); + if !alias_rows.is_empty() { + println!("{}", ui::blue("Aliases:")); + let name_w = alias_rows.iter().map(|(n, _)| n.len()).max().unwrap_or(0); + for (name, aliases) in &alias_rows { + println!(" {name:name_w$} {aliases}"); + } + println!(); + } + println!("{}", ui::blue("Options:")); let opt_rows: Vec<(String, String)> = cmd .get_arguments() @@ -167,13 +188,18 @@ fn flag_rows(cmd: &clap::Command) -> Vec<(String, String)> { } /// `-x/--long` / `-x` / `--long` for a non-positional arg, or `None` for -/// one with no visible flag at all. +/// one with no visible flag at all. A long flag with an entry in +/// `MULTI_CHAR_ALIASES` shows that alias in the short-flag position (e.g. +/// `-sv/--source-volume`), since it works the same way in practice. fn flag_names(arg: &clap::Arg) -> Option { - match (arg.get_short(), arg.get_long()) { - (Some(s), Some(l)) => Some(format!("-{s}/--{l}")), - (Some(s), None) => Some(format!("-{s}")), - (None, Some(l)) => Some(format!("--{l}")), - (None, None) => None, + let long = arg.get_long(); + let multi_char_alias = long.and_then(|l| MULTI_CHAR_ALIASES.iter().find(|(name, _)| *name == l)).map(|(_, a)| *a); + match (arg.get_short(), long, multi_char_alias) { + (Some(s), Some(l), _) => Some(format!("-{s}/--{l}")), + (Some(s), None, _) => Some(format!("-{s}")), + (None, Some(l), Some(a)) => Some(format!("-{a}/--{l}")), + (None, Some(l), None) => Some(format!("--{l}")), + (None, None, _) => None, } } From 3bac7c74721d57205ebb0506ba62f32a34e97691 Mon Sep 17 00:00:00 2001 From: Overlord Date: Tue, 11 Aug 2026 22:08:56 +0200 Subject: [PATCH 09/10] Expand and clarify `README.md` with updated workflows, examples, and testing instructions. --- README.md | 74 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 58 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 7fbc7b7..d564962 100644 --- a/README.md +++ b/README.md @@ -2,22 +2,40 @@ Create and manage PipeWire virtual microphones easily, from the command line. +--- + ## What it does -A vmic is two chained loopback stages: +A vmic starts as a single loopback stage: ``` -apps -> vmic_X_sink =(stage 1)=> vmic_X_mid -> vmic_X_mix =(stage 2)=> vmic_X_mic -> recorders - ^ hardware mic joins here (route -s) +apps -> vmic_X_sink =(pw-loopback)=> vmic_X_mic -> recorders ``` Point an app's output device at `vmic_X_sink`, and a recording app's input device at -`vmic_X_mic`. Optionally mix in a real microphone downstream, and optionally hear your -own output via a self-monitor loopback (which never carries the mixed-in mic). +`vmic_X_mic`. Optionally mix in a real microphone (`route -s`), and optionally hear +your own output via a self-monitor loopback (`edit -l`). + +If you enable both a self-monitor loopback *and* a mixed-in hardware source at the +same time, vmic automatically splits into a second loopback stage so the hardware +source never leaks into your self-monitor: + +``` +apps -> vmic_X_sink =(stage 1)=> vmic_X_mid -> vmic_X_mix =(stage 2)=> vmic_X_mic -> recorders + ^ hardware mic joins here instead +``` + +It collapses back to the single-stage form the moment either the loopback or the +mixed-in source goes away. `edit --loopback-no-mix true` opts a vmic out of this +split entirely, keeping it at one stage even with both active - the mixed source +just becomes audible in your self-monitor too, as an explicit tradeoff for staying +simpler. Either transition recreates the vmic's underlying sink/mic nodes (same +names, new ids); any stream that was routed into or out of them gets moved back +automatically, and vmic reports what happened either way. ## Requirements -- PipeWire + pipewire-pulse (`pactl`, `pw-loopback`, `pw-link` on `PATH`) +- PipeWire + pipewire-pulse (`pactl`, `pw-loopback` on `PATH`) - Rust (edition 2021) ## Build @@ -45,18 +63,22 @@ LIBCLANG_PATH = "/home//.local/share/vmic-build/libclang-venv/lib/python3.* ## Usage ``` -vmic create [-l] # create a vmic, optionally with self-monitor -vmic route -i # move an app's playback into the vmic -vmic route -o # move an app's recording off the vmic -vmic route -s # mix a hardware mic in, or remove it -vmic edit -l # toggle the self-monitor loopback -vmic edit -v <0.8|80> # self-monitor loopback volume -vmic edit -sv <0.8|80> # mixed-in source volume -vmic delete # tear down one vmic -vmic list # show all vmics and their status -vmic wipe # tear down every vmic, tracked or not +vmic create [-l] # create a vmic, optionally with self-monitor +vmic route -i # move an app's playback into the vmic +vmic route -o # move an app's recording off the vmic +vmic route -s # mix a hardware mic in, or remove it +vmic edit -l [] # toggle the self-monitor loopback +vmic edit --loopback-no-mix [] # keep 1 stage even with loopback + a mixed source +vmic edit -v <0.8|80> # self-monitor loopback volume +vmic edit -sv <0.8|80> # mixed-in source volume +vmic delete # tear down one vmic +vmic list # show all vmics, their status, and current node shape +vmic wipe # tear down every vmic, tracked or not ``` +`-l` and `--loopback-no-mix` accept a bare form (means `true`) or an explicit +`true`/`false`, e.g. `vmic edit podcast -l` and `vmic edit podcast -l false` both work. + Aliases: `create` = `mk`/`make`, `delete` = `rm`/`remove`, `list` = `ls`, `wipe` = `reset`. Shell completions: `vmic completions `. @@ -75,6 +97,26 @@ vmic route podcast -s "USB Microphone" Tracked in a SQLite database at `~/.config/vmic/vmic.db` (override the directory with `VMIC_STATE_DIR_OVERRIDE`). +## Testing + +`cargo test` runs a handful of fast unit tests only - no PipeWire needed. + +Two live integration test suites exercise every command against a real PipeWire +session: they spawn real `pw-loopback` processes, move real streams, and (guarded) +run the destructive `wipe` command. Neither runs automatically; opt in explicitly: + +``` +tests/live_test.sh --input-app firefox --output-app chromium --hw-source fifine +cargo test --test live_test -- --ignored --nocapture +``` + +Both take the same parameters - as flags for the shell version, or matching env vars +for either (`VMIC_TEST_INPUT_APP`, `VMIC_TEST_OUTPUT_APP`, `VMIC_TEST_HW_SOURCE`, +`VMIC_TEST_NAME`, `VMIC_TEST_SKIP_WIPE=1`) - so point them at whatever apps and +hardware source you actually have available. They only ever touch vmics named +``/`_a`/`_b`, refuse to run the `wipe` phase if any other vmic +exists at that point, and always clean up after themselves even on failure. + ## License APGL-3 From d7753670fe81001362a228e8030f3847a0255423 Mon Sep 17 00:00:00 2001 From: Overlord Date: Fri, 14 Aug 2026 21:15:02 +0200 Subject: [PATCH 10/10] Update and expand `README.md` with improved workflows, feature details, usage examples, and comprehensive testing instructions. --- README.md | 241 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 163 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index d564962..a0007dc 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,63 @@ # vmic -Create and manage PipeWire virtual microphones easily, from the command line. +Create and manage PipeWire virtual microphones easily. + +Vmic wraps `pw-loopback`/`pactl` to turn ad-hoc loopback chains into named +profiles: create a virtual sink/mic pair once, then route apps and hardware +sources into it by name. Each vmic runs the cheapest PipeWire node shape that +satisfies its current settings, and vmic migrates it live as those settings +change. --- -## What it does +## Features -A vmic starts as a single loopback stage: +- **Dynamic topology**: a vmic starts as a single `pw-loopback` process (2 + nodes, sink straight into mic) and only upgrades to an isolated 4-node + shape when it's actually needed - a self-monitor loopback *and* a mixed-in + hardware source active at once. It downgrades back the moment either goes + away. +- **`--loopback-no-mix` opt-out**: keep the cheaper 2-node shape even with + both active, accepting that the mixed-in source becomes audible in the + self-monitor loopback in exchange for never running the second stage. +- **Automatic stream reconnection**: app streams already routed into or out + of a vmic are moved to the new nodes when a topology migration happens - + nothing needs to be re-selected in the app. +- **Spawn-before-teardown migrations**: a topology change always spawns the + new node pair(s) and confirms they're alive before touching the old ones, + so a failed migration never leaves a vmic worse off than before the + command ran. +- **Self-monitor loopback** with its own adjustable volume, independent of + the mixed-in hardware source's volume. +- **Status and listing** showing liveness and the vmic's current node + architecture, so the tradeoff of any given shape is always visible. +- **`wipe`**, a full teardown of every vmic-related PipeWire node, module, + and process on the system - tracked or not. +- **Shell completions** for bash, zsh, fish, and others. -``` -apps -> vmic_X_sink =(pw-loopback)=> vmic_X_mic -> recorders -``` +--- -Point an app's output device at `vmic_X_sink`, and a recording app's input device at -`vmic_X_mic`. Optionally mix in a real microphone (`route -s`), and optionally hear -your own output via a self-monitor loopback (`edit -l`). +## Installation -If you enable both a self-monitor loopback *and* a mixed-in hardware source at the -same time, vmic automatically splits into a second loopback stage so the hardware -source never leaks into your self-monitor: +Requires a Rust toolchain, and PipeWire + pipewire-pulse (`pactl`, +`pw-loopback`) on `PATH`. -``` -apps -> vmic_X_sink =(stage 1)=> vmic_X_mid -> vmic_X_mix =(stage 2)=> vmic_X_mic -> recorders - ^ hardware mic joins here instead -``` - -It collapses back to the single-stage form the moment either the loopback or the -mixed-in source goes away. `edit --loopback-no-mix true` opts a vmic out of this -split entirely, keeping it at one stage even with both active - the mixed source -just becomes audible in your self-monitor too, as an explicit tradeoff for staying -simpler. Either transition recreates the vmic's underlying sink/mic nodes (same -names, new ids); any stream that was routed into or out of them gets moved back -automatically, and vmic reports what happened either way. - -## Requirements - -- PipeWire + pipewire-pulse (`pactl`, `pw-loopback` on `PATH`) -- Rust (edition 2021) - -## Build - -``` +```sh cargo build --release ``` -If the build fails inside `libspa-sys` (e.g. `no field 'data' on type 'spa_pod_builder'`), -your system clang is newer than the pinned `bindgen` version supports. Point `LIBCLANG_PATH` -at an older libclang - a quick fix is a pip-vendored one: +The binary is written to `target/release/vmic`. Place it on `PATH`, e.g.: +```sh +install -Dm755 target/release/vmic ~/.local/bin/vmic ``` + +If the build fails inside `libspa-sys` (e.g. `no field 'data' on type +'spa_pod_builder'`), your system clang is newer than the pinned `bindgen` +version supports. Point `LIBCLANG_PATH` at an older libclang - a quick fix +is a pip-vendored version: + +```sh python3 -m venv ~/.local/share/vmic-build/libclang-venv ~/.local/share/vmic-build/libclang-venv/bin/pip install libclang ``` @@ -60,63 +69,139 @@ then add to `.cargo/config.toml`: LIBCLANG_PATH = "/home//.local/share/vmic-build/libclang-venv/lib/python3.*/site-packages/clang/native" ``` -## Usage +--- -``` -vmic create [-l] # create a vmic, optionally with self-monitor -vmic route -i # move an app's playback into the vmic -vmic route -o # move an app's recording off the vmic -vmic route -s # mix a hardware mic in, or remove it -vmic edit -l [] # toggle the self-monitor loopback -vmic edit --loopback-no-mix [] # keep 1 stage even with loopback + a mixed source -vmic edit -v <0.8|80> # self-monitor loopback volume -vmic edit -sv <0.8|80> # mixed-in source volume -vmic delete # tear down one vmic -vmic list # show all vmics, their status, and current node shape -vmic wipe # tear down every vmic, tracked or not -``` +## Commands -`-l` and `--loopback-no-mix` accept a bare form (means `true`) or an explicit -`true`/`false`, e.g. `vmic edit podcast -l` and `vmic edit podcast -l false` both work. +| Command | Aliases | Description | +|---------------|----------------|------------------------------------------------| +| `create` | `mk`, `make` | Create a new virtual microphone. | +| `route` | | Move app streams and mix a hardware source in. | +| `edit` | | Change loopback and volume settings on a vmic. | +| `delete` | `rm`, `remove` | Delete a virtual microphone. | +| `list` | `ls` | List all virtual microphones with live status. | +| `wipe` | `reset` | Tear down every vmic, tracked or not. | +| `completions` | | Generate a shell completion script. | -Aliases: `create` = `mk`/`make`, `delete` = `rm`/`remove`, `list` = `ls`, `wipe` = `reset`. +Run `vmic` or `vmic --help` for the full flag reference. -Shell completions: `vmic completions `. +### `create` flags -## Example +| Flag | Value | Meaning | Default | +|------------------|--------|---------------------------------------------|:-------:| +| `-l, --loopback` | (bare) | Enable a self-monitor loopback at creation. | off | -``` +### `route` flags + +| Flag | Value | Meaning | Default | +|----------------|----------------|------------------------------------------------------|:-------:| +| `-i, --input` | `APP[:MEDIA]` | Move matching sink-inputs into this vmic's sink. | - | +| `-o, --output` | `APP[:MEDIA]` | Move matching source-outputs off this vmic's source. | - | +| `-s, --source` | `SOURCE\|none` | Mix a hardware source into the vmic, or remove it. | - | + +### `edit` flags + +| Flag | Value | Meaning | Default | +|------------------------|----------|--------------------------------------------------------------------------------|:-------:| +| `-l, --loopback` | `[BOOL]` | Enable/disable the self-monitor loopback (bare `-l` means `true`). | - | +| `--loopback-no-mix` | `[BOOL]` | Keep the 2-node shape even with loopback + a mixed source (bare means `true`). | `false` | +| `-v, --volume` | `PCT` | Self-monitor loopback volume: fraction (`0.8`) or percent (`80`). | - | +| `-sv, --source-volume` | `PCT` | Mixed-in source volume: fraction (`0.8`) or percent (`80`). | - | + +`edit` only touches the fields given on the command line; everything else +stays as-is. Any flag that changes `--loopback`/`--loopback-no-mix` may +trigger a topology migration - see below. + +--- + +### Examples + +```sh +# Create a vmic with a self-monitor loopback. vmic create podcast -l -# set your app's output device to vmic_podcast_sink -# set your recorder's input device to vmic_podcast_mic + +# Point your app's output device at vmic_podcast_sink, and your +# recorder's input device at vmic_podcast_mic, then mix in a real mic. vmic route podcast -s "USB Microphone" + +# Move Firefox's playback into the vmic, and OBS's recording off it. +vmic route podcast -i firefox +vmic route podcast -o obs + +# Stop isolating the mixed source from the self-monitor, collapsing back +# to the cheaper 2-node shape. +vmic edit podcast --loopback-no-mix + +# Adjust volumes independently. +vmic edit podcast -v 0.8 +vmic edit podcast -sv 60 + +# Remove the mixed source. +vmic route podcast -s none + +# Inspect status, including current node architecture. +vmic list + +# Tear everything down. +vmic wipe ``` -## State +--- -Tracked in a SQLite database at `~/.config/vmic/vmic.db` (override the directory with -`VMIC_STATE_DIR_OVERRIDE`). +## How the topology adapts -## Testing - -`cargo test` runs a handful of fast unit tests only - no PipeWire needed. - -Two live integration test suites exercise every command against a real PipeWire -session: they spawn real `pw-loopback` processes, move real streams, and (guarded) -run the destructive `wipe` command. Neither runs automatically; opt in explicitly: +A vmic always exposes the same two ports - `vmic__sink` for apps to +play into, `vmic__mic` for recorders to capture from - but how many +PipeWire nodes back them depends on what's active: ``` -tests/live_test.sh --input-app firefox --output-app chromium --hw-source fifine -cargo test --test live_test -- --ignored --nocapture +Simple2Node (default): + apps -> vmic_X_sink =(pw-loopback)=> vmic_X_mic -> recorders + +Pure4Node (loopback + mixed source, isolated): + apps -> vmic_X_sink =(stage 1)=> vmic_X_mid -> vmic_X_mix =(stage 2)=> vmic_X_mic -> recorders + ^ hardware source joins here instead ``` -Both take the same parameters - as flags for the shell version, or matching env vars -for either (`VMIC_TEST_INPUT_APP`, `VMIC_TEST_OUTPUT_APP`, `VMIC_TEST_HW_SOURCE`, -`VMIC_TEST_NAME`, `VMIC_TEST_SKIP_WIPE=1`) - so point them at whatever apps and -hardware source you actually have available. They only ever touch vmics named -``/`_a`/`_b`, refuse to run the `wipe` phase if any other vmic -exists at that point, and always clean up after themselves even on failure. +`Pure4Node` only exists to keep a mixed-in hardware source out of the +self-monitor loopback's ears - it's the shape vmic switches to the moment +both `edit -l` and `route -s` are active at once, and switches back out of +the moment either stops being true. `edit --loopback-no-mix` opts a vmic out +of ever making that switch: it stays at `Simple2Node`, and the mixed source +just becomes audible in the self-monitor too. -## License +Switching shape necessarily recreates the nodes behind `sink`/`mic` - same +names, new underlying ids - because a `pw-loopback` process's capture and +playback nodes share one lifecycle and can't be reconfigured live. Each +migration: -APGL-3 +1. Spawns the new node pair(s) first. This is the only step that can fail; + if it does, the old topology is completely untouched. +2. Only then tears down the old node pair(s), best-effort. +3. Reconnects any app streams that were routed into or out of the old nodes, + by id, so nothing needs to be re-selected. +4. Re-applies the self-monitor loopback and any mixed-in source to the new + nodes. +5. Reports what happened - always, whether triggered by `route -s` or + `edit -l`/`edit --loopback-no-mix`. + +`vmic list` shows each vmic's current shape under `architecture:`. + +--- + +## State on disk + +Tracked in a SQLite database at `$XDG_CONFIG_HOME/vmic/vmic.db`, one row per +vmic (names, node ids, pids, loopback/volume/mixed-source settings). The +directory can be overridden with the `VMIC_STATE_DIR_OVERRIDE` environment +variable. + +--- + +## Shell completions + +```sh +vmic completions bash > /etc/bash_completion.d/vmic +vmic completions zsh > "${fpath[1]}/_vmic" +vmic completions fish > ~/.config/fish/completions/vmic.fish +```