🎶

ファミコンのAPUを実装してみよう! (第3回) - ついに音が出る!サウンドプログラミングと三角波

に公開

はじめに

こんにちは。プラスウイングTV中の人です。

前回は、APUの「時間」を司るフレームシーケンサーを実装しました。

第3回となる今回は、いよいよ実際に音を出すところに踏み込みます!

エミュレータ開発で一番テンションが上がる瞬間、それは「画面が出たとき」と「音が出たとき」です。
ついにその感動を味わう時が来ました!
サウンドプログラミングの基礎と、一番実装がシンプルな 3ch(三角波) の実装を行っていきましょう。

音を出す前の準備: ステータスレジスタ ($4015)

いよいよ音を出したいところですが、その前に一つだけ、絶対に避けて通れないレジスタの実装が必要です。
それが ステータスレジスタ ($4015) です。
これはAPUの「メインスイッチ」のような役割を果たします。
ゲームソフトは起動時、まずこのレジスタに書き込みを行って「どのチャンネルを使用するか」を宣言します。

レジスタの機能

$4015 は、書き込み時と読み込み時で役割が異なります。
(APUで利用するレジスタでは、このレジスタのみ読み込みが可能で、他のレジスタは書き込み専用です。)

書き込み (Write): チャンネルの有効化

各ビットが各チャンネルの ON/OFF スイッチになっています。
1 を書き込むと有効になり、 0 を書き込むと無効になります。

Bit チャンネル 動作
0 1ch (Pulse 1) 0: 無効 (長さカウンタ=0), 1: 有効
1 2ch (Pulse 2) 0: 無効 (長さカウンタ=0), 1: 有効
2 3ch (Triangle) 0: 無効 (長さカウンタ=0), 1: 有効
3 4ch (Noise) 0: 無効 (長さカウンタ=0), 1: 有効
4 5ch (DMC) 0: 無効, 1: 有効
5-7 未使用 -

読み込み (Read): ステータスの取得

現在のAPUの状態を返します。
主に「まだ音が鳴っているか(長さカウンタが残っているか)」を確認するために使われます。
また、読み込むことでフレームIRQフラグをクリアするという重要な副作用があります。

Bit 意味
0-3 各チャンネル (1ch~4ch) の長さカウンタが 0 より大きいか (1: 演奏中)
4 DMCがアクティブか
5 未使用
6 フレームIRQ が発生中か
7 DMC IRQ が発生中か

実装

それでは apu.rs に実装を追加しましょう。
まず、ビットフラグを定義する StatusRegister 構造体を用意します。

bitflags! {
    pub struct StatusRegister: u8 {
        const ENABLE_1CH       = 0b0000_0001;
        const ENABLE_2CH       = 0b0000_0010;
        const ENABLE_3CH       = 0b0000_0100;
        const ENABLE_4CH       = 0b0000_1000;
        const ENABLE_5CH       = 0b0001_0000;

        const ENABLE_FRAME_IRQ = 0b0100_0000;
        const ENABLE_DMC_IRQ   = 0b1000_0000;
    }
}

impl StatusRegister {
    pub fn new() -> Self {
        StatusRegister::empty()
    }

    pub fn update(&mut self, data: u8) {
        *self.0.bits_mut() = data;
    }
}

次に、NesAPU 構造体にこのレジスタを持たせ、読み書きの処理を実装します。

pub struct NesAPU {
    // ... 他のフィールド ...
    status: StatusRegister,
}

impl NesAPU {
    pub fn new(sdl_context: &sdl2::Sdl) -> Self {
        NesAPU {
            // ...
            status: StatusRegister::new(),
            // ...
        }
    }

    // $4015 Write
    pub fn write_status(&mut self, data: u8) {
        self.status.update(data);
        // DMCの実装時に追加処理が必要になります。
    }

    // $4015 Read
    pub fn read_status(&mut self) -> u8 {
        let mut res = self.status.bits();
        
        // Bit 0-3: 長さカウンタが残っているか?

        // あとでch3_register本体を実装しますが、先に書いちゃいます。
        res = res
            | (if self.ch3_register.length_counter == 0 {
                0
            } else {
                1
            } << 2);
        
        // 重要な副作用: フレームIRQフラグをクリアする
        self.status.remove(StatusRegister::ENABLE_FRAME_IRQ);
        
        res
    }

