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:
2026-08-11 15:33:36 +02:00
parent be70c55728
commit 493f91bc28
10 changed files with 308 additions and 42 deletions

View File

@@ -51,20 +51,100 @@ pub struct StageHandles {
pub mix_node_id: u32,
}
/// 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).
///
/// apps -> sink =(stage1)=> mid -> mix =(stage2)=> source -> recorders
/// ^ hw mic joins here (route --source)
pub fn create_stages(graph: &PwGraph, names: &NodeNames) -> Result<StageHandles> {
let fmt = format!("audio.rate={SAMPLE_RATE} audio.position=[FL FR]");
/// OS pid and sink PipeWire node id produced by [`create_simple_stage`].
pub struct SingleStageHandles {
pub pid: u32,
pub sink_node_id: u32,
}
fn log_file_for(names: &NodeNames) -> Result<(std::path::PathBuf, std::fs::File)> {
let log_path = std::env::temp_dir().join(format!(
"vmic_{}.log",
names.sink.trim_start_matches("vmic_").trim_end_matches("_sink")
));
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(
&format!(
@@ -106,7 +186,7 @@ pub fn create_stages(graph: &PwGraph, names: &NodeNames) -> Result<StageHandles>
let mix_pid = mix_child.id();
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)?;
link_stage(graph, &names.mid, &names.mix, LINK_RETRY_TIMEOUT)?;
Ok((sink_node.id, mix_node.id))