featured.svg

NES 的聲音來自一顆叫 2A03 的 APU(Audio Processing Unit)——整合在 CPU 晶片裡的音效硬體。它只有 5 個聲道:兩個方波、一個三角波、一個噪音、一個 DMC(差分取樣)。聽起來很陽春?但 Super Mario Bros. 的地上主題、Mega Man 2 的 Dr. Wily Stage、Castlevania 的 Vampire Killer——這些經典旋律全都是從這 5 個聲道榨出來的。

這個專案用 Rust 實作了完整的 NES APU 模擬器,包含 frame counter 時序、envelope 衰減、sweep 掃頻、非線性混音,以及 DMC 的 DPCM 解碼。設計上是純 DSP——不依賴任何音訊輸出函式庫,只負責產生 PCM 樣本。

專案結構

nes-apu/
├── Cargo.toml
└── src/
    ├── lib.rs            # 模組匯出(12 行)
    ├── apu.rs            # APU 核心:tick、取樣、暫存器 I/O(468 行)
    ├── pulse.rs          # 方波聲道:duty cycle + timer(149 行)
    ├── triangle.rs       # 三角波聲道:線性計數器(164 行)
    ├── noise.rs          # 噪音聲道:LFSR 偽隨機(136 行)
    ├── dmc.rs            # DMC 聲道:1-bit DPCM 取樣播放(257 行)
    ├── envelope.rs       # 音量包絡產生器(124 行)
    ├── sweep.rs          # 頻率掃頻單元(140 行)
    ├── length_counter.rs # 長度計數器(126 行)
    ├── frame_counter.rs  # Frame counter:quarter/half-frame 時序(351 行)
    ├── mixer.rs          # 非線性混音器 + 查表(74 行)
    ├── composite_io.rs   # Bus I/O 轉接器(43 行)
    └── system.rs         # 整合系統(CPU + PPU + APU + Joypad)

依賴只有 nes-cpu(CPU 核心 + state 序列化工具),整個 APU 沒有外部音訊依賴。

NES 音訊架構

先看全貌——5 個聲道各自獨立產生波形,最後混合成一個單聲道輸出:

graph LR P1[Pulse 1
方波] --> MIX[Mixer
非線性混音] P2[Pulse 2
方波] --> MIX TRI[Triangle
三角波] --> MIX NOI[Noise
噪音] --> MIX DMC[DMC
差分取樣] --> MIX MIX --> HPF[High-Pass
Filter] --> OUT[Audio
Output] FC[Frame Counter] -.-> P1 FC -.-> P2 FC -.-> TRI FC -.-> NOI

每個聲道內部都有自己的 timer(控制頻率)和各種修飾單元(envelope、sweep、length counter),由 frame counter 定期觸發更新。

1. Pulse 聲道:方波的藝術

兩個 Pulse 聲道幾乎完全相同,差別只在 sweep 的 negation 模式。每個方波聲道由四個子系統組成:

Timer → Duty Sequencer → 輸出 (0 或 1)
         × Envelope 音量
         × Length Counter 閘控
         × Sweep 掃頻

Duty Cycle:四種波形

方波不一定是 50/50,NES 提供了四種 duty cycle:

Duty 0 (12.5%):  ░░░░░░░█  → 極窄脈衝,薄而尖的音色
Duty 1 (25%):    ░░░░░░██  → 較窄,清脆
Duty 2 (50%):    ░░░░████  → 標準方波,圓潤
Duty 3 (75%):    ██████░░  → 等同 25% 反相

實作上就是 8 步的序列查表:

const DUTY_TABLE: [[u8; 8]; 4] = [
    [0, 0, 0, 0, 0, 0, 0, 1], // 12.5%
    [0, 0, 0, 0, 0, 0, 1, 1], // 25%
    [0, 0, 0, 0, 1, 1, 1, 1], // 50%
    [1, 1, 1, 1, 1, 1, 0, 0], // 75%
];

Timer 每到 0 就把 sequencer 推進一步,duty table 決定這一步是 1(有聲)還是 0(靜音)。Timer 的 period 就是頻率的倒數——period 越小,頻率越高。

Sweep 掃頻:自動滑音

Sweep 單元每個 half-frame 自動調整 timer period——可以讓音高漸漸升高或降低,遊戲裡的「嗶嗶嗶」滑音效果就是這麼做的:

pub fn tick(&mut self, timer_period: u16) -> Option<u16> {
    let target = self.target_period(timer_period);
    let muted = timer_period < 8 || target > 0x7FF;

    if self.divider == 0 && self.enabled && self.shift > 0 && !muted {
        Some(target)
    } else {
        if self.divider == 0 || self.reload {
            self.divider = self.period;
            self.reload = false;
        } else {
            self.divider -= 1;
        }
        None
    }
}

