ファミコンの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.0~1.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 値を使って、実際に出力される周波数
-
: ファミコンのCPUクロック(約 1.78 MHz)f_{CPU} -
: 三角波の波形が32ステップで1周するため32
この式をコード(構造体のメソッド)にすると以下のようになります。
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 回の内に hz 回 0.0 -> 1.0 を繰り返せば良いことになります。
つまり、1サンプルで進める
となります。
これを踏まえて、三角波生成ロジックを実装してみましょう。
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