valentine

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

commit 41649b95e76d4e15572f71771b4b48abcba2f10c
parent 8291d0f2b2aa4f48658acc4421a3b08a4065f72f
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 15:18:38 -0500

fix: faders effective by default — no toggle needed

The adatfader probe proved the device chain works (route+level land at -40dB);
the failure was the TUI gating the fader behind a 'v' toggle that fought the
already-via-mixer state. Now: ←→ owns the routing (auto-routes the group via
the mixer on first use), Monitor-tab entry detects already-routed groups and
SEEDS each fader from the device's actual current level (get_group_level), and
the fader bar always shows. 'v' is now just an optional back-to-DAW. No toggling
required.

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

Diffstat:
Mscarlett-core/src/matrix.rs | 12++++++++++++
Mvalentine/src/main.rs | 59++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mvalentine/src/panels/monitor.rs | 39+++++++++++++++------------------------
3 files changed, 77 insertions(+), 33 deletions(-)

diff --git a/scarlett-core/src/matrix.rs b/scarlett-core/src/matrix.rs @@ -188,6 +188,18 @@ impl<T: Transport> Scarlett<T> { Ok(()) } + /// Read a group's current level (dB) — the gain of its first bus on its + /// first mixer-input. Meaningful only when the group is routed via the mixer. + pub fn get_group_level( + &mut self, + g: &MonitorGroup, + inputs: usize, + ) -> Result<f32, TransportError> { + let raw = self.get_mix(g.bus_base, inputs)?; + let idx = g.mix_in_base as usize; + Ok(raw.get(idx).map(|&v| mixer_value_to_db(v)).unwrap_or(MIXER_MIN_DB)) + } + /// 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( diff --git a/valentine/src/main.rs b/valentine/src/main.rs @@ -293,12 +293,20 @@ impl App { /// Lazy-load data a freshly-opened tab needs. fn on_tab_enter(&mut self) { if self.tab == 1 { - // Detect which monitor groups are currently routed via the mixer. + // Detect which monitor groups are already routed via the mixer, and + // seed each fader from the device's actual current level so it's live + // and accurate without any toggle. let pc = scarlett_core::mux::PORT_COUNT_18I20_GEN3; + let inputs = S18I20_GEN3.mixer_inputs() as usize; if let Ok(dev) = &mut self.device { 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); + let via = dev.scarlett.group_is_via_mixer(pc, g).unwrap_or(false); + self.group_via_mixer[i] = via; + if via { + if let Ok(db) = dev.scarlett.get_group_level(g, inputs) { + self.group_level_db[i] = db; + } + } } } } @@ -606,15 +614,21 @@ impl App { } } - /// Adjust the selected group's software level by `delta` dB. This only - /// updates the in-memory level and flags a pending device write — the actual - /// (slow, multi-channel) write happens once per frame in - /// [`Self::flush_pending_writes`], so rapid keypresses coalesce. + /// Adjust the selected group's software level by `delta` dB. + /// + /// The fader OWNS the routing: if the group isn't yet routed through the + /// mixer, the first nudge routes it (so the fader always works without the + /// user having to toggle `v` first). The level change itself only updates the + /// in-memory value + flags a pending write, coalesced once per frame in + /// [`Self::flush_pending_writes`]. fn nudge_group(&mut self, delta: f32) { let gi = self.monitor_group; + // Ensure the group is routed via the mixer so the fader has an effect. if !self.group_via_mixer.get(gi).copied().unwrap_or(false) { - self.status = Some("fader needs mixer routing — press 'v' to enable".into()); - return; + self.enable_group_via_mixer(gi); + if !self.group_via_mixer.get(gi).copied().unwrap_or(false) { + return; // routing failed; status already set + } } let g = &scarlett_core::matrix::MONITOR_GROUPS[gi]; let new_db = @@ -624,6 +638,33 @@ impl App { self.status = Some(format!("{} level {new_db:.0} dB", g.name)); } + /// Route a group through the mixer and seed its bus gains at the current + /// in-memory level. Idempotent-ish; sets `group_via_mixer[gi]` on success. + fn enable_group_via_mixer(&mut self, gi: usize) { + 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 level = self.group_level_db[gi]; + let dev = match &mut self.device { + Ok(d) => d, + Err(_) => return, + }; + match dev + .scarlett + .route_group_via_mixer(pc, g) + .and_then(|st| { + dev.routing = Some(st); + dev.scarlett.set_group_level(g, level, inputs) + }) { + Ok(()) => { + self.group_via_mixer[gi] = true; + dev.mixer.clear(); + dev.refresh_src_meter(); + } + Err(e) => self.status = Some(format!("routing failed: {e}")), + } + } + /// Write any pending group-level change to the device (once per frame). fn flush_pending_writes(&mut self) { let gi = match self.pending_group_write.take() { diff --git a/valentine/src/panels/monitor.rs b/valentine/src/panels/monitor.rs @@ -119,39 +119,30 @@ pub fn render( rows[1], ); - // Software monitor fader (active only when routed via the mixer). + // Software monitor fader — ←→ just works (auto-routes via mixer on use). let fader_label = if via_mixer { - "monitor fader (via mixer) ←→ adjust:" + "monitor fader ←→ adjust:" } else { - "monitor fader — press 'v' to route via mixer & enable:" + "monitor fader ←→ adjust (routes via mixer on first use):" }; f.render_widget( Paragraph::new(Span::styled( fader_label, - Style::default().fg(if via_mixer { theme.fg } else { theme.fg_dim }), + Style::default().fg(theme.fg), )), 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], - ); - } + // Always show the fader bar; dimmed until it's driving the mixer path. + let min = scarlett_core::matrix::MIXER_MIN_DB; + let r = ((soft_db - min) / -min).clamp(0.0, 1.0) as f64; + let bar_fg = if via_mixer { theme.armed } else { theme.fg_dim }; + f.render_widget( + Gauge::default() + .gauge_style(Style::default().fg(bar_fg).bg(theme.bg_elevated)) + .ratio(r) + .label(format!("{soft_db:.0} dB")), + rows[4], + ); // Toggle rows (mute / dim). for (i, row) in Row::ALL.iter().enumerate() {