目標頻率的計算是 current ± (current >> shift)——右移 shift 位再加或減。當目標超過 $7FF 或原始 period 小於 8 時,聲道被 mute。

Pulse 1 和 Pulse 2 的 sweep negation 不同是個有趣的硬體細節:

pub enum NegateMode {
    OnesComplement,  // Pulse 1:-change - 1
    TwosComplement,  // Pulse 2:-change
}

差 1 的原因是 Pulse 1 用一補數(ones’ complement)做減法,而 Pulse 2 用二補數(two’s complement)。這意味著 Pulse 1 sweep 到最低頻時會比 Pulse 2 多出一個微小的偏移。

2. Triangle 聲道:不需要音量控制

三角波是 NES 聲道裡最特別的——它沒有 envelope(音量控制),波形永遠是滿振幅。但它有一個 linear counter(線性計數器),配合 length counter 一起閘控聲音的開關。

波形是 32 步的固定序列:

15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0,
 0,  1,  2,  3,  4,  5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15

上去再下來,形成一個三角形。注意 0 出現了兩次——這讓波形微微不對稱。

一個重要的實作細節——三角波的 output 永遠跟著 sequencer 位置,不管計數器是否為零:

pub fn output(&self) -> u8 {
    TRIANGLE_TABLE[self.sequence_pos as usize]
}

計數器只控制 sequencer 要不要繼續推進:

pub fn tick(&mut self) {
    if self.length_counter.value() > 0 && self.linear_counter > 0 {
        self.sequence_pos = (self.sequence_pos + 1) % 32;
    }
}

為什麼這樣設計?因為如果在 sequencer 不在 0 或 15 的位置上突然停止,輸出會卡在一個中間值,造成 DC offset。真實硬體就是這樣——你在某些遊戲裡會聽到三角波聲道留下一個輕微的「嗡」聲。

另一個差異是三角波的 timer 在每個 CPU cycle 都 tick,不像 Pulse 和 Noise 是每兩個 cycle 才 tick 一次。所以三角波能達到的最高頻率是方波的兩倍。

3. Noise 聲道:LFSR 偽隨機

Noise 聲道用一個 15-bit 的 LFSR(Linear Feedback Shift Register) 產生偽隨機噪音:

pub fn tick(&mut self) {
    if self.timer == 0 {
        self.timer = self.timer_period;

        let bit = if self.mode {
            (self.shift & 1) ^ ((self.shift >> 6) & 1)  // 短模式
        } else {
            (self.shift & 1) ^ ((self.shift >> 1) & 1)  // 長模式
        };

        self.shift >>= 1;
        self.shift |= bit << 14;
    } else {
        self.timer -= 1;
    }
}

LFSR 的運作:每次 timer 歸零,把 bit 0 和另一個 bit XOR,結果塞進 bit 14,整個暫存器右移一位。長模式 XOR bit 0 和 bit 1(週期 32767),短模式 XOR bit 0 和 bit 6(週期 93)。

短模式聽起來像什麼? 像金屬撞擊或電子鼓的 hi-hat——因為短週期讓噪音產生明顯的音調感。長模式則是白噪音。

輸出很直接:bit 0 為 1 時靜音,為 0 時輸出 envelope 音量:

pub fn output(&self) -> u8 {
    if self.shift & 1 == 1 || self.length_counter.value() == 0 {
        0
    } else {
        self.envelope.output()
    }
}

16 種 period 對應 16 種「音高」的噪音,period 越長聲音越低沉:

const NOISE_PERIOD_TABLE: [u16; 16] = [
    4, 8, 16, 32, 64, 96, 128, 160,
    202, 254, 380, 508, 762, 1016, 2034, 4068,
];

4. Envelope:音量衰減產生器

Pulse 和 Noise 聲道共用同一個 Envelope 設計。它在每個 quarter-frame(約 240Hz)被觸發一次:

pub fn tick(&mut self) {
    if self.start {
        self.start = false;
        self.decay_level = 15;
        self.divider = self.divider_period;
    } else if self.divider == 0 {
        self.divider = self.divider_period;
        if self.decay_level > 0 {
            self.decay_level -= 1;
        } else if self.loop_flag {
            self.decay_level = 15;  // 循環:回到最大音量
        }
    } else {
        self.divider -= 1;
    }
}

兩種模式:

  • Constant volume:直接用 divider_period 當音量(0-15)
  • Decay envelope:從 15 開始遞減,每 divider_period + 1 個 quarter-frame 減 1

Loop flag 讓衰減的音量在到 0 之後重新回到 15——可以做出持續的「脈動」效果。

5. DMC:差分取樣播放

DMC(Delta Modulation Channel)是 NES 唯一能播放「真實」音訊樣本的聲道——但它用的是 1-bit DPCM(Differential Pulse-Code Modulation),每個 bit 只表示「+2」或「-2」:

