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.
This commit is contained in:
2026-08-11 15:33:40 +02:00
parent 493f91bc28
commit 50849540fa

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