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:
@@ -74,6 +74,11 @@ pub struct EditArgs {
|
|||||||
#[arg(short, long, value_name = "BOOL")]
|
#[arg(short, long, value_name = "BOOL")]
|
||||||
pub loopback: Option<bool>,
|
pub loopback: Option<bool>,
|
||||||
|
|
||||||
|
/// Keep a mixed-in source audible in the loopback instead of isolating
|
||||||
|
/// it (2-node topology instead of 4).
|
||||||
|
#[arg(long = "loopback-no-mix", value_name = "BOOL")]
|
||||||
|
pub loopback_no_mix: Option<bool>,
|
||||||
|
|
||||||
/// Loopback volume: fraction (0.8) or percent (80).
|
/// Loopback volume: fraction (0.8) or percent (80).
|
||||||
#[arg(short, long, value_name = "PCT")]
|
#[arg(short, long, value_name = "PCT")]
|
||||||
pub volume: Option<f32>,
|
pub volume: Option<f32>,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ pub fn run(args: CreateArgs) -> Result<()> {
|
|||||||
let orig_sink = default_output("get-default-sink");
|
let orig_sink = default_output("get-default-sink");
|
||||||
let orig_source = default_output("get-default-source");
|
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 {
|
if let Some(sink) = &orig_sink {
|
||||||
let _ = Command::new("pactl").args(["set-default-sink", sink]).status();
|
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)."));
|
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 {
|
let mut state = VmicState {
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
sink_name: names.sink.clone(),
|
sink_name: names.sink.clone(),
|
||||||
mid_name: names.mid,
|
mid_name: names.mid,
|
||||||
mix_name: names.mix,
|
mix_name: names.mix,
|
||||||
source_name: names.source.clone(),
|
source_name: names.source.clone(),
|
||||||
sink_node_id: Some(stages.sink_node_id),
|
sink_node_id: Some(stage.sink_node_id),
|
||||||
mix_node_id: Some(stages.mix_node_id),
|
sink_pid: Some(stage.pid as i64),
|
||||||
sink_pid: Some(stages.sink_pid as i64),
|
|
||||||
mix_pid: Some(stages.mix_pid as i64),
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
use crate::cli::EditArgs;
|
use crate::cli::EditArgs;
|
||||||
use crate::commands::to_pct;
|
use crate::commands::to_pct;
|
||||||
|
use crate::commands::topology;
|
||||||
use crate::error::{Result, VmicError};
|
use crate::error::{Result, VmicError};
|
||||||
use crate::pw::{loopback, PwGraph};
|
use crate::pw::{loopback, PwGraph};
|
||||||
use crate::state::{self, VmicState};
|
use crate::state::{self, VmicState};
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
|
|
||||||
pub fn run(args: EditArgs) -> Result<()> {
|
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(
|
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 {
|
if let Some(enable) = args.loopback {
|
||||||
edit_loopback(&graph, &mut state, enable)?;
|
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 {
|
if let Some(volume) = args.volume {
|
||||||
edit_volume(&graph, &mut state, volume)?;
|
edit_volume(&graph, &mut state, volume)?;
|
||||||
}
|
}
|
||||||
@@ -28,6 +36,12 @@ pub fn run(args: EditArgs) -> Result<()> {
|
|||||||
edit_source_volume(&mut state, source_volume)?;
|
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)
|
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!",
|
"vmic '{}' appears stale (matching pw-loopback process not found); changes may not apply!",
|
||||||
state.name
|
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!(
|
ui::warn(&format!(
|
||||||
"vmic '{}' mix loopback process not found; -sv changes may not apply!",
|
"vmic '{}' mix loopback process not found; -sv changes may not apply!",
|
||||||
state.name
|
state.name
|
||||||
@@ -86,6 +103,28 @@ fn edit_loopback(graph: &PwGraph, state: &mut VmicState, enable: bool) -> Result
|
|||||||
Ok(())
|
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<()> {
|
fn edit_volume(graph: &PwGraph, state: &mut VmicState, value: f32) -> Result<()> {
|
||||||
let pct = to_pct(value).ok_or_else(|| VmicError::InvalidVolume(value.to_string()))?;
|
let pct = to_pct(value).ok_or_else(|| VmicError::InvalidVolume(value.to_string()))?;
|
||||||
state.volume_pct = Some(pct);
|
state.volume_pct = Some(pct);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::commands::topology;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::pw::PwGraph;
|
use crate::pw::PwGraph;
|
||||||
use crate::state::{self, VmicState};
|
use crate::state::{self, VmicState};
|
||||||
@@ -21,6 +22,7 @@ pub fn run() -> Result<()> {
|
|||||||
println!(" output device: {}", state.sink_name);
|
println!(" output device: {}", state.sink_name);
|
||||||
println!(" mic device: {}", state.source_name);
|
println!(" mic device: {}", state.source_name);
|
||||||
println!(" status: {status_colored}");
|
println!(" status: {status_colored}");
|
||||||
|
println!(" architecture: {}", topology::architecture_label(state));
|
||||||
println!(
|
println!(
|
||||||
" self-monitor loopback: {}",
|
" self-monitor loopback: {}",
|
||||||
state
|
state
|
||||||
@@ -46,12 +48,20 @@ pub fn run() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Checks `sink_node_id`/`mix_node_id` against the live graph snapshot.
|
/// 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 {
|
fn liveness(graph: Option<&PwGraph>, state: &VmicState) -> String {
|
||||||
let Some(graph) = graph else {
|
let Some(graph) = graph else {
|
||||||
return "unknown (could not connect to PipeWire)".to_string();
|
return "unknown (could not connect to PipeWire)".to_string();
|
||||||
};
|
};
|
||||||
let nodes = graph.nodes();
|
let nodes = graph.nodes();
|
||||||
let sink_alive = state.sink_node_id.is_some_and(|id| nodes.iter().any(|n| n.id == id));
|
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));
|
let mix_alive = state.mix_node_id.is_some_and(|id| nodes.iter().any(|n| n.id == id));
|
||||||
match (sink_alive, mix_alive) {
|
match (sink_alive, mix_alive) {
|
||||||
(true, true) => "active".to_string(),
|
(true, true) => "active".to_string(),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod delete;
|
|||||||
pub mod edit;
|
pub mod edit;
|
||||||
pub mod list;
|
pub mod list;
|
||||||
pub mod route;
|
pub mod route;
|
||||||
|
pub mod topology;
|
||||||
pub mod wipe;
|
pub mod wipe;
|
||||||
|
|
||||||
use crate::error::Result;
|
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 {
|
let Some(mic) = state.mic_source.take() else {
|
||||||
return Ok(());
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
use crate::cli::RouteArgs;
|
use crate::cli::RouteArgs;
|
||||||
|
use crate::commands::topology::{self, Topology};
|
||||||
use crate::error::{Result, VmicError};
|
use crate::error::{Result, VmicError};
|
||||||
use crate::pw::{loopback, text, PwGraph};
|
use crate::pw::{text, PwGraph};
|
||||||
use crate::state::{self, VmicState};
|
use crate::state::{self, VmicState};
|
||||||
use crate::ui;
|
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<()> {
|
pub fn run(args: RouteArgs) -> Result<()> {
|
||||||
let conn = state::open_db()?;
|
let conn = state::open_db()?;
|
||||||
@@ -69,6 +65,10 @@ fn move_stream(kind: &str, move_cmd: &str, target: &str, filter: &str) -> Result
|
|||||||
Ok(())
|
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<()> {
|
fn route_source(graph: &PwGraph, state: &mut VmicState, value: &str) -> Result<()> {
|
||||||
let lowered = value.to_lowercase();
|
let lowered = value.to_lowercase();
|
||||||
if matches!(lowered.as_str(), "off" | "none" | "disable") {
|
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(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
super::unsource(graph, state)?;
|
super::unsource(graph, state)?;
|
||||||
|
topology::sync_topology(graph, state)?;
|
||||||
ui::ok(&format!("Removed source mix '{mic}' from '{}'.", state.name));
|
ui::ok(&format!("Removed source mix '{mic}' from '{}'.", state.name));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -100,19 +101,16 @@ fn route_source(graph: &PwGraph, state: &mut VmicState, value: &str) -> Result<(
|
|||||||
super::unsource(graph, state)?;
|
super::unsource(graph, state)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let linked = loopback::link_stage(graph, &mic, &state.mix_name, SOURCE_LINK_TIMEOUT)?;
|
|
||||||
state.mic_source = Some(mic.clone());
|
state.mic_source = Some(mic.clone());
|
||||||
|
topology::sync_topology(graph, state)?;
|
||||||
|
|
||||||
// Insurance: make sure the new links did not create a monitor ring.
|
let note = match topology::current_topology(state) {
|
||||||
let prefix = format!("vmic_{}_", state.name.to_lowercase());
|
Topology::Pure4Node => "isolated mix stage; not audible in self-monitor",
|
||||||
let rings = graph.break_feedback_rings(&prefix)?;
|
Topology::Simple2Node if state.loopback_id.is_some() => {
|
||||||
if rings > 0 {
|
"sink; also audible in self-monitor - see --loopback-no-mix"
|
||||||
ui::warn(&format!("removed {rings} unexpected monitor feedback link(s)."));
|
}
|
||||||
}
|
Topology::Simple2Node => "sink",
|
||||||
|
};
|
||||||
ui::ok(&format!(
|
ui::ok(&format!("Mixed '{mic}' into '{}' ({note}).", state.name));
|
||||||
"Mixed '{mic}' into the mix stage of '{}' ({linked} link(s); not audible in self-monitor).",
|
|
||||||
state.name
|
|
||||||
));
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,6 +160,11 @@ impl PwGraph {
|
|||||||
self.state.borrow().nodes.values().cloned().collect()
|
self.state.borrow().nodes.values().cloned().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Must not be called on a node name that may transiently have two live
|
||||||
|
/// nodes (e.g. mid-migration in `commands::topology`, where an old and a
|
||||||
|
/// new node can briefly share a name) - it has no way to disambiguate
|
||||||
|
/// them. Callers in that situation must resolve the specific node id
|
||||||
|
/// first (see `wait_for_new_node`) and work with ids from then on.
|
||||||
pub fn ports_for(&self, node_name: &str) -> Vec<Port> {
|
pub fn ports_for(&self, node_name: &str) -> Vec<Port> {
|
||||||
let s = self.state.borrow();
|
let s = self.state.borrow();
|
||||||
let Some(node) = s.nodes.values().find(|n| n.name == node_name) else {
|
let Some(node) = s.nodes.values().find(|n| n.name == node_name) else {
|
||||||
@@ -287,9 +292,18 @@ impl PwGraph {
|
|||||||
|
|
||||||
/// Waits (bounded) for a node to appear in the graph.
|
/// Waits (bounded) for a node to appear in the graph.
|
||||||
pub fn wait_for_node(&self, name: &str, timeout: Duration) -> Result<Node> {
|
pub fn wait_for_node(&self, name: &str, timeout: Duration) -> Result<Node> {
|
||||||
|
self.wait_for_new_node(name, &[], timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `wait_for_node`, but ignores any node whose id is in `excluding`.
|
||||||
|
/// Needed during a topology migration (`commands::topology`), where a
|
||||||
|
/// freshly spawned node can transiently share a `node.name` with the
|
||||||
|
/// old one it's about to replace - a plain name match could otherwise
|
||||||
|
/// resolve to the dying node instead of the new one.
|
||||||
|
pub fn wait_for_new_node(&self, name: &str, excluding: &[u32], timeout: Duration) -> Result<Node> {
|
||||||
let deadline = Instant::now() + timeout;
|
let deadline = Instant::now() + timeout;
|
||||||
loop {
|
loop {
|
||||||
if let Some(n) = self.nodes().into_iter().find(|n| n.name == name) {
|
if let Some(n) = self.nodes().into_iter().find(|n| n.name == name && !excluding.contains(&n.id)) {
|
||||||
return Ok(n);
|
return Ok(n);
|
||||||
}
|
}
|
||||||
if Instant::now() >= deadline {
|
if Instant::now() >= deadline {
|
||||||
|
|||||||
@@ -51,20 +51,100 @@ pub struct StageHandles {
|
|||||||
pub mix_node_id: u32,
|
pub mix_node_id: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates the two chained loopback stages as detached `pw-loopback`
|
/// OS pid and sink PipeWire node id produced by [`create_simple_stage`].
|
||||||
/// subprocesses (stdin is `/dev/null`, never waited on, so the OS
|
pub struct SingleStageHandles {
|
||||||
/// reparents them to init on exit), then links stage 1 into stage 2
|
pub pid: u32,
|
||||||
/// (mid -> mix).
|
pub sink_node_id: u32,
|
||||||
///
|
}
|
||||||
/// apps -> sink =(stage1)=> mid -> mix =(stage2)=> source -> recorders
|
|
||||||
/// ^ hw mic joins here (route --source)
|
fn log_file_for(names: &NodeNames) -> Result<(std::path::PathBuf, std::fs::File)> {
|
||||||
pub fn create_stages(graph: &PwGraph, names: &NodeNames) -> Result<StageHandles> {
|
|
||||||
let fmt = format!("audio.rate={SAMPLE_RATE} audio.position=[FL FR]");
|
|
||||||
let log_path = std::env::temp_dir().join(format!(
|
let log_path = std::env::temp_dir().join(format!(
|
||||||
"vmic_{}.log",
|
"vmic_{}.log",
|
||||||
names.sink.trim_start_matches("vmic_").trim_end_matches("_sink")
|
names.sink.trim_start_matches("vmic_").trim_end_matches("_sink")
|
||||||
));
|
));
|
||||||
let log_file = std::fs::OpenOptions::new().create(true).append(true).open(&log_path)?;
|
let log_file = std::fs::OpenOptions::new().create(true).append(true).open(&log_path)?;
|
||||||
|
Ok((log_path, log_file))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the baseline single-stage loopback: apps -> sink =(pw-loopback)=>
|
||||||
|
/// source_name -> recorders. Used whenever a 4-node split isn't needed (see
|
||||||
|
/// `commands::topology::desired_topology`) - the common case, and the reason
|
||||||
|
/// a fresh vmic only ever costs one process, not two.
|
||||||
|
///
|
||||||
|
/// `exclude_sink_ids`/`exclude_source_ids` matter only when this is called as
|
||||||
|
/// part of a topology migration (`commands::topology::migrate`), where a
|
||||||
|
/// same-named node from the topology being replaced may still be alive at
|
||||||
|
/// spawn time; see `PwGraph::wait_for_new_node`. Pass empty slices for a
|
||||||
|
/// brand-new vmic.
|
||||||
|
pub fn create_simple_stage(
|
||||||
|
graph: &PwGraph,
|
||||||
|
names: &NodeNames,
|
||||||
|
exclude_sink_ids: &[u32],
|
||||||
|
exclude_source_ids: &[u32],
|
||||||
|
) -> Result<SingleStageHandles> {
|
||||||
|
let fmt = format!("audio.rate={SAMPLE_RATE} audio.position=[FL FR]");
|
||||||
|
let (log_path, log_file) = log_file_for(names)?;
|
||||||
|
|
||||||
|
let mut child = spawn_loopback_stage(
|
||||||
|
&format!(
|
||||||
|
"media.class=Audio/Sink node.name={} node.description={} node.virtual=false {fmt}",
|
||||||
|
names.sink, names.sink_desc
|
||||||
|
),
|
||||||
|
&format!(
|
||||||
|
"media.class=Audio/Source node.name={} node.description={} node.virtual=false {fmt}",
|
||||||
|
names.source, names.source_desc
|
||||||
|
),
|
||||||
|
&log_file,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Give the process a moment to fail fast (e.g. a name collision) before
|
||||||
|
// trusting it.
|
||||||
|
std::thread::sleep(STAGE_STARTUP_CHECK);
|
||||||
|
if child.try_wait()?.is_some() {
|
||||||
|
let _ = child.kill();
|
||||||
|
return Err(VmicError::PipeWire(format!(
|
||||||
|
"pw-loopback exited immediately, check '{}'.",
|
||||||
|
log_path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pid = child.id();
|
||||||
|
|
||||||
|
// Unlike create_stages, there's no link_stage retry loop to implicitly
|
||||||
|
// wait through - both nodes must be waited for explicitly.
|
||||||
|
let sink_node_id = (|| -> Result<u32> {
|
||||||
|
let sink_node = graph.wait_for_new_node(&names.sink, exclude_sink_ids, NODE_WAIT_TIMEOUT)?;
|
||||||
|
graph.wait_for_new_node(&names.source, exclude_source_ids, NODE_WAIT_TIMEOUT)?;
|
||||||
|
Ok(sink_node.id)
|
||||||
|
})();
|
||||||
|
|
||||||
|
let sink_node_id = match sink_node_id {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = child.kill();
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(SingleStageHandles { pid, sink_node_id })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the two chained loopback stages as detached `pw-loopback`
|
||||||
|
/// subprocesses (stdin is `/dev/null`, never waited on, so the OS
|
||||||
|
/// reparents them to init on exit), then links stage 1 into stage 2
|
||||||
|
/// (mid -> mix). Used only when a mixed-in source needs to be kept out of
|
||||||
|
/// the self-monitor loopback (see `commands::topology`).
|
||||||
|
///
|
||||||
|
/// apps -> sink =(stage1)=> mid -> mix =(stage2)=> source -> recorders
|
||||||
|
/// ^ hw mic joins here (route --source)
|
||||||
|
///
|
||||||
|
/// `exclude_sink_ids` is the same migration-safety mechanism as
|
||||||
|
/// `create_simple_stage`'s - `mid`/`mix` never need it, since those names are
|
||||||
|
/// exclusive to this topology and can't already exist across a migration
|
||||||
|
/// boundary.
|
||||||
|
pub fn create_stages(graph: &PwGraph, names: &NodeNames, exclude_sink_ids: &[u32]) -> Result<StageHandles> {
|
||||||
|
let fmt = format!("audio.rate={SAMPLE_RATE} audio.position=[FL FR]");
|
||||||
|
let (log_path, log_file) = log_file_for(names)?;
|
||||||
|
|
||||||
let mut sink_child = spawn_loopback_stage(
|
let mut sink_child = spawn_loopback_stage(
|
||||||
&format!(
|
&format!(
|
||||||
@@ -106,7 +186,7 @@ pub fn create_stages(graph: &PwGraph, names: &NodeNames) -> Result<StageHandles>
|
|||||||
let mix_pid = mix_child.id();
|
let mix_pid = mix_child.id();
|
||||||
|
|
||||||
let stage_ids = (|| -> Result<(u32, u32)> {
|
let stage_ids = (|| -> Result<(u32, u32)> {
|
||||||
let sink_node = graph.wait_for_node(&names.sink, NODE_WAIT_TIMEOUT)?;
|
let sink_node = graph.wait_for_new_node(&names.sink, exclude_sink_ids, NODE_WAIT_TIMEOUT)?;
|
||||||
let mix_node = graph.wait_for_node(&names.mix, NODE_WAIT_TIMEOUT)?;
|
let mix_node = graph.wait_for_node(&names.mix, NODE_WAIT_TIMEOUT)?;
|
||||||
link_stage(graph, &names.mid, &names.mix, LINK_RETRY_TIMEOUT)?;
|
link_stage(graph, &names.mid, &names.mix, LINK_RETRY_TIMEOUT)?;
|
||||||
Ok((sink_node.id, mix_node.id))
|
Ok((sink_node.id, mix_node.id))
|
||||||
|
|||||||
@@ -188,6 +188,64 @@ fn parse_sink_input_for_module(text: &str, module_id: u32) -> Option<u32> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Numeric pulse-side id for `name` within `kind` ("sinks" or "sources"),
|
||||||
|
/// via `pactl list short <kind>` - same id/name column layout used by
|
||||||
|
/// `match_source`'s numeric branch and `wipe::first_non_vmic`.
|
||||||
|
fn resolve_pulse_id(kind: &str, name: &str) -> Result<Option<u32>> {
|
||||||
|
let output = Command::new("pactl").args(["list", "short", kind]).output()?;
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout)
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| {
|
||||||
|
let mut cols = l.split_whitespace();
|
||||||
|
let id = cols.next()?.parse::<u32>().ok()?;
|
||||||
|
(cols.next()? == name).then_some(id)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ids of every sink-input currently targeting `sink_name`. Used by
|
||||||
|
/// `commands::topology::migrate` to snapshot stream routing before a
|
||||||
|
/// migration recreates the sink node, so they can be moved back afterward.
|
||||||
|
pub fn sink_inputs_on(sink_name: &str) -> Result<Vec<u32>> {
|
||||||
|
let Some(id) = resolve_pulse_id("sinks", sink_name)? else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let output = Command::new("pactl").args(["list", "sink-inputs"]).output()?;
|
||||||
|
Ok(parse_ids_matching(&String::from_utf8_lossy(&output.stdout), "Sink Input #", "Sink: ", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ids of every source-output currently reading from `source_name`. See
|
||||||
|
/// `sink_inputs_on`.
|
||||||
|
pub fn source_outputs_on(source_name: &str) -> Result<Vec<u32>> {
|
||||||
|
let Some(id) = resolve_pulse_id("sources", source_name)? else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let output = Command::new("pactl").args(["list", "source-outputs"]).output()?;
|
||||||
|
Ok(parse_ids_matching(&String::from_utf8_lossy(&output.stdout), "Source Output #", "Source: ", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ids of every block (headed by `header_prefix<id>`) whose `key_prefix<id>`
|
||||||
|
/// property line equals `target_id`.
|
||||||
|
fn parse_ids_matching(text: &str, header_prefix: &str, key_prefix: &str, target_id: u32) -> Vec<u32> {
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
let mut current_id: Option<u32> = None;
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
if let Some(rest) = line.strip_prefix(header_prefix) {
|
||||||
|
current_id = rest.trim().parse().ok();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
if let Some(rest) = trimmed.strip_prefix(key_prefix) {
|
||||||
|
if rest.trim().parse::<u32>().ok() == Some(target_id) {
|
||||||
|
if let Some(id) = current_id {
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
/// Every `module-loopback` whose `Argument:` line mentions a `vmic_` node,
|
/// Every `module-loopback` whose `Argument:` line mentions a `vmic_` node,
|
||||||
/// tracked in state or not.
|
/// tracked in state or not.
|
||||||
pub fn list_vmic_loopback_module_ids() -> Result<Vec<u32>> {
|
pub fn list_vmic_loopback_module_ids() -> Result<Vec<u32>> {
|
||||||
@@ -238,6 +296,31 @@ Sink Input #12
|
|||||||
assert_eq!(parse_sink_input_for_module(text, 99), None);
|
assert_eq!(parse_sink_input_for_module(text, 99), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finds_sink_inputs_targeting_sink() {
|
||||||
|
let text = "\
|
||||||
|
Sink Input #10
|
||||||
|
\tSink: 65
|
||||||
|
Sink Input #11
|
||||||
|
\tSink: 99
|
||||||
|
Sink Input #12
|
||||||
|
\tSink: 65
|
||||||
|
";
|
||||||
|
assert_eq!(parse_ids_matching(text, "Sink Input #", "Sink: ", 65), vec![10, 12]);
|
||||||
|
assert_eq!(parse_ids_matching(text, "Sink Input #", "Sink: ", 1), Vec::<u32>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finds_source_outputs_targeting_source() {
|
||||||
|
let text = "\
|
||||||
|
Source Output #20
|
||||||
|
\tSource: 3
|
||||||
|
Source Output #21
|
||||||
|
\tSource: 4
|
||||||
|
";
|
||||||
|
assert_eq!(parse_ids_matching(text, "Source Output #", "Source: ", 3), vec![20]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lists_only_vmic_loopback_modules() {
|
fn lists_only_vmic_loopback_modules() {
|
||||||
let text = "\
|
let text = "\
|
||||||
|
|||||||
37
src/state.rs
37
src/state.rs
@@ -29,6 +29,11 @@ pub struct VmicState {
|
|||||||
// pitfalls here, unlike PipeWire's JSON dump (see pw::text).
|
// pitfalls here, unlike PipeWire's JSON dump (see pw::text).
|
||||||
pub mic_source: Option<String>,
|
pub mic_source: Option<String>,
|
||||||
pub mic_volume_pct: Option<u8>,
|
pub mic_volume_pct: Option<u8>,
|
||||||
|
|
||||||
|
// Opt-in: keep the 2-node topology even when a source is mixed in while
|
||||||
|
// the self-monitor loopback is on, accepting that the mix becomes
|
||||||
|
// audible in the loopback instead of paying for a 4-node split.
|
||||||
|
pub loopback_no_mix: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn db_path() -> PathBuf {
|
fn db_path() -> PathBuf {
|
||||||
@@ -90,12 +95,33 @@ pub fn open_db() -> Result<Connection> {
|
|||||||
loopback_id INTEGER,
|
loopback_id INTEGER,
|
||||||
volume_pct INTEGER,
|
volume_pct INTEGER,
|
||||||
mic_source TEXT,
|
mic_source TEXT,
|
||||||
mic_volume_pct INTEGER
|
mic_volume_pct INTEGER,
|
||||||
|
loopback_no_mix INTEGER NOT NULL DEFAULT 0
|
||||||
);",
|
);",
|
||||||
)?;
|
)?;
|
||||||
|
migrate_add_loopback_no_mix(&conn)?;
|
||||||
Ok(conn)
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `CREATE TABLE IF NOT EXISTS` above only covers fresh databases; an
|
||||||
|
/// existing `vmic.db` from before `loopback_no_mix` existed needs the column
|
||||||
|
/// added explicitly. Safe to call unconditionally - a no-op once the column
|
||||||
|
/// is present either way.
|
||||||
|
fn migrate_add_loopback_no_mix(conn: &Connection) -> Result<()> {
|
||||||
|
let has_column = conn
|
||||||
|
.prepare("PRAGMA table_info(vmics)")?
|
||||||
|
.query_map([], |row| row.get::<_, String>(1))?
|
||||||
|
.filter_map(std::result::Result::ok)
|
||||||
|
.any(|c| c == "loopback_no_mix");
|
||||||
|
if !has_column {
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE vmics ADD COLUMN loopback_no_mix INTEGER NOT NULL DEFAULT 0",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
impl VmicState {
|
impl VmicState {
|
||||||
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Self> {
|
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -112,6 +138,7 @@ impl VmicState {
|
|||||||
volume_pct: row.get("volume_pct")?,
|
volume_pct: row.get("volume_pct")?,
|
||||||
mic_source: row.get("mic_source")?,
|
mic_source: row.get("mic_source")?,
|
||||||
mic_volume_pct: row.get("mic_volume_pct")?,
|
mic_volume_pct: row.get("mic_volume_pct")?,
|
||||||
|
loopback_no_mix: row.get("loopback_no_mix")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,8 +162,8 @@ impl VmicState {
|
|||||||
"INSERT INTO vmics (
|
"INSERT INTO vmics (
|
||||||
name, sink_name, mid_name, mix_name, source_name,
|
name, sink_name, mid_name, mix_name, source_name,
|
||||||
sink_node_id, mix_node_id, sink_pid, mix_pid, loopback_id, volume_pct,
|
sink_node_id, mix_node_id, sink_pid, mix_pid, loopback_id, volume_pct,
|
||||||
mic_source, mic_volume_pct
|
mic_source, mic_volume_pct, loopback_no_mix
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
|
||||||
ON CONFLICT(name) DO UPDATE SET
|
ON CONFLICT(name) DO UPDATE SET
|
||||||
sink_name = excluded.sink_name,
|
sink_name = excluded.sink_name,
|
||||||
mid_name = excluded.mid_name,
|
mid_name = excluded.mid_name,
|
||||||
@@ -149,7 +176,8 @@ impl VmicState {
|
|||||||
loopback_id = excluded.loopback_id,
|
loopback_id = excluded.loopback_id,
|
||||||
volume_pct = excluded.volume_pct,
|
volume_pct = excluded.volume_pct,
|
||||||
mic_source = excluded.mic_source,
|
mic_source = excluded.mic_source,
|
||||||
mic_volume_pct = excluded.mic_volume_pct",
|
mic_volume_pct = excluded.mic_volume_pct,
|
||||||
|
loopback_no_mix = excluded.loopback_no_mix",
|
||||||
params![
|
params![
|
||||||
self.name,
|
self.name,
|
||||||
self.sink_name,
|
self.sink_name,
|
||||||
@@ -164,6 +192,7 @@ impl VmicState {
|
|||||||
self.volume_pct,
|
self.volume_pct,
|
||||||
self.mic_source,
|
self.mic_source,
|
||||||
self.mic_volume_pct,
|
self.mic_volume_pct,
|
||||||
|
self.loopback_no_mix,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
Reference in New Issue
Block a user