pub fn tick(&mut self) -> Option<u16> {
    let mut sample_request = None;

    if self.timer == 0 {
        self.timer = self.timer_period;

        if self.bits_remaining > 0 {
            if self.shift_register & 1 == 1 {
                if self.output_level <= 125 {
                    self.output_level += 2;  // bit=1: 音量 +2
                }
            } else {
                if self.output_level >= 2 {
                    self.output_level -= 2;  // bit=0: 音量 -2
                }
            }
            self.shift_register >>= 1;
            self.bits_remaining -= 1;
        }

        // 需要新的 sample byte
        if self.bits_remaining == 0 && self.bytes_remaining > 0 {
            sample_request = Some(self.current_address);
            // ...
        }
    } else {
        self.timer -= 1;
    }

    sample_request
}

DMC 的取樣機制很巧妙:

  1. CPU memory 的 $C000-$FFFF 區間存放 sample data
  2. DMC 發出位址請求,外部系統從 CPU memory 讀取 1 byte
  3. 每個 byte 的 8 個 bit 逐一處理,每個 bit 讓 7-bit output level 加 2 或減 2
  4. Output level 被 clamp 在 0-127,不會溢出

16 種播放速率(NTSC,CPU cycles per bit):

const DMC_RATE_TABLE: [u16; 16] = [
    428, 380, 340, 320, 286, 254, 226, 214,
    190, 160, 142, 128, 106, 84, 72, 54,
];

最慢 428 cycles/bit ≈ 4.2 kHz 取樣率,最快 54 cycles/bit ≈ 33.1 kHz。雖然只有 1-bit 差分,但因為取樣率夠高,DMC 可以播放出可辨識的語音和音效——像 Mike Tyson’s Punch-Out!! 裡的裁判喊「TKO!」就是 DMC。

DMC 還有兩個特殊功能:

  • Loop flag:播完後自動從頭開始——用來播放循環的底鼓或低音
  • IRQ flag:播完時觸發 CPU 中斷——遊戲可以在中斷裡載入下一段 sample

6. Frame Counter:APU 的心跳

Frame counter 是 APU 的「指揮」——它定期發出 quarter-frame 和 half-frame 事件,驅動 envelope、length counter、sweep 等子系統的更新。

兩種模式:

4-step mode(~60 Hz,對齊到 NTSC 的每幀):

Step    APU Cycles    動作
  1       3729       Quarter-frame(envelope、linear counter)
  2       7457       Quarter + Half-frame(+ length counter、sweep)
  3      11186       Quarter-frame
  4      14914       IRQ flag set
  5      14915       Quarter + Half-frame + IRQ set,歸零

5-step mode(~48 Hz,沒有 IRQ):

Step    APU Cycles    動作
  1       3729       Quarter-frame
  2       7457       Quarter + Half-frame
  3      11186       Quarter-frame
  4      14915       (空步)
  5      18641       Quarter + Half-frame,歸零

4-step mode 的 IRQ 讓遊戲不需要用 NMI(PPU vblank)來計時音樂——可以獨立於畫面更新率。5-step mode 則刻意避開 IRQ,寫 $4017 = $80 就切換過去。

實作上用一個 cycle counter 搭配 match:

pub fn tick(&mut self) -> FrameEvent {
    self.cycle += 1;

    let event = if self.mode == 0 {
        // 4-step mode
        match self.cycle {
            3729 => FrameEvent::quarter(),
            7457 => FrameEvent::quarter_and_half(),
            11186 => FrameEvent::quarter(),
            14914 => { /* set IRQ if not inhibited */ }
            14915 => {
                self.cycle = 0;
                FrameEvent::quarter_and_half()
            }
            _ => FrameEvent::none(),
        }
    } else {
        // 5-step mode
        match self.cycle {
            3729 => FrameEvent::quarter(),
            7457 => FrameEvent::quarter_and_half(),
            11186 => FrameEvent::quarter(),
            18641 => {
                self.cycle = 0;
                FrameEvent::quarter_and_half()
            }
            _ => FrameEvent::none(),
        }
    };

    event
}

7. Mixer:非線性混音

NES 的混音不是簡單的加法——真實硬體用的是電阻式 DAC,會產生非線性的混音曲線。模擬器用兩張查表來近似這個行為:

impl Mixer {
    pub fn new() -> Self {
        let mut pulse_table = [0.0f32; 31];
        for n in 1..31 {
            pulse_table[n] = 95.52 / (8128.0 / n as f32 + 100.0);
        }

        let mut tnd_table = [0.0f32; 203];
        for n in 1..203 {
            tnd_table[n] = 163.67 / (24329.0 / n as f32 + 100.0);
        }

        Self { pulse_table, tnd_table }
    }
}

混音公式:

