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

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