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