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:
241
src/commands/topology.rs
Normal file
241
src/commands/topology.rs
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user