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:
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),
)));