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