Skip to content

APU Mixer

DoubleGate edited this page Jun 29, 2026 · 1 revision

APU — Ricoh 2A03 audio unit

References: Nesdev APU, APU Frame Counter, APU DMC, DMA, and Controller reading.

Purpose

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).

Interfaces

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.

State

  • 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.

Behavior

Register map

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)

Frame counter

  • 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 $4017 resets 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.

DMC channel

  • 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 $8000 after $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 $4011 sets 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.

DMC load-DMA even/odd-cycle delay

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.

Mixer

// 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.

Band-limited sample emission

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.

$4015 semantics

  • 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.

Edge cases and gotchas

  1. DMC DMA stalls CPU mid-instruction. Halt only on read cycles. The 2A03 register-readout bug (extra reads of $2007, $4015-$4017 while halted) must be reproduced.
  2. Frame counter write jitter. Writing $4017 with a value that includes IRQ inhibit set clears any pending frame IRQ flag.
  3. 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 $400x halt-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.
  4. 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.
  5. 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.
  6. DMC playback stops mid-scanline? Yes; $4015 write to clear bit 4 silences the channel after the current sample byte completes.
  7. 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.
  8. 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.
  9. 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.

Expansion-chip audio

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).

NSF expansion-audio routing

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.

Clone this wiki locally