diff --git a/Cargo.lock b/Cargo.lock index 4fe2ce3..35e146d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -984,6 +984,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "dasp_frame" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6" +dependencies = [ + "dasp_sample", +] + [[package]] name = "dasp_sample" version = "0.11.0" @@ -1117,6 +1126,18 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "ebur128" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e227cc62d64d6fe01abbef48134b9c1f17d470cef1e7a56337ad05b1f81df7f9" +dependencies = [ + "bitflags 1.3.2", + "dasp_frame", + "dasp_sample", + "smallvec", +] + [[package]] name = "ecolor" version = "0.31.1" @@ -3942,9 +3963,10 @@ dependencies = [ [[package]] name = "timestretch" -version = "0.8.1" +version = "0.10.0" dependencies = [ "arc-swap", + "ebur128", "rustfft", "serde", "serde_json", diff --git a/crates/halo/src/app.rs b/crates/halo/src/app.rs index e57876e..cd0df5c 100644 --- a/crates/halo/src/app.rs +++ b/crates/halo/src/app.rs @@ -2672,9 +2672,9 @@ impl eframe::App for HaloApp { egui::CentralPanel::default().show(ctx, |ui| { let full_width = ui.available_width(); - // Sized to just fit the mixer content (119 px strip cluster) with - // ~5 px breathing room each side, rather than a wide centered panel. - let mixer_width = 130.0; + // Sized to just fit the mixer content (131 px strip cluster) with + // ~6 px breathing room each side, rather than a wide centered panel. + let mixer_width = 144.0; // The row holds 5 children (deck | sep | mixer | sep | deck): // 4 item-spacing gaps plus 2 separators (6 pt each in egui). let spacing = ui.spacing().item_spacing.x; @@ -4009,17 +4009,33 @@ fn mixer_panel(ui: &mut egui::Ui, mixer: &MixerShared, decks: &[DeckUi; 2]) { // center a multi-widget horizontal row (egui seeds it at full width), so // pad-center it to the panel mid-line — the same axis the crossfader uses. const STRIP_W: f32 = 44.0; - // 44 + 8 + 6 + 3 + 6 + 8 + 44 - const CLUSTER_W: f32 = STRIP_W + 8.0 + 6.0 + 3.0 + 6.0 + 8.0 + STRIP_W; + // 44 + 8 + 12 + 3 + 12 + 8 + 44 + const CLUSTER_W: f32 = STRIP_W + 8.0 + 12.0 + 3.0 + 12.0 + 8.0 + STRIP_W; ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.add_space(((ui.available_width() - CLUSTER_W) * 0.5).max(0.0)); let a = deck_channel_strip(ui, &decks[0].deck.shared); ui.add_space(8.0); - deck_level_meter(ui, decks[0].deck.shared.meter.load(), a.height()); - ui.add_space(3.0); - deck_level_meter(ui, decks[1].deck.shared.meter.load(), a.height()); - ui.add_space(8.0); + for (i, gap) in [(0, 3.0), (1, 8.0)] { + let shared = &decks[i].deck.shared; + // Prefer the freshest analysis: `pending_artifact` holds a + // completed analysis waiting for a non-playing moment to reach + // the engine, but the loudness marker is display-only. + let artifact = decks[i] + .pending_artifact + .as_ref() + .or(decks[i].deck.pre_analysis.as_ref()); + deck_lufs_meter( + ui, + shared.meter_lufs.load(), + shared.meter.load(), + artifact + .and_then(|a| a.loudness) + .map(|l| l.integrated_lufs as f32), + a.height(), + ); + ui.add_space(gap); + } deck_channel_strip(ui, &decks[1].deck.shared); }); @@ -4076,23 +4092,84 @@ fn filter_hint(t: f32) -> String { } } -/// Thin vertical channel meter: a dim track with a green bar rising from the -/// bottom to `level` (0..1, linear pre-fader / post-trim peak published by -/// the audio callback — the track's level regardless of fader position). -fn deck_level_meter(ui: &mut egui::Ui, level: f32, height: f32) { - let (rect, _) = ui.allocate_exact_size(egui::vec2(6.0, height), egui::Sense::hover()); +/// Vertical channel meter on a LUFS scale (−40..0 mapped linearly to the +/// bar — LUFS is already logarithmic). A segmented LED ladder rises to the +/// momentary (400 ms) loudness, each segment colored by the zone its own +/// position occupies; a light marker sits at the track's analyzed +/// integrated loudness (gain-match decks by aligning bars to markers), and +/// a red strip at the top flags peaks at the ceiling. All inputs are +/// pre-fader / post-trim, so the meter shows the track's level regardless +/// of fader position. +fn deck_lufs_meter( + ui: &mut egui::Ui, + momentary_lufs: f32, + peak_linear: f32, + integrated_lufs: Option, + height: f32, +) { + /// LUFS at the bar bottom (top is 0). + const RANGE_LO: f32 = -40.0; + /// Healthy up to here (common normalization target)… + const GREEN_TO: f32 = -14.0; + /// …amber to here (hot but normal for club masters at unity trim), + /// red above: trim too high, headed into the limiter. + const AMBER_TO: f32 = -9.0; + let norm = |lufs: f32| ((lufs - RANGE_LO) / -RANGE_LO).clamp(0.0, 1.0); + + let (rect, response) = ui.allocate_exact_size(egui::vec2(12.0, height), egui::Sense::hover()); if !ui.is_rect_visible(rect) { return; } + response.on_hover_text(match integrated_lufs { + Some(i) => format!("Momentary {momentary_lufs:.1} LUFS\nTrack {i:.1} LUFS integrated"), + None => format!("Momentary {momentary_lufs:.1} LUFS\nTrack: not analyzed"), + }); + let painter = ui.painter(); - painter.rect_filled(rect, 1.0, ui.visuals().extreme_bg_color); - let h = level.clamp(0.0, 1.0) * height; - if h > 0.0 { - let fill = egui::Rect::from_min_max( - egui::pos2(rect.left(), rect.bottom() - h), - rect.right_bottom(), + painter.rect_filled(rect, 1.0, egui::Color32::from_rgb(30, 30, 34)); + + // 3 px segments with 1 px gaps, lit bottom-up to the momentary level. + let fill_h = norm(momentary_lufs) * height; + let mut y = 0.0; + while y < fill_h { + let seg_top = (y + 3.0).min(fill_h); + let lufs_here = RANGE_LO * (1.0 - (y + seg_top) * 0.5 / height); + let color = if lufs_here < GREEN_TO { + egui::Color32::from_rgb(110, 200, 110) + } else if lufs_here < AMBER_TO { + ACCENT + } else { + egui::Color32::from_rgb(230, 80, 80) + }; + painter.rect_filled( + egui::Rect::from_min_max( + egui::pos2(rect.left() + 1.0, rect.bottom() - seg_top), + egui::pos2(rect.right() - 1.0, rect.bottom() - y), + ), + 0.0, + color, + ); + y += 4.0; + } + + if let Some(i) = integrated_lufs { + let marker_y = rect.bottom() - norm(i) * height; + painter.rect_filled( + egui::Rect::from_min_max( + egui::pos2(rect.left(), marker_y - 1.0), + egui::pos2(rect.right(), marker_y + 1.0), + ), + 0.0, + egui::Color32::from_gray(200), + ); + } + + if peak_linear >= 0.99 { + painter.rect_filled( + egui::Rect::from_min_max(rect.left_top(), egui::pos2(rect.right(), rect.top() + 2.0)), + 1.0, + egui::Color32::from_rgb(230, 80, 80), ); - painter.rect_filled(fill, 1.0, egui::Color32::from_rgb(64, 210, 96)); } } diff --git a/crates/halo/src/audio.rs b/crates/halo/src/audio.rs index 1223970..ac0efde 100644 --- a/crates/halo/src/audio.rs +++ b/crates/halo/src/audio.rs @@ -16,6 +16,7 @@ use std::time::Instant; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{SampleRate, Stream, StreamConfig}; +use timestretch::MomentaryLoudness; use timestretch::engine::EngineProcessor; use crate::deck::{ProcessorSlot, SampleSlot}; @@ -116,6 +117,11 @@ impl AudioOutput { let mut pre_gains: [f32; 3] = [0.0; 3]; let mut gains: [f32; 3] = [0.0; 3]; let mut meters: [f32; 3] = [0.0; 3]; + // Streaming momentary LUFS per deck, fed the same post-trim + // pre-fader signal as the peak meter. Option so an exotic device + // rate degrades to "no LUFS readout" instead of failing the stream. + let mut lufs: [Option; 3] = + std::array::from_fn(|_| MomentaryLoudness::new(sample_rate, 2)); let mut master_meter = 0.0f32; let mut scratch: Vec = vec![0.0; 16_384]; // Scrub state: per-deck varispeed voice (raw-sample snapshot, its @@ -167,6 +173,9 @@ impl AudioOutput { && let Ok(mut retired) = deck.retired.try_lock() { *retired = std::mem::replace(&mut procs[i], slot.take()); + if let Some(m) = &mut lufs[i] { + m.reset(); + } } // Acknowledge a pending warm-start reset before @@ -175,6 +184,9 @@ impl AudioOutput { if let Some(p) = &mut procs[i] { p.reset(); } + if let Some(m) = &mut lufs[i] { + m.reset(); + } deck.reset_request.store(false, Ordering::Release); } @@ -318,6 +330,9 @@ impl AudioOutput { out[0] += pl * g; out[1] += pr * g; peak = peak.max(pl.abs()).max(pr.abs()); + if let Some(m) = &mut lufs[i] { + m.push_stereo(pl, pr); + } } pre_gains[i] = g_pre; gains[i] = if !live && !scrubbing && g < 1e-4 { @@ -344,6 +359,13 @@ impl AudioOutput { *m + (peak - *m) * meter_release }; deck.shared.meter.store(*m); + // Momentary LUFS needs no extra ballistics — the + // 400 ms window is its own integration. + deck.shared.meter_lufs.store( + lufs[i] + .as_mut() + .map_or(MomentaryLoudness::SILENCE_LUFS, |m| m.momentary_lufs()), + ); } for s in data.iter_mut() { diff --git a/crates/halo/src/state.rs b/crates/halo/src/state.rs index 3a0a8ee..1054034 100644 --- a/crates/halo/src/state.rs +++ b/crates/halo/src/state.rs @@ -71,6 +71,9 @@ pub struct DeckShared { /// the audio callback for the channel meter — shows the track's level /// regardless of fader/crossfader. Fast attack, slow release. pub meter: AtomicF32, + /// Pre-fader (post-trim) momentary loudness in LUFS (BS.1770 400 ms + /// window), published by the audio callback. Floor -100.0 (silence). + pub meter_lufs: AtomicF32, /// Isolator EQ band gains, linear 0..2 (0 = kill, 1 = unity). pub eq_low: AtomicF32, pub eq_mid: AtomicF32, @@ -106,6 +109,7 @@ impl DeckShared { trim: AtomicF32::new(1.0), fader: AtomicF32::new(1.0), meter: AtomicF32::new(0.0), + meter_lufs: AtomicF32::new(-100.0), eq_low: AtomicF32::new(1.0), eq_mid: AtomicF32::new(1.0), eq_high: AtomicF32::new(1.0), diff --git a/crates/halo/src/worker.rs b/crates/halo/src/worker.rs index 192a632..5139c09 100644 --- a/crates/halo/src/worker.rs +++ b/crates/halo/src/worker.rs @@ -67,9 +67,20 @@ fn analyze_one(lib: &Library, id: i64, path: &std::path::Path) -> Result<(), Str let decoded = decode_file(path)?; let signal = timestretch::downmix_to_mid(&decoded.samples, 2); let start = std::time::Instant::now(); - let artifact = timestretch::analyze_for_dj(&signal, decoded.sample_rate); + let mut artifact = timestretch::analyze_for_dj(&signal, decoded.sample_rate); + // BS.1770 sums per-channel energies, so loudness is measured on the + // original interleaved signal — the mono analysis downmix would read + // up to ~3 dB low. `analyze_for_dj` deliberately leaves this None. + artifact.loudness = timestretch::measure_loudness( + &decoded.samples, + decoded.channels as usize, + decoded.sample_rate, + ); + let lufs = artifact + .loudness + .map_or("n/a".to_string(), |l| format!("{:.1}", l.integrated_lufs)); log::info!( - "Analyzed {}: {:.1} BPM, confidence {:.2} ({:.2}s)", + "Analyzed {}: {:.1} BPM, confidence {:.2}, {lufs} LUFS ({:.2}s)", path.display(), artifact.bpm, artifact.confidence,