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:
326
src/pw/loopback.rs
Normal file
326
src/pw/loopback.rs
Normal file
@@ -0,0 +1,326 @@
|
||||
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,
|
||||
}
|
||||
|
||||
/// 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).
|
||||
///
|
||||
/// apps -> sink =(stage1)=> mid -> mix =(stage2)=> source -> recorders
|
||||
/// ^ hw mic joins here (route --source)
|
||||
pub fn create_stages(graph: &PwGraph, names: &NodeNames) -> Result<StageHandles> {
|
||||
let fmt = format!("audio.rate={SAMPLE_RATE} audio.position=[FL FR]");
|
||||
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)?;
|
||||
|
||||
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_node(&names.sink, 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user