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}