Skip to main content

max / audiofiles

DSP/export: fix stereo LUFS normalize and export correctness (ultra-fuzz) LUFS normalize fed the interleaved multichannel buffer to a single-channel BS.1770 meter, so every stereo file was normalized to the wrong gain. Mix to mono before measuring, matching how analysis reports loudness; add a stereo LUFS test. Also: export batch no longer aborts on a per-item create_dir_all failure, AIFF SSND chunks are word-aligned with a pad byte on odd data, and the WAV dither seed is a fixed constant so streaming and buffered paths are bit-identical at every depth (16-bit parity test added). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-19 20:43 UTC
Commit: b7ef117b05c96b7b33db2a9a4744d334954c9f7d
Parent: bf2fb93
6 files changed, +105 insertions, -22 deletions
@@ -189,7 +189,11 @@ fn decode_wav_hound(path: &Path) -> Result<DecodedAudio, CoreError> {
189 189 })
190 190 }
191 191
192 - fn mix_to_mono(interleaved: &[f32], channels: usize) -> Vec<f32> {
192 + /// Average all interleaved channels down to a single mono channel.
193 + ///
194 + /// Shared by the analysis decode path and the LUFS-normalize edit path so both
195 + /// measure loudness on the same mono mixdown.
196 + pub(crate) fn mix_to_mono(interleaved: &[f32], channels: usize) -> Vec<f32> {
193 197 if channels == 1 {
194 198 return interleaved.to_vec();
195 199 }
@@ -1,6 +1,7 @@
1 1 //! Normalize operations: peak normalize and LUFS normalize.
2 2
3 3 use crate::analysis::basic::peak_db;
4 + use crate::analysis::decode::mix_to_mono;
4 5 use crate::analysis::loudness::measure_lufs;
5 6 use crate::error::CoreError;
6 7
@@ -40,12 +41,17 @@ pub fn apply_normalize_peak(samples: &mut [f32], target_db: f64) -> Result<(), C
40 41 /// ([`measure_lufs`] — the same meter analysis reports), then applies a flat
41 42 /// gain to reach `target_lufs`. Like the peak path, this applies no clipping:
42 43 /// loudness normalize can legitimately push peaks past full scale, and the
43 - /// final encode is the single place that clamps. `channels` is unused because
44 - /// the meter operates on the interleaved buffer (matching how analysis
45 - /// measures loudness), but is kept for signature symmetry with the other ops.
44 + /// final encode is the single place that clamps.
45 + ///
46 + /// The meter is single-channel, so multichannel input must be mixed to mono
47 + /// before measurement — matching how analysis reports loudness. Feeding the
48 + /// interleaved buffer to the mono meter doubles the apparent sample count at
49 + /// the same rate (corrupting the rate-dependent K-weighting and 100 ms gating)
50 + /// and bypasses BS.1770's per-channel power summation, yielding a wrong gain.
51 + /// The gain itself is then applied to every interleaved sample.
46 52 pub fn apply_normalize_lufs(
47 53 samples: &mut [f32],
48 - _channels: u16,
54 + channels: u16,
49 55 sample_rate: u32,
50 56 target_lufs: f64,
51 57 ) -> Result<(), CoreError> {
@@ -53,7 +59,12 @@ pub fn apply_normalize_lufs(
53 59 return Ok(());
54 60 }
55 61
56 - let current_lufs = measure_lufs(samples, sample_rate);
62 + let current_lufs = if channels > 1 {
63 + let mono = mix_to_mono(samples, channels as usize);
64 + measure_lufs(&mono, sample_rate)
65 + } else {
66 + measure_lufs(samples, sample_rate)
67 + };
57 68 if current_lufs <= -70.0 {
58 69 // Silent / below the absolute gate — nothing to normalize.
59 70 return Ok(());
@@ -166,6 +177,32 @@ mod tests {
166 177 }
167 178
168 179 #[test]
180 + fn normalize_lufs_stereo_hits_target_on_mono_mixdown() {
181 + // Interleaved stereo (L == R) 1kHz tone. The meter must measure the mono
182 + // mixdown, so the normalized loudness of the mixdown must hit target.
183 + // The pre-fix bug fed the interleaved buffer to a mono meter, missing
184 + // the target on every stereo file.
185 + let sr = 48000u32;
186 + let frames = sr as usize;
187 + let mut samples: Vec<f32> = Vec::with_capacity(frames * 2);
188 + for i in 0..frames {
189 + let s = 0.5 * (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / sr as f32).sin();
190 + samples.push(s); // L
191 + samples.push(s); // R
192 + }
193 +
194 + apply_normalize_lufs(&mut samples, 2, sr, -14.0).unwrap();
195 +
196 + // Measure the mono mixdown the same way analysis/normalize now do.
197 + let mono: Vec<f32> = samples.chunks_exact(2).map(|f| (f[0] + f[1]) / 2.0).collect();
198 + let measured = measure_lufs(&mono, sr);
199 + assert!(
200 + (measured - (-14.0)).abs() < 0.5,
201 + "stereo-normalized mono loudness should be ~-14 LUFS, got {measured}"
202 + );
203 + }
204 +
205 + #[test]
169 206 fn normalize_lufs_does_not_silently_clip_when_boosting() {
170 207 // A quiet tone boosted toward 0 LUFS will exceed full scale; the LUFS
171 208 // path must NOT clamp (the old code did, silently distorting the tone).
@@ -60,11 +60,14 @@ pub(crate) fn write_sample<W: Write + Seek>(
60 60 .map_err(|e| CoreError::Export(format!("WAV write: {e}")))
61 61 }
62 62
63 - /// Seed a dither RNG from a sample count (the buffered path seeds off the buffer
64 - /// pointer too, but dither is intentionally non-deterministic either way).
65 - pub(crate) fn dither_seed(sample_count: usize) -> u64 {
66 - 0x9E3779B97F4A7C15u64 ^ sample_count as u64
67 - }
63 + /// Fixed dither seed shared by the buffered and streaming WAV paths.
64 + ///
65 + /// A constant (not a count- or pointer-derived value) so the two paths produce
66 + /// byte-identical dithered output at every bit depth — the streaming path can't
67 + /// know the final output sample count up front, so any count-derived seed would
68 + /// diverge from the buffered path on 8/16-bit. Fixed-seed TPDF dither is still
69 + /// signal-independent noise; the only effect is that exports are reproducible.
70 + pub(crate) const DITHER_SEED: u64 = 0x9E3779B97F4A7C15;
68 71
69 72 /// Encode audio to a WAV file at the given path.
70 73 ///
@@ -92,7 +95,7 @@ pub fn encode_wav(audio: &ConvertedAudio, bit_depth: u16, dest: &Path) -> Result
92 95 WavWriter::create(dest, spec).map_err(|e| io_err(dest, std::io::Error::other(e)))?;
93 96
94 97 // Dither (8/16-bit) is seeded once per file; 24/32-bit ignore the RNG.
95 - let mut rng = SimpleRng::new(dither_seed(audio.samples.len()));
98 + let mut rng = SimpleRng::new(DITHER_SEED);
96 99 for &sample in &audio.samples {
97 100 write_sample(&mut writer, sample, bit_depth, &mut rng)?;
98 101 }
@@ -50,10 +50,19 @@ pub fn encode_aiff(audio: &ConvertedAudio, bit_depth: u16, dest: &Path) -> Resul
50 50 // COMM chunk: always 18 bytes for standard AIFF
51 51 let comm_chunk_size: u32 = 18;
52 52
53 - // FORM size: 4 (AIFF) + 8+comm + 8+ssnd
53 + // IFF chunks are word-aligned: a chunk whose data is an odd number of bytes
54 + // is followed by a trailing 0 pad byte. The pad is not counted in the
55 + // chunk's ckSize but is physically present and counted in FORM size. Only
56 + // SSND can be odd here (COMM is 18; the 8-byte offset/blockSize is even), so
57 + // the SSND data parity is the sample-data parity. Strict AIFF parsers on
58 + // some hardware samplers reject a non-word-aligned SSND chunk.
59 + let ssnd_pad: u32 = sample_data_size & 1;
60 +
61 + // FORM size: 4 (AIFF) + 8+comm + 8+ssnd + pad
54 62 let form_size: u32 = 4u32
55 63 .checked_add(8 + comm_chunk_size)
56 64 .and_then(|v| v.checked_add(8 + ssnd_chunk_size))
65 + .and_then(|v| v.checked_add(ssnd_pad))
57 66 .ok_or_else(|| CoreError::Export("AIFF: FORM size overflow".to_string()))?;
58 67
59 68 let mut buf = Vec::with_capacity(12 + form_size as usize);
@@ -104,6 +113,11 @@ pub fn encode_aiff(audio: &ConvertedAudio, bit_depth: u16, dest: &Path) -> Resul
104 113 _ => unreachable!(), // checked at top of function
105 114 }
106 115
116 + // Word-align the SSND chunk with a trailing pad byte when its data is odd.
117 + if ssnd_pad == 1 {
118 + buf.push(0);
119 + }
120 +
107 121 let mut file = std::fs::File::create(dest).map_err(|e| io_err(dest, e))?;
108 122 file.write_all(&buf).map_err(|e| io_err(dest, e))?;
109 123
@@ -129,10 +129,14 @@ pub fn run_export(
129 129 }
130 130 };
131 131
132 - // Ensure parent directory exists
133 - if let Some(parent) = dest.parent() {
134 - fs::create_dir_all(parent).map_err(|e| io_err(parent, e))?;
135 - }
132 + // Ensure parent directory exists. A failure here is per-item (e.g. a
133 + // path component that is a file, or a permission error on one subtree)
134 + // and must not abort the whole batch.
135 + if let Some(parent) = dest.parent()
136 + && let Err(e) = fs::create_dir_all(parent) {
137 + errors.push((item.name.clone(), io_err(parent, e).to_string()));
138 + continue;
139 + }
136 140
137 141 // Avoid silently overwriting existing files in the user's export dir.
138 142 let dest = resolve_collision(&dest);
@@ -16,7 +16,7 @@ use rubato::Resampler;
16 16 use super::convert::{convert_channels, make_resampler, RESAMPLE_CHUNK};
17 17 use super::decode::{decode_next_into, open_decoder};
18 18 use super::dither::SimpleRng;
19 - use super::encode::{dither_seed, sample_format_for, write_sample};
19 + use super::encode::{sample_format_for, write_sample, DITHER_SEED};
20 20 use super::ExportChannels;
21 21 use crate::error::{io_err, CoreError};
22 22
@@ -90,7 +90,7 @@ pub(crate) fn try_stream_wav_export(
90 90 };
91 91 let mut writer =
92 92 WavWriter::create(dest, spec).map_err(|e| io_err(dest, std::io::Error::other(e)))?;
93 - let mut rng = SimpleRng::new(dither_seed(n_frames));
93 + let mut rng = SimpleRng::new(DITHER_SEED);
94 94
95 95 if dst_rate == src_rate {
96 96 // No resampling: decode → channel-convert → write, one packet at a time.
@@ -245,25 +245,46 @@ mod tests {
245 245 target_channels: ExportChannels,
246 246 target_rate: Option<u32>,
247 247 ) {
248 + // 24-bit (no dither) and 16-bit (fixed-seed TPDF dither) must both be
249 + // byte-identical between the streaming and buffered paths.
250 + for bit_depth in [24u16, 16] {
251 + assert_streaming_matches_buffered_at(
252 + src_channels,
253 + src_rate,
254 + samples,
255 + target_channels.clone(),
256 + target_rate,
257 + bit_depth,
258 + );
259 + }
260 + }
261 +
262 + fn assert_streaming_matches_buffered_at(
263 + src_channels: u16,
264 + src_rate: u32,
265 + samples: &[f32],
266 + target_channels: ExportChannels,
267 + target_rate: Option<u32>,
268 + bit_depth: u16,
269 + ) {
248 270 let dir = tempfile::tempdir().unwrap();
249 271 let src = dir.path().join("src.wav");
250 272 write_wav(&src, src_channels, src_rate, samples);
251 273
252 - // 24-bit: deterministic (no dither), so the two paths must be byte-equal.
253 274 let streamed = dir.path().join("streamed.wav");
254 275 let did = try_stream_wav_export(
255 276 &src,
256 277 &streamed,
257 278 &target_channels,
258 279 target_rate,
259 - 24,
280 + bit_depth,
260 281 &AtomicBool::new(false),
261 282 )
262 283 .unwrap();
263 284 assert!(did, "WAV source must be streamable (known frame count)");
264 285
265 286 let buffered = dir.path().join("buffered.wav");
266 - buffered_export(&src, &buffered, &target_channels, target_rate, 24);
287 + buffered_export(&src, &buffered, &target_channels, target_rate, bit_depth);
267 288
268 289 let (spec_s, samp_s) = read_i32(&streamed);
269 290 let (spec_b, samp_b) = read_i32(&buffered);