Skip to content

Commit d89514d

Browse files
roderickvddvdsk
andauthored
feat: add audio dithering support (#794)
* feat: add audio dithering support with dither source and feature flag * refactor: Velvet noise to use usize for density and positions * refactor: replace f32 with Sample where appropriate for consistency * refactor: remove explicit SmallRng from noise type impl blocks * refactor: remove SeedableRng bound from noise generators and change Velvet density to NonZero --------- Co-authored-by: dvdsk <[email protected]>
1 parent 317a8b6 commit d89514d

File tree

8 files changed

+372
-64
lines changed

8 files changed

+372
-64
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
SamplesBuffer.
2222
- Adds `wav_to_writer` which writes a `Source` to a writer.
2323
- Added supported for `I24` output (24-bit samples on 4 bytes storage).
24+
- Added audio dithering support with `dither` feature (enabled by default):
25+
- Four dithering algorithms: `TPDF`, `RPDF`, `GPDF`, and `HighPass`
26+
- `DitherAlgorithm` enum for algorithm selection
27+
- `Source::dither()` function for applying dithering
2428

2529
### Fixed
2630
- docs.rs will now document all features, including those that are optional.
@@ -32,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3236
- `output_to_wav` renamed to `wav_to_file` and now takes ownership of the `Source`.
3337
- `Blue` noise generator uses uniform instead of Gaussian noise for better performance.
3438
- `Gaussian` noise generator has standard deviation of 0.6 for perceptual equivalence.
39+
- `Velvet` noise generator takes density in Hz as `usize` instead of `f32`.
3540

3641
## Version [0.21.1] (2025-07-14)
3742

Cargo.toml

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,23 @@ edition = "2021"
1111

1212
[features]
1313
# Default feature set provides audio playback and common format support
14-
default = ["playback", "recording", "flac", "mp3", "mp4", "vorbis", "wav"]
14+
default = [
15+
"playback",
16+
"recording",
17+
"flac",
18+
"mp3",
19+
"mp4",
20+
"vorbis",
21+
"wav",
22+
"dither",
23+
]
1524

1625
# Core functionality features
1726
#
1827
# Enable audio playback
1928
playback = ["dep:cpal"]
2029
# Enable audio recording
21-
recording = ["dep:cpal", "rtrb"]
30+
recording = ["dep:cpal", "dep:rtrb"]
2231
# Enable writing audio to WAV files
2332
wav_output = ["dep:hound"]
2433
# Enable structured observability and instrumentation
@@ -28,6 +37,8 @@ experimental = ["dep:atomic_float"]
2837

2938
# Audio generation features
3039
#
40+
# Enable audio dithering
41+
dither = ["noise"]
3142
# Enable noise generation (white noise, pink noise, etc.)
3243
noise = ["rand", "rand_distr"]
3344

src/common.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ pub type SampleRate = NonZero<u32>;
77
/// Number of channels in a stream. Can never be Zero
88
pub type ChannelCount = NonZero<u16>;
99

10+
/// Number of bits per sample. Can never be zero.
11+
pub type BitDepth = NonZero<u32>;
12+
1013
/// Represents value of a single sample.
1114
/// Silence corresponds to the value `0.0`. The expected amplitude range is -1.0...1.0.
1215
/// Values below and above this range are clipped in conversion to other sample types.

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ pub mod queue;
189189
pub mod source;
190190
pub mod static_buffer;
191191

192-
pub use crate::common::{ChannelCount, Sample, SampleRate};
192+
pub use crate::common::{BitDepth, ChannelCount, Sample, SampleRate};
193193
pub use crate::decoder::Decoder;
194194
pub use crate::sink::Sink;
195195
pub use crate::source::Source;

src/source/dither.rs

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
//! Dithering for audio quantization and requantization.
2+
//!
3+
//! Dithering is a technique in digital audio processing that eliminates quantization
4+
//! artifacts during various stages of audio processing. This module provides tools for
5+
//! adding appropriate dither noise to maintain audio quality during quantization
6+
//! operations.
7+
//!
8+
//! ## Example
9+
//!
10+
//! ```rust
11+
//! use rodio::source::{DitherAlgorithm, SineWave};
12+
//! use rodio::{BitDepth, Source};
13+
//!
14+
//! let source = SineWave::new(440.0);
15+
//! let dithered = source.dither(BitDepth::new(16).unwrap(), DitherAlgorithm::TPDF);
16+
//! ```
17+
//!
18+
//! ## Guidelines
19+
//!
20+
//! - **Apply dithering before volume changes** for optimal results
21+
//! - **Dither once** - Apply only at the final output stage to avoid noise accumulation
22+
//! - **Choose TPDF** for most professional audio applications (it's the default)
23+
//! - **Use target output bit depth** - Not the source bit depth!
24+
//!
25+
//! When you later change volume (e.g., with `Sink::set_volume()`), both the signal
26+
//! and dither noise scale together, maintaining proper dithering behavior.
27+
28+
use rand::{rngs::SmallRng, Rng};
29+
use std::time::Duration;
30+
31+
use crate::{
32+
source::noise::{Blue, WhiteGaussian, WhiteTriangular, WhiteUniform},
33+
BitDepth, ChannelCount, Sample, SampleRate, Source,
34+
};
35+
36+
/// Dither algorithm selection for runtime choice
37+
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
38+
pub enum Algorithm {
39+
/// GPDF (Gaussian PDF) - normal/bell curve distribution.
40+
///
41+
/// Uses Gaussian white noise which more closely mimics natural processes and
42+
/// analog circuits. Higher noise floor than TPDF.
43+
GPDF,
44+
45+
/// High-pass dithering - reduces low-frequency artifacts.
46+
///
47+
/// Uses blue noise (high-pass filtered white noise) to push dither energy
48+
/// toward higher frequencies. Particularly effective for reducing audible
49+
/// low-frequency modulation artifacts.
50+
HighPass,
51+
52+
/// RPDF (Rectangular PDF) - uniform distribution.
53+
///
54+
/// Uses uniform white noise for basic decorrelation. Simpler than TPDF but
55+
/// allows some correlation between signal and quantization error at low levels.
56+
/// Slightly lower noise floor than TPDF.
57+
RPDF,
58+
59+
/// TPDF (Triangular PDF) - triangular distribution.
60+
///
61+
/// The gold standard for audio dithering. Provides mathematically optimal
62+
/// decorrelation by completely eliminating correlation between the original
63+
/// signal and quantization error.
64+
#[default]
65+
TPDF,
66+
}
67+
68+
#[derive(Clone, Debug)]
69+
#[allow(clippy::upper_case_acronyms)]
70+
enum NoiseGenerator<R: Rng = SmallRng> {
71+
TPDF(WhiteTriangular<R>),
72+
RPDF(WhiteUniform<R>),
73+
GPDF(WhiteGaussian<R>),
74+
HighPass(Blue<R>),
75+
}
76+
77+
impl NoiseGenerator {
78+
fn new(algorithm: Algorithm, sample_rate: SampleRate) -> Self {
79+
match algorithm {
80+
Algorithm::TPDF => Self::TPDF(WhiteTriangular::new(sample_rate)),
81+
Algorithm::RPDF => Self::RPDF(WhiteUniform::new(sample_rate)),
82+
Algorithm::GPDF => Self::GPDF(WhiteGaussian::new(sample_rate)),
83+
Algorithm::HighPass => Self::HighPass(Blue::new(sample_rate)),
84+
}
85+
}
86+
87+
#[inline]
88+
fn next(&mut self) -> Option<Sample> {
89+
match self {
90+
Self::TPDF(gen) => gen.next(),
91+
Self::RPDF(gen) => gen.next(),
92+
Self::GPDF(gen) => gen.next(),
93+
Self::HighPass(gen) => gen.next(),
94+
}
95+
}
96+
97+
fn algorithm(&self) -> Algorithm {
98+
match self {
99+
Self::TPDF(_) => Algorithm::TPDF,
100+
Self::RPDF(_) => Algorithm::RPDF,
101+
Self::GPDF(_) => Algorithm::GPDF,
102+
Self::HighPass(_) => Algorithm::HighPass,
103+
}
104+
}
105+
}
106+
107+
/// A dithered audio source that applies quantization noise to reduce artifacts.
108+
///
109+
/// This struct wraps any audio source and applies dithering noise according to the
110+
/// selected algorithm. Dithering is essential for digital audio playback and when
111+
/// converting audio to different bit depths to prevent audible distortion.
112+
///
113+
/// # Example
114+
///
115+
/// ```rust
116+
/// use rodio::source::{DitherAlgorithm, SineWave};
117+
/// use rodio::{BitDepth, Source};
118+
///
119+
/// let source = SineWave::new(440.0);
120+
/// let dithered = source.dither(BitDepth::new(16).unwrap(), DitherAlgorithm::TPDF);
121+
/// ```
122+
#[derive(Clone, Debug)]
123+
pub struct Dither<I> {
124+
input: I,
125+
noise: NoiseGenerator,
126+
lsb_amplitude: f32,
127+
}
128+
129+
impl<I> Dither<I>
130+
where
131+
I: Source,
132+
{
133+
/// Creates a new dithered source with the specified algorithm
134+
pub fn new(input: I, target_bits: BitDepth, algorithm: Algorithm) -> Self {
135+
// LSB amplitude for signed audio: 1.0 / (2^(bits-1))
136+
// For high bit depths (> mantissa precision), we're limited by the sample type's
137+
// mantissa bits. Instead of dithering to a level that would be truncated,
138+
// we dither at the actual LSB level representable by the sample format.
139+
let lsb_amplitude = if target_bits.get() >= Sample::MANTISSA_DIGITS {
140+
Sample::MIN_POSITIVE
141+
} else {
142+
1.0 / (1_i64 << (target_bits.get() - 1)) as f32
143+
};
144+
145+
let sample_rate = input.sample_rate();
146+
Self {
147+
input,
148+
noise: NoiseGenerator::new(algorithm, sample_rate),
149+
lsb_amplitude,
150+
}
151+
}
152+
153+
/// Change the dithering algorithm at runtime
154+
/// This recreates the noise generator with the new algorithm
155+
pub fn set_algorithm(&mut self, algorithm: Algorithm) {
156+
if self.noise.algorithm() != algorithm {
157+
let sample_rate = self.input.sample_rate();
158+
self.noise = NoiseGenerator::new(algorithm, sample_rate);
159+
}
160+
}
161+
162+
/// Get the current dithering algorithm
163+
#[inline]
164+
pub fn algorithm(&self) -> Algorithm {
165+
self.noise.algorithm()
166+
}
167+
}
168+
169+
impl<I> Iterator for Dither<I>
170+
where
171+
I: Source,
172+
{
173+
type Item = Sample;
174+
175+
#[inline]
176+
fn next(&mut self) -> Option<Self::Item> {
177+
let input_sample = self.input.next()?;
178+
let noise_sample = self.noise.next().unwrap_or(0.0);
179+
180+
// Apply subtractive dithering at the target quantization level
181+
Some(input_sample - noise_sample * self.lsb_amplitude)
182+
}
183+
}
184+
185+
impl<I> Source for Dither<I>
186+
where
187+
I: Source,
188+
{
189+
#[inline]
190+
fn current_span_len(&self) -> Option<usize> {
191+
self.input.current_span_len()
192+
}
193+
194+
#[inline]
195+
fn channels(&self) -> ChannelCount {
196+
self.input.channels()
197+
}
198+
199+
#[inline]
200+
fn sample_rate(&self) -> SampleRate {
201+
self.input.sample_rate()
202+
}
203+
204+
#[inline]
205+
fn total_duration(&self) -> Option<Duration> {
206+
self.input.total_duration()
207+
}
208+
209+
#[inline]
210+
fn try_seek(&mut self, pos: Duration) -> Result<(), crate::source::SeekError> {
211+
self.input.try_seek(pos)
212+
}
213+
}
214+
215+
#[cfg(test)]
216+
mod tests {
217+
use super::*;
218+
use crate::source::{SineWave, Source};
219+
use crate::{nz, BitDepth, SampleRate};
220+
221+
const TEST_SAMPLE_RATE: SampleRate = nz!(44100);
222+
const TEST_BIT_DEPTH: BitDepth = nz!(16);
223+
224+
#[test]
225+
fn test_dither_adds_noise() {
226+
let source = SineWave::new(440.0).take_duration(std::time::Duration::from_millis(10));
227+
let mut dithered = Dither::new(source.clone(), TEST_BIT_DEPTH, Algorithm::TPDF);
228+
let mut undithered = source;
229+
230+
// Collect samples from both sources
231+
let dithered_samples: Vec<f32> = (0..10).filter_map(|_| dithered.next()).collect();
232+
let undithered_samples: Vec<f32> = (0..10).filter_map(|_| undithered.next()).collect();
233+
234+
let lsb = 1.0 / (1_i64 << (TEST_BIT_DEPTH.get() - 1)) as f32;
235+
236+
// Verify dithered samples differ from undithered and are reasonable
237+
for (i, (&dithered_sample, &undithered_sample)) in dithered_samples
238+
.iter()
239+
.zip(undithered_samples.iter())
240+
.enumerate()
241+
{
242+
// Should be finite
243+
assert!(
244+
dithered_sample.is_finite(),
245+
"Dithered sample {} should be finite",
246+
i
247+
);
248+
249+
// The difference should be small (just dither noise)
250+
let diff = (dithered_sample - undithered_sample).abs();
251+
let max_expected_diff = lsb * 2.0; // Max triangular dither amplitude
252+
assert!(
253+
diff <= max_expected_diff,
254+
"Dither noise too large: sample {}, diff {}, max expected {}",
255+
i,
256+
diff,
257+
max_expected_diff
258+
);
259+
}
260+
}
261+
}

src/source/limit.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -817,7 +817,7 @@ pub struct LimitMulti<I> {
817817
fn process_sample(sample: Sample, threshold: f32, knee_width: f32, inv_knee_8: f32) -> f32 {
818818
// Add slight DC offset. Some samples are silence, which is -inf dB and gets the limiter stuck.
819819
// Adding a small positive offset prevents this.
820-
let bias_db = math::linear_to_db(sample.abs() + f32::MIN_POSITIVE) - threshold;
820+
let bias_db = math::linear_to_db(sample.abs() + Sample::MIN_POSITIVE) - threshold;
821821
let knee_boundary_db = bias_db * 2.0;
822822
if knee_boundary_db < -knee_width {
823823
0.0

src/source/mod.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use std::sync::Arc;
66
use crate::{
77
buffer::SamplesBuffer,
88
common::{assert_error_traits, ChannelCount, SampleRate},
9-
math, Sample,
9+
math, BitDepth, Sample,
1010
};
1111

1212
use dasp_sample::FromSample;
@@ -85,6 +85,13 @@ mod triangle;
8585
mod uniform;
8686
mod zero;
8787

88+
#[cfg(feature = "dither")]
89+
#[cfg_attr(docsrs, doc(cfg(feature = "dither")))]
90+
pub mod dither;
91+
#[cfg(feature = "dither")]
92+
#[cfg_attr(docsrs, doc(cfg(feature = "dither")))]
93+
pub use self::dither::{Algorithm as DitherAlgorithm, Dither};
94+
8895
#[cfg(feature = "noise")]
8996
#[cfg_attr(docsrs, doc(cfg(feature = "noise")))]
9097
pub mod noise;
@@ -190,6 +197,31 @@ pub trait Source: Iterator<Item = Sample> {
190197
buffered::buffered(self)
191198
}
192199

200+
/// Applies dithering to the source at the specified bit depth.
201+
///
202+
/// Dithering eliminates quantization artifacts during digital audio playback
203+
/// and when converting between bit depths. Apply at the target output bit depth.
204+
///
205+
/// # Example
206+
///
207+
/// ```
208+
/// use rodio::source::{SineWave, Source, DitherAlgorithm};
209+
/// use rodio::BitDepth;
210+
///
211+
/// let source = SineWave::new(440.0)
212+
/// .amplify(0.5)
213+
/// .dither(BitDepth::new(16).unwrap(), DitherAlgorithm::default());
214+
/// ```
215+
#[cfg(feature = "dither")]
216+
#[cfg_attr(docsrs, doc(cfg(feature = "dither")))]
217+
#[inline]
218+
fn dither(self, target_bits: BitDepth, algorithm: DitherAlgorithm) -> Dither<Self>
219+
where
220+
Self: Sized,
221+
{
222+
Dither::new(self, target_bits, algorithm)
223+
}
224+
193225
/// Mixes this source with another one.
194226
#[inline]
195227
fn mix<S>(self, other: S) -> Mix<Self, S>

0 commit comments

Comments
 (0)