max / audiofiles
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); |