valentine

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

commit 5a53c4fc3ff00d1e2c003db19882eb19fad6b08a
parent 4e9662a218da717b7626d46eaefa800582145330
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 14:13:31 -0500

feat: generalize monitor fader to groups; add ADAT 1-8 group

Refactored the monitor-via-mixer fader into reusable MonitorGroup (out/bus/
mixer-in/pcm bases + count) with route_group_via_mixer/route_group_direct/
set_group_level/group_is_via_mixer. MONITOR_GROUPS = Analogue 1-2 (buses 0-1)
and ADAT 1-8 (buses 2-9, PCM 13-20) — non-overlapping so both work at once.
Monitor tab: [ ] switches group, v routes it via mixer, arrows are its fader;
title shows the active group. ADAT is the user's main monitoring path.

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

Diffstat:
Mscarlett-core/src/matrix.rs | 126++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mvalentine/src/main.rs | 108+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mvalentine/src/panels/monitor.rs | 8++++++--
3 files changed, 158 insertions(+), 84 deletions(-)

diff --git a/scarlett-core/src/matrix.rs b/scarlett-core/src/matrix.rs @@ -61,6 +61,43 @@ impl MuxEntry { } } +/// A set of output channels that can be monitored "via the mixer" so a software +/// fader controls their level. Each channel `i` (0..count) uses output hardware +/// id `out_id_base+i`, mix bus `bus_base+i`, mixer-input `mix_in_base+i`, fed by +/// PCM `pcm_base+i`. Bases are chosen so groups don't collide on buses/inputs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MonitorGroup { + pub name: &'static str, + pub out_id_base: u16, // hardware id of first output (e.g. 0x080 analogue, 0x200 ADAT) + pub bus_base: u16, // first mix bus index (0-based) + pub mix_in_base: u16, // first mixer-input index (0-based) + pub pcm_base: u16, // first PCM source index (0-based; matches the DAW channel) + pub count: u16, // number of channels +} + +/// The 18i20 g3 monitor groups. Analogue 1-2 on buses 0-1 / mixer-in 0-1 / PCM +/// 1-2; ADAT 1-8 on buses 2-9 / mixer-in 2-9 / PCM 13-20 (the user's ADAT +/// monitoring path). Non-overlapping so both can be active at once (10 of 12 +/// buses, 10 of 25 mixer-inputs). +pub const MONITOR_GROUPS: &[MonitorGroup] = &[ + MonitorGroup { + name: "Analogue 1-2", + out_id_base: 0x080, + bus_base: 0, + mix_in_base: 0, + pcm_base: 0, // PCM 1-2 + count: 2, + }, + MonitorGroup { + name: "ADAT 1-8", + out_id_base: 0x200, + bus_base: 2, + mix_in_base: 2, + pcm_base: 12, // PCM 13-20 + count: 8, + }, +]; + /// Convenience operations layered on a connected [`Scarlett`]. impl<T: Transport> Scarlett<T> { /// Read the input gains feeding mix bus `mix_num`. Returns one raw `u16` @@ -87,66 +124,83 @@ impl<T: Transport> Scarlett<T> { Ok(()) } - /// Route the monitor output pair (Analogue Out 1/2) THROUGH the mixer so its - /// level is controllable: `Mixer In 1/2 ← PCM 1/2`, `Analogue Out 1/2 ← Mix - /// A/B`. With [`Self::set_monitor_level`], Mix A/B's gain is the monitor - /// volume. One atomic routing write. Port numbers derived via mux helpers. - pub fn route_monitor_via_mixer( + /// Route a monitor group's outputs THROUGH the mixer so their level is + /// controllable by a software fader: for each channel `i`, + /// `MixerIn(mix_in_base+i) ← PCM(pcm_base+i)` and `Out(out_hw+i) ← Mix(bus_base+i)`. + /// With [`Self::set_group_level`], the buses' gains are the group's volume. + /// One atomic routing write. See [`MonitorGroup`]. + pub fn route_group_via_mixer( &mut self, pc: [(u16, u16); 6], + g: &MonitorGroup, ) -> Result<crate::mux::MuxState, TransportError> { use crate::mux::{id_to_num, Dir}; let n = |dir, id| id_to_num(&pc, dir, id).unwrap_or(0); - let changes = [ - (n(Dir::Out, 0x300), n(Dir::In, 0x600)), // Mixer In 1 ← PCM 1 - (n(Dir::Out, 0x301), n(Dir::In, 0x601)), // Mixer In 2 ← PCM 2 - (n(Dir::Out, 0x080), n(Dir::In, 0x300)), // Analogue Out 1 ← Mix A - (n(Dir::Out, 0x081), n(Dir::In, 0x301)), // Analogue Out 2 ← Mix B - ]; + let mut changes = Vec::with_capacity(g.count as usize * 2); + for i in 0..g.count { + // MixerIn(base+i) ← PCM(pcm_base+i) + changes.push(( + n(Dir::Out, 0x300 + g.mix_in_base + i), + n(Dir::In, 0x600 + g.pcm_base + i), + )); + // Out(base+i) ← Mix(bus_base+i) + changes.push(( + n(Dir::Out, g.out_id_base + i), + n(Dir::In, 0x300 + g.bus_base + i), + )); + } self.set_routes(pc, &changes) } - /// Route the monitor pair straight from the DAW again (Analogue Out 1/2 ← - /// PCM 1/2), undoing [`Self::route_monitor_via_mixer`]. - pub fn route_monitor_direct( + /// Route a group's outputs straight from the DAW again + /// (`Out(out_hw+i) ← PCM(pcm_base+i)`), undoing [`Self::route_group_via_mixer`]. + pub fn route_group_direct( &mut self, pc: [(u16, u16); 6], + g: &MonitorGroup, ) -> Result<crate::mux::MuxState, TransportError> { use crate::mux::{id_to_num, Dir}; let n = |dir, id| id_to_num(&pc, dir, id).unwrap_or(0); - let changes = [ - (n(Dir::Out, 0x080), n(Dir::In, 0x600)), // Analogue Out 1 ← PCM 1 - (n(Dir::Out, 0x081), n(Dir::In, 0x601)), // Analogue Out 2 ← PCM 2 - ]; + let changes: Vec<(u16, u16)> = (0..g.count) + .map(|i| (n(Dir::Out, g.out_id_base + i), n(Dir::In, 0x600 + g.pcm_base + i))) + .collect(); self.set_routes(pc, &changes) } - /// Set the monitor level (dB) when monitoring via the mixer: Mix A passes - /// only mixer-input 1, Mix B only mixer-input 2, both at `db`; other inputs - /// on those buses are silenced for a clean monitor path. `inputs` = mixer - /// input count. - pub fn set_monitor_level(&mut self, db: f32, inputs: usize) -> Result<(), TransportError> { + /// Set a group's level (dB): each of the group's buses passes only its own + /// mixer-input at `db`; all other inputs on those buses are silenced for a + /// clean monitor path. `inputs` = mixer input count. + pub fn set_group_level( + &mut self, + g: &MonitorGroup, + db: f32, + inputs: usize, + ) -> Result<(), TransportError> { let v = db_to_mixer_value(db); - let mut bus_a = vec![0u16; inputs]; - let mut bus_b = vec![0u16; inputs]; - if inputs >= 2 { - bus_a[0] = v; // Mix A ← mixer input 1 - bus_b[1] = v; // Mix B ← mixer input 2 + for i in 0..g.count { + let mut bus = vec![0u16; inputs]; + let in_idx = (g.mix_in_base + i) as usize; + if in_idx < inputs { + bus[in_idx] = v; + } + self.set_mix(g.bus_base + i, &bus)?; } - self.set_mix(0, &bus_a)?; - self.set_mix(1, &bus_b)?; Ok(()) } - /// True if the monitor pair is currently routed via the mixer (Analogue Out 1 - /// fed by Mix A). - pub fn monitor_is_via_mixer(&mut self, pc: [(u16, u16); 6]) -> Result<bool, TransportError> { + /// True if a group is currently routed via the mixer (its first output is fed + /// by its first mix bus). + pub fn group_is_via_mixer( + &mut self, + pc: [(u16, u16); 6], + g: &MonitorGroup, + ) -> Result<bool, TransportError> { use crate::mux::{id_to_num, Dir, MuxState}; let entries = self.get_mux(crate::mux::num_dsts(&pc))?; let st = MuxState::from_entries(pc, &entries); - let out1 = id_to_num(&pc, Dir::Out, 0x080).unwrap_or(0); - let mix_a = id_to_num(&pc, Dir::In, 0x300).unwrap_or(0); - Ok(st.get(out1) == mix_a) + let out0 = id_to_num(&pc, Dir::Out, g.out_id_base).unwrap_or(0); + let bus0 = id_to_num(&pc, Dir::In, 0x300 + g.bus_base).unwrap_or(0); + Ok(st.get(out0) == bus0) } /// Read the full routing table: `count` destination assignments. diff --git a/valentine/src/main.rs b/valentine/src/main.rs @@ -157,12 +157,13 @@ struct App { modal: Modal, /// Inputs panel: stereo-pair view (default) vs. one row per channel. stereo_inputs: bool, - /// Software monitor level in dB (used when monitoring via the mixer). The - /// device has no writable output volume, so this is a Mix A/B gain. - monitor_level_db: f32, - /// Whether the monitor pair is currently routed through the mixer (so the - /// software fader is active). Detected on connect / monitor-tab open. - monitor_via_mixer: bool, + /// Which monitor group is selected on the Monitor tab (index into + /// `scarlett_core::matrix::MONITOR_GROUPS`: 0 = Analogue 1-2, 1 = ADAT 1-8). + monitor_group: usize, + /// Per-group software level in dB (the group's mix-bus gain when via mixer). + group_level_db: Vec<f32>, + /// Per-group: is it currently routed through the mixer (fader active)? + group_via_mixer: Vec<bool>, status: Option<String>, show_help: bool, last_poll: Instant, @@ -189,8 +190,9 @@ impl App { routing_pending: None, modal: Modal::None, stereo_inputs: true, - monitor_level_db: 0.0, - monitor_via_mixer: false, + monitor_group: 0, + group_level_db: vec![0.0; scarlett_core::matrix::MONITOR_GROUPS.len()], + group_via_mixer: vec![false; scarlett_core::matrix::MONITOR_GROUPS.len()], status: None, show_help: false, last_poll: Instant::now(), @@ -287,11 +289,13 @@ impl App { /// Lazy-load data a freshly-opened tab needs. fn on_tab_enter(&mut self) { if self.tab == 1 { - // Detect whether the monitor pair is currently routed via the mixer. + // Detect which monitor groups are currently routed via the mixer. let pc = scarlett_core::mux::PORT_COUNT_18I20_GEN3; if let Ok(dev) = &mut self.device { - self.monitor_via_mixer = - dev.scarlett.monitor_is_via_mixer(pc).unwrap_or(false); + for (i, g) in scarlett_core::matrix::MONITOR_GROUPS.iter().enumerate() { + self.group_via_mixer[i] = + dev.scarlett.group_is_via_mixer(pc, g).unwrap_or(false); + } } } if self.tab == 2 { @@ -575,79 +579,89 @@ impl App { } fn monitor_key(&mut self, code: KeyCode) { + let ngroups = scarlett_core::matrix::MONITOR_GROUPS.len(); match code { KeyCode::Up | KeyCode::Char('k') => self.monitor_cursor.up(), KeyCode::Down | KeyCode::Char('j') => self.monitor_cursor.down(), KeyCode::Char(' ') | KeyCode::Enter => self.toggle_monitor_button(), - // Software monitor fader (works when routed via the mixer). - KeyCode::Left | KeyCode::Char('-') => self.nudge_monitor(-2.0), - KeyCode::Right | KeyCode::Char('=') | KeyCode::Char('+') => self.nudge_monitor(2.0), - // Toggle routing the monitor pair through the mixer (enables the fader). - KeyCode::Char('v') => self.toggle_monitor_via_mixer(), + // Tab/[ ] selects which monitor group the fader controls. + KeyCode::Char('[') => { + self.monitor_group = self.monitor_group.saturating_sub(1); + } + KeyCode::Char(']') => { + if self.monitor_group + 1 < ngroups { + self.monitor_group += 1; + } + } + // Software fader for the selected group (works when via mixer). + KeyCode::Left | KeyCode::Char('-') => self.nudge_group(-2.0), + KeyCode::Right | KeyCode::Char('=') | KeyCode::Char('+') => self.nudge_group(2.0), + // Toggle routing the selected group through the mixer. + KeyCode::Char('v') => self.toggle_group_via_mixer(), _ => {} } } - /// Adjust the software monitor level by `delta` dB (only meaningful when the - /// monitor pair is routed via the mixer). - fn nudge_monitor(&mut self, delta: f32) { - if !self.monitor_via_mixer { - self.status = - Some("monitor fader needs mixer routing — press 'v' to enable".into()); + /// Adjust the selected group's software level by `delta` dB. + fn nudge_group(&mut self, delta: f32) { + let gi = self.monitor_group; + if !self.group_via_mixer.get(gi).copied().unwrap_or(false) { + self.status = Some("fader needs mixer routing — press 'v' to enable".into()); return; } + let g = &scarlett_core::matrix::MONITOR_GROUPS[gi]; let inputs = S18I20_GEN3.mixer_inputs() as usize; - let new_db = (self.monitor_level_db + delta).clamp(scarlett_core::matrix::MIXER_MIN_DB, 0.0); + let new_db = + (self.group_level_db[gi] + delta).clamp(scarlett_core::matrix::MIXER_MIN_DB, 0.0); let dev = match &mut self.device { Ok(d) => d, Err(_) => return, }; - match dev.scarlett.set_monitor_level(new_db, inputs) { + match dev.scarlett.set_group_level(g, new_db, inputs) { Ok(()) => { - self.monitor_level_db = new_db; + self.group_level_db[gi] = new_db; dev.mixer.clear(); - self.status = Some(format!("monitor level {new_db:.0} dB")); + self.status = Some(format!("{} level {new_db:.0} dB", g.name)); } - Err(e) => self.status = Some(format!("monitor set failed: {e}")), + Err(e) => self.status = Some(format!("level set failed: {e}")), } } - /// Toggle whether the monitor pair (Analogue Out 1/2) runs through the mixer - /// (enabling the software fader) or straight from the DAW. - fn toggle_monitor_via_mixer(&mut self) { + /// Toggle whether the selected group runs through the mixer (fader on) or + /// straight from the DAW. + fn toggle_group_via_mixer(&mut self) { + let gi = self.monitor_group; + let g = &scarlett_core::matrix::MONITOR_GROUPS[gi]; let pc = scarlett_core::mux::PORT_COUNT_18I20_GEN3; let inputs = S18I20_GEN3.mixer_inputs() as usize; - let enable = !self.monitor_via_mixer; - let level = self.monitor_level_db; + let enable = !self.group_via_mixer.get(gi).copied().unwrap_or(false); + let level = self.group_level_db[gi]; let dev = match &mut self.device { Ok(d) => d, Err(_) => return, }; let res = if enable { - // Route via mixer, then set the bus gains to the current fader level. - dev.scarlett - .route_monitor_via_mixer(pc) - .and_then(|st| { - dev.routing = Some(st); - dev.scarlett.set_monitor_level(level, inputs) - }) + dev.scarlett.route_group_via_mixer(pc, g).and_then(|st| { + dev.routing = Some(st); + dev.scarlett.set_group_level(g, level, inputs) + }) } else { - dev.scarlett.route_monitor_direct(pc).map(|st| { + dev.scarlett.route_group_direct(pc, g).map(|st| { dev.routing = Some(st); }) }; match res { Ok(()) => { - self.monitor_via_mixer = enable; + self.group_via_mixer[gi] = enable; dev.mixer.clear(); dev.refresh_src_meter(); self.status = Some(if enable { - "monitor via mixer ON — ←→ adjusts level".into() + format!("{} via mixer ON — ←→ adjusts level", g.name) } else { - "monitor direct from DAW (fader off)".into() + format!("{} direct from DAW (fader off)", g.name) }); } - Err(e) => self.status = Some(format!("monitor routing failed: {e}")), + Err(e) => self.status = Some(format!("routing failed: {e}")), } } @@ -1083,14 +1097,16 @@ fn ui(f: &mut Frame, app: &App) { ); } (Ok(dev), 1) => { + let gi = app.monitor_group; monitor::render( f, chunks[2], t, &dev.monitor, app.monitor_cursor, - app.monitor_via_mixer, - app.monitor_level_db, + scarlett_core::matrix::MONITOR_GROUPS[gi].name, + app.group_via_mixer[gi], + app.group_level_db[gi], true, ); } diff --git a/valentine/src/panels/monitor.rs b/valentine/src/panels/monitor.rs @@ -70,13 +70,17 @@ pub fn render( theme: &Theme, state: &MonitorState, cursor: Cursor, + group_name: &str, via_mixer: bool, soft_db: f32, focused: bool, ) { let border = if focused { theme.border_focus } else { theme.border }; let block = Block::default() - .title(Span::styled(" monitor ", Style::default().fg(theme.accent))) + .title(Span::styled( + format!(" monitor · {group_name} "), + Style::default().fg(theme.accent), + )) .borders(Borders::ALL) .border_type(ratatui::widgets::BorderType::Rounded) .border_style(Style::default().fg(border)); @@ -179,7 +183,7 @@ pub fn render( f.render_widget( Paragraph::new(Span::styled( - "↑↓ select space toggle ←→ fader v via-mixer", + "↑↓ select space toggle ←→ fader v via-mixer [ ] group", Style::default().fg(theme.fg_dim), )), rows[8],