From 9dca87dc45fda524267c0fe4ad95348a2ca98e53 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 19 Oct 2026 00:53:27 +0200 Subject: [PATCH 1/4] fix(audio): dither 16-bit exclusive output where the chain moved a sample Rounding a sample that volume, ReplayGain or the EQ moved off a 16-bit step leaves an error that follows the signal, heard as distortion on quiet passages and fade-outs. TPDF dither of one step peak turns it into a steady hiss near -96 dBFS. A sample that already is a 16-bit value is left alone, so an unaltered 16-bit stream stays bit-perfect; all 65 536 values are tested. --- docs/features/playback.md | 1 + .../crates/app/src/audio/alsa_exclusive.rs | 65 +++++++++++--- src-tauri/crates/app/src/audio/pcm_scale.rs | 87 +++++++++++++++++++ .../crates/app/src/audio/wasapi_exclusive.rs | 46 ++++++++-- 4 files changed, 180 insertions(+), 19 deletions(-) diff --git a/docs/features/playback.md b/docs/features/playback.md index 6412bc6d6..736e879f9 100644 --- a/docs/features/playback.md +++ b/docs/features/playback.md @@ -10,6 +10,7 @@ The audio path lives in [`src-tauri/crates/app/src/audio/`](../../src-tauri/crat - **Native DSD via DoP** (DSD over PCM, #495, opt-in `profile_setting['audio.dsd_dop']` default OFF) — when the toggle is on AND the active output has exclusive device access AND the DAC accepts the format, a DSD track skips the FIR entirely: [`DsdToDop`](../../src-tauri/crates/core/src/audio_format/dsd/dop.rs) repackages the raw 1-bit stream into 24-bit DoP frames (marker `0x05`/`0xFA` alternating per frame, payload MSB-first — DFF verbatim, DSF bit-reversed) at `dsd_rate / 16` (DSD64 → 176.4 kHz, DSD128 → 352.8, DSD256 → 705.6), and the DAC reconstructs the 1-bit stream in hardware (truly bit-perfect, nothing on our side filters / resamples / gains it). **Per-platform exclusive backend** — DoP needs a mixer-free path to the DAC, so each OS has its own: **Windows** = WASAPI Exclusive ([`run_dop_event_loop`](../../src-tauri/crates/app/src/audio/wasapi_exclusive.rs)); **Linux** = a raw `hw:` ALSA device at `S32_LE`, marker MSB-justified ([`alsa_exclusive`](../../src-tauri/crates/app/src/audio/alsa_exclusive.rs)); **macOS** = CoreAudio **hog mode** + a forced physical stream format at the DoP rate in 32-bit int, fed through an `AudioUnit` render callback ([`coreaudio_exclusive`](../../src-tauri/crates/app/src/audio/coreaudio_exclusive.rs)); that format is a property of the **device**, so the previous one is saved and restored on teardown — otherwise the rest of the machine keeps talking to a DAC clocked for DoP — and device loss normally arrives through an `IsAlive` property listener, with a periodic `get_hogging_pid` query as the fallback when the listener can't be registered. All three ship the DoP words MSB-justified (marker in the top byte) and share the byte/idle packing in [`audio/dop_pack`](../../src-tauri/crates/app/src/audio/dop_pack.rs), which is also where the `0x05`/`0xFA` marker is **re-stamped** onto every outgoing frame from a single running counter: the encoder's own phase restarts on each seek and never sees the idle frames generated on pause / underrun, so letting both sides number frames independently would repeat or skip a marker exactly at those seams and drop the DAC out of DSD lock. On a cold `LoadAndPlay`, [`maybe_switch_dop_output`](../../src-tauri/crates/app/src/audio/decoder.rs) parses the DSD header for the DoP rate and asks the engine to re-open the exclusive output at that exact format ([`AudioEngine::switch_output_for_track`](../../src-tauri/crates/app/src/audio/engine.rs), which hands the fresh ring producer straight back rather than via the `SwapProducer` channel); the backend ships the words bit-exact and emits marker-carrying DoP idle frames (`0x69` payload) on pause/underrun so the DAC keeps DoP lock. **Linux: the card is asked for, not given up on.** PipeWire / PulseAudio hold every card from login, so the raw `hw:` open returned `EBUSY` on any desktop and DoP fell back silently — the toggle did nothing and said nothing. [`device_reservation`](../../src-tauri/crates/app/src/audio/device_reservation.rs) now takes the `org.freedesktop.ReserveDevice1.Audio` name on the session bus, which is the protocol both servers watch in order to release a device, then the open is retried for up to a second while the server finishes letting go (releasing is asynchronous on its side). The reservation is bound to the stream and released with it. No session bus, or an owner that refuses to be replaced, lands exactly where the code was before. **Fully fail-soft**: a DAC that refuses the DoP rate, a non-exclusive output, or a platform without a DoP backend all fall back transparently to the DSD → PCM path above. The two load paths that never negotiate a format — remote files and HTTP streams — force the output back to PCM before opening, since a leftover DoP output would read their PCM samples as 24-bit words and ship them to the DAC as noise. On Windows DoP rides the separate WASAPI Exclusive opt-in; on Linux and macOS the DoP toggle itself engages the exclusive path (raw `hw:` / hog mode). DoP tracks never crossfade / gaplessly prefetch (the words can't be mixed), so they always transition through a cold load + output re-open; EQ / ReplayGain / normalize / mono / speed are bypassed (bit-perfect, volume is the DAC's job — and playback speed is pinned to 1× for the track, since the resampler that implements it isn't in the chain and only the reported position would move). Seeking is the one transport action that survives untouched, so **A-B loop works on DoP tracks** just like on PCM ones. The pipeline popover shows a "Native DSD" pill sourced from `player_get_state.dop_active` — what actually engaged, not just the opt-in. **Playing DoP to a non-DoP DAC produces white noise**, so the toggle is opt-in and default OFF for users who know their DAC supports it. - **Output** — [`cpal 0.17`](https://crates.io/crates/cpal) on a dedicated thread because `cpal::Stream` is `!Send` on Windows. Samples cross the thread via an [`rtrb 0.4`](https://crates.io/crates/rtrb) SPSC ring (`RING_CAPACITY = 96 000` `f32`s ≈ 1 s @ 48 kHz stereo). - **Hot-path rules** — the cpal callback never allocates, locks or logs. It only reads the `rtrb::Consumer` and `Atomic*` fields in `SharedPlayback`. +- **Integer output and dither** — the exclusive backends turn the `f32` stream back into integer PCM through [`audio/pcm_scale`](../../src-tauri/crates/app/src/audio/pcm_scale.rs), which multiplies by the same power of two symphonia divided by, so an untouched source reaches the device bit for bit. **16-bit output is dithered** (TPDF, one step peak, `Dither16`) wherever the chain moved a sample off a 16-bit step — volume, ReplayGain, the EQ, a fade. Rounding alone leaves an error that follows the signal and is heard as distortion on quiet passages and fade-outs; the noise turns it into a steady hiss at about −96 dBFS. A sample that already is a 16-bit value is left alone, so a 16-bit track nothing altered stays bit-perfect (every one of the 65 536 values is tested). 24- and 32-bit output is not dithered: its step sits below any converter's own noise. The shared-mode output is converted by cpal and the system mixer, not by us. ## Spectrum visualizer diff --git a/src-tauri/crates/app/src/audio/alsa_exclusive.rs b/src-tauri/crates/app/src/audio/alsa_exclusive.rs index bffe3dcbe..89caf97e6 100644 --- a/src-tauri/crates/app/src/audio/alsa_exclusive.rs +++ b/src-tauri/crates/app/src/audio/alsa_exclusive.rs @@ -783,7 +783,9 @@ fn set_period_and_buffer(hwp: &HwParams<'_>) -> AppResult<()> { /// The integer formats go through [`super::pcm_scale`], which inverts the /// decoder's own float conversion exactly, so an untouched stream reaches /// the device bit for bit, and a sample past full scale saturates at the -/// endpoint rather than wrapping. Gain (volume, normalize, the mono mix) is applied +/// endpoint rather than wrapping. 16-bit output is dithered where the +/// chain moved a sample off a 16-bit step, and only there +/// ([`pcm_scale::Dither16`]). Gain (volume, normalize, the mono mix) is applied /// upstream in [`super::output::fill_pcm_period`], so a sample that clips /// here is one /// the chain genuinely pushed past 0 dBFS. @@ -791,7 +793,12 @@ fn set_period_and_buffer(hwp: &HwParams<'_>) -> AppResult<()> { /// Every path is bounded by both slice lengths, so a short `samples` /// leaves the tail of `bytes` at whatever it held — the caller keeps one /// buffer for the life of the stream and rewrites it whole each period. -fn pack_samples(format: AlsaSampleFormat, samples: &[f32], bytes: &mut [u8]) { +fn pack_samples( + format: AlsaSampleFormat, + samples: &[f32], + bytes: &mut [u8], + dither: &mut pcm_scale::Dither16, +) { match format { AlsaSampleFormat::F32 => { for (sample, chunk) in samples.iter().zip(bytes.chunks_exact_mut(4)) { @@ -823,7 +830,7 @@ fn pack_samples(format: AlsaSampleFormat, samples: &[f32], bytes: &mut [u8]) { } AlsaSampleFormat::S16 => { for (sample, chunk) in samples.iter().zip(bytes.chunks_exact_mut(2)) { - let v = pcm_scale::to_i16(*sample); + let v = dither.quantize(*sample); chunk.copy_from_slice(&v.to_le_bytes()); } } @@ -1129,6 +1136,7 @@ fn pcm_output_thread_main( // in all of them is an all-zero byte pattern — so one pre-zeroed // buffer serves the paused and drain paths whichever we landed on. let silence: Vec = vec![0u8; period_frames * frame_bytes]; + let mut dither = pcm_scale::Dither16::new(); let exit = 'run: loop { if shutdown_rx.try_recv().is_ok() { @@ -1148,7 +1156,7 @@ fn pcm_output_thread_main( } else { let written = super::output::fill_pcm_period(&shared, &mut consumer, &mut samples, channels); - pack_samples(format, &samples, &mut wire); + pack_samples(format, &samples, &mut wire, &mut dither); if written > 0 { shared.samples_played.fetch_add(written, Ordering::Relaxed); } @@ -1234,7 +1242,12 @@ mod tests { #[test] fn twenty_four_in_thirty_two_is_right_aligned_not_left() { let mut bytes = [0u8; 4]; - pack_samples(AlsaSampleFormat::S24In32, &[1.0], &mut bytes); + pack_samples( + AlsaSampleFormat::S24In32, + &[1.0], + &mut bytes, + &mut pcm_scale::Dither16::new(), + ); // 8_388_607 = 0x7F_FF_FF, little-endian, top byte untouched. assert_eq!(bytes, [0xFF, 0xFF, 0x7F, 0x00]); // The left-aligned layout would have been [0x00, 0xFF, 0xFF, 0x7F]. @@ -1244,7 +1257,12 @@ mod tests { #[test] fn a_negative_twenty_four_bit_sample_keeps_its_sign_extension() { let mut bytes = [0u8; 4]; - pack_samples(AlsaSampleFormat::S24In32, &[-1.0], &mut bytes); + pack_samples( + AlsaSampleFormat::S24In32, + &[-1.0], + &mut bytes, + &mut pcm_scale::Dither16::new(), + ); // -8_388_608 as i32 = 0xFF_80_00_00. The driver reads the low // three bytes; the 0xFF above them is the sign extension a plain // i32 store produces and is ignored. @@ -1254,14 +1272,24 @@ mod tests { #[test] fn the_packed_form_spends_three_bytes_and_no_container() { let mut bytes = [0u8; 3]; - pack_samples(AlsaSampleFormat::S24Packed, &[1.0], &mut bytes); + pack_samples( + AlsaSampleFormat::S24Packed, + &[1.0], + &mut bytes, + &mut pcm_scale::Dither16::new(), + ); assert_eq!(bytes, [0xFF, 0xFF, 0x7F]); } #[test] fn sixteen_bit_full_scale_lands_on_the_endpoints() { let mut bytes = [0u8; 4]; - pack_samples(AlsaSampleFormat::S16, &[1.0, -1.0], &mut bytes); + pack_samples( + AlsaSampleFormat::S16, + &[1.0, -1.0], + &mut bytes, + &mut pcm_scale::Dither16::new(), + ); assert_eq!(i16::from_le_bytes([bytes[0], bytes[1]]), 32_767); assert_eq!(i16::from_le_bytes([bytes[2], bytes[3]]), -32_768); } @@ -1272,16 +1300,31 @@ mod tests { // endpoint. Wrapping here would turn a loud passage // into a burst of full-scale noise of the opposite sign. let mut bytes = [0u8; 4]; - pack_samples(AlsaSampleFormat::S32, &[9.0], &mut bytes); + pack_samples( + AlsaSampleFormat::S32, + &[9.0], + &mut bytes, + &mut pcm_scale::Dither16::new(), + ); assert_eq!(i32::from_le_bytes(bytes), 2_147_483_647); - pack_samples(AlsaSampleFormat::S32, &[-9.0], &mut bytes); + pack_samples( + AlsaSampleFormat::S32, + &[-9.0], + &mut bytes, + &mut pcm_scale::Dither16::new(), + ); assert_eq!(i32::from_le_bytes(bytes), -2_147_483_648); } #[test] fn float_output_is_a_byte_copy() { let mut bytes = [0u8; 4]; - pack_samples(AlsaSampleFormat::F32, &[0.25], &mut bytes); + pack_samples( + AlsaSampleFormat::F32, + &[0.25], + &mut bytes, + &mut pcm_scale::Dither16::new(), + ); assert_eq!(f32::from_le_bytes(bytes), 0.25); } diff --git a/src-tauri/crates/app/src/audio/pcm_scale.rs b/src-tauri/crates/app/src/audio/pcm_scale.rs index 43725772c..d36f7220b 100644 --- a/src-tauri/crates/app/src/audio/pcm_scale.rs +++ b/src-tauri/crates/app/src/audio/pcm_scale.rs @@ -24,6 +24,61 @@ pub fn to_i16(sample: f32) -> i16 { (sample * 32_768.0).round().clamp(-32_768.0, 32_767.0) as i16 } +/// TPDF dither for 16-bit output. +/// +/// A sample the chain changed (volume, ReplayGain, the EQ, a fade) falls +/// between two 16-bit steps, and rounding it alone leaves an error that +/// follows the signal: at low level, on a fade-out, it is heard as +/// distortion rather than noise. Adding triangular noise of one step peak +/// before rounding turns that error into a steady, signal-independent hiss +/// at about -96 dBFS. +/// +/// A sample that is already a 16-bit value is passed through untouched: +/// that is every sample of a 16-bit source nothing has altered, so the +/// bit-perfect path stays bit-perfect. 24- and 32-bit output need no +/// dither: their step sits below any converter's own noise. +/// +/// The noise comes from a xorshift generator held by the render loop, so +/// the hot path neither allocates nor locks. +pub struct Dither16 { + state: u32, +} + +impl Dither16 { + pub const fn new() -> Self { + Self { state: 0x9E37_79B9 } + } + + /// Uniform in [0, 1), from the top 24 bits of a xorshift32 step. + #[inline] + fn unit(&mut self) -> f32 { + let mut x = self.state; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + self.state = x; + (x >> 8) as f32 / 16_777_216.0 + } + + /// A sample as 16-bit PCM, dithered unless it already is one. + #[inline] + pub fn quantize(&mut self, sample: f32) -> i16 { + let scaled = sample * 32_768.0; + if scaled == scaled.round() { + return to_i16(sample); + } + // The difference of two uniforms is triangular over (-1, 1) step. + let noise = self.unit() - self.unit(); + (scaled + noise).round().clamp(-32_768.0, 32_767.0) as i16 + } +} + +impl Default for Dither16 { + fn default() -> Self { + Self::new() + } +} + /// A sample as 24-bit PCM, in the low 24 bits of an `i32`. #[inline] pub fn to_i24(sample: f32) -> i32 { @@ -85,6 +140,38 @@ mod tests { assert_eq!(to_i32(f32::NAN), 0); } + /// Dither never touches a sample that is already a 16-bit value, so + /// an unaltered 16-bit stream still comes back bit for bit. + #[test] + fn dither_leaves_every_16_bit_sample_unchanged() { + let mut dither = Dither16::new(); + for v in i16::MIN..=i16::MAX { + let decoded = f32::from_sample(v); + assert_eq!(dither.quantize(decoded), v, "i16 {v}"); + } + assert_eq!(dither.quantize(0.0), 0); + assert_eq!(dither.quantize(3.0), i16::MAX); + assert_eq!(dither.quantize(f32::NAN), 0); + } + + /// A sample between two steps lands on one of its neighbours, and on + /// average where it really is: the dither adds noise, not a bias. + #[test] + fn dither_is_bounded_and_unbiased() { + let mut dither = Dither16::new(); + let target = 1000.3_f32; + let sample = target / 32_768.0; + let runs = 200_000; + let mut sum = 0.0_f64; + for _ in 0..runs { + let v = dither.quantize(sample); + assert!((999..=1002).contains(&v), "{v}"); + sum += f64::from(v); + } + let mean = sum / f64::from(runs); + assert!((mean - f64::from(target)).abs() < 0.01, "mean {mean}"); + } + /// The old scaling, kept as a witness of what this module fixes: it /// moved a plain mid-level sample. #[test] diff --git a/src-tauri/crates/app/src/audio/wasapi_exclusive.rs b/src-tauri/crates/app/src/audio/wasapi_exclusive.rs index ca19388f7..24e61b930 100644 --- a/src-tauri/crates/app/src/audio/wasapi_exclusive.rs +++ b/src-tauri/crates/app/src/audio/wasapi_exclusive.rs @@ -1117,6 +1117,9 @@ fn run_event_loop( let mut samples: Vec = vec![0.0; need_samples]; let mut bytes_scratch: Vec = vec![0u8; buffer_bytes]; let silent_buf = vec![0u8; buffer_bytes]; + // Noise source for 16-bit output, held here so the period loop + // neither allocates nor reseeds. + let mut dither = pcm_scale::Dither16::new(); // 2 s timeout is generous — for a 10 ms device period the event // should fire every 10 ms. A timeout means the device went away @@ -1170,7 +1173,7 @@ fn run_event_loop( // exclusive format expects (#174). Hot path: no // allocations, no branches inside the inner loop // beyond the format dispatch above. - pack_samples(format, &samples, &mut bytes_scratch); + pack_samples(format, &samples, &mut bytes_scratch, &mut dither); if written > 0 { shared .samples_played @@ -1323,7 +1326,9 @@ fn run_dop_event_loop( /// /// The integer formats go through [`super::pcm_scale`], which inverts the /// decoder's own float conversion exactly, so an untouched stream reaches -/// the device bit for bit. The decoder upstream applies +/// the device bit for bit. 16-bit output is dithered where the chain +/// moved a sample off a 16-bit step, and only there +/// ([`pcm_scale::Dither16`]). The decoder upstream applies /// `volume * norm_gain` BEFORE this function, so the input is /// already at the final analogue amplitude; a clipped sample here /// means the upstream chain (EQ, ReplayGain, mono mix) pushed @@ -1333,7 +1338,12 @@ fn run_dop_event_loop( /// `bytes.len()` so a mismatched slot count (`samples` short by /// one) leaves trailing bytes at their previous values (which the /// caller pre-cleared to zero). No panics in the hot path. -fn pack_samples(format: ExclusiveSampleFormat, samples: &[f32], bytes: &mut [u8]) { +fn pack_samples( + format: ExclusiveSampleFormat, + samples: &[f32], + bytes: &mut [u8], + dither: &mut pcm_scale::Dither16, +) { match format { ExclusiveSampleFormat::Float32 => { for (sample, chunk) in samples.iter().zip(bytes.chunks_exact_mut(4)) { @@ -1364,7 +1374,7 @@ fn pack_samples(format: ExclusiveSampleFormat, samples: &[f32], bytes: &mut [u8] } ExclusiveSampleFormat::Pcm16 => { for (sample, chunk) in samples.iter().zip(bytes.chunks_exact_mut(2)) { - let v = pcm_scale::to_i16(*sample); + let v = dither.quantize(*sample); chunk.copy_from_slice(&v.to_le_bytes()); } } @@ -1567,7 +1577,12 @@ mod tests { fn pack_samples_float32_round_trips() { let samples = [0.0_f32, 1.0, -1.0, 0.5]; let mut bytes = vec![0u8; samples.len() * 4]; - pack_samples(ExclusiveSampleFormat::Float32, &samples, &mut bytes); + pack_samples( + ExclusiveSampleFormat::Float32, + &samples, + &mut bytes, + &mut pcm_scale::Dither16::new(), + ); for (i, sample) in samples.iter().enumerate() { let round = f32::from_le_bytes(bytes[i * 4..i * 4 + 4].try_into().unwrap()); assert_eq!(round, *sample); @@ -1578,7 +1593,12 @@ mod tests { fn pack_samples_s16_saturates_and_round_trips() { let samples = [0.0_f32, 1.0, -1.0, 0.5, 2.0, -2.0]; let mut bytes = vec![0u8; samples.len() * 2]; - pack_samples(ExclusiveSampleFormat::Pcm16, &samples, &mut bytes); + pack_samples( + ExclusiveSampleFormat::Pcm16, + &samples, + &mut bytes, + &mut pcm_scale::Dither16::new(), + ); let extract = |i: usize| i16::from_le_bytes(bytes[i * 2..i * 2 + 2].try_into().unwrap()); assert_eq!(extract(0), 0); assert_eq!(extract(1), 32_767); // +1.0 has no 16-bit value: saturates @@ -1592,7 +1612,12 @@ mod tests { fn pack_samples_s24_packed_three_bytes_per_sample() { let samples = [0.0_f32, 1.0, -1.0]; let mut bytes = vec![0u8; samples.len() * 3]; - pack_samples(ExclusiveSampleFormat::Pcm24Packed, &samples, &mut bytes); + pack_samples( + ExclusiveSampleFormat::Pcm24Packed, + &samples, + &mut bytes, + &mut pcm_scale::Dither16::new(), + ); // 0.0 → 0 assert_eq!(&bytes[0..3], &[0, 0, 0]); // 1.0 → 8_388_607 = 0x7F_FF_FF → LE [0xFF, 0xFF, 0x7F] @@ -1606,7 +1631,12 @@ mod tests { fn pack_samples_s24_padded_is_left_aligned_in_the_container() { let samples = [1.0_f32, -1.0_f32, 0.0_f32]; let mut bytes = vec![0u8; 12]; - pack_samples(ExclusiveSampleFormat::Pcm24Padded, &samples, &mut bytes); + pack_samples( + ExclusiveSampleFormat::Pcm24Padded, + &samples, + &mut bytes, + &mut pcm_scale::Dither16::new(), + ); // WAVEFORMATEXTENSIBLE: the 24 valid bits sit in the MOST significant // part of the 32-bit container, unused low bits zeroed. assert_eq!(&bytes[0..4], &(8_388_607_i32 << 8).to_le_bytes()); From a3cc03b51abe5ae9cf21558c1005ce057f817669 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 19 Oct 2026 00:53:28 +0200 Subject: [PATCH 2/4] fix(audio): give the eq automatic headroom so a boost no longer clips A boost pushed a loud master past full scale and the decoder's final clamp turned it into hard clipping. The chain now starts with a preamp equal to the peak of its own response, measured on the cascade since adjacent bands overlap and their boosts add up, plus 1 dB for the transients a filter can overshoot by (0.9 dB measured at worst). A flat or cut-only setting keeps a preamp of exactly 1.0. Also drops the brand names from the module's comments. --- docs/features/playback.md | 6 + src-tauri/crates/app/src/audio/eq.rs | 176 +++++++++++++++++++++++++-- 2 files changed, 171 insertions(+), 11 deletions(-) diff --git a/docs/features/playback.md b/docs/features/playback.md index 736e879f9..92f44d765 100644 --- a/docs/features/playback.md +++ b/docs/features/playback.md @@ -186,6 +186,12 @@ The total is bounded to [−30, +12] dB regardless, which is what stands between The scalar is recomputed **per decoded buffer** rather than baked into the stream at load time, so moving the pre-amp is audible immediately instead of at the next track. One `powf` per buffer, on the decoder thread — never in the cpal callback. +## Equalizer + +[`audio/eq.rs`](../../src-tauri/crates/app/src/audio/eq.rs): six peaking biquads in series (60 / 150 / 400 / 1000 / 2400 / 15 000 Hz, Q 0.707, ±12 dB per band, RBJ cookbook), on the decoder thread next to ReplayGain. Gains are atomics the sliders write; the coefficients are recomputed on the decoder thread when a band moves or the sample rate changes. Off, the stage is skipped entirely. + +**Headroom.** A boost raises a loud master past full scale, and the decoder's last stage clamps the signal to ±1.0, so +12 dB of bass used to be heard as hard clipping. The chain is now preceded by an automatic preamp equal to the peak of its own response, measured on the cascade (1 024 log-spaced points from 20 Hz to 20 kHz, plus each band's centre) rather than taken from the largest slider: adjacent bands overlap and their boosts add up, so the Bass booster preset (+6 / +5 / +3 dB) peaks near +8 dB. A boosting setting also takes 1 dB more for transients: the response peak bounds a steady signal, and a sharp full-scale pattern through +12 dB at 15 kHz overshoots it by 0.9 dB (measured). The strict bound, the sum of the impulse response, would cost every boosting setting 2 to 4 dB more for patterns music does not contain, so the margin covers the measured worst case and the final clamp stays the backstop. Bass booster thus plays about 9 dB lower. A setting that only cuts, or a flat one, keeps a preamp of exactly 1.0. The cost is that a boosting setting plays quieter than before; that is the level at which it no longer distorts. + ## Seek `format.seek()` + `decoder.reset()` + `resampler.flush()`. The cpal callback enters `drain_silent` mode, which (since 70c1968) drains the ring in **one bulk `while consumer.pop()` pass** instead of one sample per output slot — total perceived gap on seek dropped from ~270 ms (one full ring at 44.1 kHz × 8 ch) to ~10-15 ms (one cpal callback period). diff --git a/src-tauri/crates/app/src/audio/eq.rs b/src-tauri/crates/app/src/audio/eq.rs index c58544d4f..c62db681e 100644 --- a/src-tauri/crates/app/src/audio/eq.rs +++ b/src-tauri/crates/app/src/audio/eq.rs @@ -1,4 +1,4 @@ -//! 6-band peaking-EQ DSP, Spotify-style. +//! 6-band peaking-EQ DSP. //! //! Six biquad peaking filters in series at fixed ISO-ish frequencies //! (60 / 150 / 400 / 1000 / 2400 / 15000 Hz). Gains are atomic so the @@ -15,12 +15,21 @@ //! biquad). Direct Form II Transposed for the per-sample loop — //! numerically stable enough for f32 at audio rates and one register //! shorter than DF1. +//! +//! **Headroom.** A boost raises the signal past full scale on a loud +//! master, and the decoder's last stage clamps it to ±1.0: +12 dB of bass +//! on a modern mastered track was heard as hard clipping. So the chain is +//! preceded by a preamp equal to the peak of its own response, read off +//! the cascade itself rather than the largest slider — adjacent bands +//! overlap and their boosts add up — plus 1 dB for transients. A setting +//! with no boost has a preamp of exactly 1.0 and leaves the signal +//! untouched. use std::f32::consts::PI; use std::sync::atomic::{AtomicBool, AtomicI16, Ordering}; -/// Centre frequencies for each band, picked to match Spotify so the -/// visual UI maps 1:1 with what users may already be familiar with. +/// Centre frequencies for each band, the usual six-band consumer layout, +/// so the sliders sit where users expect them. pub const BAND_FREQS: [f32; 6] = [60.0, 150.0, 400.0, 1000.0, 2400.0, 15000.0]; /// Number of bands. Pulled into a const so the array sizes stay in @@ -32,9 +41,9 @@ pub const BAND_COUNT: usize = BAND_FREQS.len(); /// naturally, narrow enough that cuts/boosts feel surgical. const BAND_Q: f32 = 0.707; -/// Maximum cap (absolute value) for each band's gain in dB. Mirrors -/// Spotify's ±12 dB. The atomic stores tenths of dB (i16 range fits -/// up to ±3276 dB so we have plenty of head-room). +/// Maximum cap (absolute value) for each band's gain in dB, the usual +/// ±12 dB of a consumer EQ. The atomic stores tenths of dB (i16 range +/// fits up to ±3276 dB so we have plenty of head-room). pub const MAX_GAIN_DB: f32 = 12.0; /// Compute a peaking-EQ biquad coefficient set per the RBJ cookbook. @@ -56,6 +65,70 @@ fn peaking_biquad(freq_hz: f32, sample_rate: f32, gain_db: f32, q: f32) -> [f32; [b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0] } +/// Peak gain (linear) of the whole cascade, read over the audible band. +/// +/// Each biquad's magnitude is evaluated on the unit circle and multiplied +/// in, over 1024 log-spaced points from 20 Hz to 20 kHz (or just under +/// Nyquist) plus every band's centre, where a lone boost peaks. In `f64`: +/// this runs once per change of setting or sample rate, never per sample. +fn cascade_peak(coeffs: &[[f32; 5]; BAND_COUNT], sample_rate: f32) -> f32 { + let rate = f64::from(sample_rate); + if rate <= 0.0 { + return 1.0; + } + let top = 20_000.0_f64.min(rate * 0.49); + let low = 20.0_f64.min(top); + const POINTS: usize = 1024; + let sweep = (0..POINTS).map(|i| low * (top / low).powf(i as f64 / (POINTS - 1) as f64)); + let centres = BAND_FREQS + .iter() + .map(|&f| f64::from(f)) + .filter(|&f| f < rate * 0.5); + let mut peak = 0.0_f64; + for freq in sweep.chain(centres) { + let w = 2.0 * std::f64::consts::PI * freq / rate; + let (c1, s1) = (w.cos(), w.sin()); + let (c2, s2) = ((2.0 * w).cos(), (2.0 * w).sin()); + let mut gain = 1.0_f64; + for c in coeffs { + let [b0, b1, b2, a1, a2] = c.map(f64::from); + let num_re = b0 + b1 * c1 + b2 * c2; + let num_im = -(b1 * s1 + b2 * s2); + let den_re = 1.0 + a1 * c1 + a2 * c2; + let den_im = -(a1 * s1 + a2 * s2); + gain *= + ((num_re * num_re + num_im * num_im) / (den_re * den_re + den_im * den_im)).sqrt(); + } + peak = peak.max(gain); + } + peak as f32 +} + +/// Extra attenuation, as a linear factor (-1 dB), for a boosting setting. +/// +/// The response peak bounds a steady signal, not a transient: a filter +/// can overshoot on a sharp full-scale pattern. Measured with full-scale +/// `[1, -1, -1, 1, …]` input, the worst case is +12 dB at 15 kHz, which +/// overshoots its response peak by 0.9 dB. The strict bound (the sum of +/// the impulse response) would cost every boosting setting 2 to 4 dB more +/// for patterns music does not contain, so the margin covers the measured +/// worst case instead, and the decoder's final clamp stays the backstop. +const TRANSIENT_MARGIN: f32 = 0.891_250_9; + +/// The preamp that keeps the cascade at or under unity: `1 / peak`, less +/// the transient margin, for a boosting setting; exactly 1.0 for one that +/// only cuts or is flat. +fn headroom_preamp(coeffs: &[[f32; 5]; BAND_COUNT], sample_rate: f32) -> f32 { + let peak = cascade_peak(coeffs, sample_rate); + // A flat chain computes a hair over 1.0 in floating point; anything + // within 0.01 dB of unity is no boost. + if peak > 1.001 { + TRANSIENT_MARGIN / peak + } else { + 1.0 + } +} + /// Per-channel, per-band biquad memory (Direct Form II Transposed). /// Two state variables per filter — the bare minimum. #[derive(Debug, Default, Clone, Copy)] @@ -157,6 +230,9 @@ pub struct EqProcessor { /// Tracked separately so a sample-rate change (track A 44.1 → /// track B 48) forces a recompute even when bands didn't move. cached_rate: f32, + /// Linear gain applied ahead of the filters so the cascade's peak + /// stays at or under unity; see the module's note on headroom. + preamp: f32, } impl EqProcessor { @@ -165,6 +241,7 @@ impl EqProcessor { coeffs: [[1.0, 0.0, 0.0, 0.0, 0.0]; BAND_COUNT], states: Vec::new(), cached_rate: 0.0, + preamp: 1.0, } } @@ -186,6 +263,7 @@ impl EqProcessor { for (i, &freq) in BAND_FREQS.iter().enumerate() { self.coeffs[i] = peaking_biquad(freq, sample_rate, bands[i], BAND_Q); } + self.preamp = headroom_preamp(&self.coeffs, sample_rate); self.cached_rate = sample_rate; } @@ -211,10 +289,11 @@ impl EqProcessor { self.states.push([BiquadState::default(); BAND_COUNT]); } let frames = buf.len() / channels; + let preamp = self.preamp; for f in 0..frames { for ch in 0..channels { let i = f * channels + ch; - let mut x = buf[i]; + let mut x = buf[i] * preamp; let chan_state = &mut self.states[ch]; for (b, biquad) in chan_state.iter_mut().enumerate().take(BAND_COUNT) { x = biquad.process(x, &self.coeffs[b]); @@ -225,10 +304,8 @@ impl EqProcessor { } } -/// Built-in presets, gain in dB per band. Calibrated to feel close to -/// Spotify's own selections without claiming to be byte-identical -/// (Spotify doesn't publish the exact values — these are tuned by ear -/// against typical tracks). Order matches `BAND_FREQS`. +/// Built-in presets, gain in dB per band, tuned by ear against typical +/// tracks. Order matches `BAND_FREQS`. pub const PRESETS: &[(&str, [f32; BAND_COUNT])] = &[ ("flat", [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]), ("acoustic", [5.0, 4.5, 3.5, 1.0, 1.5, 3.0]), @@ -256,3 +333,80 @@ pub const PRESETS: &[(&str, [f32; BAND_COUNT])] = &[ pub fn preset_gains(key: &str) -> Option<[f32; BAND_COUNT]> { PRESETS.iter().find(|(k, _)| *k == key).map(|(_, g)| *g) } + +#[cfg(test)] +mod tests { + use super::*; + + const RATE: f32 = 44_100.0; + + fn chain(gains: [f32; BAND_COUNT]) -> [[f32; 5]; BAND_COUNT] { + let mut coeffs = [[1.0, 0.0, 0.0, 0.0, 0.0]; BAND_COUNT]; + for (i, &freq) in BAND_FREQS.iter().enumerate() { + coeffs[i] = peaking_biquad(freq, RATE, gains[i], BAND_Q); + } + coeffs + } + + fn db(linear: f32) -> f32 { + 20.0 * linear.log10() + } + + #[test] + fn a_flat_or_cutting_setting_leaves_the_level_alone() { + assert_eq!(headroom_preamp(&chain([0.0; BAND_COUNT]), RATE), 1.0); + let cuts = preset_gains("bass_reducer").unwrap(); + assert_eq!(headroom_preamp(&chain(cuts), RATE), 1.0); + } + + #[test] + fn a_lone_boost_is_matched_by_the_preamp() { + let mut gains = [0.0; BAND_COUNT]; + gains[3] = 6.0; + // The boost, plus the 1 dB transient margin. + let preamp = db(headroom_preamp(&chain(gains), RATE)); + assert!((preamp + 7.0).abs() < 0.1, "{preamp} dB"); + } + + #[test] + fn overlapping_boosts_take_more_than_the_largest_slider() { + // Bass booster: +6 / +5 / +3 dB on the three lowest bands, which + // overlap; their sum peaks past the +6 dB slider. + let gains = preset_gains("bass_booster").unwrap(); + let preamp = db(headroom_preamp(&chain(gains), RATE)); + assert!(preamp < -7.5, "{preamp} dB"); + } + + /// The worst transient measured: a sharp full-scale pattern through + /// +12 dB at 15 kHz overshoots the response peak by 0.9 dB, which the + /// margin absorbs. + #[test] + fn a_sharp_full_scale_pattern_under_a_treble_boost_stays_under_unity() { + let shared = EqShared::new(); + shared.set_enabled(true); + shared.set_band_db(BAND_COUNT - 1, MAX_GAIN_DB); + let mut eq = EqProcessor::new(); + let mut buf: Vec = [1.0, -1.0, -1.0, 1.0, -1.0, -1.0, 1.0].repeat(3); + eq.process(&mut buf, 1, RATE, &shared); + let peak = buf.iter().fold(0.0_f32, |m, x| m.max(x.abs())); + assert!(peak <= 1.0, "peak {peak}"); + } + + #[test] + fn a_full_scale_tone_under_full_boost_stays_under_unity() { + let shared = EqShared::new(); + shared.set_enabled(true); + shared.set_band_db(0, MAX_GAIN_DB); + let mut eq = EqProcessor::new(); + // One second of a full-scale 60 Hz sine, mono. + let mut buf: Vec = (0..RATE as usize) + .map(|n| (2.0 * PI * 60.0 * n as f32 / RATE).sin()) + .collect(); + eq.process(&mut buf, 1, RATE, &shared); + // Past the filter's settling time, the output peaks just under + // full scale (the 1 dB margin) instead of +12 dB over it. + let settled = &buf[buf.len() / 2..]; + let peak = settled.iter().fold(0.0_f32, |m, x| m.max(x.abs())); + assert!(peak <= 0.9 && peak > 0.85, "peak {peak}"); + } +} From 83300efb901fcccefeecfdc4152dcf163b4c4862 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 19 Oct 2026 01:28:17 +0200 Subject: [PATCH 3/4] test(audio): import pcm_scale in the alsa packing tests --- src-tauri/crates/app/src/audio/alsa_exclusive.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/crates/app/src/audio/alsa_exclusive.rs b/src-tauri/crates/app/src/audio/alsa_exclusive.rs index 89caf97e6..5d3158719 100644 --- a/src-tauri/crates/app/src/audio/alsa_exclusive.rs +++ b/src-tauri/crates/app/src/audio/alsa_exclusive.rs @@ -1216,7 +1216,7 @@ mod tests { assert!(!is_system_route("PCH")); } - use super::{pack_samples, AlsaSampleFormat}; + use super::{pack_samples, pcm_scale, AlsaSampleFormat}; #[test] fn an_unopened_engine_asks_for_stereo_not_mono() { From c29b6dbf873728a979acc07a66d71cb208953c25 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 19 Oct 2026 02:01:57 +0200 Subject: [PATCH 4/4] fix(audio): raise the eq preamp over a buffer instead of in one step A slider moved during playback changed the preamp between two samples, heard as a click. More attenuation still applies at once, since the new boost acts from the next sample, and is given back over one buffer. Also notes the measured cost of the response sweep (about 60 us in a release build) and adds dither tests for determinism and a state that never reaches zero. --- docs/features/playback.md | 2 +- src-tauri/crates/app/src/audio/eq.rs | 80 +++++++++++++++++++-- src-tauri/crates/app/src/audio/pcm_scale.rs | 24 +++++++ 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/docs/features/playback.md b/docs/features/playback.md index 92f44d765..c2fa52e76 100644 --- a/docs/features/playback.md +++ b/docs/features/playback.md @@ -190,7 +190,7 @@ The scalar is recomputed **per decoded buffer** rather than baked into the strea [`audio/eq.rs`](../../src-tauri/crates/app/src/audio/eq.rs): six peaking biquads in series (60 / 150 / 400 / 1000 / 2400 / 15 000 Hz, Q 0.707, ±12 dB per band, RBJ cookbook), on the decoder thread next to ReplayGain. Gains are atomics the sliders write; the coefficients are recomputed on the decoder thread when a band moves or the sample rate changes. Off, the stage is skipped entirely. -**Headroom.** A boost raises a loud master past full scale, and the decoder's last stage clamps the signal to ±1.0, so +12 dB of bass used to be heard as hard clipping. The chain is now preceded by an automatic preamp equal to the peak of its own response, measured on the cascade (1 024 log-spaced points from 20 Hz to 20 kHz, plus each band's centre) rather than taken from the largest slider: adjacent bands overlap and their boosts add up, so the Bass booster preset (+6 / +5 / +3 dB) peaks near +8 dB. A boosting setting also takes 1 dB more for transients: the response peak bounds a steady signal, and a sharp full-scale pattern through +12 dB at 15 kHz overshoots it by 0.9 dB (measured). The strict bound, the sum of the impulse response, would cost every boosting setting 2 to 4 dB more for patterns music does not contain, so the margin covers the measured worst case and the final clamp stays the backstop. Bass booster thus plays about 9 dB lower. A setting that only cuts, or a flat one, keeps a preamp of exactly 1.0. The cost is that a boosting setting plays quieter than before; that is the level at which it no longer distorts. +**Headroom.** A boost raises a loud master past full scale, and the decoder's last stage clamps the signal to ±1.0, so +12 dB of bass used to be heard as hard clipping. The chain is now preceded by an automatic preamp equal to the peak of its own response, measured on the cascade (1 024 log-spaced points from 20 Hz to 20 kHz, plus each band's centre) rather than taken from the largest slider: adjacent bands overlap and their boosts add up, so the Bass booster preset (+6 / +5 / +3 dB) peaks near +8 dB. A boosting setting also takes 1 dB more for transients: the response peak bounds a steady signal, and a sharp full-scale pattern through +12 dB at 15 kHz overshoots it by 0.9 dB (measured). The strict bound, the sum of the impulse response, would cost every boosting setting 2 to 4 dB more for patterns music does not contain, so the margin covers the measured worst case and the final clamp stays the backstop. Bass booster thus plays about 9 dB lower. A setting that only cuts, or a flat one, keeps a preamp of exactly 1.0. The preamp drops at once when a setting asks for more attenuation, since the new boost acts from the next sample, and comes back up over one buffer when less is needed, so a slider moved during playback never clips and never clicks. Recomputing the response costs about 60 µs (release build), once per change, on the decoder thread. The cost is that a boosting setting plays quieter than before; that is the level at which it no longer distorts. ## Seek diff --git a/src-tauri/crates/app/src/audio/eq.rs b/src-tauri/crates/app/src/audio/eq.rs index c62db681e..7591fc12c 100644 --- a/src-tauri/crates/app/src/audio/eq.rs +++ b/src-tauri/crates/app/src/audio/eq.rs @@ -70,7 +70,10 @@ fn peaking_biquad(freq_hz: f32, sample_rate: f32, gain_db: f32, q: f32) -> [f32; /// Each biquad's magnitude is evaluated on the unit circle and multiplied /// in, over 1024 log-spaced points from 20 Hz to 20 kHz (or just under /// Nyquist) plus every band's centre, where a lone boost peaks. In `f64`: -/// this runs once per change of setting or sample rate, never per sample. +/// this runs once per change of setting or sample rate, never per sample, +/// on the decoder thread. Measured at about 60 µs in a release build, at +/// 44.1 to 192 kHz alike, so a slider dragged across its range costs the +/// decoder nothing it would notice with a second of audio queued. fn cascade_peak(coeffs: &[[f32; 5]; BAND_COUNT], sample_rate: f32) -> f32 { let rate = f64::from(sample_rate); if rate <= 0.0 { @@ -129,6 +132,28 @@ fn headroom_preamp(coeffs: &[[f32; 5]; BAND_COUNT], sample_rate: f32) -> f32 { } } +/// Scale an interleaved buffer by a gain moving linearly from `start` to +/// `target` across its frames, every channel of a frame alike. With +/// `start == target` the gain is that value throughout, so a steady 1.0 +/// leaves the samples bit for bit. +fn apply_preamp(buf: &mut [f32], channels: usize, start: f32, target: f32) { + let frames = buf.len() / channels.max(1); + if frames == 0 || (start == 1.0 && target == 1.0) { + return; + } + let step = (target - start) / frames as f32; + for (f, frame) in buf.chunks_exact_mut(channels).enumerate() { + let gain = if start == target { + target + } else { + start + step * (f + 1) as f32 + }; + for sample in frame { + *sample *= gain; + } + } +} + /// Per-channel, per-band biquad memory (Direct Form II Transposed). /// Two state variables per filter — the bare minimum. #[derive(Debug, Default, Clone, Copy)] @@ -231,8 +256,15 @@ pub struct EqProcessor { /// track B 48) forces a recompute even when bands didn't move. cached_rate: f32, /// Linear gain applied ahead of the filters so the cascade's peak - /// stays at or under unity; see the module's note on headroom. + /// stays at or under unity; see the module's note on headroom. The + /// gain the last buffer ended on. preamp: f32, + /// The gain a new setting asks for. Lowered at once, raised over one + /// buffer: a new boost acts from the very next sample, so the + /// attenuation that keeps it under full scale has to as well, while + /// giving that attenuation back can take a buffer, where a step of + /// several dB between two samples would be heard as a click. + preamp_target: f32, } impl EqProcessor { @@ -242,6 +274,7 @@ impl EqProcessor { states: Vec::new(), cached_rate: 0.0, preamp: 1.0, + preamp_target: 1.0, } } @@ -263,7 +296,7 @@ impl EqProcessor { for (i, &freq) in BAND_FREQS.iter().enumerate() { self.coeffs[i] = peaking_biquad(freq, sample_rate, bands[i], BAND_Q); } - self.preamp = headroom_preamp(&self.coeffs, sample_rate); + self.preamp_target = headroom_preamp(&self.coeffs, sample_rate); self.cached_rate = sample_rate; } @@ -288,12 +321,14 @@ impl EqProcessor { while self.states.len() < channels { self.states.push([BiquadState::default(); BAND_COUNT]); } + let start = self.preamp.min(self.preamp_target); + apply_preamp(buf, channels, start, self.preamp_target); + self.preamp = self.preamp_target; let frames = buf.len() / channels; - let preamp = self.preamp; for f in 0..frames { for ch in 0..channels { let i = f * channels + ch; - let mut x = buf[i] * preamp; + let mut x = buf[i]; let chan_state = &mut self.states[ch]; for (b, biquad) in chan_state.iter_mut().enumerate().take(BAND_COUNT) { x = biquad.process(x, &self.coeffs[b]); @@ -352,6 +387,41 @@ mod tests { 20.0 * linear.log10() } + /// A new preamp is reached over the buffer, not between two samples. + #[test] + fn the_preamp_ramps_across_a_buffer() { + let mut buf = vec![1.0_f32; 8]; // 4 stereo frames + apply_preamp(&mut buf, 2, 1.0, 0.5); + assert_eq!(buf, vec![0.875, 0.875, 0.75, 0.75, 0.625, 0.625, 0.5, 0.5]); + + let mut steady = vec![0.3_f32, -0.7, 0.1, 0.9]; + apply_preamp(&mut steady, 2, 1.0, 1.0); + assert_eq!(steady, vec![0.3, -0.7, 0.1, 0.9]); + } + + /// More attenuation is applied at once, less is given back over the + /// next buffer. + #[test] + fn the_preamp_drops_at_once_and_recovers_over_a_buffer() { + let shared = EqShared::new(); + shared.set_enabled(true); + shared.set_band_db(3, 6.0); + let mut eq = EqProcessor::new(); + let mut silence = vec![0.0_f32; 64]; + eq.process(&mut silence, 1, RATE, &shared); + let boosted = eq.preamp; + assert!(boosted < 0.5, "{boosted}"); + + shared.set_band_db(3, 0.0); + let mut buf = vec![1.0_f32; 4]; + eq.process(&mut buf, 1, RATE, &shared); + // Flat again: the gain climbs back to 1.0 over the buffer, from + // where it was, rather than jumping. + assert!(buf[0] < 0.9 && buf[0] > boosted, "{buf:?}"); + assert!((buf[3] - 1.0).abs() < 1e-6, "{buf:?}"); + assert_eq!(eq.preamp, 1.0); + } + #[test] fn a_flat_or_cutting_setting_leaves_the_level_alone() { assert_eq!(headroom_preamp(&chain([0.0; BAND_COUNT]), RATE), 1.0); diff --git a/src-tauri/crates/app/src/audio/pcm_scale.rs b/src-tauri/crates/app/src/audio/pcm_scale.rs index d36f7220b..749a2b5d3 100644 --- a/src-tauri/crates/app/src/audio/pcm_scale.rs +++ b/src-tauri/crates/app/src/audio/pcm_scale.rs @@ -154,6 +154,30 @@ mod tests { assert_eq!(dither.quantize(f32::NAN), 0); } + /// The same seed gives the same noise: two outputs started together + /// dither a given stream identically, which keeps a reopened stream's + /// output reproducible. + #[test] + fn two_dithers_from_new_agree() { + let mut a = Dither16::new(); + let mut b = Dither16::new(); + for n in 0..10_000 { + let sample = (n as f32 * 0.37).sin() * 0.3 + 1e-5; + assert_eq!(a.quantize(sample), b.quantize(sample)); + } + } + + /// xorshift32 never reaches zero from a non-zero seed (zero would be a + /// fixed point, and the noise would stop for good). + #[test] + fn the_noise_state_never_reaches_zero() { + let mut dither = Dither16::new(); + for _ in 0..1_000_000 { + dither.unit(); + assert_ne!(dither.state, 0); + } + } + /// A sample between two steps lands on one of its neighbours, and on /// average where it really is: the dither adds noise, not a bias. #[test]