valentine

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

commit d31fadaa431b331e68027618f7b88c3642cb8119
parent 0bcf3fd3f6d2d95b646e6d4025567e301c3eb53a
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 10:41:44 -0500

fix: meter each input where it's routed (PCM capture), dBFS bars

Inputs are normally routed straight to the DAW (PCM capture), not the mixer,
so the old mixer-input meters showed nothing for ADAT and wrong levels.
Now read each source's PCM-capture meter via live routing
(source_to_pcm_capture) and draw a dBFS bar (-60..0). Verified on hardware:
Analogue 3->PCM cap 3 (raw 2), ADAT 1->PCM cap 13 (raw 10). Adds
pcm_capture_level/raw_to_dbfs (tested) + Source::hw_id.

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

Diffstat:
Mscarlett-core/src/matrix.rs | 21+++++++++++++++++++++
Mscarlett-core/src/meter.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscarlett-core/src/sources.rs | 13+++++++++++++
Mvalentine/src/main.rs | 17+++++++++++++++++
Mvalentine/src/panels/inputs.rs | 44+++++++++++++++++++++++++++-----------------
5 files changed, 149 insertions(+), 17 deletions(-)

diff --git a/scarlett-core/src/matrix.rs b/scarlett-core/src/matrix.rs @@ -118,6 +118,27 @@ impl<T: Transport> Scarlett<T> { .collect()) } + /// Read routing and build a map `source_hw_id -> PCM-capture channel (1-based)` + /// for every source routed to a PCM capture destination. This is how we meter + /// physical inputs: each input is normally routed to a DAW (PCM) capture, and + /// the PCM-capture meters carry the real signal (the mixer-input meters only + /// cover sources routed into the mixer). `count` = total routing destinations. + pub fn source_to_pcm_capture( + &mut self, + count: usize, + ) -> Result<std::collections::HashMap<u16, u16>, TransportError> { + // PCM hardware destination IDs are 0x600.. ; capture channel = id & 0xff + 1. + const PCM_BASE: u16 = 0x600; + let mut map = std::collections::HashMap::new(); + for e in self.get_mux(count)? { + if e.source != 0 && (e.dest & 0xf00) == PCM_BASE { + let pcm_ch = (e.dest - PCM_BASE) + 1; // 1-based PCM capture channel + map.entry(e.source).or_insert(pcm_ch); + } + } + Ok(map) + } + /// Write the full routing table. pub fn set_mux(&mut self, entries: &[MuxEntry]) -> Result<(), TransportError> { let mut payload = Vec::with_capacity(4 + entries.len() * 4); diff --git a/scarlett-core/src/meter.rs b/scarlett-core/src/meter.rs @@ -101,6 +101,47 @@ pub fn mixer_input_level(raw: &[u32], n: u16) -> u32 { .unwrap_or(0) } +/// Raw GET_METER index for **PCM capture channel `n`** (1-based, 1..=20) — what +/// the DAW records. This is the right per-input meter: every physical input is +/// routed to a PCM capture, whereas only some reach the mixer. +/// +/// Destination layout (verified 2026-06-01): PCM-capture destinations are +/// numbered 45..64; their positions in the meter report are: +/// PCM 1-8 → raw 0-7, PCM 9-10 → raw 38-39, PCM 11-20 → raw 8-17. +/// Confirmed: Analogue 3 → PCM cap 3 lit raw 2; ADAT 1 → PCM cap 13 lit raw 10. +pub fn pcm_capture_raw_index(n: u16) -> Option<usize> { + match n { + 1..=8 => Some((n - 1) as usize), + 9..=10 => Some(38 + (n - 9) as usize), + 11..=20 => Some(8 + (n - 11) as usize), + _ => None, + } +} + +/// Level for 1-based PCM capture channel `n` from a raw GET_METER array. +pub fn pcm_capture_level(raw: &[u32], n: u16) -> u32 { + pcm_capture_raw_index(n) + .and_then(|i| raw.get(i).copied()) + .unwrap_or(0) +} + +/// Estimated linear full-scale of the raw meter value. The value is linear +/// amplitude (a ~−30 dBFS tone read ~548 on hardware → full-scale ≈ 2^14). +/// Tunable; a small error just shifts the dB readout by a constant. +pub const METER_FULL_SCALE: f32 = 16384.0; + +/// dBFS floor returned for silence / unrouted inputs. +pub const METER_DB_FLOOR: f32 = -90.0; + +/// Convert a raw (linear-amplitude) meter value to dBFS. Returns +/// [`METER_DB_FLOOR`] for zero. Use a dB scale for bars so they match a DAW VU. +pub fn raw_to_dbfs(raw: u32) -> f32 { + if raw == 0 { + return METER_DB_FLOOR; + } + (20.0 * (raw as f32 / METER_FULL_SCALE).log10()).max(METER_DB_FLOOR) +} + #[cfg(test)] mod tests { use super::*; @@ -157,6 +198,36 @@ mod tests { } #[test] + fn pcm_capture_meter_index_matches_hardware() { + // Verified on the real 18i20 (2026-06-01): + // Analogue 3 → PCM cap 3 lit raw 2; ADAT 1 → PCM cap 13 lit raw 10. + assert_eq!(pcm_capture_raw_index(3), Some(2)); + assert_eq!(pcm_capture_raw_index(13), Some(10)); + assert_eq!(pcm_capture_raw_index(1), Some(0)); + assert_eq!(pcm_capture_raw_index(8), Some(7)); + assert_eq!(pcm_capture_raw_index(9), Some(38)); + assert_eq!(pcm_capture_raw_index(20), Some(17)); + assert_eq!(pcm_capture_raw_index(0), None); + assert_eq!(pcm_capture_raw_index(21), None); + + let mut raw = vec![0u32; 65]; + raw[2] = 548; // analogue 3 reading from the probe + raw[10] = 73; // adat 1 reading + assert_eq!(pcm_capture_level(&raw, 3), 548); + assert_eq!(pcm_capture_level(&raw, 13), 73); + } + + #[test] + fn dbfs_scale_matches_probe_observation() { + // ~548 raw was ~−30 dBFS on the user's meter; check we land close. + let db = raw_to_dbfs(548); + assert!((db - (-29.5)).abs() < 2.0, "got {db}"); + assert_eq!(raw_to_dbfs(0), METER_DB_FLOOR); + // full scale → ~0 dB + assert!(raw_to_dbfs(METER_FULL_SCALE as u32).abs() < 0.01); + } + + #[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/scarlett-core/src/sources.rs b/scarlett-core/src/sources.rs @@ -30,6 +30,19 @@ impl Source { pub fn has_preamp(&self) -> bool { self.has_air || self.has_pad || self.has_inst || self.phantom_group.is_some() } + + /// The device hardware ID for this source (kind base | index), matching the + /// mux/routing encoding. Used to find where the source is routed. + pub fn hw_id(&self) -> u16 { + let base = match self.kind { + PortType::Analogue => 0x080, + PortType::Spdif => 0x180, + PortType::Adat => 0x200, + PortType::Mix => 0x300, + PortType::Pcm => 0x600, + }; + base | self.index + } } fn kind_word(kind: PortType) -> &'static str { diff --git a/valentine/src/main.rs b/valentine/src/main.rs @@ -52,6 +52,9 @@ struct Device { routing: Vec<(String, String)>, /// The full input source catalog (analogue/ADAT/SPDIF/PCM) shown on Inputs. sources: Vec<scarlett_core::sources::Source>, + /// source hardware id → PCM-capture channel (1-based), for per-input meters. + /// Built from the live routing; refreshed with the slow poll. + src_meter: std::collections::HashMap<u16, u16>, } impl Device { @@ -61,6 +64,9 @@ impl Device { let locked = scarlett.get_sync().unwrap_or(false); let inputs = scarlett.read_input_state().unwrap_or_default(); let monitor = scarlett.read_monitor_state().unwrap_or_default(); + let src_meter = scarlett + .source_to_pcm_capture(S18I20_GEN3.mux_dst_count()) + .unwrap_or_default(); Ok(Self { scarlett, firmware, @@ -71,9 +77,17 @@ impl Device { mixer: Vec::new(), routing: Vec::new(), sources: scarlett_core::sources::catalog(&S18I20_GEN3), + src_meter, }) } + /// Refresh the source→PCM-capture meter map from live routing. + fn refresh_src_meter(&mut self) { + if let Ok(m) = self.scarlett.source_to_pcm_capture(S18I20_GEN3.mux_dst_count()) { + self.src_meter = m; + } + } + /// Load the full mixer matrix (12 buses × 25 inputs) — called when the Mixer /// tab is first opened; it's 12 round-trips so we don't do it every poll. fn load_mixer(&mut self) { @@ -108,6 +122,8 @@ impl Device { if let Ok(m) = self.scarlett.read_monitor_state() { self.monitor = m; } + // Routing can change (in FC or another app); keep the meter map current. + self.refresh_src_meter(); } } @@ -678,6 +694,7 @@ fn ui(f: &mut Frame, app: &App) { &dev.sources, &rows, &dev.meters, + &dev.src_meter, app.input_cursor, app.stereo_inputs, true, diff --git a/valentine/src/panels/inputs.rs b/valentine/src/panels/inputs.rs @@ -144,19 +144,21 @@ 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; +/// Bottom of the meter bar's dB range (top is 0 dBFS). A DAW-like VU window. +const METER_DB_MIN: f32 = -60.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; +/// A dBFS level bar: maps `db` (≤0) across a -60..0 window, coloured by level. +fn meter_cell(theme: &Theme, db: f32, width: usize) -> Span<'static> { + let ratio = ((db - METER_DB_MIN) / -METER_DB_MIN).clamp(0.0, 1.0); + let filled = (ratio * 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, `meters` the raw -/// GET_METER array (per-input level via the hardware-verified mixer-input span). +/// Render the input grid into `area`. `rows` is the precomputed visible-row list, +/// `sources` the catalog, `meters` the raw GET_METER array, and `src_meter` maps +/// a source's hardware id → its PCM-capture channel (so we meter each input where +/// it's actually routed to the DAW — verified against hardware). pub fn render( f: &mut Frame, area: Rect, @@ -165,11 +167,22 @@ pub fn render( sources: &[Source], rows: &[Row], meters: &[u32], + src_meter: &std::collections::HashMap<u16, u16>, cursor: Cursor, stereo: bool, focused: bool, ) { - use scarlett_core::meter::mixer_input_level; + use scarlett_core::meter::{pcm_capture_level, raw_to_dbfs}; + + // dBFS for one source: find its PCM-capture channel via routing, read that + // meter. Sources not routed to a capture return the floor (no bar). + let src_db = |src: &Source| -> f32 { + match src_meter.get(&src.hw_id()) { + Some(&pcm) => raw_to_dbfs(pcm_capture_level(meters, pcm)), + None => scarlett_core::meter::METER_DB_FLOOR, + } + }; + let border = if focused { theme.border_focus } else { theme.border }; let mode = if stereo { "stereo" } else { "mono" }; let block = Block::default() @@ -257,17 +270,14 @@ 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); + // Meter bar: each input is metered where it's routed to the DAW (its + // PCM-capture channel). A stereo pair shows the louder channel. + let mut db = src_db(left); if let Some(r) = row.right { - raw = raw.max(mixer_input_level(meters, (r + 1) as u16)); + db = db.max(src_db(&sources[r])); } - let ratio = raw as f32 / METER_FS; line.push(Span::raw(" ")); - line.push(meter_cell(theme, ratio, 12)); + line.push(meter_cell(theme, db, 12)); lines.push(Line::from(line)); }