valentine

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

commit 81a1380dbaf27b6b7929e3722e592f28547b734a
parent 3b1ff1bf89f035fd06526cc9de818e9d6e57a7f3
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 13:01:34 -0500

feat: interactive routing edit (hardware-verified write path)

Routing panel is now editable: up/down select a destination, left/right cycle
its source (Off = unrouted), written immediately via Scarlett::set_route
(MuxState -> encode 3 tables -> write_routing_tables). Adds mux dest_list/
source_list/get/encode_all; Device.routing is now MuxState. The write path was
proven safe first (muxcheck + --write-noop on hardware).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Diffstat:
Mscarlett-core/src/matrix.rs | 20++++++++++++++++++++
Mscarlett-core/src/mux.rs | 45+++++++++++++++++++++++++++++++++++++++++++++
Mvalentine/src/main.rs | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mvalentine/src/panels/routing.rs | 67+++++++++++++++++++++++++++++++++++++++----------------------------
4 files changed, 188 insertions(+), 44 deletions(-)

diff --git a/scarlett-core/src/matrix.rs b/scarlett-core/src/matrix.rs @@ -118,6 +118,26 @@ impl<T: Transport> Scarlett<T> { .collect()) } + /// Change one routing assignment: route `src` (source port number, 0 = Off) + /// to `dst` (destination port number), then write the full mux back. Reads + /// current routing first so only the one destination changes. Returns the + /// updated [`crate::mux::MuxState`]. Uses the hardware-verified write path. + pub fn set_route( + &mut self, + pc: [(u16, u16); 6], + dst: u16, + src: 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); + 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 @@ -182,6 +182,16 @@ impl MuxState { } } + /// Current source port number feeding `dst`. + pub fn get(&self, dst: u16) -> u16 { + self.mux.get(dst as usize).copied().unwrap_or(0) + } + + /// Encode all 3 sample-rate-band tables, ready for `write_routing_tables`. + pub fn encode_all(&self, assign: &[Vec<Assign>; 3]) -> Vec<Vec<u32>> { + assign.iter().map(|a| self.encode_table(a)).collect() + } + /// Encode one mux table to the `u32` payload values for SET_MUX, exactly as /// the kernel does: walk the assignment, pack `dst_id | (src_id << 12)`, /// empty (None/id 0) slots as 0. @@ -209,6 +219,29 @@ impl MuxState { } } +/// 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) + .map(|d| (d, crate::ports::sink_name(num_to_id(pc, Dir::Out, d)))) + .collect() +} + +/// Enumerate every selectable source as `(src_port_num, display_name)`, starting +/// with Off (port 0). Order matches the device's source numbering. +pub fn source_list(pc: &[(u16, u16); 6]) -> Vec<(u16, String)> { + let total_in: u16 = pc.iter().map(|(i, _)| *i).sum(); + (0..total_in) + .map(|s| { + let name = if s == 0 { + "Off".to_string() + } else { + crate::ports::source_name(num_to_id(pc, Dir::In, s)) + }; + (s, name) + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -216,6 +249,18 @@ mod tests { const PC: [(u16, u16); 6] = PORT_COUNT_18I20_GEN3; #[test] + fn dest_and_source_lists_are_complete() { + let dests = dest_list(&PC); + assert_eq!(dests.len(), 65); + assert_eq!(dests[0].1, "Analogue Out 1"); + let srcs = source_list(&PC); + // 1 off-as-port0 already counted in the (1,0) None inputs; total inputs + // = 1+9+2+8+12+20 = 52. + assert_eq!(srcs.len(), 52); + assert_eq!(srcs[0].1, "Off"); + } + + #[test] fn source_num_to_id_matches_kernel_walk() { // src 0 = Off; src 1 = Analogue 1 (0x080); src 9 = Analogue 9 (0x088); // then S/PDIF, ADAT, Mix, PCM. diff --git a/valentine/src/main.rs b/valentine/src/main.rs @@ -48,8 +48,8 @@ struct Device { meters: Vec<u32>, /// Mixer grid `[bus][input]` in dB; loaded on demand when the Mixer tab opens. mixer: Vec<Vec<f32>>, - /// Decoded `(sink, source)` routing; loaded on demand when the Routing tab opens. - routing: Vec<(String, String)>, + /// Editable routing state (mux[dest]=source), loaded on Routing-tab open. + routing: Option<scarlett_core::mux::MuxState>, /// The full input source catalog (analogue/ADAT/SPDIF/PCM) shown on Inputs. sources: Vec<scarlett_core::sources::Source>, /// source hardware id → PCM-capture channel (1-based), for per-input meters. @@ -75,7 +75,7 @@ impl Device { monitor, meters: Vec::new(), mixer: Vec::new(), - routing: Vec::new(), + routing: None, sources: scarlett_core::sources::catalog(&S18I20_GEN3), src_meter, }) @@ -97,10 +97,11 @@ impl Device { } } - /// Load the routing table (decoded sink←source names) on Routing-tab open. + /// Load the editable routing state on Routing-tab open. fn load_routing(&mut self) { - if let Ok(r) = self.scarlett.read_routing(S18I20_GEN3.mux_dst_count()) { - self.routing = r; + let pc = scarlett_core::mux::PORT_COUNT_18I20_GEN3; + if let Ok(entries) = self.scarlett.get_mux(S18I20_GEN3.mux_dst_count()) { + self.routing = Some(scarlett_core::mux::MuxState::from_entries(pc, &entries)); } } @@ -225,14 +226,7 @@ impl App { 0 => self.inputs_key(code), 1 => self.monitor_key(code), 2 => self.mixer_key(code), - 3 => match code { - KeyCode::Up | KeyCode::Char('k') => self.routing_cursor.up(), - KeyCode::Down | KeyCode::Char('j') => { - let n = self.device.as_ref().map(|d| d.routing.len()).unwrap_or(0); - self.routing_cursor.down(n); - } - _ => {} - }, + 3 => self.routing_key(code), // Tabs without interactive controls: h/l moves between tabs. _ => match code { KeyCode::Char('l') => { @@ -262,6 +256,78 @@ impl App { } } + fn routing_key(&mut self, code: KeyCode) { + let n = scarlett_core::mux::num_dsts(&scarlett_core::mux::PORT_COUNT_18I20_GEN3); + match code { + KeyCode::Up | KeyCode::Char('k') => self.routing_cursor.up(), + KeyCode::Down | KeyCode::Char('j') => self.routing_cursor.down(n), + KeyCode::Left | KeyCode::Char('h') => self.cycle_route(-1), + KeyCode::Right | KeyCode::Char('l') => self.cycle_route(1), + _ => {} + } + } + + /// Cycle the focused destination's source by `delta` in the source list and + /// write the change to the device. + fn cycle_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 dev = match &mut self.device { + Ok(d) => d, + Err(_) => return, + }; + let state = match &dev.routing { + Some(s) => s, + None => return, + }; + let cur = state.get(dst); + // index of current source in the list, then step with wraparound + 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(); + + match dev.scarlett.set_route(pc, dst, new_src) { + Ok(updated) => { + dev.routing = Some(updated); + let dname = scarlett_core::ports::sink_name( + scarlett_core::mux::num_to_id(&pc, scarlett_core::mux::Dir::Out, dst), + ); + self.status = Some(format!("{dname} ← {new_name}")); + } + Err(e) => self.status = Some(format!("route write failed: {e}")), + } + } + + /// Build the `(sink, source, is_off)` display rows for the routing panel. + fn routing_rows(&self) -> Vec<(String, String, bool)> { + let pc = scarlett_core::mux::PORT_COUNT_18I20_GEN3; + let state = match &self.device { + Ok(d) => match &d.routing { + Some(s) => s, + None => return Vec::new(), + }, + Err(_) => return Vec::new(), + }; + scarlett_core::mux::dest_list(&pc) + .into_iter() + .map(|(d, sink)| { + let src = state.get(d); + let src_name = 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, + )) + }; + (sink, src_name, src == 0) + }) + .collect() + } + fn mixer_key(&mut self, code: KeyCode) { let inputs = S18I20_GEN3.mixer_inputs() as usize; let buses = S18I20_GEN3.mix_buses() as usize; @@ -706,8 +772,9 @@ fn ui(f: &mut Frame, app: &App) { (Ok(dev), 2) => { mixer::render(f, chunks[2], t, &dev.mixer, app.mixer_cursor, true); } - (Ok(dev), 3) => { - routing::render(f, chunks[2], t, &dev.routing, app.routing_cursor, true); + (Ok(_), 3) => { + let rows = app.routing_rows(); + routing::render(f, chunks[2], t, &rows, app.routing_cursor, true); } (Ok(dev), 4) => { meters::render(f, chunks[2], t, &dev.meters, true); @@ -778,6 +845,7 @@ fn help_overlay(f: &mut Frame, t: &Theme) { key("Space/Enter", "toggle / engage focused control"), key("+ - 0 m", "mixer: ±1 dB, unity, mute (on Mixer tab)"), key("s", "inputs: toggle stereo-pair / mono view"), + key("←→", "routing: change a destination's source (Off = unrouted)"), Line::from(""), head("Presets & device"), key("S", "save current config to a preset file"), diff --git a/valentine/src/panels/routing.rs b/valentine/src/panels/routing.rs @@ -1,17 +1,16 @@ -//! The Routing panel — shows the device's signal routing: which source feeds each -//! physical/virtual output (sink), with human names decoded from the hardware IDs. +//! The Routing panel — view AND edit the device's signal routing: which source +//! feeds each output (sink). The write path is hardware-verified (see +//! `scarlett-core::mux` + the muxcheck probe). //! -//! This is **read-only for now**. Editing the routing requires the kernel's -//! per-sample-rate-band mux-table write semantics, which the core's `set_mux` -//! doesn't yet replicate; that needs hardware verification before being exposed. -//! Until then this is a faithful view of the current routing. +//! ↑↓ select a destination; ←→ cycle its source (Off = unrouted/mute); the +//! change is written to the device immediately. use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Paragraph}; use crate::theme::Theme; -/// Scroll offset (which sink row is at the top). +/// Which destination row is selected. #[derive(Debug, Clone, Copy, Default)] pub struct Cursor { pub row: usize, @@ -28,12 +27,13 @@ impl Cursor { } } -/// `routes` is the decoded `(sink, source)` list from `Scarlett::read_routing`. +/// `dests[i] = (dest_name, current_source_name, is_off)` for each destination, +/// in destination-port order (matches the core's dest_list / mux indexing). pub fn render( f: &mut Frame, area: Rect, theme: &Theme, - routes: &[(String, String)], + dests: &[(String, String, bool)], cursor: Cursor, focused: bool, ) { @@ -46,10 +46,10 @@ pub fn render( let inner = block.inner(area); f.render_widget(block, area); - if routes.is_empty() { + if dests.is_empty() { f.render_widget( Paragraph::new(Span::styled( - "reading routing… (if this stays empty, routing read needs a hardware check)", + "reading routing…", Style::default().fg(theme.fg_dim), )), inner, @@ -58,44 +58,55 @@ pub fn render( } let visible = (inner.height as usize).saturating_sub(2).max(1); - let start = cursor.row.saturating_sub(visible - 1).min(routes.len().saturating_sub(visible).max(0)); - let end = (start + visible).min(routes.len()); + let start = cursor + .row + .saturating_sub(visible - 1) + .min(dests.len().saturating_sub(visible).max(0)); + let end = (start + visible).min(dests.len()); let mut lines: Vec<Line> = Vec::new(); lines.push(Line::from(Span::styled( - format!("{:<22} {}", "Output (sink)", "Source"), + format!("{:<20} {}", "Output (sink)", "Source"), Style::default().fg(theme.fg_dim).add_modifier(Modifier::BOLD), ))); - for (i, (sink, source)) in routes.iter().enumerate().take(end).skip(start) { + for (i, (sink, source, off)) in dests.iter().enumerate().take(end).skip(start) { let here = focused && i == cursor.row; - let off = source == "Off"; - let src_style = if off { + let src_style = if *off { Style::default().fg(theme.fg_dim) } else { - Style::default().fg(theme.armed) // a live route = amber + Style::default().fg(theme.armed) }; let sink_style = if here { Style::default().fg(theme.accent).add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.fg) }; - let arrow = if off { " · " } else { " ← " }; - let mut spans = vec![ - Span::styled(format!("{sink:<22}"), sink_style), - Span::styled(arrow, Style::default().fg(theme.fg_dim)), + // When selected, show ‹ source › to signal it's editable with ←→. + let (lbr, rbr, arrow) = if here { + ("‹", "›", " ← ") + } else if *off { + (" ", " ", " · ") + } else { + (" ", " ", " ← ") + }; + let spans = vec![ + Span::styled(if here { "▸" } else { " " }, Style::default().fg(theme.accent)), + Span::styled(format!("{sink:<20}"), sink_style), + Span::styled(arrow.to_string(), Style::default().fg(theme.fg_dim)), + Span::styled(lbr.to_string(), Style::default().fg(theme.accent)), Span::styled(source.clone(), src_style), + Span::styled(rbr.to_string(), Style::default().fg(theme.accent)), ]; - if here { - spans.insert(0, Span::styled("▸", Style::default().fg(theme.accent))); - } else { - spans.insert(0, Span::raw(" ")); - } lines.push(Line::from(spans)); } lines.push(Line::from(Span::styled( - "↑↓ scroll (read-only — editing pending hardware check)", + format!( + "↑↓ output ←→ source (Off = unrouted) [{}/{}]", + cursor.row + 1, + dests.len() + ), Style::default().fg(theme.fg_dim), )));