Compare commits

..

8 Commits

Author SHA1 Message Date
45f2f13970 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.
2026-08-11 15:56:33 +02:00
b9b9986833 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.
2026-08-11 15:51:26 +02:00
ab99a9da3e 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.
2026-08-11 15:43:03 +02:00
50849540fa 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.
2026-08-11 15:33:40 +02:00
493f91bc28 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.
2026-08-11 15:33:36 +02:00
be70c55728 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
2026-08-11 13:51:18 +02:00
b3c87b02da 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.
2026-08-11 13:44:20 +02:00
e19f398397 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.
2026-08-11 13:28:33 +02:00
21 changed files with 3652 additions and 1 deletions

27
Cargo.toml Normal file
View File

@@ -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

View File

@@ -1,3 +1,80 @@
# vmic # vmic
Create and manage PipeWire virtual microphones easily. 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/<you>/.local/share/vmic-build/libclang-venv/lib/python3.*/site-packages/clang/native"
```
## Usage
```
vmic create <name> [-l] # create a vmic, optionally with self-monitor
vmic route <name> -i <app[:media]> # move an app's playback into the vmic
vmic route <name> -o <app[:media]> # move an app's recording off the vmic
vmic route <name> -s <source|none> # mix a hardware mic in, or remove it
vmic edit <name> -l <true|false> # toggle the self-monitor loopback
vmic edit <name> -v <0.8|80> # self-monitor loopback volume
vmic edit <name> -sv <0.8|80> # mixed-in source volume
vmic delete <name> # 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 <bash|zsh|fish|...>`.
## 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

95
src/cli.rs Normal file
View File

@@ -0,0 +1,95 @@
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<String>,
/// Move matching source-outputs off this vmic's source.
#[arg(short, long = "output", value_name = "APP[:MEDIA]")]
pub outputs: Vec<String>,
/// Mix a hardware source into the vmic, or "none" to remove it.
#[arg(short, long = "source", value_name = "SOURCE|none")]
pub source: Option<String>,
}
#[derive(Args)]
pub struct EditArgs {
/// Name of the vmic to edit.
pub name: String,
/// Enable or disable the self-monitor loopback.
#[arg(short, long, num_args = 0..=1, default_missing_value = "true", value_name = "BOOL")]
pub loopback: Option<bool>,
/// Keep a mixed-in source audible in the loopback instead of isolating
/// it (2-node topology instead of 4).
#[arg(long = "loopback-no-mix", num_args = 0..=1, default_missing_value = "true", value_name = "BOOL")]
pub loopback_no_mix: Option<bool>,
/// Loopback volume: fraction (0.8) or percent (80).
#[arg(short, long, value_name = "PCT")]
pub volume: Option<f32>,
/// Mixed-in source volume: fraction (0.8) or percent (80).
#[arg(long = "source-volume", value_name = "PCT")]
pub source_volume: Option<f32>,
}
#[derive(Args)]
pub struct DeleteArgs {
/// Name of the vmic to delete.
pub name: String,
}

80
src/commands/create.rs Normal file
View File

@@ -0,0 +1,80 @@
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 stage = loopback::create_simple_stage(&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)."));
}
// 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(stage.sink_node_id),
sink_pid: Some(stage.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<String> {
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)
}

31
src/commands/delete.rs Normal file
View File

@@ -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(())
}

166
src/commands/edit.rs Normal file
View File

@@ -0,0 +1,166 @@
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.loopback_no_mix.is_none()
&& args.volume.is_none()
&& args.source_volume.is_none()
{
return Err(VmicError::NothingToDo(
"pass --loopback, --loopback-no-mix, --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()?;
warn_if_stale(&graph, &state);
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)?;
}
if let Some(source_volume) = args.source_volume {
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)
}
/// 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<u32>| 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 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
));
}
}
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_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);
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(())
}

72
src/commands/list.rs Normal file
View File

@@ -0,0 +1,72 @@
use crate::commands::topology;
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!(" architecture: {}", topology::architecture_label(state));
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.
/// `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(),
(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(),
}
}

40
src/commands/mod.rs Normal file
View File

