valentine

Terminal control panel for the Focusrite Scarlett 18i20 — a from-scratch replacement for Focusrite Control.
Log | Files | Refs | README | LICENSE

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:
Mscarlett-core/src/matrix.rs | 20++++++++++++++++++++
Mscarlett-core/src/mux.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mvalentine/src/main.rs | 140++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
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() }