valentine

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

commit 4e9662a218da717b7626d46eaefa800582145330
parent c89c962772742c013271b3b881fc1f8ba948ce51
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 14:02:53 -0500

feat: software monitor fader via mixer-inserted output path

The device has no writable output volume, so to give Valentine real monitor
level control: 'v' on the Monitor tab routes Analogue Out 1/2 through Mix A/B
(Mixer In 1/2 <- PCM 1/2; Out <- Mix A/B), and ←→ sets Mix A/B gain as the
monitor fader. Reversible (v again = direct from DAW). Core adds
route_monitor_via_mixer/route_monitor_direct/set_monitor_level/
monitor_is_via_mixer. Monitor panel shows hardware knob (read-only) + the
software fader. Prototype for the monitor pair; ADAT outs to follow once
verified on hardware.

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

Diffstat:
Mscarlett-core/src/matrix.rs | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mvalentine/src/main.rs | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mvalentine/src/panels/monitor.rs | 58+++++++++++++++++++++++++++++++++++++++++++++++++---------
3 files changed, 205 insertions(+), 10 deletions(-)

diff --git a/scarlett-core/src/matrix.rs b/scarlett-core/src/matrix.rs @@ -87,6 +87,68 @@ 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( + &mut self, + pc: [(u16, u16); 6], + ) -> 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 + ]; + 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( + &mut self, + pc: [(u16, u16); 6], + ) -> 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 + ]; + 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> { + 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 + } + 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> { + 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) + } + /// Read the full routing table: `count` destination assignments. pub fn get_mux(&mut self, count: usize) -> Result<Vec<MuxEntry>, TransportError> { let mut payload = Vec::with_capacity(4); diff --git a/valentine/src/main.rs b/valentine/src/main.rs @@ -157,6 +157,12 @@ 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, status: Option<String>, show_help: bool, last_poll: Instant, @@ -183,6 +189,8 @@ impl App { routing_pending: None, modal: Modal::None, stereo_inputs: true, + monitor_level_db: 0.0, + monitor_via_mixer: false, status: None, show_help: false, last_poll: Instant::now(), @@ -278,6 +286,14 @@ 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. + 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); + } + } if self.tab == 2 { if let Ok(dev) = &mut self.device { dev.load_mixer(); @@ -563,10 +579,78 @@ impl App { 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(), _ => {} } } + /// 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()); + return; + } + 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 dev = match &mut self.device { + Ok(d) => d, + Err(_) => return, + }; + match dev.scarlett.set_monitor_level(new_db, inputs) { + Ok(()) => { + self.monitor_level_db = new_db; + dev.mixer.clear(); + self.status = Some(format!("monitor level {new_db:.0} dB")); + } + Err(e) => self.status = Some(format!("monitor 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) { + 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 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) + }) + } else { + dev.scarlett.route_monitor_direct(pc).map(|st| { + dev.routing = Some(st); + }) + }; + match res { + Ok(()) => { + self.monitor_via_mixer = enable; + dev.mixer.clear(); + dev.refresh_src_meter(); + self.status = Some(if enable { + "monitor via mixer ON — ←→ adjusts level".into() + } else { + "monitor direct from DAW (fader off)".into() + }); + } + Err(e) => self.status = Some(format!("monitor routing failed: {e}")), + } + } + fn toggle_monitor_button(&mut self) { let row = self.monitor_cursor.current(); let dev = match &mut self.device { @@ -999,7 +1083,16 @@ fn ui(f: &mut Frame, app: &App) { ); } (Ok(dev), 1) => { - monitor::render(f, chunks[2], t, &dev.monitor, app.monitor_cursor, true); + monitor::render( + f, + chunks[2], + t, + &dev.monitor, + app.monitor_cursor, + app.monitor_via_mixer, + app.monitor_level_db, + true, + ); } (Ok(dev), 2) => { mixer::render(f, chunks[2], t, &dev.mixer, app.mixer_cursor, true); diff --git a/valentine/src/panels/monitor.rs b/valentine/src/panels/monitor.rs @@ -63,12 +63,15 @@ fn row_on(state: &MonitorState, row: Row) -> bool { } } +#[allow(clippy::too_many_arguments)] pub fn render( f: &mut Frame, area: Rect, theme: &Theme, state: &MonitorState, cursor: Cursor, + via_mixer: bool, + soft_db: f32, focused: bool, ) { let border = if focused { theme.border_focus } else { theme.border }; @@ -81,8 +84,11 @@ pub fn render( f.render_widget(block, area); let rows = Layout::vertical([ - Constraint::Length(1), // label - Constraint::Length(1), // gauge + Constraint::Length(1), // hardware-knob label + Constraint::Length(1), // hardware-knob gauge + Constraint::Length(1), // spacer + Constraint::Length(1), // software fader label + Constraint::Length(1), // software fader gauge Constraint::Length(1), // spacer Constraint::Length(1), // mute Constraint::Length(1), // dim @@ -90,12 +96,12 @@ pub fn render( ]) .split(inner); - // Master level (read-only): bias so 0 dB = full, -127 dB = empty. + // Hardware monitor knob (read-only). let db = state.master_db; let ratio = ((db + VOLUME_BIAS) as f64 / VOLUME_BIAS as f64).clamp(0.0, 1.0); f.render_widget( Paragraph::new(Span::styled( - format!("Master level (hardware knob): {db} dB"), + "hardware knob (read-only):", Style::default().fg(theme.fg_dim), )), rows[0], @@ -105,11 +111,45 @@ pub fn render( Gauge::default() .gauge_style(Style::default().fg(gauge_color).bg(theme.bg_elevated)) .ratio(ratio) - .label(format!("{} dB", db)), + .label(format!("{db} dB")), rows[1], ); - // Toggle rows. + // Software monitor fader (active only when routed via the mixer). + let fader_label = if via_mixer { + "monitor fader (via mixer) ←→ adjust:" + } else { + "monitor fader — press 'v' to route via mixer & enable:" + }; + f.render_widget( + Paragraph::new(Span::styled( + fader_label, + Style::default().fg(if via_mixer { theme.fg } else { theme.fg_dim }), + )), + rows[3], + ); + if via_mixer { + // soft_db in MIXER_MIN..0 → ratio + let min = scarlett_core::matrix::MIXER_MIN_DB; + let r = ((soft_db - min) / -min).clamp(0.0, 1.0) as f64; + f.render_widget( + Gauge::default() + .gauge_style(Style::default().fg(theme.armed).bg(theme.bg_elevated)) + .ratio(r) + .label(format!("{soft_db:.0} dB")), + rows[4], + ); + } else { + f.render_widget( + Paragraph::new(Span::styled( + " (monitors currently direct from DAW)", + Style::default().fg(theme.fg_dim), + )), + rows[4], + ); + } + + // Toggle rows (mute / dim). for (i, row) in Row::ALL.iter().enumerate() { let row = *row; let on = row_on(state, row); @@ -134,14 +174,14 @@ pub fn render( }, ), ]); - f.render_widget(Paragraph::new(line), rows[3 + i]); + f.render_widget(Paragraph::new(line), rows[6 + i]); } f.render_widget( Paragraph::new(Span::styled( - "↑↓ select space/enter toggle", + "↑↓ select space toggle ←→ fader v via-mixer", Style::default().fg(theme.fg_dim), )), - rows[5], + rows[8], ); }