M7: correct export loudness after peak limiting

Bound post-limiter loudness correction and cover high-crest signals, infeasible targets, cancellation, and output alignment. Focused tests and lint pass; full workspace validation pending for draft review.
This commit is contained in:
Earl Co 2026-10-11 06:09:02 +02:00
parent 4e2d0bc2d3
commit 5d7dc800a8
2 changed files with 133 additions and 4 deletions

View File

@ -1,6 +1,6 @@
//! The export audio path: the sequence mix at the output sample rate, folded to the output
//! channel count, with Export ▸ Effects ▸ Loudness Normalization (a BS.1770-4 measuring pass over
//! the export range, then a linear gain and a look-ahead true-peak limiter).
//! the export range, then bounded gain refinement through a look-ahead true-peak limiter).
//!
//! The limiter's look-ahead delays its output; [`AudioOut`] mixes that many samples ahead (silence
//! past the end of the range) and drops the first ones, so the exported audio stays aligned with
@ -20,6 +20,7 @@ use crate::{ExportError, ExportSettings, Result};
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize)]
pub struct LoudnessReport {
pub measured_lufs: f64,
/// Applied gain before the limiter, including bounded loudness correction.
pub gain_db: f64,
}
@ -84,11 +85,62 @@ impl AudioOut {
}
let measured = meter.integrated();
let gain_db = filmcraft_audio_dsp::normalize_gain_db(measured, ln.target_lufs);
self.gain = 10f64.powf(gain_db / 20.0) as f32;
self.loudness = Some(LoudnessReport { measured_lufs: measured, gain_db });
// Limiting changes integrated loudness. Measure the actual limited signal and
// refine its INPUT gain; the peak ceiling remains in force on every pass.
// Four trials and at most 9 dB of extra drive bound both work and compression.
let start = self.out;
let mut trial_gain = gain_db;
let mut best_gain = gain_db;
let mut best_error = f64::INFINITY;
let mut previous = None;
if measured.is_finite() && ln.target_lufs.is_finite() {
for _ in 0..4 {
self.rewind(start, trial_gain);
let mut limited = filmcraft_audio_dsp::LoudnessMeter::new(self.sr as f64, self.channels);
while self.out < self.end {
if cancel() {
self.rewind(start, best_gain);
return Err(ExportError::Cancelled);
}
let until = self.out.saturating_add(self.sr as i64).min(self.end);
if let Some(buf) = self.pull(until, sources) {
let refs: Vec<&[f32]> = buf.iter().map(Vec::as_slice).collect();
limited.process(&refs);
}
}
let achieved = limited.integrated();
if !achieved.is_finite() {
break;
}
let error = ln.target_lufs - achieved;
if error.abs() < best_error {
best_error = error.abs();
best_gain = trial_gain;
}
// Stop on convergence or a ceiling-limited plateau. An impossible target
// must not cause unbounded passes or ever bypass the limiter.
if error.abs() <= 0.1 || previous.is_some_and(|last: f64| (achieved - last).abs() < 0.02) {
break;
}
previous = Some(achieved);
trial_gain += error.clamp(-3.0, 3.0);
}
}
self.rewind(start, best_gain);
self.loudness = Some(LoudnessReport { measured_lufs: measured, gain_db: best_gain });
Ok(())
}
/// Restore the output cursor and limiter history between measuring passes and delivery.
fn rewind(&mut self, start: i64, gain_db: f64) {
self.out = start;
self.next_in = start;
self.gain = 10f64.powf(gain_db / 20.0) as f32;
if let Some(limiter) = self.limiter.as_mut() {
limiter.reset();
}
}
/// Mix `n` samples from `pos`, folded to the output channels (silence past the range end).
/// Stereo output of a 5.1 Mix is the ITU-R BS.775 downmix; 5.1 output of a stereo Mix places it
/// on L and R; 5.1 is in L, R, C, LFE, Ls, Rs order.

View File

