commit 3c7c75fb6710c6bfa0746b2c94c9972de0fac496
parent 273b13d1071a6d14921f41b73d491b1477c95325
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date: Mon, 1 Jun 2026 13:18:11 -0500
feat: routing grouped by stereo pairs, staged + atomic pair writes
Routing panel now lists destinations as stereo pairs (Analogue Out 1-2, ...);
left/right stages a SOURCE PAIR, Enter commits both channels in one atomic
write (set_routes) so a pair never passes through a half-routed state. Off
maps both to Off. Core adds dest_pairs/source_pairs/PairRow + set_routes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat:
3 files changed, 196 insertions(+), 59 deletions(-)
diff --git a/scarlett-core/src/matrix.rs b/scarlett-core/src/matrix.rs
@@ -138,6 +138,26 @@ impl<T: Transport> Scarlett<T> {
Ok(state)
}
+ /// Apply several routing changes in ONE atomic write. `changes` is a list of
+ /// `(dest_port, source_port)`. All are set in the model, then the full mux is
+ /// written once — so a stereo pair never passes through a half-routed state.
+ pub fn set_routes(
+ &mut self,
+ pc: [(u16, u16); 6],
+ changes: &[(u16, u16)],
+ ) -> Result<crate::mux::MuxState, TransportError> {
+ use crate::mux::{mux_assignment_18i20_gen3, num_dsts, MuxState};
+ let count = num_dsts(&pc);
+ let entries = self.get_mux(count)?;
+ let mut state = MuxState::from_entries(pc, &entries);
+ for &(dst, src) in changes {
+ state.set(dst, src);
+ }
+ let tables = state.encode_all(&mux_assignment_18i20_gen3());
+ self.write_routing_tables(&tables)?;
+ Ok(state)
+ }
+
/// Read routing and build a map `source_hw_id -> PCM-capture channel (1-based)`
/// for every source routed to a PCM capture destination. This is how we meter
/// physical inputs: each input is normally routed to a DAW (PCM) capture, and
diff --git a/scarlett-core/src/mux.rs b/scarlett-core/src/mux.rs
@@ -219,6 +219,75 @@ impl MuxState {
}
}
+/// A left/right grouping of ports: `right` is None for a lone mono port.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PairRow {
+ pub left: u16,
+ pub right: Option<u16>,
+ pub name: String,
+}
+
+/// Group ports of one direction into stereo pairs by kind: consecutive same-kind
+/// ports at even/odd index collapse into one row ("Analogue Out 1-2"); a lone
+/// trailing port stays mono. Used for both destinations (Dir::Out) and sources
+/// (Dir::In). For sources, a leading Off row (port 0) is included.
+fn pair_ports(pc: &[(u16, u16); 6], dir: Dir, name: impl Fn(u16) -> String) -> Vec<PairRow> {
+ // Flat list of (port_num, kind, index_within_kind) in enumeration order.
+ let mut flat: Vec<(u16, PortKind, u16)> = Vec::new();
+ let mut port = 0u16;
+ for kind in PortKind::ORDER {
+ let c = count(pc, kind, dir);
+ for idx in 0..c {
+ flat.push((port, kind, idx));
+ port += 1;
+ }
+ }
+
+ let mut rows = Vec::new();
+ let mut i = 0;
+ while i < flat.len() {
+ let (pl, kl, il) = flat[i];
+ // None/Off ports stay mono (the single Off entry).
+ let can_pair = kl != PortKind::None
+ && i + 1 < flat.len()
+ && flat[i + 1].1 == kl
+ && il % 2 == 0
+ && flat[i + 1].2 == il + 1;
+ if can_pair {
+ let pr = flat[i + 1].0;
+ // "Analogue Out 1-2": kind word from the left name, indices joined.
+ let lname = name(pl);
+ let word = lname.rsplit_once(' ').map(|(w, _)| w).unwrap_or(&lname);
+ rows.push(PairRow {
+ left: pl,
+ right: Some(pr),
+ name: format!("{word} {}-{}", il + 1, il + 2),
+ });
+ i += 2;
+ } else {
+ rows.push(PairRow { left: pl, right: None, name: name(pl) });
+ i += 1;
+ }
+ }
+ rows
+}
+
+/// Destinations grouped into stereo pairs (for the routing panel's default view).
+pub fn dest_pairs(pc: &[(u16, u16); 6]) -> Vec<PairRow> {
+ pair_ports(pc, Dir::Out, |d| crate::ports::sink_name(num_to_id(pc, Dir::Out, d)))
+}
+
+/// Selectable sources grouped into stereo pairs, with Off first.
+pub fn source_pairs(pc: &[(u16, u16); 6]) -> Vec<PairRow> {
+ pair_ports(pc, Dir::In, |s| {
+ if s == 0 {
+ "Off".to_string()
+ } else {
+ crate::ports::source_name(num_to_id(pc, Dir::In, s))
+ }
+ })
+}
+
/// Enumerate every destination as `(dest_port_num, display_name)` in port order.
pub fn dest_list(pc: &[(u16, u16); 6]) -> Vec<(u16, String)> {
(0..num_dsts(pc) as u16)
@@ -249,6 +318,32 @@ mod tests {
const PC: [(u16, u16); 6] = PORT_COUNT_18I20_GEN3;
#[test]
+ fn dest_pairs_group_stereo_and_keep_mono() {
+ let dp = dest_pairs(&PC);
+ // First row: Analogue Out 1-2 (paired).
+ assert_eq!(dp[0].left, 0);
+ assert_eq!(dp[0].right, Some(1));
+ assert!(dp[0].name.contains("1-2"));
+ // Every destination appears exactly once across the rows.
+ let mut seen = std::collections::HashSet::new();
+ for r in &dp {
+ assert!(seen.insert(r.left));
+ if let Some(rt) = r.right {
+ assert!(seen.insert(rt));
+ }
+ }
+ assert_eq!(seen.len(), num_dsts(&PC));
+ }
+
+ #[test]
+ fn source_pairs_start_with_off_mono() {
+ let sp = source_pairs(&PC);
+ assert_eq!(sp[0].left, 0);
+ assert_eq!(sp[0].right, None);
+ assert_eq!(sp[0].name, "Off");
+ }
+
+ #[test]
fn dest_and_source_lists_are_complete() {
let dests = dest_list(&PC);
assert_eq!(dests.len(), 65);
diff --git a/valentine/src/main.rs b/valentine/src/main.rs
@@ -136,11 +136,12 @@ struct App {
monitor_cursor: MonitorCursor,
mixer_cursor: MixerCursor,
routing_cursor: RoutingCursor,
- /// Staged (uncommitted) routing change: `(dest_port, source_port)`. `←→`
- /// stages a new source for the selected destination WITHOUT writing —
- /// Enter commits, Esc cancels. This avoids momentarily routing through every
- /// source while cycling (which could create a feedback loop).
- routing_pending: Option<(u16, u16)>,
+ /// Staged (uncommitted) routing change: `(dest_pair_row, source_pair_index)`.
+ /// `←→` stages a new source-pair for the selected destination-pair WITHOUT
+ /// writing — Enter commits (both channels atomically), Esc cancels. This
+ /// avoids momentarily routing through every source while cycling (which could
+ /// create a feedback loop).
+ routing_pending: Option<(usize, usize)>,
/// A preset loaded from disk but not yet applied — `L` stages it (it can
/// rewrite many controls at once), Enter/`Y` applies, Esc/`N` discards.
pending_preset: Option<scarlett_core::preset::Preset>,
@@ -281,7 +282,7 @@ impl App {
}
fn routing_key(&mut self, code: KeyCode) {
- let n = scarlett_core::mux::num_dsts(&scarlett_core::mux::PORT_COUNT_18I20_GEN3);
+ let n = scarlett_core::mux::dest_pairs(&scarlett_core::mux::PORT_COUNT_18I20_GEN3).len();
match code {
// Moving rows abandons any uncommitted staged change.
KeyCode::Up | KeyCode::Char('k') => {
@@ -292,7 +293,7 @@ impl App {
self.routing_pending = None;
self.routing_cursor.down(n);
}
- // ←→ only STAGE a source; nothing is written until Enter.
+ // ←→ only STAGE a source pair; nothing is written until Enter.
KeyCode::Left | KeyCode::Char('h') => self.stage_route(-1),
KeyCode::Right | KeyCode::Char('l') => self.stage_route(1),
KeyCode::Enter => self.commit_route(),
@@ -305,93 +306,114 @@ impl App {
}
}
- /// Staged source (port number) shown for `dst`: the pending choice if one is
- /// staged for this destination, else the device's current source.
- fn routing_shown_src(&self, dst: u16) -> u16 {
- if let Some((pd, ps)) = self.routing_pending {
- if pd == dst {
- return ps;
+ /// The source-pair index currently shown for the focused dest-pair row: the
+ /// staged choice if any, else the device's current source on the left dest.
+ fn routing_shown_src_pair(&self) -> usize {
+ let pc = scarlett_core::mux::PORT_COUNT_18I20_GEN3;
+ let dpairs = scarlett_core::mux::dest_pairs(&pc);
+ let spairs = scarlett_core::mux::source_pairs(&pc);
+ let row = self.routing_cursor.row;
+ if let Some((r, si)) = self.routing_pending {
+ if r == row {
+ return si;
}
}
- match &self.device {
- Ok(d) => d.routing.as_ref().map(|s| s.get(dst)).unwrap_or(0),
- Err(_) => 0,
- }
+ // Map the current left-dest source to its source-pair row.
+ let cur_left_src = dpairs
+ .get(row)
+ .and_then(|dp| match &self.device {
+ Ok(d) => d.routing.as_ref().map(|s| s.get(dp.left)),
+ Err(_) => None,
+ })
+ .unwrap_or(0);
+ spairs
+ .iter()
+ .position(|sp| sp.left == cur_left_src)
+ .unwrap_or(0)
}
- /// Stage (do NOT write) a new source for the focused destination by stepping
- /// `delta` through the source list from whatever is currently shown.
+ /// Stage (no write) a source-pair for the focused dest-pair by stepping
+ /// `delta` through the source-pair list.
fn stage_route(&mut self, delta: i32) {
let pc = scarlett_core::mux::PORT_COUNT_18I20_GEN3;
- let srcs = scarlett_core::mux::source_list(&pc);
- let dst = self.routing_cursor.row as u16;
+ let spairs = scarlett_core::mux::source_pairs(&pc);
if !matches!(&self.device, Ok(d) if d.routing.is_some()) {
return;
}
- let cur = self.routing_shown_src(dst);
- let cur_i = srcs.iter().position(|(n, _)| *n == cur).unwrap_or(0) as i32;
- let new_i = (cur_i + delta).rem_euclid(srcs.len() as i32) as usize;
- let (new_src, new_name) = srcs[new_i].clone();
- self.routing_pending = Some((dst, new_src));
- self.status = Some(format!("staged: ← {new_name} (Enter to apply, Esc to cancel)"));
+ let cur = self.routing_shown_src_pair() as i32;
+ let new_i = (cur + delta).rem_euclid(spairs.len() as i32) as usize;
+ self.routing_pending = Some((self.routing_cursor.row, new_i));
+ self.status = Some(format!(
+ "staged: ← {} (Enter to apply, Esc to cancel)",
+ spairs[new_i].name
+ ));
}
- /// Commit the staged routing change to the device (the only place that writes).
+ /// Commit the staged source-pair to the focused dest-pair — both channels in
+ /// ONE atomic write. Off maps both dests to Off.
fn commit_route(&mut self) {
let pc = scarlett_core::mux::PORT_COUNT_18I20_GEN3;
- let (dst, src) = match self.routing_pending {
+ let (row, si) = match self.routing_pending {
Some(p) => p,
None => return,
};
+ let dpairs = scarlett_core::mux::dest_pairs(&pc);
+ let spairs = scarlett_core::mux::source_pairs(&pc);
+ let (dp, sp) = match (dpairs.get(row), spairs.get(si)) {
+ (Some(d), Some(s)) => (d.clone(), s.clone()),
+ _ => return,
+ };
+
+ // Build the (dest, src) changes: left→left; right→right if both exist,
+ // else right→Off. Off source (port 0) maps every dest to Off.
+ let mut changes: Vec<(u16, u16)> = Vec::new();
+ let src_off = sp.left == 0;
+ changes.push((dp.left, if src_off { 0 } else { sp.left }));
+ if let Some(rd) = dp.right {
+ let rs = if src_off { 0 } else { sp.right.unwrap_or(0) };
+ changes.push((rd, rs));
+ }
+
let dev = match &mut self.device {
Ok(d) => d,
Err(_) => return,
};
- match dev.scarlett.set_route(pc, dst, src) {
+ match dev.scarlett.set_routes(pc, &changes) {
Ok(updated) => {
dev.routing = Some(updated);
self.routing_pending = None;
- let dname = scarlett_core::ports::sink_name(
- scarlett_core::mux::num_to_id(&pc, scarlett_core::mux::Dir::Out, dst),
- );
- let sname = if src == 0 {
- "Off".to_string()
- } else {
- scarlett_core::ports::source_name(scarlett_core::mux::num_to_id(
- &pc,
- scarlett_core::mux::Dir::In,
- src,
- ))
- };
- self.status = Some(format!("applied: {dname} ← {sname}"));
+ self.status = Some(format!("applied: {} ← {}", dp.name, sp.name));
}
Err(e) => self.status = Some(format!("route write failed: {e}")),
}
}
- /// Build the display rows for the routing panel:
- /// `(sink, source_name, is_off, is_pending)`. The shown source reflects any
- /// staged (uncommitted) change; `is_pending` marks the row awaiting Enter.
+ /// Build the pair-grouped display rows for the routing panel.
fn routing_rows(&self) -> Vec<routing::RouteRow> {
let pc = scarlett_core::mux::PORT_COUNT_18I20_GEN3;
if !matches!(&self.device, Ok(d) if d.routing.is_some()) {
return Vec::new();
}
- scarlett_core::mux::dest_list(&pc)
- .into_iter()
- .map(|(d, sink)| {
- let src = self.routing_shown_src(d);
- let src_name = if src == 0 {
- "Off".to_string()
+ let spairs = scarlett_core::mux::source_pairs(&pc);
+ let dpairs = scarlett_core::mux::dest_pairs(&pc);
+ dpairs
+ .iter()
+ .enumerate()
+ .map(|(row, dp)| {
+ let pending = self.routing_pending.map(|(r, _)| r == row).unwrap_or(false);
+ // Which source-pair to show for this row.
+ let si = if pending {
+ self.routing_pending.unwrap().1
} else {
- scarlett_core::ports::source_name(scarlett_core::mux::num_to_id(
- &pc,
- scarlett_core::mux::Dir::In,
- src,
- ))
+ let cur_left = match &self.device {
+ Ok(d) => d.routing.as_ref().map(|s| s.get(dp.left)).unwrap_or(0),
+ Err(_) => 0,
+ };
+ spairs.iter().position(|sp| sp.left == cur_left).unwrap_or(0)
};
- let pending = self.routing_pending.map(|(pd, _)| pd == d).unwrap_or(false);
- routing::RouteRow { sink, source: src_name, off: src == 0, pending }
+ let sname = spairs.get(si).map(|s| s.name.clone()).unwrap_or_else(|| "Off".into());
+ let off = spairs.get(si).map(|s| s.left == 0).unwrap_or(true);
+ routing::RouteRow { sink: dp.name.clone(), source: sname, off, pending }
})
.collect()
}