valentine

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

commit 60b2ccd30be1b4caeb5a5eb16238e81b2b2d94a9
parent c83c637bb41372512d77d7153026470c8cd06b28
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 10:56:05 -0500

fix: unify meters tab + inputs panel onto one dBFS scale (clip=0dB)

The two views disagreed: meters tab was linear (raw/4095) while inputs used
log dBFS with full-scale 16384 (~12dB too high, never hit red). Now both use
raw_to_dbfs + a shared meter_fill() window (-48..0) + shared color thresholds,
and METER_FULL_SCALE = 4095 so an overloading input pegs at 0 dBFS / red.

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

Diffstat:
Mscarlett-core/src/meter.rs | 21+++++++++++----------
Mvalentine/src/panels/inputs.rs | 9+++------
Mvalentine/src/panels/meters.rs | 16+++++++---------
Mvalentine/src/panels/mod.rs | 13+++++++++++--
4 files changed, 32 insertions(+), 27 deletions(-)

diff --git a/scarlett-core/src/meter.rs b/scarlett-core/src/meter.rs @@ -125,10 +125,11 @@ pub fn pcm_capture_level(raw: &[u32], n: u16) -> u32 { .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; +/// Linear full-scale (0 dBFS) of the raw meter value. The device's meter is +/// 12-bit: it saturates at ~4095 = 0 dBFS, so an overloading input pegs there. +/// Calibrated against a user overload reference (clipping → red). A small error +/// just shifts the dB readout by a constant. +pub const METER_FULL_SCALE: f32 = 4095.0; /// dBFS floor returned for silence / unrouted inputs. pub const METER_DB_FLOOR: f32 = -90.0; @@ -218,13 +219,13 @@ mod tests { } #[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 + fn dbfs_scale_anchored_at_full_scale() { + // Calibrated so the 12-bit clip point (~4095) = 0 dBFS (verified: an + // overloading input pegs the meter and reads ~0 dB / red). assert!(raw_to_dbfs(METER_FULL_SCALE as u32).abs() < 0.01); + assert_eq!(raw_to_dbfs(0), METER_DB_FLOOR); + // half amplitude ≈ −6 dB + assert!((raw_to_dbfs((METER_FULL_SCALE / 2.0) as u32) - (-6.0)).abs() < 0.2); } #[test] diff --git a/valentine/src/panels/inputs.rs b/valentine/src/panels/inputs.rs @@ -144,13 +144,10 @@ fn row_label(sources: &[Source], row: Row) -> String { } } -/// Bottom of the meter bar's dB range (top is 0 dBFS). A DAW-like VU window — -/// −48 dB so normal program material fills a useful amount of the bar. -const METER_DB_MIN: f32 = -48.0; - -/// A dBFS level bar: maps `db` (≤0) across a -60..0 window, coloured by level. +/// A dBFS level bar using the shared meter window/colours (so it matches the +/// meters tab exactly). 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 ratio = crate::panels::meter_fill(db); 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))) diff --git a/valentine/src/panels/meters.rs b/valentine/src/panels/meters.rs @@ -1,18 +1,16 @@ //! The Meters panel — a live wall of horizontal level bars over GET_METER. //! -//! The device returns raw u32 levels (dBFS-ish, larger = louder). We don't yet -//! decode the exact dB curve, so bars are normalized against the observed max and -//! coloured green→amber→red by fill. This is a monitoring view; no input. +//! Uses the SAME dBFS scale and fill window as the inputs panel (see +//! [`crate::panels::meter_fill`]) so the two views agree: 0 dBFS = full, the +//! window bottom is −48 dB, and colors turn amber/red at the shared thresholds. use ratatui::prelude::*; use ratatui::widgets::{Block, Borders}; -use crate::theme::Theme; +use scarlett_core::meter::raw_to_dbfs; -/// Raw meter level treated as full scale. Hardware (18i20 g3) returns ~12-bit -/// values — idle peak was 4095 — so 4095 is the working ceiling until the exact -/// dB curve is mapped. Revisit with signal present. -const FULL_SCALE: f32 = 4095.0; +use crate::panels::meter_fill; +use crate::theme::Theme; pub fn render(f: &mut Frame, area: Rect, theme: &Theme, meters: &[u32], focused: bool) { let border = if focused { theme.border_focus } else { theme.border }; @@ -44,7 +42,7 @@ pub fn render(f: &mut Frame, area: Rect, theme: &Theme, meters: &[u32], focused: } for (i, &raw) in meters.iter().enumerate() { - let level = (raw as f32 / FULL_SCALE).clamp(0.0, 1.0); + let level = meter_fill(raw_to_dbfs(raw)); // shared dBFS→0..1 mapping let filled = (level * bar_cells as f32).round() as usize; let color = theme.meter_color(level); diff --git a/valentine/src/panels/mod.rs b/valentine/src/panels/mod.rs @@ -1,5 +1,4 @@ -//! Feature panels. Each tab is a panel module; Phase 3 fills them in one by one. -//! `inputs` is live; the rest render a "coming soon" placeholder for now. +//! Feature panels. Each tab is a panel module. pub mod clock; pub mod inputs; @@ -7,3 +6,13 @@ pub mod meters; pub mod mixer; pub mod monitor; pub mod routing; + +/// Bottom of every level meter's dB window (top is 0 dBFS). Shared by the inputs +/// and meters panels so both views agree. −48 dB fills usefully for program +/// material; colour thresholds (amber ≈ −18, red ≈ −6) live in `Theme::meter_color`. +pub const METER_DB_MIN: f32 = -48.0; + +/// Map a dBFS value to a 0..=1 bar-fill ratio over the shared meter window. +pub fn meter_fill(db: f32) -> f32 { + ((db - METER_DB_MIN) / -METER_DB_MIN).clamp(0.0, 1.0) +}