diff --git a/src/source/dither.rs b/src/source/dither.rs index 2f08395a..79b0a980 100644 --- a/src/source/dither.rs +++ b/src/source/dither.rs @@ -71,29 +71,40 @@ enum NoiseGenerator { TPDF(WhiteTriangular), RPDF(WhiteUniform), GPDF(WhiteGaussian), - HighPass(Blue), + HighPass(Vec>), } impl NoiseGenerator { - fn new(algorithm: Algorithm, sample_rate: SampleRate) -> Self { + fn new(algorithm: Algorithm, sample_rate: SampleRate, channels: ChannelCount) -> Self { match algorithm { Algorithm::TPDF => Self::TPDF(WhiteTriangular::new(sample_rate)), Algorithm::RPDF => Self::RPDF(WhiteUniform::new(sample_rate)), Algorithm::GPDF => Self::GPDF(WhiteGaussian::new(sample_rate)), - Algorithm::HighPass => Self::HighPass(Blue::new(sample_rate)), + Algorithm::HighPass => { + // Create per-channel generators for HighPass to prevent prev_white state from + // crossing channel boundaries in interleaved audio. Each channel must have an + // independent RNG to avoid correlation. Use this iterator instead of the `vec!` + // macro to avoid cloning the RNG. + Self::HighPass( + (0..channels.get()) + .map(|_| Blue::new(sample_rate)) + .collect(), + ) + } } } #[inline] - fn next(&mut self) -> Option { + fn next(&mut self, channel: usize) -> Option { match self { Self::TPDF(gen) => gen.next(), Self::RPDF(gen) => gen.next(), Self::GPDF(gen) => gen.next(), - Self::HighPass(gen) => gen.next(), + Self::HighPass(gens) => gens[channel].next(), } } + #[inline] fn algorithm(&self) -> Algorithm { match self { Self::TPDF(_) => Algorithm::TPDF, @@ -102,6 +113,32 @@ impl NoiseGenerator { Self::HighPass(_) => Algorithm::HighPass, } } + + #[inline] + fn sample_rate(&self) -> SampleRate { + match self { + Self::TPDF(gen) => gen.sample_rate(), + Self::RPDF(gen) => gen.sample_rate(), + Self::GPDF(gen) => gen.sample_rate(), + Self::HighPass(gens) => gens + .first() + .map(|g| g.sample_rate()) + .expect("HighPass should have at least one generator"), + } + } + + #[inline] + fn update_parameters(&mut self, sample_rate: SampleRate, channels: ChannelCount) { + if self.sample_rate() != sample_rate { + // The noise generators that we use are currently not dependent on sample rate, + // but we recreate them anyway in case that changes in the future. + *self = Self::new(self.algorithm(), sample_rate, channels); + } else if let Self::HighPass(gens) = self { + // Sample rate unchanged - only adjust channel count for stateful algorithms + // resize_with is a no-op if the size hasn't changed + gens.resize_with(channels.get() as usize, || Blue::new(sample_rate)); + } + } } /// A dithered audio source that applies quantization noise to reduce artifacts. @@ -123,6 +160,8 @@ impl NoiseGenerator { pub struct Dither { input: I, noise: NoiseGenerator, + current_channel: usize, + remaining_in_span: Option, lsb_amplitude: f32, } @@ -133,29 +172,29 @@ where /// Creates a new dithered source with the specified algorithm pub fn new(input: I, target_bits: BitDepth, algorithm: Algorithm) -> Self { // LSB amplitude for signed audio: 1.0 / (2^(bits-1)) - // For high bit depths (> mantissa precision), we're limited by the sample type's - // mantissa bits. Instead of dithering to a level that would be truncated, - // we dither at the actual LSB level representable by the sample format. - let lsb_amplitude = if target_bits.get() >= Sample::MANTISSA_DIGITS { - Sample::MIN_POSITIVE - } else { - 1.0 / (1_i64 << (target_bits.get() - 1)) as f32 - }; + // Using f64 intermediate prevents precision loss and u64 handles all bit depths without + // overflow (64-bit being the theoretical maximum for audio samples). Values stay well + // above f32 denormal threshold, avoiding denormal arithmetic performance penalty. + let lsb_amplitude = (1.0 / (1_u64 << (target_bits.get() - 1)) as f64) as f32; let sample_rate = input.sample_rate(); + let channels = input.channels(); + let active_span_len = input.current_span_len(); + Self { input, - noise: NoiseGenerator::new(algorithm, sample_rate), + noise: NoiseGenerator::new(algorithm, sample_rate, channels), + current_channel: 0, + remaining_in_span: active_span_len, lsb_amplitude, } } /// Change the dithering algorithm at runtime - /// This recreates the noise generator with the new algorithm pub fn set_algorithm(&mut self, algorithm: Algorithm) { if self.noise.algorithm() != algorithm { - let sample_rate = self.input.sample_rate(); - self.noise = NoiseGenerator::new(algorithm, sample_rate); + self.noise = + NoiseGenerator::new(algorithm, self.input.sample_rate(), self.input.channels()); } } @@ -174,14 +213,43 @@ where #[inline] fn next(&mut self) -> Option { + if let Some(ref mut remaining) = self.remaining_in_span { + *remaining = remaining.saturating_sub(1); + } + + // Consume next input sample *after* decrementing span position and *before* checking for + // span boundary crossing. This ensures that the source has its parameters updated + // correctly before we generate noise for the next sample. let input_sample = self.input.next()?; - let noise_sample = self.noise.next().unwrap_or(0.0); + let num_channels = self.input.channels(); + + if self.remaining_in_span == Some(0) { + self.noise + .update_parameters(self.input.sample_rate(), num_channels); + self.current_channel = 0; + self.remaining_in_span = self.input.current_span_len(); + } + + let noise_sample = self + .noise + .next(self.current_channel) + .expect("Noise generator should always produce samples"); + + // Advance to next channel (wrapping around) + self.current_channel = (self.current_channel + 1) % num_channels.get() as usize; // Apply subtractive dithering at the target quantization level Some(input_sample - noise_sample * self.lsb_amplitude) } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.input.size_hint() + } } +impl ExactSizeIterator for Dither where I: Source + ExactSizeIterator {} + impl Source for Dither where I: Source, @@ -258,4 +326,61 @@ mod tests { ); } } + + #[test] + fn test_highpass_dither_multichannel_independence() { + use crate::source::Zero; + + // Create a stereo source that outputs zeros + // This makes it easy to extract just the dither noise + let constant_source = Zero::new(nz!(2), TEST_SAMPLE_RATE); + + // Apply HighPass dithering to stereo + let mut dithered = Dither::new(constant_source, TEST_BIT_DEPTH, Algorithm::HighPass); + + // Collect interleaved samples (L, R, L, R, ...) + let samples: Vec = dithered.by_ref().take(1000).collect(); + + // De-interleave into left and right channels + let left: Vec = samples.iter().step_by(2).copied().collect(); + let right: Vec = samples.iter().skip(1).step_by(2).copied().collect(); + + assert_eq!(left.len(), 500); + assert_eq!(right.len(), 500); + + // Calculate autocorrelation at lag 1 for each channel + // Blue noise (high-pass) should have negative correlation at lag 1 + let left_autocorr: f32 = + left.windows(2).map(|w| w[0] * w[1]).sum::() / (left.len() - 1) as f32; + + let right_autocorr: f32 = + right.windows(2).map(|w| w[0] * w[1]).sum::() / (right.len() - 1) as f32; + + // Blue noise should have negative autocorrelation (high-pass characteristic) + // If channels were cross-contaminated, this property would be broken + assert!( + left_autocorr < 0.0, + "Left channel should have negative autocorr (high-pass), got {}", + left_autocorr + ); + assert!( + right_autocorr < 0.0, + "Right channel should have negative autocorr (high-pass), got {}", + right_autocorr + ); + + // Channels should be independent - cross-correlation between L and R should be near zero + let cross_corr: f32 = left + .iter() + .zip(right.iter()) + .map(|(l, r)| l * r) + .sum::() + / left.len() as f32; + + assert!( + cross_corr.abs() < 0.1, + "Channels should be independent, cross-correlation should be near 0, got {}", + cross_corr + ); + } }