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