@@ -0,0 +1,40 @@
pub mod create;
pub mod delete;
pub mod edit;
pub mod list;
pub mod route;
pub mod topology;
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<u8> {
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(());
};
// 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(())
}

116
src/commands/route.rs Normal file
View File

@@ -0,0 +1,116 @@
use crate::cli::RouteArgs;
use crate::commands::topology::{self, Topology};
use crate::error::{Result, VmicError};
use crate::pw::{text, PwGraph};
use crate::state::{self, VmicState};
use crate::ui;
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(());
}
let mut moved = 0;
for m in &matches {
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
));
}
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") {
let Some(mic) = state.mic_source.clone() else {
ui::warn(&format!("no source is mixed into '{}'.", state.name));
return Ok(());
};
super::unsource(graph, state)?;
topology::sync_topology(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)?;
}
state.mic_source = Some(mic.clone());
topology::sync_topology(graph, state)?;
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(())
}

241
src/commands/topology.rs Normal file
View File

@@ -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<u32> =
live_nodes.iter().filter(|n| n.name == state.sink_name).map(|n| n.id).collect();
let exclude_source_ids: Vec<u32> =
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)
}

103
src/commands/wipe.rs Normal file
View File

@@ -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<u32> {
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::<libc::pid_t>() 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<String> {
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<String> {
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)
}

45
src/error.rs Normal file
View File

@@ -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<T> = std::result::Result<T, VmicError>;

226
src/main.rs Normal file
View File

@@ -0,0 +1,226 @@
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);
}
}
/// `(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<String>) -> Vec<String> {
args.into_iter()
.map(|a| {
a.strip_prefix('-')
.and_then(|rest| MULTI_CHAR_ALIASES.iter().find(|(_, alias)| *alias == rest))
.map(|(long, _)| format!("--{long}"))
.unwrap_or(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: 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
if let Some(about) = cmd.get_about() {
println!("{about}");
println!();
}
println!("{} vmic {} {}", ui::blue("Usage:"), ui::cyan("<COMMAND>"), ui::yellow("[ARGS]"));
println!();
println!("{}", ui::blue("Commands:"));
let subcommands: Vec<&clap::Command> = cmd.get_subcommands().filter(|s| s.get_name() != "help").collect();
let flag_rows_by_cmd: Vec<Vec<(String, String)>> = 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();
println!(" {name:name_w$} {} {about}", pad_visual(&positionals, pos_w));
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!();
let alias_rows: Vec<(String, String)> = subcommands
.iter()
.filter_map(|s| {
let aliases = s.get_visible_aliases().collect::<Vec<_>>().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()
.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}");
}
}
/// `<name>` 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::<Vec<_>>()
.join(" ")
}
/// `(flag display, help text)` for each of `cmd`'s non-positional, non-help
/// args, e.g. `("-i, --input <APP[:MEDIA]>", "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. 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<String> {
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,
}
}
/// 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))))
}

362
src/pw/graph.rs Normal file
View File