    // CPUへのIRQ通知
    pub fn frame_irq(&self) -> bool {
        self.status.contains(StatusRegister::ENABLE_FRAME_IRQ)
    }
}

bus.rs への組み込みも忘れずに行いましょう。

impl Bus {

    // mem_write() メソッド内
    0x4015 => {
         self.apu.write_status(value);
    }

    // mem_read() メソッド内
    0x4015 => {
         self.apu.read_status();
    }

}

フレームIRQの実装

ついでと言っては何ですが、ここでフレームIRQの実装もしておきましょう。
基本は、NMI割り込みとほぼ一緒なので、割り込みについて理解していれば難しいことはありません。

NMI割り込みと同じく、IRQが発生しているかを cpu.rs から確認する必要があるため、
bus.rs にプロキシとなるメソッドを用意します。

impl Bus {
    pub fn poll_frame_irq(&mut self) -> bool {
        self.apu.frame_irq()
    }
}

cpu.rs から、このメソッドを呼び出し、命令実行前にIRQが発生しているかを確認します。

impl CPU {
        // run_with_callback メソッド内
        loop {
            if let Some(_nmi) = self.bus.poll_nmi_status() {
                self.interrupt_nmi();
            }
            // このif文を追加。
            if self.bus.poll_frame_irq() {
                self.interrupt_frame();
            }
            let opscode = self.mem_read(self.program_counter);
            ...
        }
  ...
}

interrupt_frame()の中身以下2点を除きinterrupt_nmi()と同じです。

  • FLAG_INTERRUPT が立っていれば何もしないこと。
  • ジャンプ先のアドレスが、0xFFFE になること。

では、実装しましょう。

impl CPU {
   fn interrupt_frame(&mut self) {
     // FLAG_INTERRUPT が立っていれば何もしない
        if self.status & FLAG_INTERRUPT != 0 {
            return;
        }

        self._push_u16(self.program_counter);
        let mut status = self.status;
        status = status & !FLAG_BREAK;
        status = status | FLAG_BREAK2;
        self._push(status);

        self.status = self.status | FLAG_INTERRUPT;
        self.bus.tick(2);
     // ジャンプ先は0xFFFE
        self.program_counter = self.mem_read_u16(0xFFFE);
    }
}

これで、ステータスレジスタの実装が完了しました。
それでは、いよいよ音を出すためのプログラミングに進みましょう!

サウンドプログラミング入門

ファミコンの内部実装に入る前に、そもそも現代のPC(Rust)で「音を鳴らす」とはどういうことか、少し整理しましょう。

サウンドプログラミングとは

スピーカーから音が聞こえるのは、空気の振動があるからです。コンピュータでこれを再現するには、「波の高さ(振幅)」を細かい時間間隔で記録した数値データ(サンプル) を、オーディオデバイス(DAC)に送り続ける必要があります。

  • サンプリングレート: 1秒間に何回データを送るか(今回はCDと同じ 44100Hz を採用します)。
  • サンプル: 音の大きさ。今回は Float (f32) を使い、 -1.01.0 の範囲で波形を表現します。

つまり、エミュレータの仕事は、ファミコンのAPUの状態から計算して、1秒間に44,100個の f32 の数値を生成し、スピーカーに送りつけることです。

SDL2 Audio on Rust

Writing NES Emulator in Rustで利用している SDL2 (rust-sdl2) にはオーディオを扱う機能もあるので、今回はこれを利用します。

SDL2のオーディオ機能にはいくつかモードがありますが、今回は扱いやすい Audio Queue(キュー) 方式を採用します。これは「バッファ(予約領域)に音声データを放り込んでおくと、SDLが勝手に再生してくれる」という仕組みです。

SDL Audioの初期化

まずは main.rs 等でSDLを初期化する際、オーディオサブシステムも初期化し、apu.rs に渡す準備をします。

apu.rs に、初期化関数とデバイス保持用の構造体を追加しましょう。

use sdl2::audio::{AudioQueue, AudioSpecDesired};

// 定数定義
const SAMPLE_RATE: f32 = 44100.0;
const MASTER_VOLUME: f32 = 0.4; // 音量調整用

