diff --git a/docs/features/playback.md b/docs/features/playback.md index 6412bc6d..c2fa52e7 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 @@ -185,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 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 `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/alsa_exclusive.rs b/src-tauri/crates/app/src/audio/alsa_exclusive.rs index bffe3dcb..5d315871 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); } @@ -1208,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() { @@ -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/eq.rs b/src-tauri/crates/app/src/audio/eq.rs index c58544d4..7591fc12 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,95 @@ 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, +/// 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 { + 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 + } +} + +/// 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)] @@ -157,6 +255,16 @@ 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. 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 { @@ -165,6 +273,8 @@ 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, + preamp_target: 1.0, } } @@ -186,6 +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_target = headroom_preamp(&self.coeffs, sample_rate); self.cached_rate = sample_rate; } @@ -210,6 +321,9 @@ 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; for f in 0..frames { for ch in 0..channels { @@ -225,10 +339,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 +368,115 @@ 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() + } + + /// 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); + 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}"); + } +} diff --git a/src-tauri/crates/app/src/audio/pcm_scale.rs b/src-tauri/crates/app/src/audio/pcm_scale.rs index 43725772..749a2b5d 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,62 @@ 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); + } + + /// 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] + 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 ca19388f..24e61b93 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());