From 493f91bc28b574ec1fa4d1a9f689656c08931ed9 Mon Sep 17 00:00:00 2001 From: Overlord Date: Tue, 11 Aug 2026 15:33:36 +0200 Subject: [PATCH] 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. --- src/cli.rs | 5 +++ src/commands/create.rs | 13 +++--- src/commands/edit.rs | 45 +++++++++++++++++-- src/commands/list.rs | 10 +++++ src/commands/mod.rs | 7 ++- src/commands/route.rs | 34 +++++++------- src/pw/graph.rs | 16 ++++++- src/pw/loopback.rs | 100 ++++++++++++++++++++++++++++++++++++----- src/pw/text.rs | 83 ++++++++++++++++++++++++++++++++++ src/state.rs | 37 +++++++++++++-- 10 files changed, 308 insertions(+), 42 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 00ef73e..ee5df02 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -74,6 +74,11 @@ pub struct EditArgs { #[arg(short, long, value_name = "BOOL")] pub loopback: Option, + /// 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, + /// Loopback volume: fraction (0.8) or percent (80). #[arg(short, long, value_name = "PCT")] pub volume: Option, diff --git a/src/commands/create.rs b/src/commands/create.rs index d08217e..e2c44f6 100644 --- a/src/commands/create.rs +++ b/src/commands/create.rs @@ -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() }; diff --git a/src/commands/edit.rs b/src/commands/edit.rs index 2922eaa..595fb8f 100644 --- a/src/commands/edit.rs +++ b/src/commands/edit.rs @@ -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); diff --git a/src/commands/list.rs b/src/commands/list.rs index aab50f6..7508d0c 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -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(), diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 2bb746d..bb5c310 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -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(()) } diff --git a/src/commands/route.rs b/src/commands/route.rs index 83bf4b0..36b7353 100644 --- a/src/commands/route.rs +++ b/src/commands/route.rs @@ -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(()) } diff --git a/src/pw/graph.rs b/src/pw/graph.rs index 73ce47a..8786c4a 100644 --- a/src/pw/graph.rs +++ b/src/pw/graph.rs @@ -160,6 +160,11 @@ impl PwGraph { 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 { let s = self.state.borrow(); 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. pub fn wait_for_node(&self, name: &str, timeout: Duration) -> Result { + 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 { let deadline = Instant::now() + timeout; 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); } if Instant::now() >= deadline { diff --git a/src/pw/loopback.rs b/src/pw/loopback.rs index 7c4e5ed..9f2fa0e 100644 --- a/src/pw/loopback.rs +++ b/src/pw/loopback.rs @@ -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 { - 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 { + 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 { + 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 { + 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 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)) diff --git a/src/pw/text.rs b/src/pw/text.rs index a75e52c..f18c2da 100644 --- a/src/pw/text.rs +++ b/src/pw/text.rs @@ -188,6 +188,64 @@ fn parse_sink_input_for_module(text: &str, module_id: u32) -> Option { None } +/// Numeric pulse-side id for `name` within `kind` ("sinks" or "sources"), +/// via `pactl list short ` - 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> { + 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::().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> { + 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> { + 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`) whose `key_prefix` +/// property line equals `target_id`. +fn parse_ids_matching(text: &str, header_prefix: &str, key_prefix: &str, target_id: u32) -> Vec { + let mut ids = Vec::new(); + let mut current_id: Option = 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::().ok() == Some(target_id) { + if let Some(id) = current_id { + ids.push(id); + } + } + } + } + ids +} + /// Every `module-loopback` whose `Argument:` line mentions a `vmic_` node, /// tracked in state or not. pub fn list_vmic_loopback_module_ids() -> Result> { @@ -238,6 +296,31 @@ Sink Input #12 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::::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] fn lists_only_vmic_loopback_modules() { let text = "\ diff --git a/src/state.rs b/src/state.rs index 203df46..2ccd0c4 100644 --- a/src/state.rs +++ b/src/state.rs @@ -29,6 +29,11 @@ pub struct VmicState { // pitfalls here, unlike PipeWire's JSON dump (see pw::text). pub mic_source: Option, pub mic_volume_pct: Option, + + // 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 { @@ -90,12 +95,33 @@ pub fn open_db() -> Result { loopback_id INTEGER, volume_pct INTEGER, 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) } +/// `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 { fn from_row(row: &rusqlite::Row) -> rusqlite::Result { Ok(Self { @@ -112,6 +138,7 @@ impl VmicState { volume_pct: row.get("volume_pct")?, mic_source: row.get("mic_source")?, 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 ( name, sink_name, mid_name, mix_name, source_name, sink_node_id, mix_node_id, sink_pid, mix_pid, loopback_id, volume_pct, - mic_source, mic_volume_pct - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) + mic_source, mic_volume_pct, loopback_no_mix + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) ON CONFLICT(name) DO UPDATE SET sink_name = excluded.sink_name, mid_name = excluded.mid_name, @@ -149,7 +176,8 @@ impl VmicState { loopback_id = excluded.loopback_id, volume_pct = excluded.volume_pct, 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![ self.name, self.sink_name, @@ -164,6 +192,7 @@ impl VmicState { self.volume_pct, self.mic_source, self.mic_volume_pct, + self.loopback_no_mix, ], )?; Ok(())