// 初期化関数
fn init_channel(sdl_context: &sdl2::Sdl) -> AudioQueue<f32> {
    let audio_subsystem = sdl_context.audio().unwrap();
    
    let desired_spec = AudioSpecDesired {
        freq: Some(SAMPLE_RATE as i32), // 44.1kHz
        channels: Some(1),              // モノラル(ファミコンはモノラルです。)
        samples: Some(4410),            // バッファサイズ (適当な大きさ)
    };

    // デバイスを開く
    let device = audio_subsystem.open_queue(None, &desired_spec).unwrap();
    // 再生開始(これを忘れると音が出ません)
    device.resume();

    return device;
}

pub struct NesAPU {
    // ... 他のフィールド ...
    device: AudioQueue<f32>, // オーディオデバイスへのハンドル
}

impl NesAPU {
    pub fn new(sdl_context: &sdl2::Sdl) -> Self {
        let device = init_channel(sdl_context);
        NesAPU {
            // ...
            device: device,
            // ...
        }
    }
}

これで、「数値を投げ込めば音になる箱 (device)」が手に入りました。


3ch (三角波) の実装

さて、ファミコンには 1ch, 2ch (矩形波), 3ch (三角波), 4ch (ノイズ), 5ch (DPCM) という5つの音源があります。
通常は 1ch から実装しがちですが、今回は あえて 3ch (三角波) から 実装します。

なぜ3chから?

理由はシンプルで、パラメータが少なくて簡単だからです。

  • 1ch/2ch: 「デューティ比」「エンベロープ(音量の自動減衰)」「スイープ(音程の自動変化)」があり複雑。
  • 4ch: 乱数生成が必要。
  • 3ch: 長さカウンタ/リニアカウンタ(音を自動で止める機能)しかない。

3ch は「音が鳴っているか・止まっているか」の制御だけで、音色・ボリュームは変更できません。最初に音出しテストをするにはうってつけなのです。


三角波チャンネルの仕組み

実装に入る前に、三角波チャンネルを構成する要素を理解しましょう。

1. レジスタの構成と実装

3chに関連するレジスタは $4008$400B です。
3chは以下の3つのレジスタで制御されます。

アドレス Bit 機能 解説
$4008 7 Length Halt / Linear Control 長さカウンタの停止 / リニアカウンタの制御フラグ
6-0 Linear Counter Load リニアカウンタの初期値 (音の細かな長さを指定)
$4009 - 未使用 -
$400A 7-0 Timer Low 周波数タイマーの下位8bit
$400B 7-3 Length Counter Load 長さカウンタのインデックス (音の大まかな長さを指定)
2-0 Timer High 周波数タイマーの上位3bit

これらを保持する構造体 Ch3Register を定義し、書き込み処理 write を実装します。

struct Ch3Register {
    // レジスタからの設定値
    length: u8,               // $4008
    key_off_counter_flag: bool, // $4008
    frequency: u16,           // $400A + $400B
    key_off_count: u8,        // $400B (長さテーブルへのIndex)

    // 内部で変化するカウンター類
    linear_counter: u8,       // リニアカウンタ (詳細な長さ制御)
    length_counter: u8,       // 長さカウンタ (大まかな長さ制御)
    phase: f32,               // 現在の波形の位置
}

impl Ch3Register {
    pub fn new() -> Self {
        Ch3Register {
            length: 0,
            key_off_counter_flag: false,
            frequency: 0,
            key_off_count: 0,
            linear_counter: 0,
            length_counter: 0,
            phase: 0.0,
        }
    }

    pub fn write(&mut self, addr: u16, value: u8) {
        match addr {
            0x4008 => {
                self.length = value & 0x7F;
                self.linear_counter = self.length; // 即時ロード
                self.key_off_counter_flag = (value & 0x80) == 0;
            }
            0x4009 => {} // 未使用
            0x400A => {
                // タイマーの下位8bit更新
                self.frequency = (self.frequency & 0x0700) | value as u16;
            }
            0x400B => {
                // タイマーの上位3bit更新
                self.frequency = (self.frequency & 0x00FF) | (value as u16 & 0x07) << 8;
                
                // 長さカウンタのロード
                self.key_off_count = (value & 0xF8) >> 3;
                self.length_counter = LENGTH_COUNTER_TABLE[self.key_off_count as usize];
                
                // 副作用: リニアカウンタのリロードと位相リセット
                self.linear_counter = self.length;
                self.phase = 0.0;
            }
            _ => panic!("can't be"),
        }
    }
}