output = pulse_table[pulse1 + pulse2]
       + tnd_table[3 × triangle + 2 × noise + dmc]

Pulse 的索引最大 30(兩個聲道各最大 15),TND 的索引最大 202(3×15 + 2×15 + 127)。查表 O(1),避免了每個 sample 都做除法。

為什麼三角波的權重是 3、噪音是 2?這反映了真實硬體裡各聲道的電阻值比例——三角波聲道的輸出阻抗比噪音低,所以貢獻更大。

8. Downsampling 和高通濾波

NES CPU 跑在 1.789773 MHz,但音訊輸出通常是 44100 Hz。APU 在每個 CPU cycle 都 tick,但只在累積夠足夠 cycle 後才取一個 sample:

pub fn tick(&mut self) {
    // ... tick 各聲道 ...

    self.sample_counter += 1.0;
    if self.sample_counter >= self.sample_period {
        self.sample_counter -= self.sample_period;

        let mixed = self.mixer.mix(p1, p2, tri, noise, dmc);

        // 一階高通濾波,去除 DC offset
        let filtered = self.hpf_alpha * (self.hpf_prev_out + mixed - self.hpf_prev_in);
        self.hpf_prev_in = mixed;
        self.hpf_prev_out = filtered;

        self.sample_buffer.push(filtered);
    }
}

sample_period = 1789773 / 44100 ≈ 40.59 cycles/sample。累計器用 f64 維持相位對齊,避免 sample 漂移。

高通濾波器的截止頻率約 37 Hz——真實 NES 硬體也有類似的 RC 電路濾掉 DC 偏移。參數計算:

RC = 1 / (2π × 37) ≈ 0.0043
α = RC / (RC + dt),其中 dt = 1 / sample_rate

9. DMC 的 DMA 機制

DMC 取樣需要從 CPU memory 讀資料,但 APU 和 CPU 共享匯流排——DMC 每讀一個 byte 要「偷」CPU 4 個 cycle。這個交互在 system.rs 裡實現:

fn step(&mut self) {
    // APU tick
    self.apu.borrow_mut().tick();

    // 檢查 DMC 是否需要 sample
    if let Some(addr) = self.apu.borrow().dmc_sample_request() {
        let val = self.cpu.bus_read(addr); // 從 CPU memory 讀取
        self.apu.borrow_mut().dmc_load_sample(val);
        // 真實硬體這裡會偷 4 個 CPU cycle
    }

    // 檢查 APU 的 IRQ
    if self.apu.borrow().irq_pending() {
        self.cpu.irq();
    }
}

dmc_sample_request() 回傳 Option<u16>——有值時表示 DMC 需要讀取那個位址的 sample byte。外部系統從 CPU 的記憶體映射讀取後,用 dmc_load_sample() 送回去。

10. State 序列化

每個元件都支援 save_state() / load_state() — 存檔和讀檔功能:

所有有狀態的元件(envelope、sweep、length counter、frame counter、各聲道、mixer 的 per-channel waveform buffer)都參與序列化。狀態用 cursor-based 的方式寫入,配合 nes_cpu::state 模組的 helper function,確保每個元件的狀態都能完整保存和恢復。

11. 測試

APU 的測試以 blargg 的 apu_test ROM 為基準,涵蓋:

  • Length counter 行為和 lookup table
  • Frame counter 在 4-step / 5-step 模式下的時序
  • IRQ 的觸發和清除條件
  • DMC 的播放速率表和位址計算
  • Envelope 的衰減邏輯
  • Sweep 的 negation 差異(ones’ vs two’s complement)
  • 混音器的非線性查表

全部模組加起來超過 99 個測試案例。

總結

元件 行數 核心機制
Pulse × 2 ~149 Duty cycle 方波 + sweep 掃頻
Triangle ~164 32-step 三角波 + linear counter
Noise ~136 15-bit LFSR + 長/短模式
DMC ~257 1-bit DPCM + DMA 取樣
Envelope ~124 4-bit 音量衰減/常量
Sweep ~140 頻率自動調整 + mute 條件
Length Counter ~126 32-entry 查表 + halt 控制
Frame Counter ~351 4/5-step 時序 + IRQ
Mixer ~74 非線性查表混音
APU 核心 ~468 Tick、downsampling、HPF

NES APU 最讓我印象深刻的是它的層疊式設計——每個聲道都是 timer → sequencer → output 的基本架構,但透過不同的修飾單元(envelope、sweep、length counter、linear counter)組合出截然不同的音色。5 個聲道、總共不到 2000 行 Rust,就能模擬出那個年代所有經典遊戲的配樂。

CPUPPUJoypadMapper 再到 APU,NES 模擬器的每一塊拼圖都就位了。接下來就是把它們全部接在一起,讓畫面動起來、聲音出來、按鍵有反應——一台完整的 NES 模擬器。

參考資源