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.
This commit is contained in:
2026-08-11 13:28:33 +02:00
parent 76c129a5dc
commit e19f398397
18 changed files with 2151 additions and 1 deletions

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

@@ -0,0 +1,77 @@
use crate::cli::CreateArgs;
use crate::error::{Result, VmicError};
use crate::pw::{loopback, PwGraph};
use crate::state::{self, VmicState};
use crate::ui;
use std::process::Command;
pub fn run(args: CreateArgs) -> Result<()> {
state::require_valid_name(&args.name)?;
let name = state::normalize(&args.name);
let conn = state::open_db()?;
if VmicState::exists(&conn, &name)? {
return Err(VmicError::AlreadyExists(name));
}
let names = loopback::NodeNames::for_vmic(&name);
let graph = PwGraph::connect()?;
// pw-loopback may steal the default sink/source; restore them afterward.
let orig_sink = default_output("get-default-sink");
let orig_source = default_output("get-default-source");
let stages = loopback::create_stages(&graph, &names)?;
if let Some(sink) = &orig_sink {
let _ = Command::new("pactl").args(["set-default-sink", sink]).status();
}
if let Some(source) = &orig_source {
let _ = Command::new("pactl").args(["set-default-source", source]).status();
}
// Insurance: a sink monitor feeding any vmic node is a feedback ring;
// it should never exist, but tear it down if one appears anyway.
let prefix = format!("vmic_{name}_");
let removed = graph.break_feedback_rings(&prefix)?;
if removed > 0 {
ui::warn(&format!("removed {removed} unexpected monitor feedback link(s)."));
}
let mut state = VmicState {
name: name.clone(),
sink_name: names.sink.clone(),
mid_name: names.mid,
mix_name: names.mix,
source_name: names.source.clone(),
sink_node_id: Some(stages.sink_node_id),
mix_node_id: Some(stages.mix_node_id),
sink_pid: Some(stages.sink_pid as i64),
mix_pid: Some(stages.mix_pid as i64),
..Default::default()
};
if args.loopback {
let loop_id = loopback::enable_self_monitor(&graph, &names.sink)?;
state.loopback_id = Some(loop_id);
}
state.save(&conn)?;
ui::ok(&format!("Created virtual microphone '{name}'."));
if let (Some(sink), Some(source)) = (&orig_sink, &orig_source) {
ui::info(&format!(" Default Output/Input kept intact ('{sink}' / '{source}')."));
}
println!(" In the source app, set output device to:");
println!(" '{}'", ui::blue(&names.sink));
println!(" In the receiving app, set input/mic device to:");
println!(" '{}'", ui::blue(&names.source));
Ok(())
}
fn default_output(subcommand: &str) -> Option<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(())
}

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

