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