@ -518,7 +518,7 @@ fn loudness_normalization_hits_target_and_true_peak() {
assert!((i - target).abs() < 0.5, "target {target}: measured {i:.2} LUFS");
assert!(tp <= -0.9, "true peak {tp:.2} dBTP");
let rep = prog.loudness.lock().unwrap().unwrap();
assert!((rep.measured_lufs - (target - rep.gain_db)).abs() < 1e-6);
assert!((rep.measured_lufs + rep.gain_db - target).abs() < 0.1, "unlimited tone needs no correction");
}
// a target the peaks cannot reach: the limiter holds the true-peak ceiling
let path = dir.path("hot.wav");
@ -870,3 +870,80 @@ fn gpu_rendering_is_off_by_default() {
let auto: ExportSettings = serde_json::from_value(serde_json::json!({"format": "h264", "gpuRendering": "auto"})).unwrap();
assert_eq!(auto.gpu_rendering, crate::GpuRendering::Auto);
}
/// Brief large peaks over a quiet bed exercise loudness lost during limiter release.
struct CrestSource(filmcraft_media::SharedSource, Arc<std::sync::atomic::AtomicUsize>);
impl MediaSource for CrestSource {
fn info(&self) -> &filmcraft_media::MediaInfo {
self.0.info()
}
fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result<Arc<filmcraft_frame::VideoFrame>> {
self.0.video_frame(req)
}
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<filmcraft_frame::AudioBuffer> {
self.1.fetch_add(1, Ordering::Relaxed);
let samples: Vec<f32> = (0..frames)
.map(|i| {
let at = start + i as i64;
let phase = at.rem_euclid(sample_rate as i64 / 4);
let amplitude = if phase < sample_rate as i64 / 500 { 0.9 } else { 0.08 };
amplitude * (std::f32::consts::TAU * 1000.0 * at as f32 / sample_rate as f32).sin()
})
.collect();
Ok(filmcraft_frame::AudioBuffer { sample_rate, channels: vec![samples.clone(), samples] })
}
}
#[test]
fn loudness_normalization_corrects_limiter_loss_and_bounds_impossible_targets() {
use std::cell::Cell;
let (p, seq, mut sources) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0));
let reads = Arc::new(std::sync::atomic::AtomicUsize::new(0));
for source in sources.0.values_mut() {
*source = Arc::new(CrestSource(source.clone(), reads.clone()));
}
let range = TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND));
let mut settings = ExportSettings::default();
settings.effects.loudness = LoudnessNormalization { enabled: true, target_lufs: -14.0, true_peak_dbtp: -2.0 };
for target in [-14.0, 2.0] {
settings.effects.loudness.target_lufs = target;
let mut audio = audio_out::AudioOut::new(p.clone(), seq, &settings, range).unwrap();
let chunks = Cell::new(0);
audio
.measure(&settings, &sources, &|| {
chunks.set(chunks.get() + 1);
false
})
.unwrap();
let report = audio.loudness.unwrap();
let result = audio.rest(&sources).unwrap();
let (integrated, peak) = lufs(&result, 48_000);
eprintln!("target {target}: output {integrated:.3} LUFS, {peak:.3} dBTP, gain {:.3}, chunks {}", report.gain_db, chunks.get());
assert!(chunks.get() <= 5, "one raw pass plus at most four limited trials");
assert_eq!(result[0].len(), 48_000, "measuring must rewind output and limiter latency");
assert!(peak <= -1.8, "limiting must remain enabled: {peak}");
if target == -14.0 {
assert!((integrated - target).abs() < 0.15, "achieved {integrated}");
assert!(report.gain_db > target - report.measured_lufs + 0.3, "fixture must require limiter correction");
} else {
assert!(integrated < target - 1.0, "impossible target sacrifices loudness, not peak ceiling");
}
}
// Cancel after raw measurement and one limited trial, before further refinement.
// Record source reads at cancellation to prove no further mixing occurs.
let mut audio = audio_out::AudioOut::new(p, seq, &settings, range).unwrap();
let chunks = Cell::new(0);
let reads_at_cancel = Cell::new(None);
let cancel = || {
chunks.set(chunks.get() + 1);
if chunks.get() > 2 {
reads_at_cancel.set(Some(reads.load(Ordering::Relaxed)));
true
} else {
false
}
};
assert!(matches!(audio.measure(&settings, &sources, &cancel), Err(ExportError::Cancelled)));
assert_eq!(chunks.get(), 3);
assert_eq!(reads_at_cancel.get(), Some(reads.load(Ordering::Relaxed)));
}