これで、「どのような音を出すか」という指示を受け取る部分は完成です。

2. 周波数(タイマー)の計算式

レジスタ $400A$400B を合わせると、11bit の値(frequency)になります。
この frequency 値を使って、実際に出力される周波数 f は以下の式で求められます。

f = \frac{f_{CPU}}{32 \times (frequency + 1)}
  • f_{CPU}: ファミコンのCPUクロック(約 1.78 MHz)
  • 32: 三角波の波形が32ステップで1周するため

この式をコード(構造体のメソッド)にすると以下のようになります。

const NES_CPU_CLOCK: f32 = 1_789_772.5;

impl Ch3Register {
    // 設定されたタイマー値から周波数(Hz)を算出
    fn hz(&self) -> f32 {
        NES_CPU_CLOCK / (32.0 * (self.frequency as f32 + 1.0))
    }
}

3. 「位相 (Phase)」とは何か?

ここから波形生成のロジックに入りますが、その前に最重要概念である Phase (位相) について詳しく解説します。

デジタルオーディオで波形を作る際、「今、波のサイクルのどの位置にいるか」 を管理する変数が不可欠です。これを phase と呼びます。

今回は phase を 0.0 から 1.0 の小数 として扱います。

  • 0.0: 波の開始地点
  • 0.5: 波の折り返し地点(半周)
  • 0.99...: 波の終了直前
  • 1.0: 一周完了(0.0に戻る)

この phase が 0.0 から 1.0 まで進む=1回分の振動になります。
440Hz(ラの音)を出す場合、1秒間に、phaseが440回 0.0 から 1.0まで進むのを繰り返すということです。

三角波における phase と出力値 (-1.0 ~ 1.0) の関係は以下のようになります。

  • 0.0 <= phase <= 0.5 (前半)
    • 波は -1.0 からスタートして、一直線に 1.0 まで登ります。
  • 0.5 < phase < 1.0 (後半)
    • 波は 1.0 からスタートして、一直線に -1.0 まで下ります。

この「登って降りる」動きを繰り返すことで、三角波が生成されます。

4. 波形生成ロジック

さて、 phase はどのくらいのスピードで進めればよいのでしょうか?
これは、先に計算した周波数(hz)とサンプリングレート(SAMPLE_RATE。今回は 44100)で計算ができます。
hzは、1秒間の振動数です。サンプリングレートは、1秒間のサンプル数です。
なので、SAMPLE_RATE 回の内に hz0.0 -> 1.0 を繰り返せば良いことになります。
つまり、1サンプルで進める phase_{delta}

\text{phase}_{delta} = \frac{\text{hz}}{\text{SAMPLE\_RATE}}

となります。
これを踏まえて、三角波生成ロジックを実装してみましょう。

impl Ch3Register {
    fn next(&mut self) -> f32 {
        // カウンタが0なら音を止める(後述)
        if self.length_counter == 0 || self.linear_counter == 0 {
            return 0.0;
        }

        // 三角波の計算: 0.0~1.0 の phase を -1.0~1.0 の波形に変換
        let val = if self.phase <= 0.5 {
            self.phase
        } else {
            1.0 - self.phase
        };
        // 0.0~0.5 の範囲になっているので、中心をずらして4倍して -1.0~1.0 に正規化
        let x = (val - 0.25) * 4.0;

        // 次のサンプルのために位相を進める
        self.phase = (self.phase + self.hz() / SAMPLE_RATE) % 1.0;

        // そのまま出力すると、音が大きすぎる場合があるので、MASTER_VOLUMEで調整。
        return x * MASTER_VOLUME;
    }
}

音の長さを制御する2つのカウンタ

三角波には、音の長さを決めるカウンタが2種類あります。「長さカウンタ (Length Counter)」と「リニアカウンタ (Linear Counter)」です。両方のカウンタが 0 でないときだけ音が鳴ります。

1. 長さカウンタ (Length Counter)

これは全音源(DMC以外)に共通する仕組みです。
レジスタ $400B の上位5bitで指定しますが、書かれた値そのものが長さになるわけではありません
「長さカウンタテーブル」という変換表を通り、実際のカウント値に変換されます。

定義:

