Skip to main content

rustynes_apu/
blip.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2//
3// Provenance: the band-limited (BLEP) synthesis is derived from blip_buf by Shay Green (Blargg), LGPL-2.1-or-later (GPLv3-compatible). See docs/originality-and-provenance.md (Section 1)
4// and NOTICE for the complete, audited derivation record.
5//! Band-limited synthesis for the APU's audio output.
6//!
7//! # What this is
8//!
9//! A streaming **BLEP (Band-Limited Step) decimator** that takes the
10//! per-CPU-cycle mixer output and produces band-limited samples at the
11//! host audio rate (default 44.1 kHz).
12//!
13//! The technique is band-limited step (BLEP) synthesis — the same general
14//! approach popularized by Shay Green's `blip_buf` and used by many emulators.
15//! Provenance: the band-limited-step technique is **derived from Shay Green's
16//! `blip_buf`** (LGPL-2.1-or-later, which is GPLv3-compatible); our polyphase
17//! kernel in [`crate::blip_kernel`] uses a finer 32-phase resolution than
18//! `blip_buf`. See NOTICE and docs/originality-and-provenance.md (Section 1):
19//!
20//! - Pre-compute a polyphase windowed-sinc kernel ([`crate::blip_kernel`])
21//!   keyed by `PHASES = 32` sub-output-sample fractional offsets, with
22//!   `TAPS = 32` coefficients per row giving the FIR a ±16-output-sample
23//!   reach.
24//! - For each CPU-rate input, compute the amplitude **delta** from the
25//!   previous value.
26//! - Position the delta at its fractional output-sample location and add
27//!   `delta * kernel[phase][m]` to `TAPS` positions of a **host-rate
28//!   delta buffer**.
29//! - Integrate the delta buffer (running cumulative sum) to recover the
30//!   reconstructed signal, then push through the existing analog
31//!   [`crate::mixer::FilterChain`] (90 Hz HPF + 440 Hz HPF + 14 kHz LPF).
32//!
33//! This is the textbook BLEP structure: each abrupt step in the input is
34//! replaced by the band-limited equivalent (`sinc * window`-shaped step
35//! response), eliminating the alias products that a naive sample-and-
36//! hold decimator leaves above Nyquist.
37//!
38//! # Why this replaces the previous ratio-counter decimator
39//!
40//! The pre-v0.9.x decimator was a ratio-counter with sample-and-hold
41//! reconstruction — functionally a rectangular-window decimator that
42//! left aliased energy above Nyquist (~22.05 kHz at 44.1 kHz host rate).
43//! The existing 14 kHz LPF in the [`crate::mixer::FilterChain`] suppressed
44//! the audible portion, but the band beyond 14 kHz contained alias-
45//! pumping products that the analog filter could not fully kill. With
46//! the v0.9.x mapper-audio extensions (VRC6 / Sunsoft 5B / Namco 163 /
47//! MMC5) adding wavetable + envelope-modulated FM-like complexity, the
48//! alias floor started clearing the LPF's stopband attenuation in spots.
49//!
50//! The polyphase BLEP decimator pushes the alias rejection well below
51//! -60 dB across the audible band even for input frequencies above the
52//! host Nyquist (verified by the spectral FFT regression test in
53//! `tests/spectral.rs`).
54//!
55//! # API contract
56//!
57//! Unchanged from the previous decimator:
58//!
59//! - [`BlipBuf::new(sample_rate, cpu_rate)`] — same signature.
60//! - [`BlipBuf::add_sample(value)`] — called once per CPU cycle.
61//! - [`BlipBuf::drain`], [`BlipBuf::drain_all`], [`BlipBuf::len`],
62//!   [`BlipBuf::is_empty`], [`BlipBuf::reset`] — same semantics.
63//!
64//! Save-state compat: the snapshot module reads/writes `sample_rate`,
65//! `cpu_rate`, `phase`, `filter`, and `held_value`. All five are
66//! preserved on this rewrite. The internal delta ring is NOT serialized
67//! — same intentional behavior as before (the ring's contents are sub-
68//! audio-sample-window detail; a fresh restored state begins emitting
69//! samples as soon as `tick()` runs forward).
70//!
71//! # Determinism
72//!
73//! All math is `f32` with a fixed operation order. The kernel is pre-
74//! computed bit-identically across builds (see
75//! [`crate::blip_kernel::Kernel`]). No allocations on the hot path.
76
77#[cfg(test)]
78use crate::blip_kernel::PHASES;
79use crate::blip_kernel::{Kernel, TAPS};
80use crate::mixer::FilterChain;
81use alloc::vec::Vec;
82
83/// CPU cycles per second, NTSC.
84pub const CPU_HZ_NTSC: f64 = 1_789_773.0;
85/// CPU cycles per second, PAL (slightly slower).
86pub const CPU_HZ_PAL: f64 = 1_662_607.0;
87
88/// Size of the host-rate delta ring buffer. Must be a power of two for
89/// the modulo via bitmask. Held large enough to comfortably absorb a
90/// burst of pending output samples plus the kernel's TAPS-sample reach.
91/// 4096 samples ≈ 93 ms of audio at 44.1 kHz — far more than any single
92/// frame's worth of output (~735 samples).
93const RING_SIZE: usize = 4096;
94const RING_MASK: usize = RING_SIZE - 1;
95
96/// Streaming BLEP decimator + filter chain that feeds host-rate samples
97/// to the frontend's audio thread.
98#[derive(Debug, Clone)]
99pub struct BlipBuf {
100    /// Host sample rate (Hz).
101    pub(crate) sample_rate: u32,
102    /// CPU rate (Hz, fractional).
103    pub(crate) cpu_rate: f64,
104    /// Fractional output-sample position. Advances by `step = sample_rate
105    /// / cpu_rate` per input sample. Whenever the integer part advances,
106    /// one host-rate output sample is "ready" (its delta contributions
107    /// have been written for at least `TAPS/2` future samples ahead, so
108    /// it's safe to read out).
109    pub(crate) phase: f64,
110    /// Step per input sample (`sample_rate / cpu_rate`).
111    step: f64,
112    /// Output filter chain (90 Hz HPF + 440 Hz HPF + 14 kHz LPF).
113    pub(crate) filter: FilterChain,
114    /// Output ring of finalized post-filter samples awaiting drain.
115    pub(crate) samples: Vec<f32>,
116    /// Most recent input value handed to [`Self::add_sample`]. The next
117    /// call's delta is `value - held_value`.
118    pub(crate) held_value: f32,
119    /// Pre-computed polyphase windowed-sinc kernel.
120    kernel: Kernel,
121    /// Host-rate delta ring buffer. Each input delta scatters its
122    /// `kernel * delta` contributions across `TAPS` positions of this
123    /// buffer; the buffer is then integrated (cumulative sum) to
124    /// recover the actual sample stream.
125    delta_ring: [f32; RING_SIZE],
126    /// Integer output-sample index of the **current** input's scatter
127    /// center. Each scatter writes to ring positions
128    /// `[head - TAPS/2, head + TAPS/2)`. The next emit reads at
129    /// `head - TAPS/2 - 1` (one past the leftmost scatter slot).
130    /// Wraps within `RING_SIZE` via `& RING_MASK`.
131    head: usize,
132    /// True once `head` has advanced far enough that the leftmost
133    /// scatter slot `head - TAPS/2` has settled (no future input can
134    /// write to it). Output emission gated on this flag — for the very
135    /// first `TAPS/2 + 1` inputs, the integrator is just warming up
136    /// and no samples are emitted yet. This costs one frame of startup
137    /// latency (~16 samples = 0.36 ms @ 44.1 kHz, well below human-
138    /// perceptible).
139    primed: bool,
140    /// Running integrator state (cumulative sum of consumed delta-ring
141    /// entries since reset). Converts the scattered delta stream back
142    /// into an absolute-amplitude sample stream.
143    integrator: f32,
144}
145
146impl BlipBuf {
147    /// Create a new band-limited buffer.
148    ///
149    /// `sample_rate` is the host audio rate in Hz (typically 44 100).
150    /// `cpu_rate` is the NES CPU rate in Hz ([`CPU_HZ_NTSC`] or
151    /// [`CPU_HZ_PAL`]).
152    #[must_use]
153    pub fn new(sample_rate: u32, cpu_rate: f64) -> Self {
154        let step = f64::from(sample_rate) / cpu_rate;
155        let mut b = Self {
156            sample_rate,
157            cpu_rate,
158            phase: 0.0,
159            step,
160            filter: FilterChain::new(sample_rate),
161            samples: Vec::with_capacity(8192),
162            held_value: 0.0,
163            kernel: Kernel::new(),
164            delta_ring: [0.0; RING_SIZE],
165            // Start `head` at TAPS so the initial scatter (writing to
166            // `head - TAPS/2 .. head + TAPS/2`) lands at indices
167            // `[TAPS/2, 3*TAPS/2)` and never goes negative.
168            head: TAPS,
169            primed: false,
170            integrator: 0.0,
171        };
172        b.reset();
173        b
174    }
175
176    /// v2.1.3 — swap the analog output-filter model (see
177    /// [`crate::mixer::FilterModel`]), rebuilding the filter chain at the
178    /// current sample rate. This resets the filter's IIR state, so switching
179    /// while audio is playing (the Settings selector applies it live) produces a
180    /// brief transient — as with any live filter swap. The frontend also applies
181    /// it at ROM load / power-cycle, where there is no audible discontinuity.
182    pub fn set_filter_model(&mut self, model: crate::mixer::FilterModel) {
183        self.filter = crate::mixer::FilterChain::for_model(self.sample_rate, model);
184    }
185
186    /// Reset to silence. Empties the input ring, the pending output
187    /// queue, and the filter chain state.
188    pub fn reset(&mut self) {
189        self.phase = 0.0;
190        self.filter.reset();
191        self.samples.clear();
192        self.held_value = 0.0;
193        self.delta_ring = [0.0; RING_SIZE];
194        self.head = TAPS;
195        self.primed = false;
196        self.integrator = 0.0;
197    }
198
199    /// Add one mixed sample at CPU resolution. The buffer accumulates
200    /// host-rate samples internally; drain via [`Self::drain`] or
201    /// [`Self::drain_all`].
202    ///
203    /// The mixer's output is approximately in `[-0.5, 0.5]` for the 2A03
204    /// alone, with on-cart audio expansions pushing the absolute range
205    /// up somewhat. We clamp `value` defensively so a stray NaN/Inf or
206    /// runaway mapper can't propagate non-finite values into the FIR
207    /// state. The clamp range is far outside any sane mixer output, so
208    /// the gate is purely a saturation backstop, not a normal-operation
209    /// limiter.
210    #[inline]
211    pub fn add_sample(&mut self, value: f32) {
212        // Defensive saturation. Real mixer output never exceeds ~1.5; the
213        // clamp catches NaN/Inf from a runaway mapper-audio path so the
214        // FIR can't propagate non-finite values through the ring.
215        let value = if value.is_finite() {
216            value.clamp(-4.0, 4.0)
217        } else {
218            0.0
219        };
220        let delta = value - self.held_value;
221        self.held_value = value;
222
223        // Scatter the delta into the host-rate ring via the kernel row
224        // matching the input's fractional output-sample position.
225        //
226        // `self.phase` ∈ [0, 1) is the fractional output-sample position
227        // of THIS input within the current output-grid interval. The
228        // kernel row matching this fractional offset spreads the delta
229        // across `TAPS` output samples centered at `head`. The center
230        // sits at `head + TAPS/2 - 1` (the right-half tap that's closest
231        // to the input's position); we walk the kernel from `head` for
232        // `TAPS` slots.
233        if delta != 0.0 {
234            #[allow(clippy::cast_possible_truncation)]
235            let row = self.kernel.row(self.phase as f32);
236            // Center the scatter at `head` (the current integer output
237            // index). Tap 0 lands at `head - TAPS/2`; tap TAPS-1 lands
238            // at `head + TAPS/2 - 1`. The kernel itself encodes the
239            // sub-sample fractional shift via the row index.
240            //
241            // v2.8.0 Phase 4b — the 32-tap window wraps the ring at most
242            // once, so split it into (at most) two CONTIGUOUS runs instead
243            // of masking every index: each run is a plain SAXPY LLVM
244            // auto-vectorizes (SSE2/NEON/wasm-simd), which the per-tap
245            // `& RING_MASK` form structurally prevented. Per-slot math is
246            // unchanged (`slot += delta * coeff`, one touch per slot, mul
247            // then add — no FMA contraction), so output is bit-identical
248            // to the scalar form.
249            let start = self.head.wrapping_sub(TAPS / 2) & RING_MASK;
250            let first = (RING_SIZE - start).min(TAPS);
251            for (slot, &coeff) in self.delta_ring[start..start + first]
252                .iter_mut()
253                .zip(&row[..first])
254            {
255                *slot += delta * coeff;
256            }
257            for (slot, &coeff) in self.delta_ring[..TAPS - first]
258                .iter_mut()
259                .zip(&row[first..])
260            {
261                *slot += delta * coeff;
262            }
263        }
264
265        // Advance phase. When it crosses 1.0, advance `head` and
266        // emit the output sample that just fell out of the scatter
267        // window (one slot to the left of `head - TAPS/2`).
268        self.phase += self.step;
269        while self.phase >= 1.0 {
270            self.phase -= 1.0;
271            self.head = self.head.wrapping_add(1);
272
273            // The slot at `head - TAPS/2 - 1` is now permanently
274            // finalized: any future scatter writes to `[new_head -
275            // TAPS/2, new_head + TAPS/2)`, which doesn't reach back
276            // this far. Emit it.
277            //
278            // During warm-up (the first TAPS/2 + 1 head advances), the
279            // initial slots haven't been written by any scatter yet
280            // (they're still zero from construction); we burn through
281            // them silently to flush the startup phase, then start
282            // emitting once `primed` is set.
283            let emit_idx = self.head.wrapping_sub(TAPS / 2 + 1) & RING_MASK;
284            self.integrator += self.delta_ring[emit_idx];
285            self.delta_ring[emit_idx] = 0.0;
286
287            if !self.primed {
288                // Have we advanced past the initial dead zone? The
289                // first scatter wrote at `head_initial = TAPS` (i.e.,
290                // covering `[TAPS/2, 3*TAPS/2)`). We start emitting
291                // once `head >= TAPS + TAPS/2 + 1`, i.e., the emit
292                // slot has caught up with the first scatter's right
293                // edge.
294                if self.head > TAPS + TAPS / 2 {
295                    self.primed = true;
296                }
297                continue;
298            }
299
300            let filtered = self.filter.process(self.integrator);
301            self.samples.push(filtered);
302        }
303    }
304
305    /// Drain finalized samples into `out`. Returns the number written.
306    /// Excess pending samples are kept; if `out.len() < self.samples.len()`
307    /// the remainder waits for the next drain.
308    pub fn drain(&mut self, out: &mut [f32]) -> usize {
309        let n = self.samples.len().min(out.len());
310        out[..n].copy_from_slice(&self.samples[..n]);
311        self.samples.drain(..n);
312        n
313    }
314
315    /// Drain all finalized samples into a new `Vec`.
316    #[must_use]
317    pub fn drain_all(&mut self) -> Vec<f32> {
318        // `core::mem::take` is `std::mem::take` (the std path re-exports).
319        // Using the `core` path keeps this module portable to `#![no_std]`
320        // builds. See `docs/architecture.md` §no_std boundary.
321        core::mem::take(&mut self.samples)
322    }
323
324    /// Number of samples currently buffered, awaiting drain.
325    #[must_use]
326    pub fn len(&self) -> usize {
327        self.samples.len()
328    }
329
330    /// Whether the pending-output queue is empty.
331    #[must_use]
332    pub fn is_empty(&self) -> bool {
333        self.samples.is_empty()
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    /// Total CPU cycles in 1 second of NTSC playback. Used as a "long-
342    /// run" fixture for the determinism / DC tests.
343    const ONE_SECOND_NTSC: usize = 1_789_773;
344
345    /// 10 frames at 60 Hz (used by spectral tests + decay tests).
346    const TEN_FRAMES_NTSC: usize = ONE_SECOND_NTSC / 6;
347
348    #[test]
349    fn empty_buffer_reads_zero_samples() {
350        let mut b = BlipBuf::new(44_100, CPU_HZ_NTSC);
351        let mut out = [0.0_f32; 16];
352        assert_eq!(b.drain(&mut out), 0);
353        assert!(b.is_empty());
354        assert_eq!(b.len(), 0);
355    }
356
357    #[test]
358    fn emits_expected_sample_count() {
359        let mut b = BlipBuf::new(44_100, CPU_HZ_NTSC);
360        for _ in 0..ONE_SECOND_NTSC {
361            b.add_sample(0.0);
362        }
363        let drained = b.drain_all();
364        let n = drained.len();
365        // Expected: 44_100 samples ± a small warm-up loss. The BLEP
366        // structure delays output by `TAPS/2 + 1` host samples after
367        // construction so each emitted sample has received its full
368        // kernel scatter — that's ~17 samples of startup delay at our
369        // TAPS=32 configuration. After 1 s, output count is
370        // `44_100 - 17 ± rounding`.
371        let max_loss = TAPS / 2 + 4;
372        assert!(
373            n <= 44_100 && n + max_loss >= 44_100,
374            "expected 44100 - {max_loss}..=44100 samples in 1 s, got {n}"
375        );
376    }
377
378    #[test]
379    fn dc_passes_through_with_settled_gain() {
380        // A constant 0.5 input. The FIR scatters NO delta (the input
381        // never changes after the first sample), so the integrator
382        // converges to 0.5. The HPFs then attenuate the DC; after
383        // ~200 000 samples the output is near zero.
384        let mut b = BlipBuf::new(44_100, CPU_HZ_NTSC);
385        for _ in 0..200_000 {
386            b.add_sample(0.5);
387        }
388        let drained = b.drain_all();
389        let last = *drained.last().unwrap();
390        assert!(last.abs() < 0.05, "DC not removed; last sample = {last}");
391    }
392
393    #[test]
394    fn drain_empties_buffer() {
395        let mut b = BlipBuf::new(44_100, CPU_HZ_NTSC);
396        for _ in 0..1000 {
397            b.add_sample(0.0);
398        }
399        let _ = b.drain_all();
400        assert!(b.is_empty());
401    }
402
403    #[test]
404    fn single_delta_produces_band_limited_step() {
405        // Feed silence, then a unit step. The output should be a
406        // band-limited ramp (sinc-ringing) reaching the new amplitude
407        // over ~TAPS host-rate samples.
408        let mut b = BlipBuf::new(44_100, CPU_HZ_NTSC);
409        for _ in 0..10_000 {
410            b.add_sample(0.0);
411        }
412        for _ in 0..10_000 {
413            b.add_sample(1.0);
414        }
415        // Push enough samples to fully settle the FIR + HPF response.
416        for _ in 0..50_000 {
417            b.add_sample(1.0);
418        }
419        let drained = b.drain_all();
420        // After 50_000+ samples of constant 1.0, the HPFs drive output
421        // back to ~0 (DC blocked).
422        let last = *drained.last().unwrap();
423        assert!(
424            last.abs() < 0.05,
425            "constant input not DC-blocked: last = {last}"
426        );
427        // And the step transient produced finite peak < the saturation
428        // clip (1.5 is our cap before clamp).
429        let max_abs = drained.iter().map(|s| s.abs()).fold(0.0_f32, f32::max);
430        assert!(
431            max_abs > 0.0 && max_abs < 1.5,
432            "step transient max = {max_abs}, expected (0, 1.5)"
433        );
434    }
435
436    #[test]
437    fn opposing_deltas_cancel_to_dc() {
438        // Equal amounts of +1 and -1 in long runs, end with HPF-blocked
439        // output. The integrator must NOT drift.
440        let mut b = BlipBuf::new(44_100, CPU_HZ_NTSC);
441        for _ in 0..100_000 {
442            b.add_sample(1.0);
443        }
444        for _ in 0..100_000 {
445            b.add_sample(-1.0);
446        }
447        let drained = b.drain_all();
448        let last = *drained.last().unwrap();
449        assert!(last.abs() < 0.05, "didn't cancel; last = {last}");
450    }
451
452    #[test]
453    fn deterministic_across_runs() {
454        // Same input sequence produces bit-identical output.
455        let drive = |b: &mut BlipBuf| {
456            for i in 0..TEN_FRAMES_NTSC {
457                #[allow(clippy::cast_precision_loss)]
458                let v = ((i as f32) * 0.0001).sin() * 0.5;
459                b.add_sample(v);
460            }
461        };
462        let mut a = BlipBuf::new(44_100, CPU_HZ_NTSC);
463        drive(&mut a);
464        let av = a.drain_all();
465        let mut c = BlipBuf::new(44_100, CPU_HZ_NTSC);
466        drive(&mut c);
467        let cv = c.drain_all();
468        assert_eq!(av.len(), cv.len(), "same input, different output length");
469        for (i, (x, y)) in av.iter().zip(cv.iter()).enumerate() {
470            assert_eq!(
471                x.to_bits(),
472                y.to_bits(),
473                "non-determinism at index {i}: {x} vs {y}"
474            );
475        }
476    }
477
478    #[test]
479    fn saturation_clips_extreme_values() {
480        // A pathological NaN / Inf input must not poison the output ring.
481        let mut b = BlipBuf::new(44_100, CPU_HZ_NTSC);
482        b.add_sample(f32::NAN);
483        b.add_sample(f32::INFINITY);
484        b.add_sample(f32::NEG_INFINITY);
485        for _ in 0..200 {
486            b.add_sample(1.0e20);
487        }
488        // No panics, drained samples are all finite.
489        let drained = b.drain_all();
490        for v in &drained {
491            assert!(v.is_finite(), "output poisoned by extreme input: {v}");
492        }
493        assert!(b.held_value.is_finite());
494    }
495
496    #[test]
497    fn reset_clears_all_state() {
498        let mut b = BlipBuf::new(44_100, CPU_HZ_NTSC);
499        for _ in 0..1000 {
500            b.add_sample(0.5);
501        }
502        b.reset();
503        assert_eq!(b.phase, 0.0);
504        assert_eq!(b.held_value, 0.0);
505        assert!(b.is_empty());
506        for v in &b.delta_ring {
507            assert_eq!(*v, 0.0);
508        }
509        assert_eq!(b.integrator, 0.0);
510    }
511
512    #[test]
513    fn drain_slice_handles_partial_read() {
514        let mut b = BlipBuf::new(44_100, CPU_HZ_NTSC);
515        for _ in 0..10_000 {
516            b.add_sample(0.0);
517        }
518        let queued = b.len();
519        assert!(queued > 0);
520        let mut out = [0.0_f32; 16];
521        let n = b.drain(&mut out);
522        assert_eq!(n, 16);
523        assert_eq!(b.len(), queued - 16);
524    }
525
526    #[test]
527    fn long_run_produces_finite_output() {
528        // Sweep amplitudes so the FIR convolution + integrator path is
529        // exercised. All drained samples must be finite (no NaN/Inf
530        // leakage from the kernel-row lookup or integrator drift).
531        let mut b = BlipBuf::new(44_100, CPU_HZ_NTSC);
532        for i in 0..(ONE_SECOND_NTSC / 100) {
533            #[allow(clippy::cast_precision_loss)]
534            let v = ((i % 100) as f32) / 100.0 - 0.5;
535            b.add_sample(v);
536        }
537        let drained = b.drain_all();
538        for (i, v) in drained.iter().enumerate() {
539            assert!(v.is_finite(), "non-finite at index {i}: {v}");
540        }
541    }
542
543    #[test]
544    fn kernel_phases_accessible() {
545        // Sanity: PHASES + TAPS are the kernel's dimensional parameters.
546        // Powers of two keep the delta_ring's RING_MASK modulo working
547        // and allow the kernel-row lookup to stay branch-free under
548        // the hood. PHASES ≥ 32 ensures sub-sample-phase quantization
549        // noise stays below the spectral acceptance gate (verified by
550        // `tests/spectral.rs`).
551        assert!(PHASES.is_power_of_two() && PHASES >= 32);
552        assert!(TAPS.is_power_of_two() && TAPS >= 16);
553    }
554}