-
-
Notifications
You must be signed in to change notification settings - Fork 0
APU Mixer
References: Nesdev APU, APU Frame Counter, APU DMC, DMA, and Controller reading.
Implement the 2A03 APU in crates/rustynes-apu: five sound channels (pulse 1, pulse 2, triangle, noise, DMC), the 4-step or 5-step frame counter that drives sub-channel events, the nonlinear mixer, and the analog-style high-pass / low-pass filter chain. Output is band-limited (blip_buf-style) to a configurable host sample rate (typically 44.1 or 48 kHz).
The implementation polls the bus: rather than a callback-style ApuBus trait, the APU exposes dmc_dma_pending() / dmc_dma_addr() / complete_dmc_dma(byte) that the lockstep bus polls and services on its halt cycles.
pub struct Apu { /* opaque */ }
impl Apu {
pub fn new(region: Region, sample_rate: u32) -> Self;
pub fn reset(&mut self);
pub fn tick(&mut self); // 1 CPU cycle of APU work
pub fn read_status(&mut self) -> u8; // $4015 with side effects
pub fn write_register(&mut self, addr: u16, value: u8); // $4000-$4017
// DMC DMA cooperation with the bus.
pub fn dmc_dma_pending(&self) -> bool;
pub fn dmc_dma_addr(&self) -> u16;
pub fn complete_dmc_dma(&mut self, byte: u8);
// Audio drain (host sample rate).
pub fn drain_audio(&mut self) -> Vec<f32>;
pub fn drain_audio_into(&mut self, out: &mut [f32]) -> usize;
pub fn frame_irq_pending(&self) -> bool;
pub fn dmc_irq_pending(&self) -> bool;
pub fn irq_line(&self) -> bool; // either source asserting
// Per-channel raw outputs for tests.
pub fn pulse1_out(&self) -> u8;
pub fn pulse2_out(&self) -> u8;
pub fn triangle_out(&self) -> u8;
pub fn noise_out(&self) -> u8;
pub fn dmc_out(&self) -> u8;
}The DMC sample DMA path is intentionally a polling protocol on the Apu, not a callback trait. When the DMC bit-shift register empties, Apu::dmc_dma_pending() returns true and Apu::dmc_dma_addr() exposes the target address; the LockstepBus polls these on its halt cycles, performs the read (which can stall the CPU for the documented 1-4 cycles depending on what the CPU was doing), and feeds the byte back via Apu::complete_dmc_dma(byte). This keeps the rustynes-apu crate from needing any reference to the bus, which in turn keeps the workspace dep graph one-directional.
The APU is clocked by the master scheduler at CPU cadence (every other PPU dot triple on NTSC). The triangle wave timer runs at CPU clock; pulses, noise, and DMC timer-divide at half CPU clock. The frame counter divides further to ~240 Hz.
- Per channel: 11/12-bit timer (counts down to reload), sequencer (4 step for pulse, 32 step for triangle, 1-bit LFSR for noise), length counter (5-bit, with halt flag), envelope (4-bit volume + decay), sweep (pulse only), linear counter (triangle only), DMC bit-shift register + sample buffer + memory reader.
- Frame counter: 4-step or 5-step mode, internal cycle counter (CPU clock granularity), IRQ inhibit flag, IRQ pending flag.
- Mixer state: high-pass filter state (two stages), low-pass filter state (one stage), output accumulator.
- Sample emitter: blip_buf-style ring of pending step responses + windowed-sinc kernel cache.
| Addr | Name | Purpose |
|---|---|---|
| $4000 | PULSE1_DDLC.NNNN | Duty (DD), envelope loop / length halt (L), constant volume (C), volume / envelope period (NNNN) |
| $4001 | PULSE1_EPPP.NSSS | Sweep enable (E), period (PPP), negate (N), shift (SSS) |
| $4002 | PULSE1_LLLL.LLLL | Timer low |
| $4003 | PULSE1_lllL.LHHH | Length counter load (lllL.L), timer high (HHH) |
| $4004-$4007 | PULSE2 | Same layout as Pulse 1 |
| $4008 | TRI_CRRR.RRRR | Length counter halt / linear counter control (C), linear counter reload (RRRR.RRR) |
| $400A | TRI_LLLL.LLLL | Timer low |
| $400B | TRI_lllL.LHHH | Length counter load + timer high |
| $400C | NOISE___LC.NNNN | Length halt (L), constant volume (C), volume / envelope period (NNNN) |
| $400E | NOISE_M___.PPPP | Mode (M, 0=15-bit / 1=6-bit), period index (PPPP) |
| $400F | NOISE_lllL.L___ | Length counter load |
| $4010 | DMC_IL__.RRRR | IRQ enable (I), loop (L), rate index (RRRR) |
| $4011 | DMC_.DDDD.DDDD | Direct DAC value (7-bit) |
| $4012 | DMC_AAAA.AAAA | Sample address ($C000 + A*64) |
| $4013 | DMC_LLLL.LLLL | Sample length (L*16+1) |
| $4015 | STATUS_IF__.DNT21 | IRQ flags (read), enable bits (write) |
| $4017 | FRAME_MI__.____ | Mode (M, 0=4-step / 1=5-step), IRQ inhibit (I) |
- 4-step (mode 0): clocks envelope+linear at every step, length+sweep at steps 2 and 4, frame IRQ at step 4 (if not inhibited). Total 14914 CPU cycles per loop NTSC.
- 5-step (mode 1): clocks envelope+linear at steps 1,2,3,5; length+sweep at 2 and 5; never sets frame IRQ. Total 18640 CPU cycles per loop NTSC.
- Writing
$4017resets the counter with a 3- or 4-CPU-cycle delay (depending on whether the write happened on an even or odd CPU cycle); if mode 1 selected, immediately clocks the half-frame and quarter-frame events.
Nesdev's frame-counter timing is expressed in APU get/put cycle terms: the reset side effects occur 3 CPU clocks after the $4017 write if the write lands during an APU cycle and 4 CPU clocks otherwise. The frame IRQ line is connected to CPU IRQ; reading $4015 returns the old frame IRQ status and then clears the frame IRQ flag, while setting $4017 bit 6 clears it immediately. The DMC IRQ flag is not cleared by reading $4015.
PAL has separate frame-counter step positions. Do not derive PAL frame-counter timing by scaling NTSC sample rates; use region tables.
-
Memory reader: when sample buffer is empty and bytes-remaining > 0, request DMA. Bus halts CPU and reads 1 byte from
$C000-$FFFF. Halt cost: 3 or 4 CPU cycles. Read advances address (wraps$8000after$FFFF) and decrements bytes-remaining. - Output unit: shift register bits modify the 7-bit output: bit 1 → +2, bit 0 → -2, clamped 0..=127.
-
IRQ: when bytes-remaining reaches 0 and IRQ enable is set (and loop is not), assert DMC IRQ. Cleared by writing
$4015. -
Direct write to
$4011sets the DAC immediately, useful for raw PCM.
DMC DMA has two scheduling classes. Load DMA follows enabling playback through $4015 and is scheduled around the second APU cycle after the write. Reload DMA follows the sample buffer emptying during playback and schedules on the opposite get/put phase. Both perform a dummy cycle after halting the CPU and may need an alignment cycle before the memory read.
A DMC sample-buffer LOAD DMA that begins on a "get" (odd) CPU cycle is deferred one extra cycle relative to one that begins on a "put" (even) cycle — the load only takes effect on its put half. This is modelled by Bus::dmc_dma_defer_load_entry in crates/rustynes-core/src/bus.rs, which gates the load entry on the APU's put_cycle() parity.
// Lookup-table (~4% accurate, default)
pulse_table[n] = 95.52 / (8128.0 / n as f32 + 100.0); // n=0 -> 0
tnd_table[n] = 163.67 / (24329.0 / n as f32 + 100.0);
output = pulse_table[(pulse1 + pulse2) as usize]
+ tnd_table[(3 * triangle + 2 * noise + dmc) as usize];After mixing, apply: 90 Hz first-order high-pass, 440 Hz first-order high-pass, 14 kHz first-order low-pass.
Naive sample-rate conversion produces aliasing. Use a blip-buf-style ring buffer:
- Each "step" (channel transition) is registered with the time-of-step at CPU-cycle resolution.
- The buffer convolves each step against a windowed-sinc kernel into the host-sample-rate output buffer.
-
drain_samples()returns finalized samples; the buffer slides forward in time.
- Read: returns frame IRQ (bit 6), DMC IRQ (bit 7), DMC bytes-remaining > 0 (bit 4), pulse 1 / 2 / triangle / noise length-counter > 0 (bits 0-3). Reading clears the frame IRQ flag. Does not clear the DMC IRQ flag.
- Write: bit 4 set enables DMC (initiates sample if buffer empty); bit 4 clear silences DMC. Bits 0-3 enable channels (clearing forces length counter to 0).
$4015 is internal to the CPU/APU package rather than an external-bus device.
-
DMC DMA stalls CPU mid-instruction. Halt only on read cycles. The 2A03 register-readout bug (extra reads of
$2007,$4015-$4017while halted) must be reproduced. -
Frame counter write jitter. Writing
$4017with a value that includes IRQ inhibit set clears any pending frame IRQ flag. -
Length counter halt / reload race. The halt flag is sampled at the right edge of the half-frame clock; the common subtle bug is sampling on the wrong cycle. A
$400xhalt-bit write — or a length reload — on the CPU cycle adjacent to the half-frame length clock has a one-cycle race over whether the length counter is clocked this step. -
Triangle disabled silently when length counter or linear counter reaches 0. Holds the last sequencer step (does not produce a click).
- Ultrasonic silence (timer period < 2). When the triangle timer period is below 2 (frequency above ~55.9 kHz), real hardware cannot follow the sequencer and the channel effectively halts. We freeze the sequencer rather than emitting the aliasing tone, matching the common-emulator convention; Mega Man 2's "Crash Man" stage relies on this to silence the triangle.
-
Pulse duty-sequencer phase reset on
$4003/$4007. Writing the length/timer-high register resets the pulse duty sequencer to step 0 (and sets the envelope-restart flag) but does not reset the timer divider. -
DMC playback stops mid-scanline? Yes;
$4015write to clear bit 4 silences the channel after the current sample byte completes. - Sweep mute. When the target period of a pulse channel is > $7FF or the negated-target underflows below 8, the channel is muted regardless of length.
-
Pulse 1 sweep negation off-by-one. Pulse 1 negates by
~target(one's complement); Pulse 2 negates by-target. This produces audible difference at certain frequencies. - Controller conflict is APU-owned timing. The standard controller code lives in the input subsystem, but DMC DMA is the root of the classic joypad bit deletion/duplication bug.
Six on-cart expansion sound chips are synthesized and summed into the external-audio mix via the Mapper::mix_audio(&mut self) -> i16 hook (default 0). Each synth core lives in the owning mapper crate, not the 2A03 APU crate, because they are cartridge hardware:
| Chip | Mapper(s) | Synth core | Clock cadence |
|---|---|---|---|
| VRC6 | 24 / 26 |
Vrc6Pulse x2 + Vrc6Saw
|
every CPU cycle ($9003 halt + freq-scale shift) |
| VRC7 | 85 |
rustynes_apu::Opll (emu2413-derived, MIT) |
OPLL calc() every 36 CPU cycles (49,716 Hz) |
| FDS | 20 (FDS device) |
FdsAudio wavetable + FM |
wave/mod every 16 CPU cycles; envelopes per cycle |
| MMC5 | 5 |
Mmc5Audio (2 pulse + 7-bit PCM) |
pulse timer every other CPU cycle; envelope/length on 2A03 frame events |
| Namco 163 | 19 / 210 |
Namco163Audio (1-8 time-multiplexed wavetable channels) |
round-robin channel update every 15 CPU cycles |
| Sunsoft 5B | 69 (FME-7) |
Sunsoft5BAudio (3 tone + noise + envelope) |
every CPU cycle |
All synth cores are behind the default-on mapper-audio Cargo feature; when it is off (e.g. the no_std build) the register decoders still latch (save-state round-trip preserved) but clock/mix are no-op shims that return silence. The VRC7 OPLL core is deliberately the MIT emu2413 lineage — not Nuked-OPLL (GPL/LGPL, license-incompatible).
A classic .nsf may declare expansion audio in the $07B bitfield. The NSF player does not reimplement any synthesis: NsfExpansion owns instances of the exact same cores listed above and routes the NSF register windows into them, clocking on notify_cpu_cycle and fanning APU frame events on notify_frame_event. Because the bit-for-bit math is shared with the cartridge path, an NSF VRC6 tune sounds identical to a VRC6 cartridge. The $5FF8-$5FFF bank registers retain priority over the overlapping expansion windows.
RustyNES is a cycle-accurate Nintendo Entertainment System emulator written in pure Rust. Licensed under MIT OR Apache-2.0. | GitHub Repository | Web Demo