@@ -0,0 +1,109 @@
use crate::cli::EditArgs;
use crate::commands::to_pct;
use crate::error::{Result, VmicError};
use crate::pw::{loopback, PwGraph};
use crate::state::{self, VmicState};
use crate::ui;
pub fn run(args: EditArgs) -> Result<()> {
if args.loopback.is_none() && args.volume.is_none() && args.source_volume.is_none() {
return Err(VmicError::NothingToDo(
"pass --loopback, --volume and/or --source-volume.".into(),
));
}
let conn = state::open_db()?;
let name = state::normalize(&args.name);
let mut state = VmicState::load(&conn, &name)?;
let graph = PwGraph::connect()?;
if let Some(enable) = args.loopback {
edit_loopback(&graph, &mut state, enable)?;
}
if let Some(volume) = args.volume {
edit_volume(&graph, &mut state, volume)?;
}
if let Some(source_volume) = args.source_volume {
edit_source_volume(&mut state, source_volume)?;
}
state.save(&conn)
}
fn edit_loopback(graph: &PwGraph, state: &mut VmicState, enable: bool) -> Result<()> {
match (enable, state.loopback_id) {
(true, Some(id)) => {
ui::warn(&format!(
"self-monitor loopback already enabled for '{}' (module {id}).",
state.name
));
}
(true, None) => {
let id = loopback::enable_self_monitor(graph, &state.sink_name)?;
state.loopback_id = Some(id);
ui::ok(&format!("Self-monitor loopback enabled (module {id})."));
// Re-apply a previously stored loopback volume, if any. A
// failure here is a warning, not a hard error - the loopback
// itself was still created successfully.
if let Some(pct) = state.volume_pct {
if loopback::set_loopback_volume(graph, id, pct).is_ok() {
ui::info(&format!("Loopback volume restored to {pct}%."));
} else {
ui::warn(&format!("could not apply stored loopback volume ({pct}%)."));
}
}
}
(false, None) => {
ui::warn(&format!("self-monitor loopback is not enabled for '{}'.", state.name));
}
(false, Some(id)) => {
// A failed unload propagates as a real error rather than being
// silently swallowed.
loopback::unload_pulse_module(id)?;
state.loopback_id = None;
ui::ok(&format!("Self-monitor loopback disabled (module {id} unloaded)."));
}
}
Ok(())
}
fn edit_volume(graph: &PwGraph, state: &mut VmicState, value: f32) -> Result<()> {
let pct = to_pct(value).ok_or_else(|| VmicError::InvalidVolume(value.to_string()))?;
state.volume_pct = Some(pct);
let Some(id) = state.loopback_id else {
ui::ok(&format!(
"Loopback volume for '{}' saved ({pct}%); applies when the loopback is enabled.",
state.name
));
return Ok(());
};
if loopback::set_loopback_volume(graph, id, pct).is_ok() {
ui::ok(&format!("Loopback volume for '{}' set to {pct}%.", state.name));
} else {
ui::warn(&format!("could not find the loopback playback stream (module {id})."));
}
Ok(())
}
fn edit_source_volume(state: &mut VmicState, value: f32) -> Result<()> {
let pct = to_pct(value).ok_or_else(|| VmicError::InvalidVolume(value.to_string()))?;
state.mic_volume_pct = Some(pct);
let Some(mic) = state.mic_source.clone() else {
ui::ok(&format!("Source volume for '{}' saved ({pct}%).", state.name));
return Ok(());
};
let status = std::process::Command::new("pactl")
.args(["set-source-volume", &mic, &format!("{pct}%")])
.status()?;
if status.success() {
ui::ok(&format!("Source volume for '{}' set to {pct}% ('{mic}').", state.name));
} else {
ui::warn(&format!("could not set volume on '{mic}'."));
}
Ok(())
}

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

@@ -0,0 +1,62 @@
use crate::error::Result;
use crate::pw::PwGraph;
use crate::state::{self, VmicState};
use crate::ui;
pub fn run() -> Result<()> {
let conn = state::open_db()?;
let states = VmicState::list_all(&conn)?;
if states.is_empty() {
ui::info("No virtual mics created.");
return Ok(());
}
let graph = PwGraph::connect().ok();
for (i, state) in states.iter().enumerate() {
let status = liveness(graph.as_ref(), state);
let status_colored = if status == "active" { ui::green(&status) } else { ui::yellow(&status) };
println!("{}", ui::blue(&state.name));
println!(" output device: {}", state.sink_name);
println!(" mic device: {}", state.source_name);
println!(" status: {status_colored}");
println!(
" self-monitor loopback: {}",
state
.loopback_id
.map(|id| format!("yes (module {id})"))
.unwrap_or_else(|| "no".into())
);
if let Some(pct) = state.volume_pct {
println!(" loopback volume: {}", ui::blue(&format!("{pct}%")));
}
if let Some(mic) = &state.mic_source {
println!(" mixed source: {mic}");
if let Some(pct) = state.mic_volume_pct {
println!(" source volume: {}", ui::blue(&format!("{pct}%")));
}
}
if i + 1 < states.len() {
println!();
}
}
Ok(())
}
/// Checks `sink_node_id`/`mix_node_id` against the live graph snapshot.
fn liveness(graph: Option<&PwGraph>, state: &VmicState) -> String {
let Some(graph) = graph else {
return "unknown (could not connect to PipeWire)".to_string();
};
let nodes = graph.nodes();
let sink_alive = state.sink_node_id.is_some_and(|id| nodes.iter().any(|n| n.id == id));
let mix_alive = state.mix_node_id.is_some_and(|id| nodes.iter().any(|n| n.id == id));
match (sink_alive, mix_alive) {
(true, true) => "active".to_string(),
(true, false) => "degraded (mix loopback process not found)".to_string(),
(false, true) => "degraded (sink loopback process not found)".to_string(),
(false, false) => "stale (matching pw-loopback process not found)".to_string(),
}
}

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

