Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

119 changes: 98 additions & 21 deletions crates/halo/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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<f32>,
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));
}
}

Expand Down
22 changes: 22 additions & 0 deletions crates/halo/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<MomentaryLoudness>; 3] =
std::array::from_fn(|_| MomentaryLoudness::new(sample_rate, 2));
let mut master_meter = 0.0f32;
let mut scratch: Vec<f32> = vec![0.0; 16_384];
// Scrub state: per-deck varispeed voice (raw-sample snapshot, its
Expand Down Expand Up @@ -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
Expand All @@ -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);
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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() {
Expand Down
4 changes: 4 additions & 0 deletions crates/halo/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
15 changes: 13 additions & 2 deletions crates/halo/src/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading