From 50849540fa1b71681e649847022d6919a3609b61 Mon Sep 17 00:00:00 2001 From: Overlord Date: Tue, 11 Aug 2026 15:33:40 +0200 Subject: [PATCH] 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. --- src/commands/topology.rs | 241 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 src/commands/topology.rs diff --git a/src/commands/topology.rs b/src/commands/topology.rs new file mode 100644 index 0000000..5fdcc01 --- /dev/null +++ b/src/commands/topology.rs @@ -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 = + live_nodes.iter().filter(|n| n.name == state.sink_name).map(|n| n.id).collect(); + let exclude_source_ids: Vec = + 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) +}