Browse / github.com/storytold/soundcraft / patch
Meters: true peak, RMS, VU and K-System meter types
Meters: true peak, RMS, VU and K-System meter types **Depends on #1047 and #1046**; only the last two commits are new here (plus the merge of the two). Draft until they land. Track and main meters can measure sample peak, true peak (dBTP), RMS, VU or K-12/14/20 (`options.meter_type`, saved with the session; chosen from a meter's right-click menu, which also offers "next type"). - `soundcraft_dsp::meter::MeterBank` measures one block of a strip per call: every channel's sample peak plus the selected type only; no allocation once the channel count is stable. - True peak: the 4× polyphase filter of ITU-R BS.1770-4 Annex 2 the loudness meter al
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- forwardaudio (imported from pull request #1049, open)
- Base
37e155c8287b8ffbdcc1cc7de35842edf9aaee52- Size
- +1684 −151 in 17 files
- Review
- pending
- Shared
- 2026-10-28
- Id
3413b3ea2bf1d5742869a5d2
How it went
No other operator has reported using it yet.
Diff
raw · crates/dsp/src/meter.rs crates/dsp/tests/it/offline_meters.rs crates/engine/src/cmd/metering.rs crates/engine/src/cmd/mod.rs crates/mix/src/lib.rs crates/model/src/session.rs crates/playback/src/lib.rs crates/ui-egui/src/edit_window.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/meters.rs crates/ui-egui/src/mix_window.rs crates/ui-egui/src/panels.rs crates/ui-egui/src/toolbar.rs crates/ui-egui/src/widgets.rs docs/control-protocol.md docs/ui-parity.md
@@ -1,5 +1,7 @@ //! Level meters: DAW-style peak meter, RMS meter, and an ITU-R BS.1770 loudness meter-//! (momentary, short-term and gated integrated LUFS plus 4× oversampled true peak).+//! (momentary, short-term and gated integrated LUFS plus 4× oversampled true peak), and+//! [`MeterBank`], the realtime per-channel meters of a mixer strip (sample peak, true peak, RMS,+//! VU and K-System). //! //! None of the `process` methods allocate. @@ -196,25 +198,7 @@ impl LoudnessMeter { let channels = channels.clamp(1, crate::util::MAX_CHANNELS); let weights = if channels == 6 { vec![1.0, 1.0, 1.0, 0.0, 1.41, 1.41] } else { vec![1.0; channels] }; let [c1, c2] = k_weighting(sr);- // Interpolation filter: windowed sinc at the original Nyquist, split into 4 phases.- let mut tp_coefs = [[0.0f32; TP_TAPS]; TP_PHASES];- let total = TP_TAPS * TP_PHASES;- let center = (total as f64 - 1.0) / 2.0;- for i in 0..total {- let t = (i as f64 - center) / TP_PHASES as f64;- let sinc = if t.abs() < 1e-12 { 1.0 } else { (std::f64::consts::PI * t).sin() / (std::f64::consts::PI * t) };- let x = i as f64 / (total as f64 - 1.0);- let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * x).cos() + 0.08 * (4.0 * std::f64::consts::PI * x).cos();- if let Some(slot) = tp_coefs.get_mut(i % TP_PHASES).and_then(|ph| ph.get_mut(i / TP_PHASES)) {- *slot = (sinc * w) as f32;- }- }- for ph in &mut tp_coefs {- let s: f32 = ph.iter().sum();- if s.abs() > 1e-6 {- ph.iter_mut().for_each(|c| *c /= s);- }- }+ let tp_coefs = true_peak_coefs(); Self { sr, weights,@@ -381,3 +365,273 @@ impl LoudnessMeter { gain_to_db(self.true_peak) } }++/// What a channel meter measures (the meter type). Every type also reports the sample peak, which+/// drives the clip indicators.+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]+pub enum MeterKind {+ /// The highest sample in each block (dBFS).+ #[default]+ SamplePeak,+ /// The highest peak of the 4× oversampled signal, catching inter-sample peaks (dBTP, after+ /// ITU-R BS.1770-4 Annex 2).+ TruePeak,+ /// RMS averaged over [`RMS_WINDOW_MS`] (dBFS; a full-scale sine reads -3 dB).+ Rms,+ /// Average-responding VU ballistics (99 % of a step in [`VU_RISE_MS`]); a sine reads its RMS.+ /// Displayed relative to [`VU_REFERENCE_DBFS`].+ Vu,+ /// Bob Katz's K-System: RMS over [`K_WINDOW_MS`], sine-referenced (AES-17, a sine reads its+ /// peak), shown with 0 at -12, -14 or -20 dBFS.+ K12,+ K14,+ K20,+}++/// RMS meter integration (time constant of the exponential average).+pub const RMS_WINDOW_MS: f32 = 300.0;+/// K-System meter integration.+pub const K_WINDOW_MS: f32 = 600.0;+/// VU rise time to 99 % of a steady tone (IEC 60268-17 / ANSI C16.5), as a critically damped+/// second-order response.+pub const VU_RISE_MS: f32 = 300.0;+/// dBFS of a sine that reads 0 VU.+pub const VU_REFERENCE_DBFS: f32 = -18.0;+/// AES-17 sine reference: RMS scaled by √2 so a sine reads the same as its peak.+const AES17_GAIN: f64 = std::f64::consts::SQRT_2;+/// Average-to-RMS factor for a sine (π / 2√2): an average-responding meter reads a sine's RMS.+const SINE_AVERAGE_TO_RMS: f64 = std::f64::consts::PI / (2.0 * std::f64::consts::SQRT_2);+/// The critically damped response reaches 99 % at t ≈ 6.638 τ.+const CRITICAL_99: f32 = 6.638;++impl MeterKind {+ pub const ALL: [MeterKind; 7] =+ [MeterKind::SamplePeak, MeterKind::TruePeak, MeterKind::Rms, MeterKind::Vu, MeterKind::K12, MeterKind::K14, MeterKind::K20];++ pub fn id(self) -> &'static str {+ match self {+ MeterKind::SamplePeak => "sample_peak",+ MeterKind::TruePeak => "true_peak",+ MeterKind::Rms => "rms",+ MeterKind::Vu => "vu",+ MeterKind::K12 => "k12",+ MeterKind::K14 => "k14",+ MeterKind::K20 => "k20",+ }+ }++ pub fn label(self) -> &'static str {+ match self {+ MeterKind::SamplePeak => "Sample Peak",+ MeterKind::TruePeak => "True Peak",+ MeterKind::Rms => "RMS",+ MeterKind::Vu => "VU",+ MeterKind::K12 => "K-12",+ MeterKind::K14 => "K-14",+ MeterKind::K20 => "K-20",+ }+ }++ pub fn parse(s: &str) -> Option<MeterKind> {+ MeterKind::ALL.into_iter().find(|k| k.id() == s)+ }++ /// The next type, for cycling through them.+ pub fn next(self) -> MeterKind {+ let i = MeterKind::ALL.iter().position(|k| *k == self).unwrap_or(0);+ MeterKind::ALL.get(i + 1).copied().unwrap_or(MeterKind::SamplePeak)+ }++ /// Peak types fall back with the display's release; averaging types carry their own+ /// ballistics and are shown as measured.+ pub fn is_peak(self) -> bool {+ matches!(self, MeterKind::SamplePeak | MeterKind::TruePeak)+ }++ /// The K-System's 0 mark (dBFS), for K types.+ pub fn k_zero_dbfs(self) -> Option<f32> {+ match self {+ MeterKind::K12 => Some(-12.0),+ MeterKind::K14 => Some(-14.0),+ MeterKind::K20 => Some(-20.0),+ _ => None,+ }+ }+}++/// The 4× interpolation filter shared by the true-peak meters: a Blackman-windowed sinc at the+/// original Nyquist, split into four phases of [`TP_TAPS`] taps, each normalised to unity gain.+fn true_peak_coefs() -> [[f32; TP_TAPS]; TP_PHASES] {+ let mut coefs = [[0.0f32; TP_TAPS]; TP_PHASES];+ let total = TP_TAPS * TP_PHASES;+ let center = (total as f64 - 1.0) / 2.0;+ for i in 0..total {+ let t = (i as f64 - center) / TP_PHASES as f64;+ let sinc = if t.abs() < 1e-12 { 1.0 } else { (std::f64::consts::PI * t).sin() / (std::f64::consts::PI * t) };+ let x = i as f64 / (total as f64 - 1.0);+ let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * x).cos() + 0.08 * (4.0 * std::f64::consts::PI * x).cos();+ if let Some(slot) = coefs.get_mut(i % TP_PHASES).and_then(|ph| ph.get_mut(i / TP_PHASES)) {+ *slot = (sinc * w) as f32;+ }+ }+ for ph in &mut coefs {+ let s: f32 = ph.iter().sum();+ if s.abs() > 1e-6 {+ ph.iter_mut().for_each(|c| *c /= s);+ }+ }+ coefs+}++/// One channel's meter state.+#[derive(Debug, Clone, Default)]+struct ChannelState {+ /// True-peak history, written twice so the newest [`TP_TAPS`] samples are always one slice+ /// (`hist[pos..pos + TP_TAPS]`, newest first): no shifting per sample.+ hist: [f32; 2 * TP_TAPS],+ pos: usize,+ /// Mean square (RMS, K) or the two VU filter stages.+ ms: f64,+ vu: [f64; 2],+}++/// Realtime meters for a strip: per channel, the sample peak of each block plus the reading of+/// the selected [`MeterKind`]. Only the selected type is computed; true peak costs four+/// [`TP_TAPS`]-tap dot products per sample and channel. `process` never allocates once the+/// channel count is stable.+#[derive(Debug, Clone)]+pub struct MeterBank {+ kind: MeterKind,+ sr: f32,+ /// The true-peak filter by tap, then phase, so one pass over the history feeds all four+ /// phases' sums side by side (they vectorise).+ taps: [[f32; TP_PHASES]; TP_TAPS],+ rms_k: f64,+ k_k: f64,+ vu_k: f64,+ chans: Vec<ChannelState>,+}++impl MeterBank {+ pub fn new(sample_rate: f32) -> Self {+ let coefs = true_peak_coefs();+ let mut taps = [[0.0f32; TP_PHASES]; TP_TAPS];+ for (ph, c) in coefs.iter().enumerate() {+ for (j, v) in c.iter().enumerate() {+ if let Some(t) = taps.get_mut(j).and_then(|t| t.get_mut(ph)) {+ *t = *v;+ }+ }+ }+ let mut m = MeterBank { kind: MeterKind::SamplePeak, sr: 0.0, taps, rms_k: 0.0, k_k: 0.0, vu_k: 0.0, chans: Vec::new() };+ m.set_sample_rate(sample_rate);+ m+ }++ pub fn sample_rate(&self) -> f32 {+ self.sr+ }++ /// Re-derive the averaging coefficients for another rate (cheap; keeps the channel state).+ pub fn set_sample_rate(&mut self, sample_rate: f32) {+ let sr = sane_sr(sample_rate);+ if sr == self.sr {+ return;+ }+ self.sr = sr;+ let k = |ms: f32| f64::from(1.0 - crate::util::time_coef(ms, sr));+ self.rms_k = k(RMS_WINDOW_MS);+ self.k_k = k(K_WINDOW_MS);+ self.vu_k = k(VU_RISE_MS / CRITICAL_99);+ }++ pub fn kind(&self) -> MeterKind {+ self.kind+ }++ /// Select the meter type; a change starts every channel afresh.+ pub fn set_kind(&mut self, kind: MeterKind) {+ if kind != self.kind {+ self.kind = kind;+ self.reset();+ }+ }++ pub fn reset(&mut self) {+ self.chans.iter_mut().for_each(|c| *c = ChannelState::default());+ }++ /// Measure one block: `peaks[c]` gets channel `c`'s sample peak and `levels[c]` its reading+ /// (linear; for VU the sine RMS, for K types sine-referenced RMS). Channels past either slice+ /// are skipped; non-finite samples count as silence.+ pub fn process(&mut self, bufs: &[Vec<f32>], frames: usize, peaks: &mut [f32], levels: &mut [f32]) {+ if self.chans.len() != bufs.len() {+ // A format change (a document edit, not steady state).+ self.chans.resize_with(bufs.len(), ChannelState::default);+ }+ let kind = self.kind;+ for (((st, b), p), l) in self.chans.iter_mut().zip(bufs).zip(peaks.iter_mut()).zip(levels.iter_mut()) {+ let x = b.get(..frames).unwrap_or(b);+ let peak = x.iter().fold(0.0f32, |m, v| if v.is_finite() { m.max(v.abs()) } else { m });+ *p = peak;+ *l = match kind {+ MeterKind::SamplePeak => peak,+ MeterKind::TruePeak => true_peak_block(st, &self.taps, x).max(peak),+ MeterKind::Rms => mean_square(st, self.rms_k, x).sqrt() as f32,+ MeterKind::K12 | MeterKind::K14 | MeterKind::K20 => (mean_square(st, self.k_k, x).sqrt() * AES17_GAIN) as f32,+ MeterKind::Vu => (vu_block(st, self.vu_k, x) * SINE_AVERAGE_TO_RMS) as f32,+ };+ }+ }+}++fn finite(v: f32) -> f32 {+ if v.is_finite() { v } else { 0.0 }+}++fn true_peak_block(st: &mut ChannelState, taps: &[[f32; TP_PHASES]; TP_TAPS], x: &[f32]) -> f32 {+ let mut peak = 0.0f32;+ for &v in x {+ st.pos = if st.pos == 0 { TP_TAPS - 1 } else { st.pos - 1 };+ let v = finite(v);+ if let Some(h) = st.hist.get_mut(st.pos) {+ *h = v;+ }+ if let Some(h) = st.hist.get_mut(st.pos + TP_TAPS) {+ *h = v;+ }+ let Some(window) = st.hist.get(st.pos..st.pos + TP_TAPS) else { continue };+ let mut acc = [0.0f32; TP_PHASES];+ for (tap, w) in taps.iter().zip(window) {+ for (a, c) in acc.iter_mut().zip(tap) {+ *a += c * w;+ }+ }+ peak = acc.iter().fold(peak, |m, y| m.max(y.abs()));+ }+ peak+}++fn mean_square(st: &mut ChannelState, k: f64, x: &[f32]) -> f64 {+ for &v in x {+ let v = f64::from(finite(v));+ st.ms += (v * v - st.ms) * k;+ }+ if st.ms < 1e-30 {+ st.ms = 0.0;+ }+ st.ms+}++fn vu_block(st: &mut ChannelState, k: f64, x: &[f32]) -> f64 {+ let [mut a, mut b] = st.vu;+ for &v in x {+ a += (f64::from(finite(v).abs()) - a) * k;+ b += (a - b) * k;+ }+ if b < 1e-30 {+ (a, b) = (if a < 1e-30 { 0.0 } else { a }, 0.0);+ }+ st.vu = [a, b];+ b+}@@ -298,3 +298,91 @@ fn spectrum_edge_cases() { let m = magnitude_db(&[f32::NAN; 64], 64); assert!(m.iter().all(|v| v.is_finite())); }++/// Runs a [`MeterBank`] of `kind` over `x` in 512-frame blocks; the last block's (peak, level).+fn bank_reading(kind: soundcraft_dsp::meter::MeterKind, x: &[f32]) -> (f32, f32) {+ let mut bank = soundcraft_dsp::meter::MeterBank::new(48_000.0);+ bank.set_kind(kind);+ let (mut p, mut l) = ([0.0f32], [0.0f32]);+ for chunk in x.chunks(512) {+ bank.process(&[chunk.to_vec()], chunk.len(), &mut p, &mut l);+ }+ (p[0], l[0])+}++#[test]+fn meter_bank_true_peak_catches_inter_sample_peaks() {+ use soundcraft_dsp::meter::MeterKind;+ // fs/4 at 45°: every sample lands at ±0.707 while the waveform peaks at 1.0 (+3 dB).+ let x: Vec<f32> = (0..4800).map(|i| (std::f32::consts::FRAC_PI_2 * i as f32 + std::f32::consts::FRAC_PI_4).sin()).collect();+ let (sample, tp) = bank_reading(MeterKind::TruePeak, &x);+ assert!((sample - std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-3, "{sample}");+ assert!(gain_to_db(tp).abs() < 0.5, "true peak {} dBTP", gain_to_db(tp));+ let (_, sp) = bank_reading(MeterKind::SamplePeak, &x);+ assert_eq!(sp, sample, "sample peak reads the samples");+}++#[test]+fn meter_bank_averaging_types_read_a_sine() {+ use soundcraft_dsp::meter::MeterKind;+ let x = sine(1000.0, 1.0, 48_000.0, 96_000);+ let (_, rms) = bank_reading(MeterKind::Rms, &x);+ assert!((gain_to_db(rms) + 3.01).abs() < 0.2, "RMS {}", gain_to_db(rms));+ for k in [MeterKind::K12, MeterKind::K14, MeterKind::K20] {+ let (_, v) = bank_reading(k, &x);+ assert!(gain_to_db(v).abs() < 0.2, "{k:?}: a sine reads its peak (AES-17), {}", gain_to_db(v));+ }+ let (_, vu) = bank_reading(MeterKind::Vu, &x);+ assert!((gain_to_db(vu) + 3.01).abs() < 0.3, "VU reads the sine's RMS, {}", gain_to_db(vu));+}++#[test]+fn meter_bank_vu_rises_in_about_300_ms() {+ use soundcraft_dsp::meter::MeterKind;+ let steady = bank_reading(MeterKind::Vu, &sine(1000.0, 0.5, 48_000.0, 96_000)).1;+ let at = |ms: usize| bank_reading(MeterKind::Vu, &sine(1000.0, 0.5, 48_000.0, 48 * ms)).1 / steady;+ assert!(at(100) < 0.9, "{}", at(100));+ assert!(at(300) > 0.97 && at(300) < 1.02, "{}", at(300));+}++#[test]+fn meter_bank_survives_hostile_input_and_cycles_types() {+ use soundcraft_dsp::meter::MeterKind;+ for k in MeterKind::ALL {+ let (p, l) = bank_reading(k, &[f32::NAN, f32::INFINITY, 0.5, -0.25]);+ assert!(p.is_finite() && l.is_finite(), "{k:?}");+ assert_eq!(MeterKind::parse(k.id()), Some(k));+ }+ let mut k = MeterKind::SamplePeak;+ for _ in 0..MeterKind::ALL.len() {+ k = k.next();+ }+ assert_eq!(k, MeterKind::SamplePeak, "cycling comes back round");+ // Fewer output slots than channels: the rest are skipped, nothing panics.+ let mut bank = soundcraft_dsp::meter::MeterBank::new(48_000.0);+ bank.set_kind(MeterKind::TruePeak);+ bank.process(&[vec![0.5; 64], vec![0.5; 64]], 512, &mut [0.0], &mut []);+}++#[test]+fn meter_bank_true_peak_does_not_depend_on_block_sizes() {+ use soundcraft_dsp::meter::{MeterBank, MeterKind};+ // A sweep-ish signal with inter-sample peaks everywhere.+ let x: Vec<f32> = (0..9000).map(|i| 0.9 * (0.37 * i as f32 + 0.002 * (i * i) as f32 / 9000.0).sin()).collect();+ let max_over = |block: usize| {+ let mut bank = MeterBank::new(48_000.0);+ bank.set_kind(MeterKind::TruePeak);+ let (mut p, mut l) = ([0.0f32], [0.0f32]);+ let mut best = 0.0f32;+ for chunk in x.chunks(block) {+ bank.process(&[chunk.to_vec()], chunk.len(), &mut p, &mut l);+ best = best.max(l[0]);+ }+ best+ };+ let whole = max_over(x.len());+ assert!(whole > 0.89, "{whole}");+ for block in [1, 7, 64, 513] {+ assert!((max_over(block) - whole).abs() < 1e-6, "block {block}: {} vs {whole}", max_over(block));+ }+}new file mode 100644@@ -0,0 +1,56 @@+//! Metering options: the meter type of the track and main meters. (Pre-Fader Metering is an+//! Options toggle in `options`.)+use super::{CommandSpec, always, bad, str_param};++use crate::{Engine, Result, cmd};+use serde_json::{Value, json};+use soundcraft_dsp::meter::MeterKind;++pub fn specs() -> Vec<CommandSpec> {+ vec![cmd!(+ noundo "options.meter_type",+ "Meter Type",+ [],+ None,+ "{type?: sample_peak|true_peak|rms|vu|k12|k14|k20} — the track and main meters' type; without `type`, the next one",+ always,+ meter_type+ )]+}++fn meter_type(e: &mut Engine, p: &Value) -> Result<Value> {+ let current = MeterKind::parse(&e.session().edit.meter_type).unwrap_or_default();+ let kind = match str_param(p, "type") {+ Some(t) => MeterKind::parse(t).ok_or_else(|| {+ let ids: Vec<&str> = MeterKind::ALL.iter().map(|k| k.id()).collect();+ bad("options.meter_type", format!("unknown meter type `{t}` ({})", ids.join(", ")))+ })?,+ None => current.next(),+ };+ e.session_mut().edit.meter_type = kind.id().into();+ Ok(json!({"type": kind.id(), "label": kind.label()}))+}++#[cfg(test)]+mod tests {+ use crate::Engine;+ use serde_json::json;++ #[test]+ fn sets_and_cycles_the_meter_type() {+ let mut e = Engine::default();+ assert_eq!(e.session().edit.meter_type, "sample_peak");+ assert_eq!(e.execute("options.meter_type", &json!({})).unwrap()["type"], "true_peak");+ assert_eq!(e.execute("options.meter_type", &json!({"type": "k14"})).unwrap()["label"], "K-14");+ assert_eq!(e.session().edit.meter_type, "k14");+ assert!(e.execute("options.meter_type", &json!({"type": "ppm"})).is_err());+ assert_eq!(e.session().edit.meter_type, "k14", "a refused type changes nothing");+ e.execute("options.meter_type", &json!({"type": "k20"})).unwrap();+ assert_eq!(e.execute("options.meter_type", &json!({})).unwrap()["type"], "sample_peak", "and round again");+ // Saved with the session; older sessions without it meter sample peaks.+ let mut v = serde_json::to_value(&e.session().edit).unwrap();+ v.as_object_mut().unwrap().remove("meter_type");+ let old: soundcraft_model::EditState = serde_json::from_value(v).unwrap();+ assert_eq!(old.meter_type, "sample_peak");+ }+}@@ -19,6 +19,7 @@ mod event_more; mod file; mod health; mod instrument;+mod metering; mod midi; mod mix; mod more_util;@@ -136,6 +137,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(event::specs()); v.extend(mix::specs()); v.extend(options::specs());+ v.extend(metering::specs()); v.extend(transport::specs()); v.extend(tab::specs()); v.extend(query::specs());@@ -50,6 +50,7 @@ //! [`Insert::sample`]: soundcraft_model::Insert::sample #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +use soundcraft_dsp::meter::{MeterBank, MeterKind}; use soundcraft_dsp::pan::{PanLaw, SpeakerPos, SurroundParams, gains}; use soundcraft_dsp::{Plugin, SampleSource, Transport, db_to_gain}; use soundcraft_midi::CtrlKind;@@ -153,10 +154,23 @@ pub fn create_instance(id: &str, state: Option<&[u8]>, sample_rate: f32, max_blo /// often than blocks arrive sees the highest peak since it last looked, not just the latest block. #[derive(Debug, Clone, Default, PartialEq)] pub struct StripMeter {- /// First two channels (mono strips repeat channel 0), for stereo meters.+ /// Sample peaks of the first two channels (mono strips repeat channel 0), for stereo meters.+ /// They drive the clip indicators whatever the meter type. pub peak: [f32; 2],- /// Every channel of the strip (or of the main mix).+ /// Sample peaks of every channel of the strip (or of the main mix). pub peaks: Vec<f32>,+ /// The highest sample peak over every channel (and, on a true-peak meter, the highest true+ /// peak): what the clip indicator and peak readout watch, so a surround strip clips on any+ /// channel, not only on the two its stereo bars show.+ pub peak_all: f32,+ /// The meter type's reading ([`Session`]'s `edit.meter_type`: sample or true peak, RMS, VU or+ /// K-System), linear, first two channels; equal to `peak` for sample-peak meters.+ pub level: [f32; 2],+ /// The meter type's reading for every channel.+ pub levels: Vec<f32>,+ /// The meter type `level` and `levels` were measured as: readings of another type mean+ /// nothing on this one's scale.+ pub kind: MeterKind, /// Gain reduction of the first dynamics insert, dB. pub gain_reduction_db: f32, pub plugin_failures: PluginFailures,@@ -184,6 +198,10 @@ impl StripMeter { pub fn copy_from(&mut self, o: &StripMeter) { self.peak = o.peak; self.peaks.clone_from(&o.peaks);+ self.peak_all = o.peak_all;+ self.level = o.level;+ self.levels.clone_from(&o.levels);+ self.kind = o.kind; self.gain_reduction_db = o.gain_reduction_db; self.plugin_failures = o.plugin_failures; }@@ -192,17 +210,22 @@ impl StripMeter { /// maximum, plugin failures take the later value (the count only grows). Allocation-free once /// the channel count is stable, so it can run on the audio thread every block. pub fn accumulate(&mut self, o: &StripMeter) {- if self.peaks.len() != o.peaks.len() {- // The strip's format changed (a document edit): earlier channels no longer line up.- self.peaks.clone_from(&o.peaks);- } else {- for (p, q) in self.peaks.iter_mut().zip(&o.peaks) {- *p = max_level(*p, *q);- }- }+ accumulate_channels(&mut self.peaks, &o.peaks); for (p, q) in self.peak.iter_mut().zip(o.peak) { *p = max_level(*p, q); }+ if self.kind == o.kind {+ accumulate_channels(&mut self.levels, &o.levels);+ for (p, q) in self.level.iter_mut().zip(o.level) {+ *p = max_level(*p, q);+ }+ } else {+ // The meter type changed: the earlier readings are on another scale.+ self.levels.clone_from(&o.levels);+ self.level = o.level;+ self.kind = o.kind;+ }+ self.peak_all = max_level(self.peak_all, o.peak_all); self.gain_reduction_db = max_level(self.gain_reduction_db, o.gain_reduction_db); self.plugin_failures = o.plugin_failures; }@@ -212,22 +235,53 @@ impl StripMeter { pub fn reset_levels(&mut self) { self.peak = [0.0; 2]; self.peaks.iter_mut().for_each(|p| *p = 0.0);+ self.peak_all = 0.0;+ self.level = [0.0; 2];+ self.levels.iter_mut().for_each(|p| *p = 0.0); self.gain_reduction_db = 0.0; } - fn measure(&mut self, bufs: &[Vec<f32>], frames: usize, gr: f32) {+ /// Measure one block with `bank` as a `kind` meter.+ fn measure(&mut self, bank: &mut MeterBank, kind: MeterKind, bufs: &[Vec<f32>], frames: usize, gr: f32) { if self.peaks.len() != bufs.len() { self.peaks.resize(bufs.len(), 0.0);- }- for (p, b) in self.peaks.iter_mut().zip(bufs.iter()) {- *p = peak(b.get(..frames).unwrap_or(&[]));- }- let last = self.peaks.len().saturating_sub(1);- self.peak = [self.peaks.first().copied().unwrap_or(0.0), self.peaks.get(1.min(last)).copied().unwrap_or(0.0)];+ self.levels.resize(bufs.len(), 0.0);+ }+ bank.set_kind(kind);+ self.kind = kind;+ bank.process(bufs, frames, &mut self.peaks, &mut self.levels);+ self.peak = first_two(&self.peaks);+ self.level = first_two(&self.levels);+ let tp = if kind == MeterKind::TruePeak { self.levels.as_slice() } else { &[] };+ self.peak_all = self.peaks.iter().chain(tp).copied().fold(0.0, max_level); self.gain_reduction_db = gr; } } +/// The first two channels; a mono strip repeats channel 0.+fn first_two(v: &[f32]) -> [f32; 2] {+ let last = v.len().saturating_sub(1);+ [v.first().copied().unwrap_or(0.0), v.get(1.min(last)).copied().unwrap_or(0.0)]+}++/// Per-channel max of a later reading into an earlier one.+fn accumulate_channels(acc: &mut Vec<f32>, o: &[f32]) {+ if acc.len() != o.len() {+ // The strip's format changed (a document edit): earlier channels no longer line up.+ acc.clear();+ acc.extend_from_slice(o);+ } else {+ for (p, q) in acc.iter_mut().zip(o) {+ *p = max_level(*p, *q);+ }+ }+}++/// The session's meter type (sample peak for an unknown id).+fn meter_kind(s: &Session) -> MeterKind {+ MeterKind::parse(&s.edit.meter_type).unwrap_or_default()+}+ /// The larger of two meter levels; a NaN (from a misbehaving plugin) never wins or sticks. fn max_level(a: f32, b: f32) -> f32 { match (a.is_finite(), b.is_finite()) {@@ -281,6 +335,8 @@ struct Strip { out_extra: DelayLine, outbuf: Vec<Vec<f32>>, failures: PluginFailures,+ /// The strip's meter state (sample rate, type and per-channel ballistics).+ meter_bank: MeterBank, } /// Most latency delay compensation handles: 2^20 samples, about 22 s at 48 kHz. A strip reports@@ -385,6 +441,7 @@ impl Strip { out_extra: DelayLine::default(), outbuf: Vec::new(), failures: PluginFailures::default(),+ meter_bank: MeterBank::new(48_000.0), } } }@@ -399,6 +456,16 @@ pub struct MixEngine { main_fmt: ChannelFormat, pub meters: HashMap<TrackId, StripMeter>, pub main_meter: StripMeter,+ /// The main meter's state.+ main_bank: MeterBank,+ /// The main meter copied the last master fader's measurement of the same signal (no click+ /// added after it), rather than measuring it again.+ main_copied: bool,+ /// Measure meters at all: off for offline renders (bounces, stems), which nobody watches.+ metering: bool,+ /// This block's meter type and Options › Pre-Fader Metering, from the session.+ meter_kind: MeterKind,+ pre_fader_metering: bool, pub pan_law: PanLaw, /// Last rendered position end (to detect seeks). last_end: Samples,@@ -490,7 +557,12 @@ impl MixEngine { main: vec![vec![0.0; max_block]; MAIN_CHANNELS], main_fmt: ChannelFormat::Stereo, meters: HashMap::new(),+ pre_fader_metering: false, main_meter: StripMeter::default(),+ main_bank: MeterBank::new(if sample_rate.is_finite() && sample_rate > 0.0 { sample_rate } else { 48_000.0 }),+ main_copied: false,+ metering: true,+ meter_kind: MeterKind::SamplePeak, pan_law: PanLaw::Minus3, last_end: 0, synced: (0, 0, 0),@@ -587,6 +659,10 @@ impl MixEngine { // play, stop and locate. self.meters.values_mut().for_each(StripMeter::reset_levels); self.main_meter.reset_levels();+ self.main_bank.reset();+ for s in self.strips.values_mut() {+ s.meter_bank.reset();+ } } /// Bring plugin instances in line with the session (creates/destroys as needed). Allocates;@@ -612,6 +688,7 @@ impl MixEngine { for t in &s.tracks { let ch = strip_channels(t, main_ch); let strip = self.strips.entry(t.id).or_insert_with(Strip::new);+ strip.meter_bank.set_sample_rate(cx.sr); if strip.buf.len() != ch || strip.buf.first().map_or(0, Vec::len) != mb { strip.buf = vec![vec![0.0; mb]; ch]; }@@ -682,6 +759,8 @@ impl MixEngine { /// Render `frames` (≤ max_block) samples starting at `pos` into `out` (≥ 2 channels). pub fn render(&mut self, s: &Session, pos: Samples, frames: usize, out: &mut [Vec<f32>]) { let frames = frames.min(self.max_block);+ self.meter_kind = meter_kind(s);+ self.pre_fader_metering = s.edit.pre_fader_metering; if pos != self.last_end { // Seek: silence held notes and ringing plugins. for st in self.strips.values_mut() {@@ -773,7 +852,7 @@ impl MixEngine { let ch = st.buf.len(); st.align.set(lmax_t.saturating_sub(st.latency), ch); st.align.process(&mut st.buf, frames);- if t.mixer.sends.iter().flatten().any(|x| x.pre_fader) {+ if needs_pre_tap(s, t) { st.align_pre.set(lmax_t.saturating_sub(st.latency), ch); st.align_pre.process(&mut st.pre, frames); }@@ -812,7 +891,9 @@ impl MixEngine { self.strips.insert(t.id, st); } self.order = order;- // Master faders process the main mix.+ // Master faders process the main mix. The last one to meter it measured what the main+ // meter would (unless a click is added after it), so the main meter reuses that.+ let mut main_measured = None; let transport = transport_at(s, pos, !self.monitor_only, self.recording); for t in s.tracks.iter().filter(|t| t.kind == TrackKind::Master && !t.inactive) { if let Some(strip) = self.strips.get_mut(&t.id) {@@ -849,8 +930,12 @@ impl MixEngine { } } }+ // Master faders meter what they send out (post-fader), whatever Pre-Fader Metering says. let m = self.meters.entry(t.id).or_default();- m.measure(&self.main, frames, 0.0);+ if self.metering {+ m.measure(&mut strip.meter_bank, self.meter_kind, &self.main, frames, 0.0);+ main_measured = Some(t.id);+ } m.plugin_failures = strip.failures; } }@@ -860,6 +945,7 @@ impl MixEngine { // start of the same beat would repeat every block as a buzz. if self.metronome && !self.monitor_only && s.edit.click && (!s.edit.flag("click.only_during_record") || self.recording) { click::render(s, pos, frames, &mut self.main);+ main_measured = None; } // Output: min(out.len(), main channels) channels; any further output channels are silent. for (c, o) in out.iter_mut().enumerate() {@@ -871,7 +957,34 @@ impl MixEngine { } } }- self.main_meter.measure(&self.main, frames, 0.0);+ if self.metering {+ self.meter_main(main_measured, frames);+ }+ }++ /// The main meter: a copy of the master fader's measurement of the same signal when there+ /// is one (true peak need not oversample the main mix twice), else its own.+ fn meter_main(&mut self, master: Option<TrackId>, frames: usize) {+ match master.and_then(|id| self.meters.get(&id)) {+ Some(m) => {+ self.main_meter.copy_from(m);+ self.main_meter.plugin_failures = PluginFailures::default();+ self.main_copied = true;+ }+ None => {+ if std::mem::take(&mut self.main_copied) {+ // Its averaging state stood still while it copied.+ self.main_bank.reset();+ }+ self.main_meter.measure(&mut self.main_bank, self.meter_kind, &self.main, frames, 0.0);+ }+ }+ }++ /// Turn metering on or off (on by default). Offline renders turn it off: nobody watches+ /// their meters, and a true-peak meter costs a 4x oversampling filter per channel.+ pub fn set_metering(&mut self, on: bool) {+ self.metering = on; } /// The delay-compensation latency written onto `bus`: the slowest track insert, plus the@@ -901,7 +1014,12 @@ impl MixEngine { /// Send, meter and pan a processed strip into the busses / main mix. fn route_strip(&mut self, t: &Track, strip: &mut Strip, pos: Samples, frames: usize) { let m = self.meters.entry(t.id).or_default();- m.measure(&strip.buf, frames, strip.gr);+ if self.metering {+ // Options › Pre-Fader Metering: the pre-fader tap (after the inserts, before fader and+ // VCA, delay-compensated like the post-fader signal).+ let pre = self.pre_fader_metering && strip.pre.len() == strip.buf.len();+ m.measure(&mut strip.meter_bank, self.meter_kind, if pre { &strip.pre } else { &strip.buf }, frames, strip.gr);+ } m.plugin_failures = strip.failures; let src_fmt = strip_format(t, strip.buf.len()); let law = self.pan_law;@@ -1114,7 +1232,7 @@ fn process_strip( strip.latency = latency.min(MAX_LATENCY); let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t); strip.muted = t.mixer.mute || soloed_out;- if t.mixer.sends.iter().flatten().any(|x| x.pre_fader) {+ if needs_pre_tap(s, t) { strip.pre.resize_with(strip.buf.len(), Vec::new); for (d, src) in strip.pre.iter_mut().zip(strip.buf.iter()) { d.clear();@@ -1173,6 +1291,12 @@ fn process_strip( } } +/// A strip keeps a pre-fader copy of its signal for pre-fader sends, and for its meter under+/// Options › Pre-Fader Metering.+fn needs_pre_tap(s: &Session, t: &Track) -> bool {+ s.edit.pre_fader_metering || t.mixer.sends.iter().flatten().any(|x| x.pre_fader)+}+ /// Silences a block the plugin filled with NaN or infinity and takes the plugin out until the /// next [`MixEngine::reset`]. True then. fn contain_failure(slot: &mut PluginSlot, buf: &mut [Vec<f32>], frames: usize) -> bool {@@ -1977,10 +2101,6 @@ fn lerp_gain(db0: f32, db1: f32, i: usize, n: usize) -> f32 { if n == 0 { g0 } else { g0 + (g1 - g0) * (i as f32 / n as f32) } } -fn peak(x: &[f32]) -> f32 {- x.iter().fold(0.0f32, |m, v| if v.is_finite() { m.max(v.abs()) } else { m })-}- /// Per-block routing gains: `m[src][dst]`. type Matrix = [[f32; MAX_CHANNELS]; MAX_CHANNELS]; @@ -2297,7 +2417,9 @@ impl RangeRender { /// Renders the main mix over `range`, or `bus` when one is named. pub fn new(s: &Session, bus: Option<BusId>, range: Range, block: usize) -> Self { let nch = source_channels(s, bus);- let eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);+ let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);+ // Nobody watches an offline render's meters.+ eng.set_metering(false); let tmp = vec![vec![0.0f32; eng.max_block()]; nch]; let len = if nch == 0 { 0 } else { usize::try_from(range.len().max(0)).unwrap_or(0) }; RangeRender { eng, bus, tmp, start: range.start, done: 0, total: len, latency: 0 }@@ -2591,6 +2713,144 @@ mod tests { t } + /// The track's meter after rendering `blocks` blocks of 512 from 0.+ fn metered(s: &Session, t: TrackId, blocks: usize) -> StripMeter {+ let mut eng = MixEngine::new(48_000.0, 512);+ let mut out = vec![vec![0.0; 512]; 2];+ for b in 0..blocks {+ eng.render(s, (b * 512) as i64, 512, &mut out);+ }+ eng.meters.get(&t).cloned().unwrap_or_default()+ }++ #[test]+ fn offline_renders_do_not_meter() {+ let (mut s, t) = session_with_dc(0.5, 48_000);+ let m = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None);+ s.edit.meter_type = "true_peak".into();+ let mut rr = RangeRender::new(&s, None, Range::new(0, 4096), 512);+ while rr.next_block(&s).is_some() {}+ // The meters never measured: no channel was ever looked at, true peak included.+ assert!(rr.eng.meters.get(&t).is_none_or(|x| x.peaks.is_empty()), "{:?}", rr.eng.meters.get(&t));+ assert!(rr.eng.meters.get(&m).is_none_or(|x| x.peaks.is_empty()));+ assert!(rr.eng.main_meter.peaks.is_empty());+ }++ #[test]+ fn the_main_meter_reuses_the_master_faders_measurement() {+ let (mut s, _) = session_with_dc(0.5, 48_000);+ let m = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None);+ s.edit.meter_type = "true_peak".into();+ let mut eng = MixEngine::new(48_000.0, 512);+ render_block(&mut eng, &s, 0);+ let master = &eng.meters[&m];+ assert!(master.level[0] > 0.3, "{master:?}");+ assert!(eng.main_copied, "copied, not measured again");+ assert_eq!((eng.main_meter.peaks.clone(), eng.main_meter.levels.clone()), (master.peaks.clone(), master.levels.clone()));+ // Without a master fader the main meter measures for itself.+ let (mut s, _) = session_with_dc(0.5, 48_000);+ s.edit.meter_type = "true_peak".into();+ let mut eng = MixEngine::new(48_000.0, 512);+ render_block(&mut eng, &s, 0);+ assert!(!eng.main_copied && eng.main_meter.level[0] > 0.3, "{:?}", eng.main_meter);+ }++ #[test]+ fn accumulating_across_a_meter_type_change_keeps_only_the_new_type() {+ let peak = StripMeter { levels: vec![0.5], level: [0.5; 2], peaks: vec![0.5], peak: [0.5; 2], peak_all: 0.5, ..StripMeter::default() };+ let vu = StripMeter {+ levels: vec![0.01],+ level: [0.01; 2],+ peaks: vec![0.4],+ peak: [0.4; 2],+ peak_all: 0.4,+ kind: MeterKind::Vu,+ ..StripMeter::default()+ };+ let mut acc = StripMeter::default();+ acc.accumulate(&peak);+ acc.accumulate(&vu);+ assert_eq!((acc.kind, acc.levels.clone(), acc.level), (MeterKind::Vu, vec![0.01], [0.01; 2]), "no sample peak on the VU scale");+ assert_eq!((acc.peak, acc.peak_all), ([0.5; 2], 0.5), "sample peaks are peaks whatever the type");+ }++ #[test]+ fn the_main_meter_measures_for_itself_once_a_click_is_added() {+ let (mut s, _) = session_with_dc(0.1, 48_000);+ let m = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None);+ s.edit.click = true;+ let mut eng = MixEngine::new(48_000.0, 512);+ eng.metronome = true;+ render_block(&mut eng, &s, 0);+ assert!(!eng.main_copied, "the click comes after the master fader");+ assert!(eng.main_meter.peak[0] > eng.meters[&m].peak[0] + 0.05, "{:?} vs {:?}", eng.main_meter.peak, eng.meters[&m].peak);+ }++ #[test]+ fn plugin_failures_are_recorded_with_metering_off() {+ let (mut s, a) = session_with_dc(0.5, 48_000);+ s.track_mut(a).unwrap().mixer.inserts[2] = Some(Insert::new("clap:test.nan"));+ let mut eng = MixEngine::new(48_000.0, 512);+ eng.set_metering(false);+ eng.set_external_instances(true);+ eng.adopt(nan(a, 2, 1));+ render_block(&mut eng, &s, 0);+ assert_eq!(eng.meters[&a].plugin_failures, PluginFailures { count: 1, last: 2 });+ assert!(eng.meters[&a].peaks.is_empty(), "and nothing was measured");+ }++ #[test]+ fn accumulating_keeps_the_highest_reading_per_channel() {+ let a =+ StripMeter { levels: vec![0.2, 0.9], level: [0.2, 0.9], peaks: vec![0.3, 0.9], peak: [0.3, 0.9], peak_all: 0.9, ..StripMeter::default() };+ let b =+ StripMeter { levels: vec![0.5, 0.1], level: [0.5, 0.1], peaks: vec![0.6, 0.1], peak: [0.6, 0.1], peak_all: 0.6, ..StripMeter::default() };+ let mut acc = StripMeter::default();+ acc.accumulate(&a);+ acc.accumulate(&b);+ assert_eq!((acc.levels.clone(), acc.level, acc.peak_all), (vec![0.5, 0.9], [0.5, 0.9], 0.9));+ acc.reset_levels();+ assert_eq!((acc.levels.clone(), acc.level, acc.peak_all), (vec![0.0, 0.0], [0.0; 2], 0.0));+ }++ #[test]+ fn the_session_meter_type_sets_the_reading_and_peaks_stay_sample_peaks() {+ let (mut s, t) = session_with_dc(0.5, 240_000);+ let peak = metered(&s, t, 4);+ assert_eq!(peak.level, peak.peak, "sample peak");+ s.edit.meter_type = "rms".into();+ // DC: after many time constants the RMS converges on the level.+ let rms = metered(&s, t, 200);+ assert!((rms.levels[0] - 0.5).abs() < 0.01 && (rms.peak[0] - 0.5).abs() < 1e-3, "{rms:?}");+ let early = metered(&s, t, 1);+ assert!(early.level[0] < 0.3, "RMS rises with its time constant: {early:?}");+ s.edit.meter_type = "nonsense".into();+ assert_eq!(metered(&s, t, 2).level, metered(&s, t, 2).peak, "unknown types meter sample peaks");+ }++ #[test]+ fn a_surround_strip_reports_its_loudest_channel_for_the_clip_light() {+ let mut s = Session::default();+ s.outputs[0].format = ChannelFormat::Surround51;+ let t = s.add_track(TrackKind::Audio, ChannelFormat::Surround51, None);+ // L, R quiet; LFE (channel 3) over full scale.+ let chans: Vec<Vec<f32>> = (0..6).map(|c| vec![if c == 3 { 1.2 } else { 0.1 }; 4800]).collect();+ s.pool.insert(SourceId(901), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: chans })));+ let id = s.new_clip_id();+ s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "lfe", SourceId(901), 0, 0, 4800));+ let mut eng = MixEngine::new(48_000.0, 512);+ let mut out = vec![vec![0.0; 512]; 6];+ eng.render(&s, 0, 512, &mut out);+ let m = &eng.meters[&t];+ assert!(m.peak[0] < 0.2 && m.peak[1] < 0.2, "{m:?}");+ assert!((m.peak_all - 1.2).abs() < 1e-3, "{m:?}");+ assert!((eng.main_meter.peak_all - 1.2).abs() < 1e-3, "the main meter too: {:?}", eng.main_meter);+ let mut acc = StripMeter::default();+ acc.accumulate(m);+ acc.accumulate(&StripMeter::default());+ assert_eq!(acc.peak_all, m.peak_all, "kept until taken");+ }+ #[test] fn mono_center_pan_is_minus_3db() { let (s, _) = session_with_dc(0.5, 4800);@@ -3076,6 +3336,79 @@ mod tests { vec![PreparedInstance { track, slot, id: "clap:test.nan".into(), ch, plugin: Box::new(Nan(soundcraft_dsp::create("gain").unwrap())) }] } + /// Passes audio through but reports `.1` samples of latency, so delay compensation delays the+ /// other strips.+ struct Late(Box<dyn Plugin>, usize);++ impl Plugin for Late {+ fn info(&self) -> &'static soundcraft_dsp::PluginInfo {+ self.0.info()+ }+ fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {+ self.0.prepare(sr, mb, ch);+ }+ fn reset(&mut self) {+ self.0.reset();+ }+ fn set_param(&mut self, id: &str, v: f32) -> bool {+ self.0.set_param(id, v)+ }+ fn param(&self, id: &str) -> Option<f32> {+ self.0.param(id)+ }+ fn process(&mut self, _io: &mut [Vec<f32>], _frames: usize) {}+ fn latency(&self) -> usize {+ self.1+ }+ }++ #[test]+ fn pre_fader_metering_reads_before_the_fader_in_step_with_delay_compensation() {+ let (mut s, a) = session_with_dc(0.5, 48_000);+ let b = add_dc_track(&mut s, 0.5, 48_000);+ s.track_mut(b).unwrap().mixer.volume_db = -20.0;+ s.track_mut(a).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.late"));+ let run = |s: &Session| {+ let mut eng = MixEngine::new(48_000.0, 512);+ eng.set_external_instances(true);+ eng.adopt(vec![PreparedInstance {+ track: a,+ slot: 0,+ id: "clap:test.late".into(),+ ch: 1,+ plugin: Box::new(Late(soundcraft_dsp::create("gain").unwrap(), 1024)),+ }]);+ (0..3)+ .map(|i| {+ render_block(&mut eng, s, i * 512);+ eng.meters[&b].peak[0]+ })+ .collect::<Vec<f32>>()+ };+ // B waits 1024 samples for A's latency, so the first two blocks meter silence.+ let post = run(&s);+ assert!(post[0] == 0.0 && post[1] == 0.0 && (post[2] - 0.05).abs() < 1e-3, "post-fader: {post:?}");+ s.edit.pre_fader_metering = true;+ let pre = run(&s);+ assert!(pre[0] == 0.0 && pre[1] == 0.0, "the pre-fader meter is delay-compensated too: {pre:?}");+ assert!((pre[2] - 0.5).abs() < 1e-3, "and reads before the fader: {pre:?}");+ // The Mute button leaves the pre-fader tap alone.+ s.track_mut(b).unwrap().mixer.mute = true;+ assert!((run(&s)[2] - 0.5).abs() < 1e-3);+ }++ #[test]+ fn master_faders_meter_post_fader_even_with_pre_fader_metering() {+ let (mut s, _) = session_with_dc(0.5, 48_000);+ let m = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None);+ s.track_mut(m).unwrap().mixer.volume_db = -20.0;+ s.edit.pre_fader_metering = true;+ let mut eng = MixEngine::new(48_000.0, 512);+ render_block(&mut eng, &s, 0);+ let expect = 0.5 * std::f32::consts::FRAC_1_SQRT_2 * 0.1;+ assert!((eng.meters[&m].peak[0] - expect).abs() < 1e-3, "{:?}", eng.meters[&m]);+ }+ #[test] fn an_insert_that_puts_out_nan_is_taken_out_until_the_next_reset() { let (mut s, a) = session_with_dc(0.5, 48_000);@@ -210,6 +210,10 @@ pub struct EditState { pub scrolling: String, pub solo_mode: String, pub pre_fader_metering: bool,+ /// What the track and main meters measure (`options.meter_type`): `sample_peak`, `true_peak`,+ /// `rms`, `vu`, `k12`, `k14` or `k20`. Unknown values meter sample peaks.+ #[serde(default = "sample_peak")]+ pub meter_type: String, pub delay_compensation: bool, pub keyboard_focus: String, /// Timeline selection when it is unlinked from the edit selection.@@ -243,6 +247,10 @@ fn yes() -> bool { true } +fn sample_peak() -> String {+ "sample_peak".into()+}+ impl Default for EditState { fn default() -> Self { EditState {@@ -283,6 +291,7 @@ impl Default for EditState { scrolling: "page".into(), solo_mode: "sip".into(), pre_fader_metering: false,+ meter_type: sample_peak(), delay_compensation: true, keyboard_focus: "commands".into(), timeline_selection: Range::default(),@@ -1297,6 +1297,24 @@ mod tests { assert!(ui.tracks[&t].peak[0] > 0.005); } + #[test]+ fn a_meter_type_change_between_polls_drops_the_old_types_readings() {+ let (shared, mut st, tx, t) = spike_session(100);+ let mut out = vec![0.0f32; 2 * 512];+ st.fill(&mut out, 2);+ // Then, before the UI takes anything, the type becomes VU.+ let mut vu = (*st.session).clone();+ vu.edit.meter_type = "vu".into();+ tx.send(Cmd::Session(Arc::new(vu), Vec::new())).unwrap();+ st.fill(&mut out, 2);+ let mut ui = MeterSnapshot::default();+ take_meter_snapshot(&mut shared.meters.lock().unwrap(), &mut ui);+ let m = &ui.tracks[&t];+ assert_eq!(m.kind, soundcraft_dsp::meter::MeterKind::Vu);+ assert!(m.level[0] < 0.2, "the sample-peak spike is not read as VU: {m:?}");+ assert!(m.peak[0] > 0.85, "but the peak still counts for the clip light: {m:?}");+ }+ #[test] fn a_spike_rendered_while_the_ui_holds_the_meters_arrives_later() { let (shared, mut st, _tx, t) = spike_session(100);@@ -3,7 +3,7 @@ use crate::dialogs::Dialog; use crate::spot::Spot; use crate::theme::{Tokens, bold, clip_colors, regular, rgb};-use crate::widgets::{groove, level_pos, meter_pos, raised_key, shade};+use crate::widgets::{groove, raised_key, shade}; use crate::{Gesture, SoundApp, toolbar}; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json;@@ -1254,7 +1254,9 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele } } let md = app.meters.get(&id).copied().unwrap_or_default();- header_meter(ui, Rect::from_min_max(pos2(head.max.x - 8.0, head.min.y + 1.0), pos2(head.max.x, head.max.y - 1.5)), &md, track.channels() >= 2);+ let mr = Rect::from_min_max(pos2(head.max.x - 8.0, head.min.y + 1.0), pos2(head.max.x, head.max.y - 1.5));+ header_meter(ui, mr, &md, track.channels() >= 2, &crate::meters::Scale::of_session(app.engine.session()));+ crate::meters::interact(app, ui, mr, crate::meters::MeterTarget::Track(id)); } /// A small menu arrow, pointing down, or right for a closed folder.@@ -1328,7 +1330,7 @@ fn header_toggle(ui: &Ui, r: Rect, track: TrackId, key: HeaderKey, lit: bool, im } /// The header's level meter: black-rimmed bars, dim where unlit, with a clip light on top.-fn header_meter(ui: &Ui, r: Rect, md: &crate::MeterDisplay, stereo: bool) {+fn header_meter(ui: &Ui, r: Rect, md: &crate::MeterDisplay, stereo: bool, scale: &crate::meters::Scale) { let t = Tokens::current(); let p = ui.painter(); p.rect_filled(r, 0.0, Color32::BLACK);@@ -1338,19 +1340,19 @@ fn header_meter(ui: &Ui, r: Rect, md: &crate::MeterDisplay, stereo: bool) { let body = inner.with_min_y(clip.max.y + 1.0); let n = if stereo { 2 } else { 1 }; let w = (body.width() - (n - 1) as f32) / n as f32;- let (amber, red) = (meter_pos(-12.0), meter_pos(-3.0));+ let (amber, red) = scale.zones(); for ch in 0..n { let bar = Rect::from_min_size(pos2(body.min.x + ch as f32 * (w + 1.0), body.min.y), vec2(w, body.height())); let y = |f: f32| bar.max.y - bar.height() * f;- let lv = level_pos(md.level[ch]);+ let lv = scale.level_pos(md.level[ch]); for (lo, hi, col) in [(0.0, amber, t.meter_green), (amber, red, t.meter_yellow), (red, 1.0, t.meter_red)] { p.rect_filled(Rect::from_x_y_ranges(bar.x_range(), y(hi)..=y(lo)), 0.0, col.gamma_multiply(0.18)); if lv > lo { p.rect_filled(Rect::from_x_y_ranges(bar.x_range(), y(lv.min(hi))..=y(lo)), 0.0, col); } }- let hv = level_pos(md.hold[ch]);- if hv > 0.01 {+ let hv = scale.level_pos(md.hold[ch]);+ if hv > 0.01 && hv > lv + 0.002 { p.hline(bar.x_range(), y(hv), Stroke::new(1.0, if hv > red { t.meter_red } else { t.meter_yellow })); } }@@ -16,6 +16,7 @@ pub mod fonts; pub mod icons; pub mod import; pub mod menus;+pub mod meters; pub mod midi_editor; pub mod mix_window; pub mod ops_windows;@@ -127,6 +128,8 @@ pub struct UiState { pub window_size: [f32; 2], pub window_maximized: bool, pub ui_scale: f32,+ /// Meter peak hold, clip indication and release (`ui.meters`).+ pub meters: meters::MeterPrefs, } impl Default for UiState {@@ -187,6 +190,7 @@ impl Default for UiState { window_size: [1600.0, 1000.0], window_maximized: false, ui_scale: 1.0,+ meters: meters::MeterPrefs::default(), } } }@@ -222,8 +226,13 @@ pub struct DragSource(pub u64); pub struct MeterDisplay { pub level: [f32; 2], pub hold: [f32; 2],- pub hold_age: f32,+ /// Seconds since each side's hold was last raised.+ pub hold_age: [f32; 2], pub clip: bool,+ /// Seconds since the signal last clipped (for timed clip indication).+ pub clip_age: f32,+ /// The highest sample peak since the meter was last reset (the numeric peak readout).+ pub max_peak: f32, pub gr: f32, /// [`soundcraft_mix::PluginFailures::count`] as last seen. pub plugin_failures: u32,@@ -481,6 +490,10 @@ impl SoundApp { // Re-selecting a scrolling mode resumes the playhead follow. self.edit_layout.follow_hold = false; }+ if id == "options.meter_type" {+ // Holds and peak readouts of the old type mean nothing on the new scale.+ self.meters.values_mut().chain([&mut self.main_meter]).for_each(meters::reset);+ } if id == "track.clear_clip_indicators" { self.meters.values_mut().chain([&mut self.main_meter]).for_each(|m| m.clip = false); }@@ -1176,9 +1189,10 @@ impl SoundApp { self.last_meter_poll = now; } let snap = &self.meter_snapshot;+ let kind = meters::Scale::of_session(self.engine.session()).kind; for (id, m) in &snap.tracks { let d = self.meters.entry(*id).or_default();- feed_meter(d, m.peak, m.gain_reduction_db, dt);+ meters::feed(d, &meters::Reading::of(m, kind), dt, &self.ui.meters); if m.plugin_failures.count != d.plugin_failures { d.plugin_failures = m.plugin_failures.count; if let Some(msg) = failed_plugin_status(self.engine.session(), *id, m.plugin_failures.last) {@@ -1186,8 +1200,7 @@ impl SoundApp { } } }- let main = snap.main.peak;- feed_meter(&mut self.main_meter, main, 0.0, dt);+ meters::feed(&mut self.main_meter, &meters::Reading::of(&snap.main, kind), dt, &self.ui.meters); if ended { self.stop_play(); }@@ -1206,10 +1219,11 @@ impl SoundApp { } } if !self.meters_live() {+ let silence = meters::Reading::default(); for d in self.meters.values_mut() {- feed_meter(d, [0.0, 0.0], 0.0, dt);+ meters::feed(d, &silence, dt, &self.ui.meters); }- feed_meter(&mut self.main_meter, [0.0, 0.0], 0.0, dt);+ meters::feed(&mut self.main_meter, &silence, dt, &self.ui.meters); } } @@ -1372,33 +1386,6 @@ impl SoundApp { } } -fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) {- // Instant attack, ~26 dB/s release, 2 s peak hold.- let fall = 10f32.powf(-26.0 * dt / 20.0);- for c in 0..2 {- let v = peak.get(c).copied().unwrap_or(0.0);- let lvl = d.level.get(c).copied().unwrap_or(0.0);- let nv = if v >= lvl { v } else { (lvl * fall).max(v) };- if let Some(x) = d.level.get_mut(c) {- *x = if nv < 1e-5 { 0.0 } else { nv };- }- if v >= d.hold.get(c).copied().unwrap_or(0.0) {- if let Some(h) = d.hold.get_mut(c) {- *h = v;- }- d.hold_age = 0.0;- }- if v >= 1.0 {- d.clip = true;- }- }- d.hold_age += dt;- if d.hold_age > 2.0 {- d.hold = d.level;- }- d.gr = if gr > d.gr { gr } else { d.gr * fall };-}- /// The status line naming the plugin in `slot` (an insert index or /// [`soundcraft_mix::INSTRUMENT_SLOT`]), which the engine took out of the signal path. fn failed_plugin_status(s: &soundcraft_model::Session, track: TrackId, slot: usize) -> Option<String> {@@ -74,6 +74,8 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[ ("ui.bounce_dialog", "Bounce Mix…", "", None), ("ui.set", "Set UI State", "", None), ("ui.theme", "Appearance", "", None),+ ("ui.meters", "Meter Options", "", None),+ ("ui.meters.reset", "Reset Meter Peaks and Clips", "", None), ]; /// Extra catalog mappings handled by the UI layer.@@ -512,6 +514,11 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result< if id == "ui.audio_device" { return Some(crate::windows::audio_device_command(app, p)); }+ match id {+ "ui.meters" => return Some(crate::meters::prefs_command(app, p)),+ "ui.meters.reset" => return Some(crate::meters::reset_command(app, p)),+ _ => {}+ } let toggle = |v: &mut bool| { *v = p.get("value").and_then(Value::as_bool).unwrap_or(!*v); json!({"value": *v})new file mode 100644@@ -0,0 +1,754 @@+//! Meter ballistics, scale and preferences, in one place.+//!+//! The audio thread hands the UI the highest reading of every block since the last poll+//! (`soundcraft_playback::Player::take_meters`): the session's meter type (sample or true peak,+//! RMS, VU, K-System; `options.meter_type`, measured by `soundcraft_dsp::meter::MeterBank`), the+//! sample peak and the gain reduction. This module turns those into what the meters show: for+//! peak types instant attack and a release in dB per second (averaging types bring their own+//! ballistics), a peak hold line, a clip light and the highest peak as a number, each following+//! the meter preferences in [`MeterPrefs`] (`ui.meters`), on the meter type's [`Scale`]. Clicking+//! a meter resets its holds and clip light (`ui.meters.reset`); Alt-click resets every meter,+//! right-click picks the meter type and the options.+//!+//! Every number below is a named constant so it can be checked against Pro Tools and changed in+//! one place. Where a value comes from a published standard rather than from observing Pro Tools,+//! its comment says so.++use crate::{MeterDisplay, SoundApp};+use serde_json::{Value, json};+use soundcraft_dsp::meter::MeterKind;+use soundcraft_model::TrackId;++/// Readings at or above this linear sample peak (0 dBFS) light the clip indicator.+pub const CLIP_LEVEL: f32 = 1.0;++/// Seconds a peak hold line or clip light stays lit with the "timed" setting. Pro Tools' Display+/// preferences offer a 3 second hold (assumed equal for peak hold and clip indication).+pub const HOLD_SECS: f32 = 3.0;++/// Levels below this (linear, about -100 dBFS) read as silence, so a decaying bar ends.+pub const SILENCE: f32 = 1e-5;++/// Release (fall-back) rates, dB per second. `fast` is SoundCraft's original rate, kept as the+/// default until it is checked against Pro Tools' sample-peak meter. The others are the decay+/// rates of the IEC 60268-10 peak programme meters: Type I (DIN/Nordic, 20 dB in 1.7 s) and+/// Type II (BBC, 24 dB in 2.8 s).+pub const RELEASE_FAST_DB_S: f32 = 26.0;+pub const RELEASE_MEDIUM_DB_S: f32 = 20.0 / 1.7;+pub const RELEASE_SLOW_DB_S: f32 = 24.0 / 2.8;++/// The sample-peak (dBFS) scale: marks (numbered and ruled across the bar), from the top down.+pub const SCALE_MARKS: [f32; 8] = [0.0, -5.0, -10.0, -15.0, -20.0, -30.0, -40.0, -60.0];+/// The bottom of the dBFS scale.+pub const SCALE_FLOOR_DB: f32 = -80.0;+/// The dBFS scale's segments: `(dB at the top of the segment, position there)`, from the bottom+/// up, linear in dB within each: -80..-40 dB fills the bottom 10 %, -40..-20 the next 20 %,+/// -20..0 the top 70 %.+pub const SCALE_KNEES: [(f32, f32); 3] = [(-40.0, 0.1), (-20.0, 0.3), (0.0, 1.0)];+/// Where the dBFS bar turns from green to yellow, and from yellow to red.+pub const YELLOW_FROM_DB: f32 = -12.0;+pub const RED_FROM_DB: f32 = -3.0;++/// The VU scale (VU, 0 at [`soundcraft_dsp::meter::VU_REFERENCE_DBFS`]): the classic -20..+3+/// marks, linear in dB, red above 0 VU.+pub const VU_MARKS: [f32; 11] = [3.0, 2.0, 1.0, 0.0, -1.0, -2.0, -3.0, -5.0, -7.0, -10.0, -20.0];+const VU_KNEES: [(f32, f32); 1] = [(3.0, 1.0)];++/// K-System scales (0 at -12, -14 or -20 dBFS; full scale at the top): green to 0, yellow to +4,+/// red above, as the K-System specifies. The bottom is 40 dB below the 0 mark.+pub const K_FLOOR: f32 = -40.0;+pub const K_YELLOW_FROM: f32 = 0.0;+pub const K_RED_FROM: f32 = 4.0;+const K12_MARKS: [f32; 10] = [12.0, 8.0, 4.0, 0.0, -4.0, -8.0, -12.0, -20.0, -30.0, -40.0];+const K14_MARKS: [f32; 10] = [14.0, 8.0, 4.0, 0.0, -4.0, -8.0, -12.0, -20.0, -30.0, -40.0];+const K20_MARKS: [f32; 11] = [20.0, 12.0, 8.0, 4.0, 0.0, -4.0, -8.0, -12.0, -20.0, -30.0, -40.0];+const K12_KNEES: [(f32, f32); 2] = [(-20.0, 0.3), (12.0, 1.0)];+const K14_KNEES: [(f32, f32); 2] = [(-20.0, 0.3), (14.0, 1.0)];+const K20_KNEES: [(f32, f32); 2] = [(-20.0, 0.3), (20.0, 1.0)];++/// A meter's scale: where a reading sits on the bar, the marks beside it and the colour zones.+/// Scale units are dB relative to `zero_dbfs` (dBFS for peak and RMS meters, VU, K units).+#[derive(Debug, Clone, Copy, PartialEq)]+pub struct Scale {+ pub kind: MeterKind,+ /// The dBFS of the scale's 0.+ pub zero_dbfs: f32,+ /// The bottom (scale units).+ pub floor: f32,+ /// `(scale units at the top of a segment, bar position there)`, bottom up; the last is the top.+ pub knees: &'static [(f32, f32)],+ /// Marks, top down (scale units).+ pub marks: &'static [f32],+ pub yellow_from: f32,+ pub red_from: f32,+}++impl Scale {+ pub fn of(kind: MeterKind) -> Scale {+ let dbfs = Scale {+ kind,+ zero_dbfs: 0.0,+ floor: SCALE_FLOOR_DB,+ knees: &SCALE_KNEES,+ marks: &SCALE_MARKS,+ yellow_from: YELLOW_FROM_DB,+ red_from: RED_FROM_DB,+ };+ let k = |knees: &'static [(f32, f32)], marks: &'static [f32]| Scale {+ kind,+ zero_dbfs: kind.k_zero_dbfs().unwrap_or(0.0),+ floor: K_FLOOR,+ knees,+ marks,+ yellow_from: K_YELLOW_FROM,+ red_from: K_RED_FROM,+ };+ match kind {+ MeterKind::SamplePeak | MeterKind::TruePeak | MeterKind::Rms => dbfs,+ MeterKind::Vu => Scale {+ kind,+ zero_dbfs: soundcraft_dsp::meter::VU_REFERENCE_DBFS,+ floor: -20.0,+ knees: &VU_KNEES,+ marks: &VU_MARKS,+ yellow_from: 0.0,+ red_from: 0.0,+ },+ MeterKind::K12 => k(&K12_KNEES, &K12_MARKS),+ MeterKind::K14 => k(&K14_KNEES, &K14_MARKS),+ MeterKind::K20 => k(&K20_KNEES, &K20_MARKS),+ }+ }++ /// The scale of the session's meter type.+ pub fn of_session(s: &soundcraft_model::Session) -> Scale {+ Scale::of(MeterKind::parse(&s.edit.meter_type).unwrap_or_default())+ }++ /// Bar position (0..1) of a mark (scale units): piecewise linear between the knees.+ pub fn mark_pos(&self, u: f32) -> f32 {+ if u.is_nan() || u <= self.floor {+ return 0.0;+ }+ let (mut lo_u, mut lo_pos) = (self.floor, 0.0);+ for &(hi_u, hi_pos) in self.knees {+ if u < hi_u {+ return lo_pos + (u - lo_u) / (hi_u - lo_u).max(1e-6) * (hi_pos - lo_pos);+ }+ (lo_u, lo_pos) = (hi_u, hi_pos);+ }+ 1.0+ }++ /// Bar position (0..1) of a dBFS value.+ pub fn db_pos(&self, dbfs: f32) -> f32 {+ self.mark_pos(dbfs - self.zero_dbfs)+ }++ /// Bar position (0..1) of a linear reading.+ pub fn level_pos(&self, v: f32) -> f32 {+ if v.is_nan() || v <= 1e-6 {+ return 0.0;+ }+ self.db_pos(20.0 * v.log10())+ }++ /// Bar positions where yellow and red begin.+ pub fn zones(&self) -> (f32, f32) {+ (self.mark_pos(self.yellow_from), self.mark_pos(self.red_from))+ }++ /// A mark's number: negative marks without their sign (as Pro Tools prints them), positive+ /// ones (VU, K) with a plus.+ pub fn mark_label(u: f32) -> String {+ if u > 0.0 { format!("+{u}") } else { format!("{}", u.abs()) }+ }+}++/// How long a peak hold line or a clip light stays lit.+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]+#[serde(rename_all = "snake_case")]+pub enum Hold {+ /// Not held: the hold line follows the bar; the clip light is lit only while the signal clips.+ None,+ /// [`HOLD_SECS`] after the last peak or clip.+ Timed,+ /// Until reset: click the meter or `ui.meters.reset` (hold line, clip light and peak+ /// readout), or Track › Clear All Clip Indicators (Alt+C; clip lights only).+ Infinite,+}++impl Hold {+ const ALL: [Hold; 3] = [Hold::None, Hold::Timed, Hold::Infinite];++ pub fn id(self) -> &'static str {+ match self {+ Hold::None => "none",+ Hold::Timed => "timed",+ Hold::Infinite => "infinite",+ }+ }++ pub fn label(self) -> &'static str {+ match self {+ Hold::None => "None",+ Hold::Timed => "3 Seconds",+ Hold::Infinite => "Infinite",+ }+ }++ fn parse(s: &str) -> Option<Hold> {+ Hold::ALL.into_iter().find(|h| h.id() == s)+ }+}++/// How fast a meter falls back after a peak.+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]+#[serde(rename_all = "snake_case")]+pub enum Release {+ #[default]+ Fast,+ Medium,+ Slow,+}++impl Release {+ const ALL: [Release; 3] = [Release::Fast, Release::Medium, Release::Slow];++ pub fn id(self) -> &'static str {+ match self {+ Release::Fast => "fast",+ Release::Medium => "medium",+ Release::Slow => "slow",+ }+ }++ pub fn db_per_s(self) -> f32 {+ match self {+ Release::Fast => RELEASE_FAST_DB_S,+ Release::Medium => RELEASE_MEDIUM_DB_S,+ Release::Slow => RELEASE_SLOW_DB_S,+ }+ }++ pub fn label(self) -> String {+ let name = match self {+ Release::Fast => "Fast",+ Release::Medium => "Medium",+ Release::Slow => "Slow",+ };+ format!("{name} ({:.1} dB/s)", self.db_per_s())+ }++ fn parse(s: &str) -> Option<Release> {+ Release::ALL.into_iter().find(|r| r.id() == s)+ }++ /// The linear factor a level falls by over `dt` seconds.+ pub fn factor(self, dt: f32) -> f32 {+ 10f32.powf(-self.db_per_s() * dt.max(0.0) / 20.0)+ }+}++/// Meter preferences (per machine, saved with the UI state; `ui.meters`).+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]+#[serde(default)]+pub struct MeterPrefs {+ /// 3 seconds by default.+ pub peak_hold: Hold,+ /// Clip lights latch until cleared by default, as they always have.+ pub clip_hold: Hold,+ pub release: Release,+}++impl Default for MeterPrefs {+ fn default() -> Self {+ MeterPrefs { peak_hold: Hold::Timed, clip_hold: Hold::Infinite, release: Release::default() }+ }+}++/// Which meter a click or `ui.meters.reset` acts on.+#[derive(Debug, Clone, Copy, PartialEq, Eq)]+pub enum MeterTarget {+ Track(TrackId),+ /// The main output meters (Transport, toolbar).+ Main,+}++/// One poll's reading of a meter (linear; gain reduction in dB).+#[derive(Debug, Clone, Copy, Default)]+pub struct Reading {+ /// The meter type's reading of the two sides the bars show (mono strips repeat channel 0).+ pub level: [f32; 2],+ /// The sample peaks of those two sides.+ pub peak: [f32; 2],+ /// The highest peak over every channel of the strip (surround strips have more than the+ /// two bars of a stereo meter; on a true-peak meter, true peaks), for the clip light and+ /// the peak readout.+ pub peak_all: f32,+ pub gr: f32,+ pub kind: MeterKind,+}++impl Reading {+ /// `m` read on a `kind` meter. A reading measured as another type (the type just changed and+ /// the audio thread has not caught up) shows nothing rather than landing on the wrong scale;+ /// its sample peaks still count.+ pub fn of(m: &soundcraft_mix::StripMeter, kind: MeterKind) -> Reading {+ let level = if m.kind == kind { m.level } else { [0.0; 2] };+ Reading { level, peak: m.peak, peak_all: m.peak_all, gr: m.gain_reduction_db, kind }+ }++ /// What the clip light and peak readout look at: the highest sample peak of any channel,+ /// or true peak on a true-peak meter.+ fn loudest(&self) -> f32 {+ let tp = if self.kind == MeterKind::TruePeak { self.level } else { [0.0; 2] };+ self.peak.iter().chain(&tp).copied().map(Some).map(sane).fold(sane(Some(self.peak_all)), f32::max)+ }+}++fn sane(v: Option<f32>) -> f32 {+ v.filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0)+}++/// Feed one poll's reading into a meter's display state, `dt` seconds after the previous frame.+/// Peak types attack instantly and fall back at the release rate; averaging types (RMS, VU, K)+/// carry their own ballistics from the audio thread and are shown as read.+pub fn feed(d: &mut MeterDisplay, r: &Reading, dt: f32, prefs: &MeterPrefs) {+ let dt = if dt.is_finite() { dt.max(0.0) } else { 0.0 };+ let fall = if r.kind.is_peak() { prefs.release.factor(dt) } else { 0.0 };+ let all = r.loudest();+ d.max_peak = d.max_peak.max(all);+ let clipping = all >= CLIP_LEVEL;+ for c in 0..2 {+ let v = sane(r.level.get(c).copied());+ let lvl = d.level.get(c).copied().unwrap_or(0.0);+ let nv = if v >= lvl { v } else { (lvl * fall).max(v) };+ if let Some(x) = d.level.get_mut(c) {+ *x = if nv < SILENCE { 0.0 } else { nv };+ }+ // Each side holds on its own: a silent side (a hard-panned source, a stopped transport)+ // must not keep restarting the other side's hold time.+ let (Some(h), Some(age)) = (d.hold.get_mut(c), d.hold_age.get_mut(c)) else { continue };+ if v > 0.0 && v >= *h {+ *h = v;+ *age = 0.0;+ } else {+ *age += dt;+ }+ let expired = match prefs.peak_hold {+ Hold::None => true,+ Hold::Timed => *age > HOLD_SECS,+ Hold::Infinite => false,+ };+ if expired {+ *h = d.level.get(c).copied().unwrap_or(0.0);+ }+ }+ if clipping {+ d.clip = true;+ d.clip_age = 0.0;+ } else {+ d.clip_age += dt;+ let clip_expired = match prefs.clip_hold {+ Hold::None => true,+ Hold::Timed => d.clip_age > HOLD_SECS,+ Hold::Infinite => false,+ };+ if clip_expired {+ d.clip = false;+ }+ }+ let gr = if r.gr.is_finite() { r.gr } else { 0.0 };+ d.gr = if gr > d.gr { gr } else { d.gr * prefs.release.factor(dt) };+}++/// Clear a meter's peak hold, clip light and highest-peak readout (the bar keeps falling; the+/// readout starts again from the next reading).+pub fn reset(d: &mut MeterDisplay) {+ d.hold = d.level;+ d.hold_age = [0.0; 2];+ d.clip = false;+ d.clip_age = 0.0;+ d.max_peak = 0.0;+}++/// A linear peak as the readout shows it: dBFS with one decimal, or "-inf".+pub fn peak_text(v: f32) -> String {+ if !v.is_finite() || v <= SILENCE {+ return "-inf".into();+ }+ format!("{:.1}", 20.0 * v.log10())+}++/// `ui.meters`: read or set the meter preferences. `{peak_hold?, clip_hold?: none|timed|infinite,+/// release?: fast|medium|slow}`; nothing changes unless every given value is valid.+pub fn prefs_command(app: &mut SoundApp, p: &Value) -> Result<Value, String> {+ let mut next = app.ui.meters;+ for (key, slot) in [("peak_hold", &mut next.peak_hold), ("clip_hold", &mut next.clip_hold)] {+ if let Some(v) = p.get(key) {+ *slot = v.as_str().and_then(Hold::parse).ok_or_else(|| format!("`{key}` must be none, timed or infinite"))?;+ }+ }+ if let Some(v) = p.get("release") {+ next.release = v.as_str().and_then(Release::parse).ok_or("`release` must be fast, medium or slow")?;+ }+ app.ui.meters = next;+ Ok(prefs_json(&next))+}++fn prefs_json(m: &MeterPrefs) -> Value {+ json!({+ "peak_hold": m.peak_hold.id(),+ "clip_hold": m.clip_hold.id(),+ "release": m.release.id(),+ "release_db_per_s": m.release.db_per_s(),+ "hold_seconds": HOLD_SECS,+ })+}++/// `ui.meters.reset`: clear peak holds, clip lights and peak readouts. `{track?: id | name}` or+/// `{main: true}` (the output meters); with neither, every meter. `{track: "main"}` also means the+/// output meters unless a track is called that.+pub fn reset_command(app: &mut SoundApp, p: &Value) -> Result<Value, String> {+ let session = app.engine.session();+ let by_name = |name: &str| session.track_by_name(name).map(|t| MeterTarget::Track(t.id));+ let target = match p.get("track") {+ _ if p.get("main").and_then(Value::as_bool) == Some(true) => Some(MeterTarget::Main),+ None | Some(Value::Null) => None,+ Some(Value::String(name)) => Some(match by_name(name) {+ Some(t) => t,+ None if name == "main" => MeterTarget::Main,+ None => return Err(format!("no track named `{name}`")),+ }),+ Some(v) => {+ let id = TrackId(v.as_u64().ok_or("`track` must be a track id or name")?);+ if session.track(id).is_none() {+ return Err(format!("no track {}", id.0));+ }+ Some(MeterTarget::Track(id))+ }+ };+ match target {+ None => app.meters.values_mut().chain([&mut app.main_meter]).for_each(reset),+ Some(MeterTarget::Main) => reset(&mut app.main_meter),+ Some(MeterTarget::Track(id)) => reset(app.meters.entry(id).or_default()),+ }+ Ok(json!({"reset": target.map_or_else(|| "all".to_string(), |t| match t {+ MeterTarget::Main => "main".into(),+ MeterTarget::Track(id) => id.0.to_string(),+ })}))+}++/// Make a drawn meter clickable: click resets it, Alt-click resets every meter, right-click opens+/// the meter options.+pub fn interact(app: &mut SoundApp, ui: &mut egui::Ui, r: egui::Rect, target: MeterTarget) {+ let key = match target {+ MeterTarget::Track(id) => ("meter", id.0),+ MeterTarget::Main => ("meter", u64::MAX),+ };+ let resp = ui.interact(r, ui.id().with(key), egui::Sense::click());+ if resp.clicked() {+ let all = ui.input(|i| i.modifiers.alt);+ let params = match target {+ _ if all => json!({}),+ MeterTarget::Main => json!({"main": true}),+ MeterTarget::Track(id) => json!({"track": id.0}),+ };+ let _ = app.run("ui.meters.reset", params);+ }+ let kind = Scale::of_session(app.engine.session()).kind;+ let fader = if app.engine.session().edit.pre_fader_metering { "pre-fader" } else { "post-fader" };+ let resp = resp.on_hover_text(format!(+ "{} meter, {fader}\nClick: reset peak hold and clip · Alt-click: reset all meters · right-click: meter type and options",+ kind.label()+ ));+ resp.context_menu(|ui| options_menu(app, ui));+}++/// The meter options (right-click a meter).+pub fn options_menu(app: &mut SoundApp, ui: &mut egui::Ui) {+ let kind = Scale::of_session(app.engine.session()).kind;+ ui.menu_button("Meter Type", |ui| {+ for k in MeterKind::ALL {+ if ui.radio(kind == k, k.label()).clicked() {+ let _ = app.run("options.meter_type", json!({"type": k.id()}));+ ui.close();+ }+ }+ ui.separator();+ if ui.button(format!("Next ({})", kind.next().label())).clicked() {+ let _ = app.run("options.meter_type", json!({}));+ ui.close();+ }+ });+ let mut pre = app.engine.session().edit.pre_fader_metering;+ if ui.checkbox(&mut pre, "Pre-Fader Metering").clicked() {+ let _ = app.run("options.pre_fader_metering", json!({"value": pre}));+ ui.close();+ }+ ui.separator();+ let cur = app.ui.meters;+ for (key, title, now) in [("peak_hold", "Peak Hold", cur.peak_hold), ("clip_hold", "Clip Indication", cur.clip_hold)] {+ ui.menu_button(title, |ui| {+ for h in Hold::ALL {+ if ui.radio(now == h, h.label()).clicked() {+ let _ = app.run("ui.meters", json!({ key: h.id() }));+ ui.close();+ }+ }+ });+ }+ ui.menu_button("Release", |ui| {+ for r in Release::ALL {+ if ui.radio(cur.release == r, r.label()).clicked() {+ let _ = app.run("ui.meters", json!({"release": r.id()}));+ ui.close();+ }+ }+ });+ ui.separator();+ if ui.button("Reset All Meters").clicked() {+ let _ = app.run("ui.meters.reset", json!({}));+ ui.close();+ }+}++#[cfg(test)]+mod tests {+ use super::*;+ use crate::Services;++ const FRAME: f32 = 1.0 / 60.0;++ fn pk(v: [f32; 2]) -> Reading {+ Reading { level: v, peak: v, peak_all: v[0].max(v[1]), gr: 0.0, kind: MeterKind::SamplePeak }+ }++ fn run_for_each(d: &mut MeterDisplay, secs: f32, peak: [f32; 2], prefs: &MeterPrefs) {+ let n = (secs / FRAME).round() as usize;+ for _ in 0..n {+ feed(d, &pk(peak), FRAME, prefs);+ }+ }++ fn run_for(d: &mut MeterDisplay, secs: f32, peak: f32, prefs: &MeterPrefs) {+ let n = (secs / FRAME).round() as usize;+ for _ in 0..n {+ feed(d, &pk([peak; 2]), FRAME, prefs);+ }+ }++ #[test]+ fn timed_hold_lasts_hold_secs_and_infinite_until_reset() {+ let timed = MeterPrefs::default();+ let mut d = MeterDisplay::default();+ feed(&mut d, &pk([0.5; 2]), FRAME, &timed);+ run_for(&mut d, HOLD_SECS - 0.1, 0.0, &timed);+ assert_eq!(d.hold[0], 0.5, "still held");+ run_for(&mut d, 0.2, 0.0, &timed);+ assert!(d.hold[0] < 0.5, "released after the hold time");+ let inf = MeterPrefs { peak_hold: Hold::Infinite, ..MeterPrefs::default() };+ let mut d = MeterDisplay::default();+ feed(&mut d, &pk([0.5; 2]), FRAME, &inf);+ run_for(&mut d, 60.0, 0.0, &inf);+ assert_eq!(d.hold[0], 0.5);+ assert_eq!(d.level[0], 0.0, "the bar fell back");+ reset(&mut d);+ assert_eq!(d.hold[0], 0.0);+ let none = MeterPrefs { peak_hold: Hold::None, ..MeterPrefs::default() };+ feed(&mut d, &pk([0.5; 2]), FRAME, &none);+ run_for(&mut d, 0.5, 0.0, &none);+ assert_eq!(d.hold, d.level);+ }++ #[test]+ fn a_silent_side_does_not_keep_the_other_sides_hold() {+ // Hard-panned mono on a stereo meter: the right side reads nothing at all.+ let prefs = MeterPrefs::default();+ let mut d = MeterDisplay::default();+ feed(&mut d, &pk([0.5, 0.0]), FRAME, &prefs);+ run_for_each(&mut d, HOLD_SECS + 0.2, [0.01, 0.0], &prefs);+ assert!(d.hold[0] < 0.5, "the left hold released: {d:?}");+ }++ #[test]+ fn clip_light_follows_clip_indication() {+ for (hold, lit_after_10s) in [(Hold::Infinite, true), (Hold::Timed, false), (Hold::None, false)] {+ let prefs = MeterPrefs { clip_hold: hold, ..MeterPrefs::default() };+ let mut d = MeterDisplay::default();+ feed(&mut d, &pk([1.2, 0.1]), FRAME, &prefs);+ assert!(d.clip);+ feed(&mut d, &pk([0.1; 2]), FRAME, &prefs);+ assert_eq!(d.clip, hold != Hold::None, "{hold:?} right after");+ run_for(&mut d, 10.0, 0.1, &prefs);+ assert_eq!(d.clip, lit_after_10s, "{hold:?}");+ }+ }++ #[test]+ fn release_rates_fall_by_their_db_per_second() {+ for r in Release::ALL {+ let prefs = MeterPrefs { release: r, ..MeterPrefs::default() };+ let mut d = MeterDisplay::default();+ feed(&mut d, &pk([1.0; 2]), FRAME, &prefs);+ run_for(&mut d, 1.0, 0.0, &prefs);+ let fell = -20.0 * d.level[0].log10();+ assert!((fell - r.db_per_s()).abs() < 0.5, "{r:?}: {fell}");+ }+ }++ #[test]+ fn hostile_readings_do_not_stick() {+ let mut d = MeterDisplay::default();+ let bad = [f32::NAN, f32::INFINITY];+ feed(+ &mut d,+ &Reading { level: bad, peak: bad, peak_all: f32::NAN, gr: f32::NAN, kind: MeterKind::TruePeak },+ f32::NAN,+ &MeterPrefs::default(),+ );+ assert!(d.level.iter().chain(&d.hold).all(|x| x.is_finite()) && d.gr.is_finite() && d.max_peak.is_finite());+ }++ #[test]+ fn a_clip_on_any_channel_lights_the_clip_and_the_readout() {+ // A 5.1 strip whose bars (L, R) are quiet while the LFE clips.+ let mut d = MeterDisplay::default();+ feed(&mut d, &Reading { level: [0.1; 2], peak: [0.1; 2], peak_all: 1.2, ..Reading::default() }, FRAME, &MeterPrefs::default());+ assert!(d.clip && d.max_peak == 1.2 && d.level == [0.1; 2], "{d:?}");+ }++ #[test]+ fn clear_all_clip_indicators_leaves_holds_and_readouts() {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ let kick = app.engine.session().track_by_name("Kick").unwrap().id;+ let d = app.meters.entry(kick).or_default();+ (d.clip, d.hold, d.max_peak) = (true, [0.9; 2], 1.1);+ app.run("track.clear_clip_indicators", json!({})).unwrap();+ let d = app.meters[&kick];+ assert!(!d.clip && d.hold == [0.9; 2] && d.max_peak == 1.1, "{d:?}");+ }++ #[test]+ fn defaults_latch_clips_and_hold_peaks_for_three_seconds() {+ let prefs = MeterPrefs::default();+ assert_eq!((prefs.peak_hold, prefs.clip_hold), (Hold::Timed, Hold::Infinite));+ let ui: crate::UiState = serde_json::from_value(json!({"meters": {"release": "slow"}})).unwrap();+ assert_eq!(ui.meters.clip_hold, Hold::Infinite, "missing fields take the defaults");+ }++ #[test]+ fn averaging_meters_show_their_reading_and_clip_on_sample_peaks() {+ let prefs = MeterPrefs::default();+ let mut d = MeterDisplay::default();+ let rms = |level: f32, peak: f32| Reading { level: [level; 2], peak: [peak; 2], peak_all: peak, gr: 0.0, kind: MeterKind::Rms };+ feed(&mut d, &rms(0.5, 1.0), FRAME, &prefs);+ assert!(d.clip && d.max_peak == 1.0 && d.level[0] == 0.5);+ feed(&mut d, &rms(0.2, 0.3), FRAME, &prefs);+ assert_eq!(d.level[0], 0.2, "no display release on top of the RMS ballistics");+ // True peak: an inter-sample over lights the clip light though no sample reached 0 dBFS.+ let mut d = MeterDisplay::default();+ feed(&mut d, &Reading { level: [1.1; 2], peak: [0.9; 2], peak_all: 0.9, gr: 0.0, kind: MeterKind::TruePeak }, FRAME, &prefs);+ assert!(d.clip);+ }++ #[test]+ fn scales_place_their_zero_and_marks() {+ let peak = Scale::of(MeterKind::SamplePeak);+ assert_eq!((peak.db_pos(0.0), peak.db_pos(-80.0), peak.db_pos(-20.0)), (1.0, 0.0, 0.3));+ let k14 = Scale::of(MeterKind::K14);+ assert_eq!(k14.db_pos(0.0), 1.0, "full scale is the top of a K meter");+ assert!((k14.db_pos(-14.0) - k14.mark_pos(0.0)).abs() < 1e-6 && k14.mark_pos(0.0) > 0.7);+ let vu = Scale::of(MeterKind::Vu);+ assert_eq!(vu.db_pos(soundcraft_dsp::meter::VU_REFERENCE_DBFS + 3.0), 1.0);+ assert!((vu.db_pos(soundcraft_dsp::meter::VU_REFERENCE_DBFS) - 20.0 / 23.0).abs() < 1e-5);+ for k in MeterKind::ALL {+ let s = Scale::of(k);+ assert!(s.marks.windows(2).all(|w| w[0] > w[1]), "{k:?} marks run top down");+ assert!(s.marks.iter().all(|m| (0.0..=1.0).contains(&s.mark_pos(*m))));+ assert_eq!(s.level_pos(f32::NAN), 0.0);+ }+ assert_eq!((Scale::mark_label(-10.0), Scale::mark_label(4.0), Scale::mark_label(0.0)), ("10".into(), "+4".into(), "0".into()));+ }++ #[test]+ fn peak_readout_keeps_the_highest_peak_until_reset() {+ let prefs = MeterPrefs::default();+ let mut d = MeterDisplay::default();+ feed(&mut d, &pk([0.5, 0.25]), FRAME, &prefs);+ run_for(&mut d, 10.0, 0.01, &prefs);+ assert_eq!(peak_text(d.max_peak), "-6.0");+ reset(&mut d);+ assert_eq!(peak_text(d.max_peak), "-inf", "cleared");+ feed(&mut d, &pk([0.01; 2]), FRAME, &prefs);+ assert_eq!(peak_text(d.max_peak), "-40.0", "and counting again");+ assert_eq!(peak_text(0.0), "-inf");+ }++ #[test]+ fn a_reading_of_another_meter_type_does_not_reach_the_scale() {+ let sample = soundcraft_mix::StripMeter { level: [0.5; 2], peak: [0.5; 2], peak_all: 0.5, ..Default::default() };+ let r = Reading::of(&sample, MeterKind::Vu);+ assert_eq!((r.level, r.peak_all), ([0.0; 2], 0.5));+ let mut d = MeterDisplay::default();+ feed(&mut d, &r, FRAME, &MeterPrefs::default());+ assert_eq!((d.level, d.hold), ([0.0; 2], [0.0; 2]), "no +12 VU flash latched in the hold");+ let vu = soundcraft_mix::StripMeter { kind: MeterKind::Vu, ..sample };+ assert_eq!(Reading::of(&vu, MeterKind::Vu).level, [0.5; 2]);+ }++ #[test]+ fn changing_the_meter_type_resets_every_meter() {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ let kick = app.engine.session().track_by_name("Kick").unwrap().id;+ let d = app.meters.entry(kick).or_default();+ (d.clip, d.hold, d.max_peak) = (true, [0.9; 2], 1.1);+ app.main_meter.max_peak = 0.7;+ assert_eq!(app.run("options.meter_type", json!({"type": "vu"})).unwrap()["type"], "vu");+ assert!(!app.meters[&kick].clip && app.meters[&kick].max_peak == 0.0 && app.main_meter.max_peak == 0.0);+ assert_eq!(Scale::of_session(app.engine.session()).kind, MeterKind::Vu);+ }++ #[test]+ fn commands_set_prefs_and_reset_meters() {+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+ let r = app.run("ui.meters", json!({"peak_hold": "infinite", "release": "slow"})).unwrap();+ assert_eq!((r["peak_hold"].as_str(), r["clip_hold"].as_str(), r["release"].as_str()), (Some("infinite"), Some("infinite"), Some("slow")));+ assert!(app.run("ui.meters", json!({"clip_hold": "forever", "peak_hold": "none"})).is_err());+ assert_eq!(app.ui.meters.peak_hold, Hold::Infinite, "a refused call changes nothing");+ let kick = app.engine.session().track_by_name("Kick").unwrap().id;+ let snare = app.engine.session().track_by_name("Snare").unwrap().id;+ for id in [kick, snare] {+ let d = app.meters.entry(id).or_default();+ (d.clip, d.hold, d.max_peak) = (true, [0.9; 2], 1.1);+ }+ app.main_meter.clip = true;+ app.run("ui.meters.reset", json!({"track": kick.0})).unwrap();+ assert!(!app.meters[&kick].clip && app.meters[&kick].hold == [0.0; 2] && app.meters[&kick].max_peak == 0.0);+ assert!(app.meters[&snare].clip && app.main_meter.clip, "only that meter");+ app.run("ui.meters.reset", json!({"track": "main"})).unwrap();+ app.meters.entry(kick).or_default().clip = true;+ app.run("ui.meters.reset", json!({"track": "Kick"})).unwrap();+ assert!(!app.meters[&kick].clip, "by name");+ assert!(app.run("ui.meters.reset", json!({"track": "Nobody"})).is_err());+ app.main_meter.clip = true;+ app.run("ui.meters.reset", json!({"main": true})).unwrap();+ assert!(!app.main_meter.clip);+ // A track called "main" is reachable by name; {"main": true} still means the output.+ let main_track = app.engine.session().track_by_name("Bass").unwrap().id;+ app.engine.execute("track.rename", &json!({"track": main_track.0, "name": "main"})).unwrap();+ (app.meters.entry(main_track).or_default().clip, app.main_meter.clip) = (true, true);+ app.run("ui.meters.reset", json!({"track": "main"})).unwrap();+ assert!(!app.meters[&main_track].clip && app.main_meter.clip);+ assert!(app.meters[&snare].clip, "the others stay lit");+ assert!(app.run("ui.meters.reset", json!({"track": 999_999})).is_err());+ app.run("ui.meters.reset", json!({})).unwrap();+ assert!(!app.meters[&snare].clip);+ // Saved with the UI state, and older prefs without it get the defaults.+ let ui: crate::UiState = serde_json::from_value(json!({})).unwrap();+ assert_eq!(ui.meters, MeterPrefs::default());+ let back: crate::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap();+ assert_eq!(back.meters, app.ui.meters);+ }+}@@ -89,17 +89,19 @@ fn panner_speakers(fmt: ChannelFormat) -> Vec<PannerSpeaker> { .collect() } -/// Ballistic per-channel meter levels for multichannel strips, kept in egui memory.-fn channel_levels(ui: &Ui, key: (&str, u64), peaks: &[f32], n: usize) -> Vec<f32> {+/// Ballistic per-channel meter levels for multichannel strips, kept in egui memory (same release+/// as the other meters).+fn channel_levels(ui: &Ui, key: (&str, u64), peaks: &[f32], n: usize, release: crate::meters::Release, peak_type: bool) -> Vec<f32> { let id = egui::Id::new(key); let dt = ui.input(|i| i.stable_dt).clamp(0.0, 0.25);- let fall = 10f32.powf(-26.0 * dt / 20.0);+ // Averaging meter types carry their own ballistics from the audio thread.+ let fall = if peak_type { release.factor(dt) } else { 0.0 }; let mut lv: Vec<f32> = ui.data(|d| d.get_temp(id)).unwrap_or_default(); lv.resize(n, 0.0); for (i, l) in lv.iter_mut().enumerate() { let p = peaks.get(i).copied().filter(|x| x.is_finite()).unwrap_or(0.0); *l = if p >= *l { p } else { (*l * fall).max(p) };- if *l < 1e-5 {+ if *l < crate::meters::SILENCE { *l = 0.0; } }@@ -427,30 +429,44 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let md = app.meters.get(&id).copied().unwrap_or_default(); let slot = fader_slot(fr); let mr = Rect::from_min_max(pos2(fr.max.x + 4.0, slot.min.y - 5.0), pos2(x0 + inner_w - 2.0, slot.max.y + if wide_meter { 17.0 } else { 1.0 }));+ let scale = crate::meters::Scale::of_session(app.engine.session()); if !narrow && !wide_meter {- meter_scale(ui.painter(), mr, mr.min.x - 1.0);+ meter_scale(ui.painter(), mr, mr.min.x - 1.0, &scale); } let strip_fmt = if track.kind == TrackKind::Master { app.engine.session().main_format() } else { track.format }; let all_chans = strip_fmt.channels().min(soundcraft_mix::MAX_CHANNELS); let chans = if track.kind == TrackKind::Master { 2 } else { track.channels().min(2) }; if all_chans > 2 { // One bar per channel, labelled (L R C LFE …).- let peaks = snap.and_then(|s| s.tracks.get(&id)).map(|m| m.peaks.as_slice()).unwrap_or(&[]);- let lv = channel_levels(ui, ("mc_meter", id.0), peaks, all_chans);+ let levels = snap.and_then(|s| s.tracks.get(&id)).filter(|m| m.kind == scale.kind).map(|m| m.levels.as_slice()).unwrap_or(&[]);+ let lv = channel_levels(ui, ("mc_meter", id.0), levels, all_chans, app.ui.meters.release, scale.kind.is_peak()); let labels: Vec<String> = (0..all_chans).map(|c| strip_fmt.channel_label(c)).collect();- multi_meter(ui, mr, &lv, &labels, md.clip);+ multi_meter(ui, mr, &lv, &labels, md.clip, &scale); } else if chans >= 2 { let mw = mr.width() / 2.0 - 1.0;- meter(ui, Rect::from_min_size(mr.min, vec2(mw, mr.height())), md.level[0], md.hold[0], md.clip);- meter(ui, Rect::from_min_size(pos2(mr.min.x + mw + 2.0, mr.min.y), vec2(mw, mr.height())), md.level[1], md.hold[1], md.clip);+ meter(ui, Rect::from_min_size(mr.min, vec2(mw, mr.height())), md.level[0], md.hold[0], md.clip, &scale);+ meter(ui, Rect::from_min_size(pos2(mr.min.x + mw + 2.0, mr.min.y), vec2(mw, mr.height())), md.level[1], md.hold[1], md.clip, &scale); } else {- meter(ui, mr, md.level[0], md.hold[0], md.clip);+ meter(ui, mr, md.level[0], md.hold[0], md.clip, &scale); }+ crate::meters::interact(app, ui, mr, crate::meters::MeterTarget::Track(id)); if md.gr > 0.1 { ui.painter().text(pos2(mr.center().x, mr.min.y - 3.0), Align2::CENTER_BOTTOM, format!("-{:.0}", md.gr), regular(8.0), t.meter_yellow); } // Volume readout and name.- let vr = Rect::from_min_size(pos2(x0 + 4.0, r.max.y - bottom_h + 4.0), vec2(inner_w - 8.0, 15.0));+ let readout = Rect::from_min_size(pos2(x0 + 4.0, r.max.y - bottom_h + 4.0), vec2(inner_w - 8.0, 15.0));+ let mut vr = readout;+ if !narrow && !matches!(track.kind, TrackKind::Vca | TrackKind::Folder) {+ // The highest peak since the meter was reset, beside the volume; click resets it.+ let (vol, pk) = (vr.with_max_x(vr.center().x - 1.0), vr.with_min_x(vr.center().x + 1.0));+ vr = vol;+ inset(ui.painter(), pk, t.counter_bg);+ let color = if md.max_peak >= crate::meters::CLIP_LEVEL { t.meter_red } else { t.counter_text };+ ui.painter().with_clip_rect(pk).text(pk.center(), Align2::CENTER_CENTER, crate::meters::peak_text(md.max_peak), regular(9.0), color);+ if ui.interact(pk, ui.id().with(("peak-value", id.0)), Sense::click()).on_hover_text("Highest peak (dBFS) · click to reset").clicked() {+ let _ = app.run("ui.meters.reset", json!({"track": id.0}));+ }+ } inset(ui.painter(), vr, t.counter_bg); let (_, entered) = editable_readout( ui,@@ -474,7 +490,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh TrackKind::Folder => "folder", TrackKind::Video => "video", };- ui.painter().text(pos2(vr.center().x, vr.max.y + 9.0), Align2::CENTER_CENTER, kind, regular(9.5), t.text_dim);+ ui.painter().text(pos2(readout.center().x, readout.max.y + 9.0), Align2::CENTER_CENTER, kind, regular(9.5), t.text_dim); let nr = Rect::from_min_size(pos2(x0, r.max.y - 26.0), vec2(inner_w, 18.0)); ui.painter().rect( nr,@@ -353,8 +353,10 @@ fn transport_window(app: &mut SoundApp, ctx: &egui::Context) { if flags.contains("view.transport.output_meters") { let (r, _) = ui.allocate_exact_size(vec2(22.0, 26.0), Sense::hover()); let m = app.main_meter;- crate::widgets::meter(ui, Rect::from_min_size(r.min, vec2(10.0, 26.0)), m.level[0], m.hold[0], m.clip);- crate::widgets::meter(ui, Rect::from_min_size(pos2(r.min.x + 12.0, r.min.y), vec2(10.0, 26.0)), m.level[1], m.hold[1], m.clip);+ let sc = crate::meters::Scale::of_session(app.engine.session());+ crate::widgets::meter(ui, Rect::from_min_size(r.min, vec2(10.0, 26.0)), m.level[0], m.hold[0], m.clip, &sc);+ crate::widgets::meter(ui, Rect::from_min_size(pos2(r.min.x + 12.0, r.min.y), vec2(10.0, 26.0)), m.level[1], m.hold[1], m.clip, &sc);+ crate::meters::interact(app, ui, r, crate::meters::MeterTarget::Main); } }); let s = app.engine.session();@@ -444,9 +444,11 @@ fn tempo_value(app: &mut SoundApp, ui: &mut Ui, right: f32, y: f32, bpm: f64) { fn output_meter(app: &mut SoundApp, ui: &mut Ui) { let (r, _) = ui.allocate_exact_size(vec2(26.0, 64.0), Sense::hover()); let m = app.main_meter;+ let sc = crate::meters::Scale::of_session(app.engine.session()); let w = (r.width() - 6.0) / 2.0; for c in 0..2 { let rr = Rect::from_min_size(pos2(r.min.x + 2.0 + c as f32 * (w + 2.0), r.min.y), vec2(w, r.height()));- crate::widgets::meter(ui, rr, m.level[c], m.hold[c], m.clip);+ crate::widgets::meter(ui, rr, m.level[c], m.hold[c], m.clip, &sc); }+ crate::meters::interact(app, ui, r, crate::meters::MeterTarget::Main); }@@ -3,6 +3,7 @@ use craft_ui::buttons::{ButtonVisuals, IconButton, IconButtonStyle}; use crate::icons;+use crate::meters::Scale; use crate::theme::{Tokens, bold, regular}; use egui::{Align2, Color32, CornerRadius, Mesh, Painter, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; @@ -217,38 +218,37 @@ pub fn pan_text(v: f32) -> String { } } -/// The dB marks of a meter's scale, also ruled across its bar.-const METER_MARKS: [f32; 8] = [0.0, -5.0, -10.0, -15.0, -20.0, -30.0, -40.0, -60.0];- /// The bar of a [`meter`] drawn in `r`, below its clip light. fn meter_bar(r: Rect) -> Rect { Rect::from_min_max(pos2(r.min.x + 1.0, r.min.y + 5.0), pos2(r.max.x - 1.0, r.max.y - 1.0)) } -/// The marks that fit on `bar`, at least 9 pt apart from the top down, with their heights.-fn meter_marks(bar: Rect) -> impl Iterator<Item = (f32, f32)> {+/// The marks of `scale` that fit on `bar`, at least 9 pt apart from the top down, with their+/// heights.+fn meter_marks(bar: Rect, scale: &Scale) -> impl Iterator<Item = (f32, f32)> + '_ { let mut last = f32::NEG_INFINITY;- METER_MARKS.into_iter().filter_map(move |db| {- let y = bar.max.y - bar.height() * meter_pos(db);+ scale.marks.iter().filter_map(move |&mark| {+ let y = bar.max.y - bar.height() * scale.mark_pos(mark); (y - last >= 9.0).then(|| { last = y;- (db, y)+ (mark, y) }) }) } -/// Vertical peak meter as Pro Tools draws it: a clip light above a bar that lights green, yellow-/// and red over dark tints of those zones, ruled at the scale's marks. Values are linear peaks.-pub fn meter(ui: &Ui, r: Rect, level: f32, hold: f32, clip: bool) {+/// Vertical meter as Pro Tools draws it: a clip light above a bar that lights green, yellow and+/// red over dark tints of those zones, ruled at the scale's marks. Values are linear readings,+/// placed on `scale` (the meter type's).+pub fn meter(ui: &Ui, r: Rect, level: f32, hold: f32, clip: bool, scale: &Scale) { let t = Tokens::current(); let p = ui.painter(); p.rect_filled(Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + 3.0)), 1.0, if clip { t.meter_red } else { t.meter_clip_off }); p.rect_filled(Rect::from_min_max(pos2(r.min.x, r.min.y + 4.0), r.max), 0.0, t.meter_bg); let bar = meter_bar(r); let y = |pos: f32| bar.max.y - bar.height() * pos;- let (amber, red) = (meter_pos(-12.0), meter_pos(-3.0));+ let (amber, red) = scale.zones(); let zones = [(0.0, amber, t.meter_green), (amber, red, t.meter_yellow), (red, 1.0, t.meter_red)];- let lit = level_pos(level);+ let lit = scale.level_pos(level); let unlit = if t.meter_bg.intensity() > 0.5 { 0.08 } else { 0.18 }; let mut mesh = Mesh::default(); for (lo, hi, color) in zones {@@ -257,48 +257,26 @@ pub fn meter(ui: &Ui, r: Rect, level: f32, hold: f32, clip: bool) { quad(&mut mesh, Rect::from_x_y_ranges(bar.x_range(), y(hi)..=y(top)), [color.gamma_multiply(unlit); 4]); } p.add(mesh);- for (_, at) in meter_marks(bar).skip(1) {+ for (_, at) in meter_marks(bar, scale).skip(1) { p.hline(bar.x_range(), p.round_to_pixel_center(at), Stroke::new(1.0, Color32::from_black_alpha(90))); }- let held = level_pos(hold);- if held > 0.01 {+ // The hold line marks a peak above the bar (none when holds are off and it follows the bar).+ let held = scale.level_pos(hold);+ if held > 0.01 && held > lit + 0.002 { let color = zones.iter().find(|z| held <= z.1).map_or(t.meter_red, |z| z.2); p.rect_filled(Rect::from_x_y_ranges(bar.x_range(), y(held) - 1.0..=y(held) + 1.0), 0.0, color); } } -/// The dB scale of a [`meter`] drawn in `r`, its numbers ending at `x`.-pub fn meter_scale(p: &Painter, r: Rect, x: f32) {+/// The scale of a [`meter`] drawn in `r`, its numbers ending at `x`.+pub fn meter_scale(p: &Painter, r: Rect, x: f32, scale: &Scale) { let t = Tokens::current();- for (db, y) in meter_marks(meter_bar(r)) {- p.text(pos2(x - 3.0, y), Align2::RIGHT_CENTER, format!("{}", db.abs()), regular(8.0), t.text_dim);+ for (mark, y) in meter_marks(meter_bar(r), scale) {+ p.text(pos2(x - 3.0, y), Align2::RIGHT_CENTER, Scale::mark_label(mark), regular(8.0), t.text_dim); p.hline(x - 2.0..=x, y, Stroke::new(1.0, t.text_dim.gamma_multiply(0.7))); } } -/// A linear peak → 0..1 meter position.-pub fn level_pos(v: f32) -> f32 {- meter_pos(if v <= 1e-6 { -80.0 } else { 20.0 * v.log10() })-}--/// dB → 0..1 meter position (logarithmic-ish scale like hardware meters).-pub fn meter_pos(db: f32) -> f32 {- if !db.is_finite() || db <= -80.0 {- return 0.0;- }- if db >= 0.0 {- return 1.0;- }- // -80..-40: bottom 10 %, -40..-20: 20 %, -20..0: 70 %.- if db < -40.0 {- (db + 80.0) / 40.0 * 0.1- } else if db < -20.0 {- 0.1 + (db + 40.0) / 20.0 * 0.2- } else {- 0.3 + (db + 20.0) / 20.0 * 0.7- }-}- /// Clip gain fader taper: the top three quarters span -36..+36 dB evenly, the rest compresses to -144. pub fn clip_gain_from_pos(pos: f32) -> f32 { let p = if pos.is_finite() { pos.clamp(0.0, 1.0) } else { 0.0 };@@ -478,7 +456,7 @@ pub fn surround_panner(ui: &mut Ui, size: f32, speakers: &[PannerSpeaker], puck: } /// A bank of thin vertical peak meters, one per channel, labelled underneath (rotated text).-pub fn multi_meter(ui: &Ui, r: Rect, levels: &[f32], labels: &[String], clip: bool) {+pub fn multi_meter(ui: &Ui, r: Rect, levels: &[f32], labels: &[String], clip: bool, scale: &Scale) { let n = levels.len().max(1); let label_h = 16.0; let mr = Rect::from_min_max(r.min, pos2(r.max.x, r.max.y - label_h));@@ -487,7 +465,7 @@ pub fn multi_meter(ui: &Ui, r: Rect, levels: &[f32], labels: &[String], clip: bo for (i, lv) in levels.iter().enumerate() { let x = mr.min.x + i as f32 * (w + gap); let br = Rect::from_min_size(pos2(x, mr.min.y), vec2(w, mr.height()));- meter(ui, br, *lv, *lv, clip);+ meter(ui, br, *lv, *lv, clip, scale); if let Some(l) = labels.get(i) { let galley = ui.painter().layout_no_wrap(l.clone(), regular(7.0), Tokens::current().text_dim); let gw = galley.size().x;@@ -89,6 +89,28 @@ a portal answer it uses Dark. Manual Light and Dark stay fixed when the OS appea An offscreen render without `--system-theme` supplies no OS appearance and therefore resolves System to Dark, or on Linux to the desktop's portal setting when one is available. +Meters (UI state, saved per machine): `ui.meters {"peak_hold":"none|timed|infinite",+"clip_hold":"none|timed|infinite","release":"fast|medium|slow"}` sets any of the options and+returns them all (`{}` just reads them; "timed" is 3 seconds). Peak hold defaults to 3 seconds;+clip lights latch (infinite) until cleared. `ui.meters.reset {"track": id or name}` clears that+meter's peak hold, clip light and peak readout (`{"main": true}` for the output meters, nothing+for every meter; `{"track": "main"}` also works unless a track has that name), the same as clicking a meter (Alt-click: all); Track › Clear All Clip Indicators+(`track.clear_clip_indicators`, Alt+C) clears only the clip lights. Right-click a meter for the+meter type and the options. The meters show the highest reading of every audio block since the+UI last polled them (60 times a second while the Mix window is in front, 20 otherwise); a+surround strip's clip light and peak readout watch all its channels.+`options.pre_fader_metering` meters tracks after their inserts and before fader and VCA, in+step with delay compensation (master faders always meter what they send out). The Mute button+leaves pre-fader meters reading; mute automation silences them, as it does pre-fader sends.+Aux inputs' pre-fader tap is not delay-compensated (as for their pre-fader sends), so with plugin+latency an aux's pre-fader meter can run slightly ahead. Turning the option on sizes every+strip's pre-fader buffer and delay line in the audio callback that receives the change (a+document edit, like adding tracks), not in steady state.+`options.meter_type {"type":"sample_peak|true_peak|rms|vu|k12|k14|k20"}` (saved with the+session; without `type`, the next one) picks what the track and main meters measure, and+resets every meter's holds, clip lights and readouts. It has no menu item (Pro Tools picks the+type on the meter); offline renders (bounces, stems) don't meter at all.+ `view.scroll_into_view {"track":"Bass"}` (a track id or name; without it, the first selected track) brings the track to the top of the Edit window and the left of the Mix window, the same as Scroll Into View in the track name menu. A window not on screen catches up when it is next shown.@@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** minor (Scroll Into View in the track name menu) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (meter types) · **Target:** Avid Pro Tools Ultimate 2026.4.1 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command@@ -60,7 +60,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. |---|---|---| | Dark theme close to Pro Tools' | ✅ | Light theme (#32) and live system appearance (#85) beyond Pro Tools | | Interface scaling | 🟡 | Slider in PR #147 |-| Meter types and ballistics (peak, RMS, VU, K-scales, gain reduction) | 🟡 | Peak/RMS; meter type choice and GR meters partial |+| Meter types and ballistics (peak, RMS, VU, K-scales, gain reduction) | 🟡 | Meter types Sample Peak, True Peak (4× oversampled, dBTP), RMS, VU and K-12/14/20, each with its own scale (`options.meter_type`, right-click a meter to pick; not metered in offline renders); Options › Pre-Fader Metering meters tracks after the inserts, before the fader. Every block's reading is kept between UI polls; peak hold (default 3 s) and clip indication (default Infinite, latching as before) None / 3 s / Infinite, three release rates (`ui.meters`); click a meter to reset it (Alt-click: all); highest-peak readout beside the volume; clip lights watch every channel. Missing: Pro Tools' other types (Linear, BBC, Nordic, DIN, VENUE…), separate track and master meter types, gain reduction meter types | | Clip indicators, track colours, waveform views (peak/power/rectified/outlines) | ✅ | | | Fader resolution, fine adjust, numeric entry | 🟡 | | | macOS system menu bar | ✅ | #10 |@@ -69,6 +69,9 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | Meter types: true peak, RMS, VU and K-12/14/20 with their scales (right-click a meter). Readiness estimate unchanged |+| 2026-10-24 | minor | Meters: peak hold and clip indication options (None / 3 s / Infinite), release rates, click-to-reset, highest-peak readout. Readiness estimate unchanged |+| 2026-10-24 | minor | Options › Pre-Fader Metering now meters tracks before their faders (the option was stored but did nothing). Readiness estimate unchanged | | 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged | | 2026-10-13 | minor | A plugin opens when it's inserted (#486) | | 2026-10-13 | minor | Drag across checkboxes (#399) |