Compare commits

...

11 Commits

Author SHA1 Message Date
617dd743a5 Merge branch 'release/v1.0.0' 2026-08-14 21:15:15 +02:00
d7753670fe Update and expand README.md with improved workflows, feature details, usage examples, and comprehensive testing instructions. 2026-08-14 21:15:02 +02:00
3bac7c7472 Expand and clarify README.md with updated workflows, examples, and testing instructions. 2026-08-11 22:08:56 +02:00
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 3779 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

206
README.md
View File

@@ -1,3 +1,207 @@
# vmic
Create and manage PipeWire virtual microphones easily.
Create and manage PipeWire virtual microphones easily.
Vmic wraps `pw-loopback`/`pactl` to turn ad-hoc loopback chains into named
profiles: create a virtual sink/mic pair once, then route apps and hardware
sources into it by name. Each vmic runs the cheapest PipeWire node shape that
satisfies its current settings, and vmic migrates it live as those settings
change.
---
## Features
- **Dynamic topology**: a vmic starts as a single `pw-loopback` process (2
nodes, sink straight into mic) and only upgrades to an isolated 4-node
shape when it's actually needed - a self-monitor loopback *and* a mixed-in
hardware source active at once. It downgrades back the moment either goes
away.
- **`--loopback-no-mix` opt-out**: keep the cheaper 2-node shape even with
both active, accepting that the mixed-in source becomes audible in the
self-monitor loopback in exchange for never running the second stage.
- **Automatic stream reconnection**: app streams already routed into or out
of a vmic are moved to the new nodes when a topology migration happens -
nothing needs to be re-selected in the app.
- **Spawn-before-teardown migrations**: a topology change always spawns the
new node pair(s) and confirms they're alive before touching the old ones,
so a failed migration never leaves a vmic worse off than before the
command ran.
- **Self-monitor loopback** with its own adjustable volume, independent of
the mixed-in hardware source's volume.
- **Status and listing** showing liveness and the vmic's current node
architecture, so the tradeoff of any given shape is always visible.
- **`wipe`**, a full teardown of every vmic-related PipeWire node, module,
and process on the system - tracked or not.
- **Shell completions** for bash, zsh, fish, and others.
---
## Installation
Requires a Rust toolchain, and PipeWire + pipewire-pulse (`pactl`,
`pw-loopback`) on `PATH`.
```sh
cargo build --release
```
The binary is written to `target/release/vmic`. Place it on `PATH`, e.g.:
```sh
install -Dm755 target/release/vmic ~/.local/bin/vmic
```
If the build fails inside `libspa-sys` (e.g. `no field 'data' on type
'spa_pod_builder'`), your system clang is newer than the pinned `bindgen`
version supports. Point `LIBCLANG_PATH` at an older libclang - a quick fix
is a pip-vendored version:
```sh
python3 -m venv ~/.local/share/vmic-build/libclang-venv
~/.local/share/vmic-build/libclang-venv/bin/pip install libclang
```
then add to `.cargo/config.toml`:
```toml
[env]
LIBCLANG_PATH = "/home/<you>/.local/share/vmic-build/libclang-venv/lib/python3.*/site-packages/clang/native"
```
---
## Commands
| Command | Aliases | Description |
|---------------|----------------|------------------------------------------------|
| `create` | `mk`, `make` | Create a new virtual microphone. |
| `route` | | Move app streams and mix a hardware source in. |
| `edit` | | Change loopback and volume settings on a vmic. |
| `delete` | `rm`, `remove` | Delete a virtual microphone. |
| `list` | `ls` | List all virtual microphones with live status. |
| `wipe` | `reset` | Tear down every vmic, tracked or not. |
| `completions` | | Generate a shell completion script. |
Run `vmic` or `vmic --help` for the full flag reference.
### `create` flags
| Flag | Value | Meaning | Default |
|------------------|--------|---------------------------------------------|:-------:|
| `-l, --loopback` | (bare) | Enable a self-monitor loopback at creation. | off |
### `route` flags
| Flag | Value | Meaning | Default |
|----------------|----------------|------------------------------------------------------|:-------:|
| `-i, --input` | `APP[:MEDIA]` | Move matching sink-inputs into this vmic's sink. | - |
| `-o, --output` | `APP[:MEDIA]` | Move matching source-outputs off this vmic's source. | - |
| `-s, --source` | `SOURCE\|none` | Mix a hardware source into the vmic, or remove it. | - |
### `edit` flags
| Flag | Value | Meaning | Default |
|------------------------|----------|--------------------------------------------------------------------------------|:-------:|
| `-l, --loopback` | `[BOOL]` | Enable/disable the self-monitor loopback (bare `-l` means `true`). | - |
| `--loopback-no-mix` | `[BOOL]` | Keep the 2-node shape even with loopback + a mixed source (bare means `true`). | `false` |
| `-v, --volume` | `PCT` | Self-monitor loopback volume: fraction (`0.8`) or percent (`80`). | - |
| `-sv, --source-volume` | `PCT` | Mixed-in source volume: fraction (`0.8`) or percent (`80`). | - |
`edit` only touches the fields given on the command line; everything else
stays as-is. Any flag that changes `--loopback`/`--loopback-no-mix` may
trigger a topology migration - see below.
---
### Examples
```sh
# Create a vmic with a self-monitor loopback.
vmic create podcast -l
# Point your app's output device at vmic_podcast_sink, and your
# recorder's input device at vmic_podcast_mic, then mix in a real mic.
vmic route podcast -s "USB Microphone"
# Move Firefox's playback into the vmic, and OBS's recording off it.
vmic route podcast -i firefox
vmic route podcast -o obs
# Stop isolating the mixed source from the self-monitor, collapsing back
# to the cheaper 2-node shape.
vmic edit podcast --loopback-no-mix
# Adjust volumes independently.
vmic edit podcast -v 0.8
vmic edit podcast -sv 60
# Remove the mixed source.
vmic route podcast -s none
# Inspect status, including current node architecture.
vmic list
# Tear everything down.
vmic wipe
```
---
## How the topology adapts
A vmic always exposes the same two ports - `vmic_<name>_sink` for apps to
play into, `vmic_<name>_mic` for recorders to capture from - but how many
PipeWire nodes back them depends on what's active:
```
Simple2Node (default):
apps -> vmic_X_sink =(pw-loopback)=> vmic_X_mic -> recorders
Pure4Node (loopback + mixed source, isolated):
apps -> vmic_X_sink =(stage 1)=> vmic_X_mid -> vmic_X_mix =(stage 2)=> vmic_X_mic -> recorders
^ hardware source joins here instead
```
`Pure4Node` only exists to keep a mixed-in hardware source out of the
self-monitor loopback's ears - it's the shape vmic switches to the moment
both `edit -l` and `route -s` are active at once, and switches back out of
the moment either stops being true. `edit --loopback-no-mix` opts a vmic out
of ever making that switch: it stays at `Simple2Node`, and the mixed source
just becomes audible in the self-monitor too.
Switching shape necessarily recreates the nodes behind `sink`/`mic` - same
names, new underlying ids - because a `pw-loopback` process's capture and
playback nodes share one lifecycle and can't be reconfigured live. Each
migration:
1. Spawns the new node pair(s) first. This is the only step that can fail;
if it does, the old topology is completely untouched.
2. Only then tears down the old node pair(s), best-effort.
3. Reconnects any app streams that were routed into or out of the old nodes,
by id, so nothing needs to be re-selected.
4. Re-applies the self-monitor loopback and any mixed-in source to the new
nodes.
5. Reports what happened - always, whether triggered by `route -s` or
`edit -l`/`edit --loopback-no-mix`.
`vmic list` shows each vmic's current shape under `architecture:`.
---
## State on disk
Tracked in a SQLite database at `$XDG_CONFIG_HOME/vmic/vmic.db`, one row per
vmic (names, node ids, pids, loopback/volume/mixed-source settings). The
directory can be overridden with the `VMIC_STATE_DIR_OVERRIDE` environment
variable.
---
## Shell completions
```sh
vmic completions bash > /etc/bash_completion.d/vmic
vmic completions zsh > "${fpath[1]}/_vmic"
vmic completions fish > ~/.config/fish/completions/vmic.fish
```

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