diff --git a/crates/mix/src/meter.rs b/crates/mix/src/meter.rs index 221cee893..ed86605c4 100644 --- a/crates/mix/src/meter.rs +++ b/crates/mix/src/meter.rs @@ -9,6 +9,9 @@ pub struct StripMeter { pub peak: [f32; 2], /// Every channel of the strip (or of the main mix). pub peaks: Vec, + /// 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, @@ -19,6 +22,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; } @@ -31,6 +35,7 @@ impl StripMeter { // The strip's format changed (a document edit): earlier channels no longer line up. self.peaks.clone_from(&o.peaks); self.peak = o.peak; + self.peak_all = o.peak_all; } else { for (p, q) in self.peaks.iter_mut().zip(&o.peaks) { *p = max_level(*p, *q); @@ -38,6 +43,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; @@ -48,6 +54,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; } @@ -60,6 +67,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; } } diff --git a/crates/mix/src/tests.rs b/crates/mix/src/tests.rs index 5508f764d..70773e1b2 100644 --- a/crates/mix/src/tests.rs +++ b/crates/mix/src/tests.rs @@ -1208,3 +1208,26 @@ fn gain_ramp_matches_converting_both_ends_every_sample() { } } } + +#[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> = (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 { peaks: vec![0.0; m.peaks.len()], ..StripMeter::default() }); + assert_eq!(acc.peak_all, m.peak_all, "kept until taken"); +} diff --git a/crates/playback/src/tests.rs b/crates/playback/src/tests.rs index 593cd5575..4d7797dc4 100644 --- a/crates/playback/src/tests.rs +++ b/crates/playback/src/tests.rs @@ -113,8 +113,11 @@ fn reusable_meter_snapshot_updates_and_drops_stale_tracks() { let keep = TrackId(1); let stale = TrackId(2); let mut source = MeterSnapshot::default(); - source.tracks.insert(keep, StripMeter { peak: [0.5, 0.25], peaks: vec![0.5, 0.25], gain_reduction_db: 3.0, ..StripMeter::default() }); + source + .tracks + .insert(keep, StripMeter { peak: [0.5, 0.25], peaks: vec![0.5, 0.25], peak_all: 0.9, gain_reduction_db: 3.0, ..StripMeter::default() }); source.main.peaks = vec![0.75, 0.5]; + source.main.peak_all = 0.75; let mut out = MeterSnapshot::default(); out.tracks.insert(stale, StripMeter::default()); copy_meter_snapshot(&source, &mut out); @@ -223,9 +226,10 @@ fn a_take_without_new_blocks_still_drops_deleted_tracks() { #[test] fn a_channel_count_change_takes_the_new_stereo_peaks() { - let mut acc = StripMeter { peak: [0.9, 0.9], peaks: vec![0.9], ..StripMeter::default() }; - acc.accumulate(&StripMeter { peak: [0.9, 0.0], peaks: vec![0.9, 0.0], ..StripMeter::default() }); - assert_eq!(acc.peak, [0.9, 0.0]); + let mut acc = StripMeter { peak: [0.9, 0.9], peaks: vec![0.9], peak_all: 0.9, ..StripMeter::default() }; + acc.accumulate(&StripMeter { peak: [0.2, 0.0], peaks: vec![0.2, 0.0], peak_all: 0.2, ..StripMeter::default() }); + assert_eq!(acc.peak, [0.2, 0.0]); + assert_eq!(acc.peak_all, 0.2, "a peak from the old layout doesn't stay in the clip light's reading"); } #[test] @@ -235,8 +239,9 @@ fn gain_reduction_keeps_its_maximum_until_taken() { acc.accumulate(&StripMeter { peak: [0.1, 0.1], peaks: vec![0.1], gain_reduction_db: gr, ..StripMeter::default() }); } assert_eq!(acc.gain_reduction_db, 9.0); + acc.peak_all = 0.7; acc.reset_levels(); - assert_eq!((acc.gain_reduction_db, acc.peak, acc.peaks.len()), (0.0, [0.0; 2], 1)); + assert_eq!((acc.gain_reduction_db, acc.peak, acc.peak_all, acc.peaks.len()), (0.0, [0.0; 2], 0.0, 1)); } #[test] diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 0be771baf..bbd477162 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -387,7 +387,7 @@ impl SoundApp { if self.meters_live() { let hz = if self.ui.window == MainWindow::Mix { 60 } else { 30 }; ctx.request_repaint_after(std::time::Duration::from_millis(1000 / hz)); - } else if self.meters.values().any(|m| m.level[0] > 0.0001) { + } else if self.meters.values().chain([&self.main_meter]).any(|m| crate::meters::settling(m, &self.ui.meters)) { ctx.request_repaint_after(std::time::Duration::from_millis(80)); } else if !self.pending_shots.is_empty() || !self.synthetic.is_empty() { ctx.request_repaint(); diff --git a/crates/ui-egui/src/app/audio.rs b/crates/ui-egui/src/app/audio.rs index 3ec928491..8d4a61340 100644 --- a/crates/ui-egui/src/app/audio.rs +++ b/crates/ui-egui/src/app/audio.rs @@ -1,4 +1,4 @@ -use super::{MeterDisplay, OutputTrouble, Player, SoundApp, Trouble}; +use super::{OutputTrouble, Player, SoundApp, Trouble}; impl SoundApp { /// Whether the meters are live: while playing, and while armed or input-monitored tracks are heard. @@ -124,30 +124,3 @@ impl SoundApp { self.player = Some(Player::with_config(self.engine.session_arc(), &self.ui.audio)); } } - -pub(super) 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 }; -} diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 42a74ce8d..0396d5551 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -35,6 +35,7 @@ impl SoundApp { } if id == "track.clear_clip_indicators" { self.meters.values_mut().chain([&mut self.main_meter]).for_each(|m| m.clip = false); + crate::meters::forget_readings(&mut self.meter_snapshot); } Ok(v) } diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index ccb9e742a..2cedc7c8c 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -1,4 +1,3 @@ -use super::audio::feed_meter; use super::plugins::failed_plugin_status; use super::{Gesture, MainWindow, MeterDisplay, Range, Samples, SoundApp, TrackId, TransportRequest, json}; @@ -415,10 +414,14 @@ impl SoundApp { p.take_meters(&mut self.meter_snapshot); self.last_meter_poll = now; } + // Stopped meters fall below, once per frame: their hold times run on `dt`. + let live = self.meters_live(); 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); + if live { + crate::meters::feed(d, &crate::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) { @@ -426,8 +429,9 @@ impl SoundApp { } } } - let main = snap.main.peak; - feed_meter(&mut self.main_meter, main, 0.0, dt); + if live { + crate::meters::feed(&mut self.main_meter, &crate::meters::Reading::of(&snap.main), dt, &self.ui.meters); + } if ended { self.stop_play(); } @@ -446,10 +450,11 @@ impl SoundApp { } } if !self.meters_live() { + let silence = crate::meters::Reading::default(); for d in self.meters.values_mut() { - feed_meter(d, [0.0, 0.0], 0.0, dt); + crate::meters::feed(d, &silence, dt, &self.ui.meters); } - feed_meter(&mut self.main_meter, [0.0, 0.0], 0.0, dt); + crate::meters::feed(&mut self.main_meter, &silence, dt, &self.ui.meters); } } } diff --git a/crates/ui-egui/src/edit_window/header.rs b/crates/ui-egui/src/edit_window/header.rs index f8a002a2e..44cec337d 100644 --- a/crates/ui-egui/src/edit_window/header.rs +++ b/crates/ui-egui/src/edit_window/header.rs @@ -23,7 +23,9 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: voice_row(app, ui, track, head); field_row(app, ui, track, head, head_bg); 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)); } /// Drag the bottom edge of the header to resize the track (snaps to the height presets). @@ -432,7 +434,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; @@ -444,7 +446,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 })); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1618b315e..34e2b650a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -24,6 +24,7 @@ pub mod fonts; pub mod icons; pub mod import; pub mod menus; +pub mod meters; pub mod mix_window; pub mod palette; pub mod panels; @@ -142,6 +143,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 { @@ -203,6 +206,7 @@ impl Default for UiState { window_size: [1600.0, 1000.0], window_maximized: false, ui_scale: 1.0, + meters: meters::MeterPrefs::default(), } } } @@ -226,8 +230,13 @@ pub fn fit_window_to_screen(ctx: &egui::Context) { 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, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 450c3af0f..96757b3dd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -75,6 +75,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. @@ -518,6 +520,11 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option return Some(crate::meters::prefs_command(app, p)), + "ui.meters.reset" => return Some(crate::meters::reset_command(app, p)), + _ => {} + } // Menu clicks never get here: Setup › Playback Engine / Hardware open the window // (`MENU_WINDOWS`). A programmatic call applies the open device instead. if matches!(id, "setup.playback_engine" | "setup.hardware") { diff --git a/crates/ui-egui/src/meters.rs b/crates/ui-egui/src/meters.rs new file mode 100644 index 000000000..f2d87310e --- /dev/null +++ b/crates/ui-egui/src/meters.rs @@ -0,0 +1,603 @@ +//! 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::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::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 { + 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 }; +} + +/// Whether a stopped meter still changes on its own: a bar falling, or a timed hold or clip light +/// waiting to expire. The UI keeps repainting until it settles. +pub fn settling(d: &MeterDisplay, prefs: &MeterPrefs) -> bool { + let timed_hold = prefs.peak_hold == Hold::Timed && d.hold.iter().zip(&d.level).any(|(h, l)| h > l); + d.level.iter().any(|l| *l > 0.0001) || timed_hold || (prefs.clip_hold == Hold::Timed && d.clip) +} + +/// 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 = [0.0; 2]; + d.hold_age = [0.0; 2]; + d.clip = false; + d.clip_age = 0.0; + d.max_peak = 0.0; +} + +/// Zero the levels of the snapshot's meters, keeping their plugin failure records. Playback feeds +/// the latest snapshot every frame, so a cleared hold or clip light would otherwise come straight +/// back from a reading taken before the reset. +pub(crate) fn forget_readings(snap: &mut soundcraft_playback::MeterSnapshot) { + snap.tracks.values_mut().chain([&mut snap.main]).for_each(soundcraft_mix::StripMeter::reset_levels); +} + +/// 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 { + 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 { + 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); + forget_readings(&mut app.meter_snapshot); + } + Some(MeterTarget::Main) => { + reset(&mut app.main_meter); + app.meter_snapshot.main.reset_levels(); + } + Some(MeterTarget::Track(id)) => { + reset(app.meters.entry(id).or_default()); + if let Some(m) = app.meter_snapshot.tracks.get_mut(&id) { + m.reset_levels(); + } + } + } + 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.scope_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 a_reset_drops_the_hold_line_of_a_falling_bar() { + let prefs = MeterPrefs { peak_hold: Hold::Infinite, ..MeterPrefs::default() }; + let mut d = MeterDisplay { level: [0.5; 2], hold: [0.9; 2], ..MeterDisplay::default() }; + reset(&mut d); + assert_eq!(d.hold, [0.0; 2]); + feed(&mut d, &Reading::default(), 0.01, &prefs); + assert_eq!(d.hold, [0.0; 2], "no line stays behind at the height the bar had"); + } + + #[test] + fn stopped_meters_settle_only_when_nothing_is_left_to_expire() { + let timed = MeterPrefs { peak_hold: Hold::Timed, clip_hold: Hold::Timed, ..MeterPrefs::default() }; + let latched = MeterPrefs { peak_hold: Hold::Infinite, clip_hold: Hold::Infinite, ..MeterPrefs::default() }; + assert!(!settling(&MeterDisplay::default(), &timed)); + let right_only = MeterDisplay { level: [0.0, 0.5], hold: [0.0, 0.5], ..MeterDisplay::default() }; + assert!(settling(&right_only, &latched), "either side's bar still falls"); + let held = MeterDisplay { hold: [0.5, 0.0], ..MeterDisplay::default() }; + let clipped = MeterDisplay { clip: true, ..MeterDisplay::default() }; + assert!(settling(&held, &timed) && settling(&clipped, &timed), "waiting to expire"); + assert!(!settling(&held, &latched) && !settling(&clipped, &latched), "latched: nothing will change"); + } + + #[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_reset_is_not_undone_by_the_reading_before_it() { + 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 snare = app.engine.session().track_by_name("Snare").unwrap().id; + let prefs = MeterPrefs { clip_hold: Hold::Infinite, ..MeterPrefs::default() }; + let clipped = soundcraft_mix::StripMeter { peak: [1.2; 2], peak_all: 1.2, ..Default::default() }; + let replay = |app: &mut SoundApp, id| { + let r = Reading::of(&app.meter_snapshot.tracks[&id]); + feed(app.meters.entry(id).or_default(), &r, FRAME, &prefs); + }; + for id in [kick, snare] { + app.meter_snapshot.tracks.insert(id, clipped.clone()); + replay(&mut app, id); + } + app.meter_snapshot.main = clipped; + app.run("ui.meters.reset", json!({"track": kick.0})).unwrap(); + replay(&mut app, kick); + assert!(!app.meters[&kick].clip && app.meters[&kick].max_peak == 0.0, "stays cleared"); + replay(&mut app, snare); + assert!(app.meters[&snare].clip, "the other meter keeps its reading"); + app.run("track.clear_clip_indicators", json!({})).unwrap(); + replay(&mut app, snare); + assert!(!app.meters[&snare].clip, "and the clear-all command does the same"); + assert!(app.meter_snapshot.main.peak_all == 0.0); + } + + #[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); + } +} diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index f089499bd..89b098301 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -104,17 +104,19 @@ fn panner_speakers(fmt: ChannelFormat) -> Vec { .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 { +/// 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 { let id = egui::Id::unique(key); - let dt = ui.input(|i| i.stable_dt).clamp(0.0, 0.25); - let fall = 10f32.powf(-26.0 * dt / 20.0); + // Not `stable_dt`: it reads 1/60 s on the delayed repaints of stopped meters, so they'd fall too slowly. + let dt = ui.input(|i| i.unstable_dt).clamp(0.0, 0.25); + let fall = release.factor(dt); let mut lv: Vec = 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; } } @@ -592,7 +594,7 @@ fn fader_and_meter(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, y: f32, s 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 = (0..all_chans).map(|c| strip_fmt.channel_label(c)).collect(); multi_meter(ui, mr, &lv, &labels, md.clip); } else if chans >= 2 { @@ -602,6 +604,7 @@ fn fader_and_meter(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, y: f32, s } 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); } @@ -609,10 +612,24 @@ fn fader_and_meter(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, y: f32, s /// Volume readout, kind label and name plate. fn name_and_readout(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>) { - let StripCtx { track, id, x0, inner_w, r, w, selected, .. } = *cx; + let StripCtx { track, id, x0, inner_w, r, w, narrow, selected, .. } = *cx; let t = Tokens::current(); // Volume readout and name. - let vr = Rect::from_min_size(pos2(x0 + 4.0, r.max.y - STRIP_FOOT_H + 4.0), vec2(inner_w - 8.0, 15.0)); + let readout = Rect::from_min_size(pos2(x0 + 4.0, r.max.y - STRIP_FOOT_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 md = app.meters.get(&id).copied().unwrap_or_default(); + 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.scope_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, @@ -639,7 +656,7 @@ fn name_and_readout(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>) { 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, diff --git a/crates/ui-egui/src/panels/transport.rs b/crates/ui-egui/src/panels/transport.rs index 9451c1d66..3d668d1ae 100644 --- a/crates/ui-egui/src/panels/transport.rs +++ b/crates/ui-egui/src/panels/transport.rs @@ -40,6 +40,7 @@ pub(super) 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(); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index cc063c684..b7b4b2a9f 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -456,4 +456,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); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e4a2acb14..027ce5861 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -271,9 +271,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)) @@ -282,7 +279,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 { 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; @@ -300,7 +297,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 }; @@ -314,8 +311,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); } @@ -332,25 +330,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. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 470d39127..ddd99f344 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -91,6 +91,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, and it draws no peak hold line. + `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. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1fa184cc0..666ee97b2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Cancel Zoom Toggle) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-11-02 · **Change:** minor (meter peak hold, clip indication, release and peak readout; previously minor: Cancel Zoom Toggle) · **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 @@ -61,7 +61,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 | @@ -70,6 +70,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---| +| 2026-11-02 | 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-25 | minor | Zoom Toggle row: Alt+Shift+E and Alt-click cancel Zoom Toggle and keep the view. Readiness estimate unchanged | | 2026-10-25 | minor | Edit and Mix windows: Tracks and Groups and Clips in full-height dock columns. 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 |