valentine

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

commit 872781d3d87cfc3d871c0c628a5ce49c9c96eb49
parent 406fbc57ff9927f45af34480776e35ea1cfd9e5a
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 10:20:49 -0500

feat: live per-input meters on the inputs page (hardware-verified mapping)

Each input row shows a level bar driven by the mixer-input meter span. Mapping
confirmed on the real 18i20 (2026-06-01): mixer input N = raw GET_METER index
40+(N-1); feeding analogue input 3 lit raw 42. Inputs tab now polls meters
every tick; stereo rows show the louder channel. Also fixed metermap probe
labels/offset and added mixer_input_level/raw_index helpers (tested).

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

Diffstat:
Mscarlett-core/src/meter.rs | 41+++++++++++++++++++++++++++++++++++++++++
Mspike/src/bin/metermap.rs | 17++++++++++-------
Mvalentine/src/main.rs | 7++++---
Mvalentine/src/panels/inputs.rs | 31++++++++++++++++++++++++++++++-
4 files changed, 85 insertions(+), 11 deletions(-)

diff --git a/scarlett-core/src/meter.rs b/scarlett-core/src/meter.rs @@ -76,6 +76,31 @@ pub fn level_at(level_map: &[u16], raw: &[u32], dest: usize) -> u32 { } } +/// Raw GET_METER index for **Mixer Input `n`** (1-based) on the 18i20 g3. +/// +/// Verified on hardware (2026-06-01): the mixer-input destinations (20..44) are +/// reported as a contiguous span beginning at raw index 40, in order — so mixer +/// input 1 = raw 40, input 25 = raw 64. Feeding analogue input 3 lit raw 42 +/// (mixer input 3), confirming the analogue-N → mixer-input-N default and this +/// direct mapping (no routing inversion needed). +pub const MIXER_INPUT_METER_BASE: usize = 40; + +/// Raw meter index for 1-based mixer input `n` (1..=25). Returns None out of range. +pub fn mixer_input_raw_index(n: u16) -> Option<usize> { + if (1..=25).contains(&n) { + Some(MIXER_INPUT_METER_BASE + (n as usize - 1)) + } else { + None + } +} + +/// Level for 1-based mixer input `n` from a raw GET_METER array. +pub fn mixer_input_level(raw: &[u32], n: u16) -> u32 { + mixer_input_raw_index(n) + .and_then(|i| raw.get(i).copied()) + .unwrap_or(0) +} + #[cfg(test)] mod tests { use super::*; @@ -116,6 +141,22 @@ mod tests { } #[test] + fn mixer_input_meter_index_matches_hardware() { + // Verified on the real 18i20: mixer input 1 = raw 40, input 3 = raw 42, + // input 25 = raw 64. + assert_eq!(mixer_input_raw_index(1), Some(40)); + assert_eq!(mixer_input_raw_index(3), Some(42)); + assert_eq!(mixer_input_raw_index(25), Some(64)); + assert_eq!(mixer_input_raw_index(0), None); + assert_eq!(mixer_input_raw_index(26), None); + + let mut raw = vec![0u32; 65]; + raw[42] = 307; // what feeding analogue input 3 produced + assert_eq!(mixer_input_level(&raw, 3), 307); + assert_eq!(mixer_input_level(&raw, 4), 0); + } + + #[test] fn shared_source_reuses_first_raw_index() { // Same source feeds two destinations; both should read the SAME raw idx // (the first time it was seen), per the kernel's dedup. diff --git a/spike/src/bin/metermap.rs b/spike/src/bin/metermap.rs @@ -29,12 +29,15 @@ fn run() -> Result<(), Box<dyn std::error::Error>> { // Span labels in meter_map order (start, count) → a human hint of what that // span of destinations is, based on the gen3c destination layout. + // CORRECTED destination layout (verified 2026-06-01): destinations number in + // port order — Analogue Out 1-10 (0..9), S/PDIF Out (10..11), ADAT Out 1-8 + // (12..19), Mixer Inputs 1-25 (20..44), PCM capture 1-20 (45..64). let span_hint = [ - "span0 (dst 45..52) — likely Analogue Out 1-8", - "span1 (dst 55..64) — likely PCM/loopback returns", - "span2 (dst 0..19) — likely PCM 1-20", - "span3 (dst 53..54) — likely S/PDIF out", - "span4 (dst 20..44) — likely Mixer Inputs 1-25 ← per-input meters live here", + "span0 (dst 45..52) — PCM capture 1-8 (DAW)", + "span1 (dst 55..64) — PCM capture 11-20 (DAW)", + "span2 (dst 0..19) — Analogue Out 1-10, S/PDIF Out, ADAT Out 1-8", + "span3 (dst 53..54) — PCM capture 9-10", + "span4 (dst 20..44) — Mixer Inputs 1-25 ← per-input meters (raw 40..64)", ]; // Read routing once so we can also print the kernel-style level map. @@ -67,10 +70,10 @@ fn run() -> Result<(), Box<dyn std::error::Error>> { peak[i] = v; } } - // live one-liner of the mixer-input span (span4: raw indices 45..65) + // live one-liner of the mixer-input span (span4 = raw indices 40..64) let live: Vec<String> = raw .iter() - .skip(45) + .skip(40) .take(25) .map(|v| format!("{:>4}", v / 256)) // scale down for readability .collect(); diff --git a/valentine/src/main.rs b/valentine/src/main.rs @@ -531,9 +531,9 @@ impl App { } fn tick(&mut self) { - // Meters refresh every tick while their tab is visible (they want to - // feel live); everything else on the slower poll cadence. - if self.tab == 4 { + // Meters refresh every tick on the tabs that show live bars (Inputs=0, + // Meters=4); everything else on the slower poll cadence. + if self.tab == 0 || self.tab == 4 { if let Ok(dev) = &mut self.device { dev.poll_meters(); } @@ -677,6 +677,7 @@ fn ui(f: &mut Frame, app: &App) { &dev.inputs, &dev.sources, &rows, + &dev.meters, app.input_cursor, app.stereo_inputs, true, diff --git a/valentine/src/panels/inputs.rs b/valentine/src/panels/inputs.rs @@ -144,8 +144,19 @@ fn row_label(sources: &[Source], row: Row) -> String { } } +/// Raw meter full-scale (hardware idle peak ~4095, ~12-bit) for the input bars. +const METER_FS: f32 = 4095.0; + +/// A short level bar for a 0..=1 ratio, coloured by the theme's meter gradient. +fn meter_cell(theme: &Theme, ratio: f32, width: usize) -> Span<'static> { + let filled = (ratio.clamp(0.0, 1.0) * width as f32).round() as usize; + let bar = "▮".repeat(filled) + &"·".repeat(width.saturating_sub(filled)); + Span::styled(bar, Style::default().fg(theme.meter_color(ratio))) +} + /// Render the input grid into `area`. `rows` is the precomputed visible-row list -/// (mono or stereo-paired), `sources` the underlying catalog. +/// (mono or stereo-paired), `sources` the underlying catalog, `meters` the raw +/// GET_METER array (per-input level via the hardware-verified mixer-input span). pub fn render( f: &mut Frame, area: Rect, @@ -153,10 +164,12 @@ pub fn render( state: &InputState, sources: &[Source], rows: &[Row], + meters: &[u32], cursor: Cursor, stereo: bool, focused: bool, ) { + use scarlett_core::meter::mixer_input_level; let border = if focused { theme.border_focus } else { theme.border }; let mode = if stereo { "stereo" } else { "mono" }; let block = Block::default() @@ -191,6 +204,10 @@ pub fn render( Style::default().fg(theme.fg_dim).add_modifier(Modifier::BOLD), )); } + header.push(Span::styled( + " meter", + Style::default().fg(theme.fg_dim).add_modifier(Modifier::BOLD), + )); lines.push(Line::from(header)); // One line per visible row. A switch shows on if EITHER channel of the row @@ -239,6 +256,18 @@ pub fn render( }; line.push(cell); } + + // Meter bar: a row's level via the hardware-verified mixer-input span. + // Mixer input N == catalog position + 1 (analogue in 3 → mixer in 3, + // confirmed on hardware). A stereo pair shows the louder channel. + let mix_in_left = (row.left + 1) as u16; + let mut raw = mixer_input_level(meters, mix_in_left); + if let Some(r) = row.right { + raw = raw.max(mixer_input_level(meters, (r + 1) as u16)); + } + let ratio = raw as f32 / METER_FS; + line.push(Span::raw(" ")); + line.push(meter_cell(theme, ratio, 12)); lines.push(Line::from(line)); }