Browse / github.com/storytold/soundcraft / patch
Meters: peak hold and clip indication options, click to reset, peak readout
Meters: peak hold and clip indication options, click to reset, peak readout **Depends on #1045**; only the last commit is new here. Draft until #1045 lands. The meters get Pro Tools-style display preferences and a peak readout. - `ui.meters` (saved per machine, also on a meter's right-click menu): peak hold and clip indication each None / 3 s / Infinite, and a release rate (the existing 26 dB/s, or the IEC 60268-10 Type I and Type II PPM decay rates). Defaults: clip lights latch until cleared (as today); peak hold 3 s (was 2 s). - Clicking a meter resets its peak hold, clip light and readout (`ui.meters.reset`, by track id or name); Alt-click resets all. Track › Cle
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- forwardaudio (imported from pull request #1047, open)
- Base
37e155c8287b8ffbdcc1cc7de35842edf9aaee52- Size
- +638 −64 in 11 files
- Review
- pending
- Shared
- 2026-10-28
- Id
0f4c48e0c7c10bc02f29dbd4
How it went
No other operator has reported using it yet.
Diff
raw · crates/mix/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
@@ -157,6 +157,9 @@ pub struct StripMeter { pub peak: [f32; 2], /// Every channel of the strip (or of the main mix). pub peaks: Vec<f32>,+ /// The highest of `peaks`: 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, /// Gain reduction of the first dynamics insert, dB. pub gain_reduction_db: f32, pub plugin_failures: PluginFailures,@@ -184,6 +187,7 @@ 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.gain_reduction_db = o.gain_reduction_db; self.plugin_failures = o.plugin_failures; }@@ -203,6 +207,7 @@ impl StripMeter { for (p, q) in self.peak.iter_mut().zip(o.peak) { *p = max_level(*p, q); }+ 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,6 +217,7 @@ 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.gain_reduction_db = 0.0; } @@ -224,6 +230,7 @@ impl StripMeter { } 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.peak_all = self.peaks.iter().copied().fold(0.0, f32::max); self.gain_reduction_db = gr; } }@@ -2591,6 +2598,29 @@ mod tests { t } + #[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);@@ -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::interact(app, ui, mr, crate::meters::MeterTarget::Track(id)); } /// A small menu arrow, pointing down, or right for a closed folder.@@ -1338,7 +1340,7 @@ 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) = (meter_pos(crate::meters::YELLOW_FROM_DB), meter_pos(crate::meters::RED_FROM_DB)); 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;@@ -1350,7 +1352,7 @@ fn header_meter(ui: &Ui, r: Rect, md: &crate::MeterDisplay, stereo: bool) { } } let hv = level_pos(md.hold[ch]);- if hv > 0.01 {+ 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,@@ -1178,7 +1187,7 @@ impl SoundApp { let snap = &self.meter_snapshot; 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), 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 +1195,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), dt, &self.ui.meters); if ended { self.stop_play(); }@@ -1206,10 +1214,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 +1381,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,528 @@+//! Meter ballistics, scale and preferences, in one place.+//!+//! The audio thread hands the UI the highest sample peak (and gain reduction) of every block since+//! the last poll (`soundcraft_playback::Player::take_meters`). This module turns those readings+//! into what the meters show: instant attack, a release in dB per second, a peak hold line, a clip+//! light and the highest peak as a number, each following the meter preferences in+//! [`MeterPrefs`] (`ui.meters`). Clicking a meter resets its holds and clip light+//! (`ui.meters.reset`); Alt-click resets every meter.+//!+//! 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_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 scale: dB 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 scale.+pub const SCALE_FLOOR_DB: f32 = -80.0;+/// The 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 bar turns from green to yellow, and from yellow to red (dBFS).+pub const YELLOW_FROM_DB: f32 = -12.0;+pub const RED_FROM_DB: f32 = -3.0;++/// 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 sample peaks; gain reduction in dB).+#[derive(Debug, Clone, Copy, Default)]+pub struct Reading {+ /// The two sides the bars show (mono strips repeat channel 0).+ 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), for the clip light and the peak readout.+ pub peak_all: f32,+ pub gr: f32,+}++impl Reading {+ pub fn of(m: &soundcraft_mix::StripMeter) -> Reading {+ Reading { peak: m.peak, peak_all: m.peak_all, gr: m.gain_reduction_db }+ }+}++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.+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 = prefs.release.factor(dt);+ let all = r.peak.iter().copied().map(Some).map(sane).fold(sane(Some(r.peak_all)), f32::max);+ d.max_peak = d.max_peak.max(all);+ let clipping = all >= CLIP_LEVEL;+ for c in 0..2 {+ let v = sane(r.peak.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 * fall };+}++/// 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 resp = resp.on_hover_text("Click: reset peak hold and clip · Alt-click: reset all meters · right-click: meter options");+ 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 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 { peak: v, peak_all: v[0].max(v[1]), gr: 0.0 }+ }++ 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();+ feed(&mut d, &Reading { peak: [f32::NAN, f32::INFINITY], peak_all: f32::NAN, gr: f32::NAN }, 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 { peak: [0.1; 2], peak_all: 1.2, gr: 0.0 }, 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 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 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,18 @@ 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) -> 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);+ let fall = release.factor(dt); 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; } }@@ -436,7 +437,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh 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 lv = channel_levels(ui, ("mc_meter", id.0), peaks, all_chans, app.ui.meters.release); let labels: Vec<String> = (0..all_chans).map(|c| strip_fmt.channel_label(c)).collect(); multi_meter(ui, mr, &lv, &labels, md.clip); } else if chans >= 2 {@@ -446,11 +447,24 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } else { meter(ui, mr, md.level[0], md.hold[0], md.clip); }+ 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 +488,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,@@ -355,6 +355,7 @@ fn transport_window(app: &mut SoundApp, ctx: &egui::Context) { 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);+ crate::meters::interact(app, ui, r, crate::meters::MeterTarget::Main); } }); let s = app.engine.session();@@ -449,4 +449,5 @@ fn output_meter(app: &mut SoundApp, ui: &mut Ui) { 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::meters::interact(app, ui, r, crate::meters::MeterTarget::Main); }@@ -217,9 +217,6 @@ 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))@@ -228,7 +225,7 @@ fn meter_bar(r: Rect) -> Rect { /// 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)> { let mut last = f32::NEG_INFINITY;- METER_MARKS.into_iter().filter_map(move |db| {+ crate::meters::SCALE_MARKS.into_iter().filter_map(move |db| { let y = bar.max.y - bar.height() * meter_pos(db); (y - last >= 9.0).then(|| { last = y;@@ -246,7 +243,7 @@ pub fn meter(ui: &Ui, r: Rect, level: f32, hold: f32, clip: bool) { 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) = (meter_pos(crate::meters::YELLOW_FROM_DB), meter_pos(crate::meters::RED_FROM_DB)); 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 unlit = if t.meter_bg.intensity() > 0.5 { 0.08 } else { 0.18 };@@ -260,8 +257,9 @@ pub fn meter(ui: &Ui, r: Rect, level: f32, hold: f32, clip: bool) { for (_, at) in meter_marks(bar).skip(1) { p.hline(bar.x_range(), p.round_to_pixel_center(at), Stroke::new(1.0, Color32::from_black_alpha(90))); }+ // The hold line marks a peak above the bar (none when holds are off and it follows the bar). let held = level_pos(hold);- if held > 0.01 {+ 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); }@@ -278,25 +276,24 @@ pub fn meter_scale(p: &Painter, r: Rect, x: f32) { /// 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() })+ meter_pos(if v.is_nan() || v <= 1e-6 { crate::meters::SCALE_FLOOR_DB } else { 20.0 * v.log10() }) } -/// dB → 0..1 meter position (logarithmic-ish scale like hardware meters).+/// dB → 0..1 meter position: piecewise linear in dB between [`crate::meters::SCALE_KNEES`], from+/// [`crate::meters::SCALE_FLOOR_DB`] (0) to 0 dB (1), like hardware meters. pub fn meter_pos(db: f32) -> f32 {- if !db.is_finite() || db <= -80.0 {+ use crate::meters::{SCALE_FLOOR_DB, SCALE_KNEES};+ if db.is_nan() || db <= SCALE_FLOOR_DB { 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+ let (mut lo_db, mut lo_pos) = (SCALE_FLOOR_DB, 0.0);+ for (hi_db, hi_pos) in SCALE_KNEES {+ if db < hi_db {+ return lo_pos + (db - lo_db) / (hi_db - lo_db) * (hi_pos - lo_pos);+ }+ (lo_db, lo_pos) = (hi_db, hi_pos); }+ 1.0 } /// Clip gain fader taper: the top three quarters span -36..+36 dB evenly, the rest compresses to -144.@@ -89,6 +89,17 @@ 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+options. The meters show the highest peak 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.+ `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 peak hold, clip indication, release and peak readout) · **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) | 🟡 | Sample peak with every block's peak kept between UI frames; peak hold (default 3 s) and clip indication (default Infinite, latching as before) None / 3 s / Infinite, three release rates (`ui.meters`, right-click a meter); click a meter to reset it (Alt-click: all); highest-peak readout beside the volume. Meter type choice and GR meters partial | | Clip indicators, track colours, waveform views (peak/power/rectified/outlines) | ✅ | | | Fader resolution, fine adjust, numeric entry | 🟡 | | | macOS system menu bar | ✅ | #10 |@@ -69,6 +69,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---|+| 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-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) |