@@ -0,0 +1,362 @@
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<u32, Node>,
ports: HashMap<u32, Port>,
links: HashMap<u32, Link>,
}
/// 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<RefCell<GraphState>>,
}
impl PwGraph {
pub fn connect() -> Result<Self> {
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<Node> {
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<Port> {
let s = self.state.borrow();
let Some(node) = s.nodes.values().find(|n| n.name == node_name) else {
return Vec::new();
};
// 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<Port> = 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<Link> {
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::<pw::link::Link>(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<u32> {
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<u32> {
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<u32> {
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<Node> {
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<Node> {
let deadline = Instant::now() + timeout;
loop {
if let Some(n) = self.nodes().into_iter().find(|n| n.name == name && !excluding.contains(&n.id)) {
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(())
}

406
src/pw/loopback.rs Normal file
View File

@@ -0,0 +1,406 @@
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,
}
/// 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<SingleStageHandles> {
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<u32> {
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<StageHandles> {
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!(
"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_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))
})();
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<Child> {
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<u32> {
let deadline = Instant::now() + timeout;
loop {
let outs: Vec<Port> = graph.ports_for(from_node).into_iter().filter(|p| !p.is_input).collect();
let ins: Vec<Port> = 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<u32> {
let outs: Vec<Port> = graph.ports_for(from_node).into_iter().filter(|p| !p.is_input).collect();
let ins: Vec<Port> = 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<Vec<(u32, u32)>> {
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<u32> {
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(())
}

28
src/pw/mod.rs Normal file
View File

@@ -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;

339
src/pw/text.rs Normal file
View File

@@ -0,0 +1,339 @@
//! 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<Vec<SourceMatch>> {
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<SourceMatch> {
let mut sources = Vec::new();
let mut current_name: Option<String> = 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<Vec<SourceMatch>> {
if let Ok(idx) = filter.parse::<u32>() {
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<Vec<StreamMatch>> {
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::<u32>() {
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<StreamMatch> {
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<StreamMatch>, 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<Option<u32>> {
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<u32> {
let mut current_id: Option<u32> = 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::<u32>().ok() == Some(module_id) {
return current_id;
}
}
}
None
}
/// Numeric pulse-side id for `name` within `kind` ("sinks" or "sources"),
/// via `pactl list short <kind>` - 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<Option<u32>> {
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::<u32>().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<Vec<u32>> {
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<Vec<u32>> {
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<id>`) whose `key_prefix<id>`
/// property line equals `target_id`.
fn parse_ids_matching(text: &str, header_prefix: &str, key_prefix: &str, target_id: u32) -> Vec<u32> {
let mut ids = Vec::new();
let mut current_id: Option<u32> = 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::<u32>().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<Vec<u32>> {
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<u32> {
let mut ids = Vec::new();
let mut current_id: Option<u32> = 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 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::<u32>::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 = "\
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]);
}
}

216
src/state.rs Normal file
View File

@@ -0,0 +1,216 @@
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<u32>,
pub mix_node_id: Option<u32>,
// 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<i64>,
pub mix_pid: Option<i64>,
pub loopback_id: Option<u32>,
pub volume_pct: Option<u8>,
// May contain arbitrary multibyte text; SQLite TEXT has no encoding
// pitfalls here, unlike PipeWire's JSON dump (see pw::text).
pub mic_source: Option<String>,
pub mic_volume_pct: Option<u8>,
// 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 {
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<Connection> {
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,
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<Self> {
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")?,
loopback_no_mix: row.get("loopback_no_mix")?,
})
}
pub fn load(conn: &Connection, name: &str) -> Result<Self> {
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<bool> {
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, 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,
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,
loopback_no_mix = excluded.loopback_no_mix",
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,
self.loopback_no_mix,
],
)?;
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<Vec<Self>> {
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)
}
}

59
src/ui.rs Normal file
View File

@@ -0,0 +1,59 @@
//! Colorized status output (NO_COLOR-aware).
use std::io::IsTerminal;
use std::sync::OnceLock;
/// Honors NO_COLOR (<https://no-color.org>), dumb terminals, and redirected output.
fn color_enabled() -> bool {
static ENABLED: OnceLock<bool> = 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));
}

554
tests/live_test.rs Normal file
View File

@@ -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_<name_fragment>_` - 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::<u32>() 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<u32> {
pactl(&["list", "short", kind]).lines().find_map(|l| {
let mut cols = l.split_whitespace();
let id = cols.next()?.parse::<u32>().ok()?;
(cols.next()? == name).then_some(id)
})
}
fn wait_pulse_id(kind: &str, name: &str) -> Option<u32> {
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::<u32>().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::<u32>().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<String> {
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<T: PartialEq + std::fmt::Debug>(&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<T: PartialEq + std::fmt::Debug>(&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::<u32>() 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<u32> = 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);
}

368
tests/live_test.sh Executable file
View File

@@ -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 <desc> <expected_code>
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 <desc> <needle>
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 <vmic-name-suffix>
local n; n=$(pgrep -c -f "pw-loopback.*vmic_${1}_" 2>/dev/null); echo "${n:-0}"
}
pulse_id_for() { # pulse_id_for <sinks|sources> <exact-name>
pactl list short "$1" 2>/dev/null | awk -v n="$2" '$2==n{print $1; exit}'
}
wait_pulse_id() { # wait_pulse_id <sinks|sources> <exact-name> -> 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 <name-suffix> <expected>
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