Skip to main content

rustynes_apu/
mixer.rs

1//! Non-linear lookup-table mixer + analog-style filter chain.
2//!
3//! Per `docs/apu-2a03.md` §Mixer.  Two stages:
4//!
5//! 1. **Mixer** — non-linear sum of channel outputs into a normalized
6//!    floating-point sample in `[0.0, ~1.0]`.  Implemented as two lookup
7//!    tables computed once at construction.
8//!
9//! 2. **Filter chain** — first-order high-pass at 90 Hz, first-order high-pass
10//!    at 440 Hz, first-order low-pass at 14 kHz.  Applied at the host sample
11//!    rate (44.1 kHz default; configurable).  Bilinear-transform coefficients.
12
13use core::f32::consts::PI;
14
15// `f32::exp` is in `std::f32::FloatCore` (auto-imported in libstd) but not in
16// `core` (no_std). We route through `libm::expf` so the same math compiles on
17// both desktop and the `thumbv7em-none-eabihf` no_std target. The numeric
18// output matches `f32::exp` for the inputs we use (filter coefficient
19// initialization only; not on the per-sample hot path).
20#[inline]
21fn expf(x: f32) -> f32 {
22    #[cfg(feature = "std")]
23    {
24        x.exp()
25    }
26    #[cfg(not(feature = "std"))]
27    {
28        libm::expf(x)
29    }
30}
31
32/// Pre-computed mixer state.
33#[derive(Debug, Clone)]
34pub struct Mixer {
35    /// `pulse_table[i]` for `i = pulse1 + pulse2`, 0..=30.
36    pulse_table: [f32; 31],
37    /// `tnd_table[i]` for `i = 3*tri + 2*noise + dmc`, 0..=202.
38    tnd_table: [f32; 203],
39}
40
41impl Default for Mixer {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47impl Mixer {
48    /// Build the lookup tables.  Closed-form formulas from blargg's
49    /// "APU Mixer" docs.
50    #[must_use]
51    pub fn new() -> Self {
52        let mut pulse_table = [0.0f32; 31];
53        for (i, slot) in pulse_table.iter_mut().enumerate().skip(1) {
54            #[allow(clippy::cast_precision_loss)]
55            let n = i as f32;
56            *slot = 95.52 / (8128.0 / n + 100.0);
57        }
58        let mut tnd_table = [0.0f32; 203];
59        for (i, slot) in tnd_table.iter_mut().enumerate().skip(1) {
60            #[allow(clippy::cast_precision_loss)]
61            let n = i as f32;
62            *slot = 163.67 / (24329.0 / n + 100.0);
63        }
64        Self {
65            pulse_table,
66            tnd_table,
67        }
68    }
69
70    /// Mix one sample.  Inputs are the per-cycle channel outputs:
71    /// pulse 1/2 (0..=15), triangle (0..=15), noise (0..=15), dmc (0..=127).
72    /// Returns a value in `[0.0, ~1.0]`.
73    #[must_use]
74    pub fn mix(&self, p1: u8, p2: u8, tri: u8, noise: u8, dmc: u8) -> f32 {
75        let p_idx = (p1 + p2) as usize;
76        let t_idx = (3 * u16::from(tri) + 2 * u16::from(noise) + u16::from(dmc)) as usize;
77        // Indexing is safe since p1+p2 <= 30 and 3*tri+2*noise+dmc <= 3*15+2*15+127 = 202.
78        self.pulse_table[p_idx] + self.tnd_table[t_idx]
79    }
80}
81
82/// Single-pole IIR filter.  `lpf` flag controls whether it's a low-pass or
83/// high-pass.
84///
85/// Bilinear-transform of the analog single-pole prototype.  See nesdev wiki
86/// "APU Mixer" §Emulation.
87#[derive(Debug, Clone, Copy)]
88pub struct OnePole {
89    /// Filter coefficient.  For HPF: `b1 = exp(-2*pi*fc/fs)`.  For LPF:
90    /// `a0 = 1 - exp(-2*pi*fc/fs)`.
91    pub(crate) coeff: f32,
92    /// Last input sample.
93    pub(crate) prev_in: f32,
94    /// Last output sample.
95    pub(crate) prev_out: f32,
96    /// Filter mode.
97    pub(crate) is_hpf: bool,
98}
99
100impl OnePole {
101    /// New high-pass filter at `cutoff` Hz, sample rate `fs` Hz.
102    #[must_use]
103    pub fn high_pass(cutoff: f32, fs: f32) -> Self {
104        // y[n] = b1 * (y[n-1] + x[n] - x[n-1])
105        // b1 = exp(-2*pi*fc/fs)
106        let coeff = expf(-2.0 * PI * cutoff / fs);
107        Self {
108            coeff,
109            prev_in: 0.0,
110            prev_out: 0.0,
111            is_hpf: true,
112        }
113    }
114
115    /// New low-pass filter at `cutoff` Hz, sample rate `fs` Hz.
116    #[must_use]
117    pub fn low_pass(cutoff: f32, fs: f32) -> Self {
118        // y[n] = y[n-1] + a0 * (x[n] - y[n-1])
119        let a0 = 1.0 - expf(-2.0 * PI * cutoff / fs);
120        Self {
121            coeff: a0,
122            prev_in: 0.0,
123            prev_out: 0.0,
124            is_hpf: false,
125        }
126    }
127
128    /// Process one sample.
129    pub fn process(&mut self, x: f32) -> f32 {
130        let y = if self.is_hpf {
131            self.coeff * (self.prev_out + x - self.prev_in)
132        } else {
133            self.prev_out + self.coeff * (x - self.prev_out)
134        };
135        self.prev_in = x;
136        self.prev_out = y;
137        y
138    }
139
140    /// Reset filter state.
141    pub fn reset(&mut self) {
142        self.prev_in = 0.0;
143        self.prev_out = 0.0;
144    }
145}
146
147/// v2.1.3 — the analog output-filter model the APU emulates.
148///
149/// The console hardware genuinely differs here (nesdev "APU Mixer"): the NES
150/// front-loader's RF/composite circuit high-passes aggressively, while the
151/// Famicom uses only a gentle 37 Hz high-pass. `Clean` is a modern full-range
152/// option (no aggressive high-pass — the character Mesen2 / FCEUX / Nestopia
153/// produce, which omit the 90/440 Hz cascade). This only affects the tonal
154/// balance of the output; it never changes channel content.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
156pub enum FilterModel {
157    /// **NES front-loader** — HPF 90 Hz → HPF 440 Hz → LPF 14 kHz (nesdev). The
158    /// authentic (and thinnest) output; byte-identical to ares/tetanes. Default.
159    #[default]
160    NesRf,
161    /// **Famicom** — a single HPF ~37 Hz → LPF 14 kHz (nesdev Famicom spec).
162    /// Much fuller low end than the NES RF circuit (drops the 440 Hz HPF).
163    Famicom,
164    /// **Clean / full-range** — only a gentle ~10 Hz DC-block HPF → LPF 14 kHz.
165    /// Keeps all bass; closest to Mesen2 / FCEUX (which apply no high-pass).
166    Clean,
167}
168
169/// 3-stage filter chain. The stages are model-dependent (see [`FilterModel`]);
170/// the default [`FilterModel::NesRf`] is HPF 90 Hz → HPF 440 Hz → LPF 14 kHz.
171#[derive(Debug, Clone, Copy)]
172pub struct FilterChain {
173    pub(crate) hp1: OnePole,
174    pub(crate) hp2: OnePole,
175    pub(crate) lp: OnePole,
176}
177
178impl FilterChain {
179    /// Build the default (NES front-loader) filter chain at `sample_rate` Hz.
180    #[must_use]
181    pub fn new(sample_rate: u32) -> Self {
182        Self::for_model(sample_rate, FilterModel::NesRf)
183    }
184
185    /// Build the filter chain for a specific [`FilterModel`] at `sample_rate` Hz.
186    ///
187    /// The struct always keeps three one-pole stages; the softer models neutralize
188    /// the second high-pass by setting its corner near-DC (~1 Hz, coefficient
189    /// ≈ 1.0 → effectively transparent), so no struct/save-state layout changes.
190    #[must_use]
191    pub fn for_model(sample_rate: u32, model: FilterModel) -> Self {
192        #[allow(clippy::cast_precision_loss)]
193        let fs = sample_rate as f32;
194        // A ~1 Hz high-pass is an all-but-transparent DC blocker used to
195        // "disable" the second HPF stage for the softer models.
196        let neutral_hpf = 1.0;
197        match model {
198            FilterModel::NesRf => Self {
199                hp1: OnePole::high_pass(90.0, fs),
200                hp2: OnePole::high_pass(440.0, fs),
201                lp: OnePole::low_pass(14_000.0, fs),
202            },
203            FilterModel::Famicom => Self {
204                hp1: OnePole::high_pass(37.0, fs),
205                hp2: OnePole::high_pass(neutral_hpf, fs),
206                lp: OnePole::low_pass(14_000.0, fs),
207            },
208            FilterModel::Clean => Self {
209                hp1: OnePole::high_pass(10.0, fs),
210                hp2: OnePole::high_pass(neutral_hpf, fs),
211                lp: OnePole::low_pass(14_000.0, fs),
212            },
213        }
214    }
215
216    /// Process one sample through all three stages.
217    pub fn process(&mut self, x: f32) -> f32 {
218        let a = self.hp1.process(x);
219        let b = self.hp2.process(a);
220        self.lp.process(b)
221    }
222
223    /// Reset filter state.
224    pub fn reset(&mut self) {
225        self.hp1.reset();
226        self.hp2.reset();
227        self.lp.reset();
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn pulse_table_zero_at_zero() {
237        let m = Mixer::new();
238        assert_eq!(m.pulse_table[0], 0.0);
239    }
240
241    #[test]
242    fn pulse_table_within_tolerance() {
243        // Spot-check pulse_table[15+15] (max pulse output).
244        let m = Mixer::new();
245        // Closed-form: 95.52 / (8128/30 + 100) = ~0.2581.
246        let expected = 95.52 / (8128.0 / 30.0 + 100.0);
247        let diff = (m.pulse_table[30] - expected).abs();
248        assert!(diff < 0.001 * expected.max(0.0001));
249    }
250
251    #[test]
252    fn tnd_table_zero_at_zero() {
253        let m = Mixer::new();
254        assert_eq!(m.tnd_table[0], 0.0);
255    }
256
257    #[test]
258    fn filter_models_differ_in_low_end() {
259        // v2.1.3 — the softer models keep more bass. For a one-pole HPF,
260        // coeff = exp(-2*pi*fc/fs): a HIGHER corner → SMALLER coeff → more
261        // low-end removed. NesRf keeps the aggressive 440 Hz second HPF; the
262        // softer models neutralize it (near-DC corner → coeff ≈ 1.0).
263        let fs = 44_100;
264        let nes = FilterChain::for_model(fs, FilterModel::NesRf);
265        let fami = FilterChain::for_model(fs, FilterModel::Famicom);
266        let clean = FilterChain::for_model(fs, FilterModel::Clean);
267        // NesRf default is byte-identical to the historical `new`.
268        let legacy = FilterChain::new(fs);
269        assert_eq!(nes.hp1.coeff, legacy.hp1.coeff);
270        assert_eq!(nes.hp2.coeff, legacy.hp2.coeff);
271        // Second HPF: real 440 Hz on NesRf, transparent on the softer models.
272        assert!(nes.hp2.coeff < 0.95, "NesRf hp2 is a real 440 Hz HPF");
273        assert!(fami.hp2.coeff > 0.999, "Famicom hp2 near-transparent");
274        assert!(clean.hp2.coeff > 0.999, "Clean hp2 near-transparent");
275        // First HPF corner gentler on Famicom (37) than NesRf (90), gentlest on
276        // Clean (10): gentler corner → larger coeff.
277        assert!(
278            fami.hp1.coeff > nes.hp1.coeff,
279            "Famicom 37Hz gentler than 90Hz"
280        );
281        assert!(clean.hp1.coeff > fami.hp1.coeff, "Clean 10Hz gentlest");
282        // All keep the same 14 kHz low-pass.
283        assert_eq!(nes.lp.coeff, fami.lp.coeff);
284        assert_eq!(nes.lp.coeff, clean.lp.coeff);
285    }
286
287    #[test]
288    fn mix_zero_when_all_silent() {
289        let m = Mixer::new();
290        assert_eq!(m.mix(0, 0, 0, 0, 0), 0.0);
291    }
292
293    #[test]
294    fn mix_within_unit_range() {
295        let m = Mixer::new();
296        let v = m.mix(15, 15, 15, 15, 127);
297        assert!(v > 0.0 && v < 1.5, "max-mixed sample = {v}");
298    }
299
300    #[test]
301    fn highpass_decays_dc() {
302        let mut hp = OnePole::high_pass(90.0, 44_100.0);
303        let mut last = 0.0;
304        for _ in 0..1000 {
305            last = hp.process(0.5);
306        }
307        // DC should be heavily attenuated -- result near zero.
308        assert!(last.abs() < 0.01);
309    }
310
311    #[test]
312    fn lowpass_passes_dc() {
313        let mut lp = OnePole::low_pass(14_000.0, 44_100.0);
314        let mut last = 0.0;
315        for _ in 0..100 {
316            last = lp.process(0.5);
317        }
318        // After settling, output ~= input.
319        assert!((last - 0.5).abs() < 0.01);
320    }
321}