@@ -0,0 +1,35 @@
pub mod create;
pub mod delete;
pub mod edit;
pub mod list;
pub mod route;
pub mod wipe;
use crate::error::Result;
use crate::pw::{loopback, PwGraph};
use crate::state::VmicState;
use crate::ui;
/// Normalizes a fraction (0.8) or percentage (80) to an integer percent,
/// clamped to the storable range (0..=255 - `volume_pct`/`mic_volume_pct`
/// are `u8`). Warns rather than silently dropping precision when clamped.
pub fn to_pct(value: f32) -> Option<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(());
};
loopback::unlink_stage(graph, &mic, &state.mix_name)?;
Ok(())
}

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

@@ -0,0 +1,102 @@
use crate::cli::RouteArgs;
use crate::error::{Result, VmicError};
use crate::pw::{loopback, text, PwGraph};
use crate::state::{self, VmicState};
use crate::ui;
use std::time::Duration;
/// Shorter than stage-creation's timeout since the source already exists -
/// this is just retrying while its ports enumerate.
const SOURCE_LINK_TIMEOUT: Duration = Duration::from_secs(2);
pub fn run(args: RouteArgs) -> Result<()> {
let conn = state::open_db()?;
let name = state::normalize(&args.name);
let mut state = VmicState::load(&conn, &name)?;
if args.inputs.is_empty() && args.outputs.is_empty() && args.source.is_none() {
return Err(VmicError::NothingToDo(
"pass --input, --output and/or --source.".into(),
));
}
let graph = PwGraph::connect()?;
for filter in &args.inputs {
move_stream("sink-inputs", "move-sink-input", &state.sink_name, filter)?;
}
for filter in &args.outputs {
move_stream("source-outputs", "move-source-output", &state.source_name, filter)?;
}
if let Some(source) = &args.source {
route_source(&graph, &mut state, source)?;
state.save(&conn)?;
}
Ok(())
}
/// Matches streams via `pw::text` (name/description matching - see that
/// module for why this stays text-based) then moves each with `pactl`.
fn move_stream(kind: &str, move_cmd: &str, target: &str, filter: &str) -> Result<()> {
let matches = text::match_streams(kind, filter)?;
if matches.is_empty() {
ui::warn(&format!("'{filter}': matched 0 streams, nothing moved."));
return Ok(());
}
for m in &matches {
std::process::Command::new("pactl")
.args([move_cmd, &m.id, target])
.status()?;
}
ui::info(&format!("'{filter}': matched {} stream(s) -> moved to '{target}'.", matches.len()));
Ok(())
}
fn route_source(graph: &PwGraph, state: &mut VmicState, value: &str) -> Result<()> {
let lowered = value.to_lowercase();
if matches!(lowered.as_str(), "off" | "none" | "disable") {
let Some(mic) = state.mic_source.clone() else {
ui::warn(&format!("no source is mixed into '{}'.", state.name));
return Ok(());
};
super::unsource(graph, state)?;
ui::ok(&format!("Removed source mix '{mic}' from '{}'.", state.name));
return Ok(());
}
let matches = text::match_source(value)?;
match matches.len() {
0 => return Err(VmicError::NoMatchingSource(value.to_string())),
1 => {}
_ => {
let names: Vec<_> = matches.iter().map(|m| m.name.clone()).collect();
return Err(VmicError::AmbiguousSource {
value: value.to_string(),
matches: names.join(", "),
});
}
}
let mic = matches[0].name.clone();
// Replace a different currently-mixed source first.
if state.mic_source.as_deref().is_some_and(|old| old != mic) {
super::unsource(graph, state)?;
}
let linked = loopback::link_stage(graph, &mic, &state.mix_name, SOURCE_LINK_TIMEOUT)?;
state.mic_source = Some(mic.clone());
// Insurance: make sure the new links did not create a monitor ring.
let prefix = format!("vmic_{}_", state.name.to_lowercase());
let rings = graph.break_feedback_rings(&prefix)?;
if rings > 0 {
ui::warn(&format!("removed {rings} unexpected monitor feedback link(s)."));
}
ui::ok(&format!(
"Mixed '{mic}' into the mix stage of '{}' ({linked} link(s); not audible in self-monitor).",
state.name
));
Ok(())
}

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