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