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}