static LENGTH_COUNTER_TABLE: [u8; 32] = [
    0x0A, 0xFE, 0x14, 0x02, 0x28, 0x04, 0x50, 0x06, 0xA0, 0x08, 0x3C, 0x0A, 0x0E, 0x0C, 0x1A, 0x0E,
    0x0C, 0x10, 0x18, 0x12, 0x30, 0x14, 0x60, 0x16, 0xC0, 0x18, 0x48, 0x1A, 0x10, 0x1C, 0x20, 0x1E,
];

更新処理:
フレームシーケンサーから定期的に呼ばれ、値を減らします。$4008 の Bit7 (key_off_counter_flag) が立っていると、減算が無効(無限の長さ)になります。

impl Ch3Register {
    fn tick_length_counter(&mut self) {
        if !self.key_off_counter_flag {
            return;
        }
        if self.length_counter > 0 {
            self.length_counter -= 1;
        }
    }
}

2. リニアカウンタ (Linear Counter)

これは三角波専用のカウンタです。長さカウンタよりも 高分解能(細かい時間単位) で音を制御するために使われます。
$4008 の下位7bitに設定された値がそのままカウンタ値となります。

更新処理:
フレームシーケンサーからの呼び出しで減算されます。

impl Ch3Register {
    fn tick_linear_counter(&mut self) {
        if !self.key_off_counter_flag {
            return;
        }
        if self.linear_counter > 0 {
            self.linear_counter -= 1;
        }
    }
}

フレームシーケンサーから呼び出す

これまでに作成した tick_length_counter, tick_linear_counter メソッドをフレームシーケンサから呼び出しましょう。

impl NesAPU {
    fn send_envelope_tick(&mut self) {
        // リニアカウンタは、envelopeと同じタイミングで呼び出します。
        self.ch3_register.tick_linear_counter();
    }

    fn send_length_counter_tick(&mut self) {
        self.ch3_register.tick_length_counter();
    }
}

Audio Buffer にデータを投入する

最後に、APU全体の tick メソッドで、生成した音声をSDLのキューに追加する処理です。
add_buffer メソッドを NesAPU に実装し、tick 内で呼び出します。

impl NesAPU {
    // ...
    
    // tick内で定期的に呼ばれる想定
    fn add_buffer(&mut self) {
        // 1step分(1/240秒)で必要なサンプル数
        let must_add = SAMPLE_RATE / 240.0;
        // バッファが空になって音が途切れないように最低サイズを定義。(5フレーム分)
        let buffer_min_size = SAMPLE_RATE * (5.0 / 60.0);
        // デバイスの未再生分がどれだけあるかを取得。
        let buffer_size = self.device.size() as f32 / 4.0; // f32=4byteなので

     // 今回追加するサンプル数を計算。
        let add_buffer_size = if buffer_min_size > buffer_size {
            buffer_min_size - buffer_size
        } else {
            must_add
        };
       

        let mut buffer = vec![0.0; add_buffer_size as usize];
        // サンプルを生成して埋める
        for sample in buffer.iter_mut() {
            *sample = 0.0;
            
            // 3chが有効なら加算
            if self.status.contains(StatusRegister::ENABLE_3CH) {
                *sample += self.ch3_register.next();
            }
        }
        
        // SDLに送信!
        self.device.queue_audio(&buffer).unwrap();
    }
}

この add_buffer を、前回の記事で作成した tick メソッドの最後(intervalを超えたとき)に呼び出すようにすれば完成です。

バッファについての補足

バッファに投入したデータは問答無用で1秒間に44100個づつ消費されていきます。
バッファがなくなると、音が止まってしまうので、ある程度余裕を持たせて途切れないようにする必要があり、今回は 5/60 秒分データを余分に持たせています(つまり、今の実装だと最悪5フレーム遅れて音が出ます)
音の再生ができるだけ途切れないようにするか、リアルタイムになることを優先するか。悩ましい設定値です。

おわりに

ここまでの実装で、3chの音だけ出るようになっているはずです!
適当なROMで動作確認してみてください。

メロディ(矩形波)がないので地味ですが、これは間違いなくファミコンの音です。自分で書いたコードから音が出ると、エミュレータ開発の実感が一気に湧いてきますよね。

次回は、ファミコンサウンドの主役、矩形波 (Pulse Wave) の実装を行い、メロディを奏でられるようにします。
お楽しみに!

Discussion