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:
@@ -21,7 +21,7 @@ pub fn run(args: CreateArgs) -> Result<()> {
|
||||
let orig_sink = default_output("get-default-sink");
|
||||
let orig_source = default_output("get-default-source");
|
||||
|
||||
let stages = loopback::create_stages(&graph, &names)?;
|
||||
let stage = loopback::create_simple_stage(&graph, &names, &[], &[])?;
|
||||
|
||||
if let Some(sink) = &orig_sink {
|
||||
let _ = Command::new("pactl").args(["set-default-sink", sink]).status();
|
||||
@@ -38,16 +38,19 @@ pub fn run(args: CreateArgs) -> Result<()> {
|
||||
ui::warn(&format!("removed {removed} unexpected monitor feedback link(s)."));
|
||||
}
|
||||
|
||||
// A fresh vmic always starts as Simple2Node - no source can be mixed in
|
||||
// yet, so there's nothing for a 4-node split to isolate (see
|
||||
// commands::topology). mid_name/mix_name are still stored even though no
|
||||
// live node backs them yet: cheap, and needed if this vmic later
|
||||
// upgrades. mix_node_id/mix_pid stay at their Default (None).
|
||||
let mut state = VmicState {
|
||||
name: name.clone(),
|
||||
sink_name: names.sink.clone(),
|
||||
mid_name: names.mid,
|
||||
mix_name: names.mix,
|
||||
source_name: names.source.clone(),
|
||||
sink_node_id: Some(stages.sink_node_id),
|
||||
mix_node_id: Some(stages.mix_node_id),
|
||||
sink_pid: Some(stages.sink_pid as i64),
|
||||
mix_pid: Some(stages.mix_pid as i64),
|
||||
sink_node_id: Some(stage.sink_node_id),
|
||||
sink_pid: Some(stage.pid as i64),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
use crate::cli::EditArgs;
|
||||
use crate::commands::to_pct;
|
||||
use crate::commands::topology;
|
||||
use crate::error::{Result, VmicError};
|
||||
use crate::pw::{loopback, PwGraph};
|
||||
use crate::state::{self, VmicState};
|
||||
use crate::ui;
|
||||
|
||||
pub fn run(args: EditArgs) -> Result<()> {
|
||||
if args.loopback.is_none() && args.volume.is_none() && args.source_volume.is_none() {
|
||||
if args.loopback.is_none()
|
||||
&& args.loopback_no_mix.is_none()
|
||||
&& args.volume.is_none()
|
||||
&& args.source_volume.is_none()
|
||||
{
|
||||
return Err(VmicError::NothingToDo(
|
||||
"pass --loopback, --volume and/or --source-volume.".into(),
|
||||
"pass --loopback, --loopback-no-mix, --volume and/or --source-volume.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -21,6 +26,9 @@ pub fn run(args: EditArgs) -> Result<()> {
|
||||
if let Some(enable) = args.loopback {
|
||||
edit_loopback(&graph, &mut state, enable)?;
|
||||
}
|
||||
if let Some(no_mix) = args.loopback_no_mix {
|
||||
edit_loopback_no_mix(&mut state, no_mix);
|
||||
}
|
||||
if let Some(volume) = args.volume {
|
||||
edit_volume(&graph, &mut state, volume)?;
|
||||
}
|
||||
@@ -28,6 +36,12 @@ pub fn run(args: EditArgs) -> Result<()> {
|
||||
edit_source_volume(&mut state, source_volume)?;
|
||||
}
|
||||
|
||||
// Only --loopback/--loopback-no-mix can change the desired topology -
|
||||
// recompute it and migrate if needed.
|
||||
if args.loopback.is_some() || args.loopback_no_mix.is_some() {
|
||||
topology::sync_topology(&graph, &mut state)?;
|
||||
}
|
||||
|
||||
state.save(&conn)
|
||||
}
|
||||
|
||||
@@ -40,7 +54,10 @@ fn warn_if_stale(graph: &PwGraph, state: &VmicState) {
|
||||
"vmic '{}' appears stale (matching pw-loopback process not found); changes may not apply!",
|
||||
state.name
|
||||
));
|
||||
} else if !alive(state.mix_node_id) {
|
||||
} else if state.mix_pid.is_some() && !alive(state.mix_node_id) {
|
||||
// A missing mix stage is only suspicious when one is supposed to
|
||||
// exist (Pure4Node topology, see commands::topology) - a
|
||||
// Simple2Node vmic never has one.
|
||||
ui::warn(&format!(
|
||||
"vmic '{}' mix loopback process not found; -sv changes may not apply!",
|
||||
state.name
|
||||
@@ -86,6 +103,28 @@ fn edit_loopback(graph: &PwGraph, state: &mut VmicState, enable: bool) -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_loopback_no_mix(state: &mut VmicState, no_mix: bool) {
|
||||
if state.loopback_no_mix == no_mix {
|
||||
ui::warn(&format!(
|
||||
"--loopback-no-mix is already {no_mix} for '{}'.",
|
||||
state.name
|
||||
));
|
||||
return;
|
||||
}
|
||||
state.loopback_no_mix = no_mix;
|
||||
if no_mix {
|
||||
ui::ok(&format!(
|
||||
"'{}' will keep a mixed-in source in the self-monitor loopback instead of isolating it.",
|
||||
state.name
|
||||
));
|
||||
} else {
|
||||
ui::ok(&format!(
|
||||
"'{}' will isolate a mixed-in source from the self-monitor loopback again.",
|
||||
state.name
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_volume(graph: &PwGraph, state: &mut VmicState, value: f32) -> Result<()> {
|
||||
let pct = to_pct(value).ok_or_else(|| VmicError::InvalidVolume(value.to_string()))?;
|
||||
state.volume_pct = Some(pct);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::commands::topology;
|
||||
use crate::error::Result;
|
||||
use crate::pw::PwGraph;
|
||||
use crate::state::{self, VmicState};
|
||||
@@ -21,6 +22,7 @@ pub fn run() -> Result<()> {
|
||||
println!(" output device: {}", state.sink_name);
|
||||
println!(" mic device: {}", state.source_name);
|
||||
println!(" status: {status_colored}");
|
||||
println!(" architecture: {}", topology::architecture_label(state));
|
||||
println!(
|
||||
" self-monitor loopback: {}",
|
||||
state
|
||||
@@ -46,12 +48,20 @@ pub fn run() -> Result<()> {
|
||||
}
|
||||
|
||||
/// Checks `sink_node_id`/`mix_node_id` against the live graph snapshot.
|
||||
/// `mix_node_id` is only expected to be alive when this vmic is currently
|
||||
/// running as `Pure4Node` (`mix_pid.is_some()`) - a `Simple2Node` vmic never
|
||||
/// has one, and that's not a problem.
|
||||
fn liveness(graph: Option<&PwGraph>, state: &VmicState) -> String {
|
||||
let Some(graph) = graph else {
|
||||
return "unknown (could not connect to PipeWire)".to_string();
|
||||
};
|
||||
let nodes = graph.nodes();
|
||||
let sink_alive = state.sink_node_id.is_some_and(|id| nodes.iter().any(|n| n.id == id));
|
||||
|
||||
if topology::current_topology(state) == topology::Topology::Simple2Node {
|
||||
return if sink_alive { "active".to_string() } else { "stale (matching pw-loopback process not found)".to_string() };
|
||||
}
|
||||
|
||||
let mix_alive = state.mix_node_id.is_some_and(|id| nodes.iter().any(|n| n.id == id));
|
||||
match (sink_alive, mix_alive) {
|
||||
(true, true) => "active".to_string(),
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod delete;
|
||||
pub mod edit;
|
||||
pub mod list;
|
||||
pub mod route;
|
||||
pub mod topology;
|
||||
pub mod wipe;
|
||||
|
||||
use crate::error::Result;
|
||||
@@ -30,6 +31,10 @@ pub(crate) fn unsource(graph: &PwGraph, state: &mut VmicState) -> Result<()> {
|
||||
let Some(mic) = state.mic_source.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
loopback::unlink_stage(graph, &mic, &state.mix_name)?;
|
||||
// Target depends on the current topology: a Simple2Node vmic mixes the
|
||||
// source straight into its sink, not a (nonexistent) mix stage.
|
||||
let current = topology::current_topology(state);
|
||||
let target = topology::mic_target(state, current).to_string();
|
||||
loopback::unlink_stage(graph, &mic, &target)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
use crate::cli::RouteArgs;
|
||||
use crate::commands::topology::{self, Topology};
|
||||
use crate::error::{Result, VmicError};
|
||||
use crate::pw::{loopback, text, PwGraph};
|
||||
use crate::pw::{text, PwGraph};
|
||||
use crate::state::{self, VmicState};
|
||||
use crate::ui;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Shorter than stage-creation's timeout since the source already exists -
|
||||
/// this is just retrying while its ports enumerate.
|
||||
const SOURCE_LINK_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
pub fn run(args: RouteArgs) -> Result<()> {
|
||||
let conn = state::open_db()?;
|
||||
@@ -69,6 +65,10 @@ fn move_stream(kind: &str, move_cmd: &str, target: &str, filter: &str) -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets or clears the mixed-in hardware source, then hands off to
|
||||
/// `topology::sync_topology` to (re)link it and, if the loopback/no-mix
|
||||
/// flags call for it, upgrade or downgrade between the 2-node and 4-node
|
||||
/// shapes.
|
||||
fn route_source(graph: &PwGraph, state: &mut VmicState, value: &str) -> Result<()> {
|
||||
let lowered = value.to_lowercase();
|
||||
if matches!(lowered.as_str(), "off" | "none" | "disable") {
|
||||
@@ -77,6 +77,7 @@ fn route_source(graph: &PwGraph, state: &mut VmicState, value: &str) -> Result<(
|
||||
return Ok(());
|
||||
};
|
||||
super::unsource(graph, state)?;
|
||||
topology::sync_topology(graph, state)?;
|
||||
ui::ok(&format!("Removed source mix '{mic}' from '{}'.", state.name));
|
||||
return Ok(());
|
||||
}
|
||||
@@ -100,19 +101,16 @@ fn route_source(graph: &PwGraph, state: &mut VmicState, value: &str) -> Result<(
|
||||
super::unsource(graph, state)?;
|
||||
}
|
||||
|
||||
let linked = loopback::link_stage(graph, &mic, &state.mix_name, SOURCE_LINK_TIMEOUT)?;
|
||||
state.mic_source = Some(mic.clone());
|
||||
topology::sync_topology(graph, state)?;
|
||||
|
||||
// Insurance: make sure the new links did not create a monitor ring.
|
||||
let prefix = format!("vmic_{}_", state.name.to_lowercase());
|
||||
let rings = graph.break_feedback_rings(&prefix)?;
|
||||
if rings > 0 {
|
||||
ui::warn(&format!("removed {rings} unexpected monitor feedback link(s)."));
|
||||
}
|
||||
|
||||
ui::ok(&format!(
|
||||
"Mixed '{mic}' into the mix stage of '{}' ({linked} link(s); not audible in self-monitor).",
|
||||
state.name
|
||||
));
|
||||
let note = match topology::current_topology(state) {
|
||||
Topology::Pure4Node => "isolated mix stage; not audible in self-monitor",
|
||||
Topology::Simple2Node if state.loopback_id.is_some() => {
|
||||
"sink; also audible in self-monitor - see --loopback-no-mix"
|
||||
}
|
||||
Topology::Simple2Node => "sink",
|
||||
};
|
||||
ui::ok(&format!("Mixed '{mic}' into '{}' ({note}).", state.name));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user