ファミコンのDMCチャンネルの再現方法

2023-04-06

ファミコンは音楽を鳴らす機能として矩形波×2、三角波、ノイズというシンプルな方法に加えて、DMCというサンプリング音源による方法がある。 そんな機能が搭載されてたこと自体も驚きだが、サンプリング音源なんて容量制限が厳しくて使ってるゲームなんてほとんどないでしょと後回しにしてたんだが、後期の豪華なゲームでは結構使われているので再現する方法を調べた。

DMC概要

DMCはDelta Modulation Channelの略で、時間ごとの波形の上げ下げを1ビットで表現するデータで音を再生する。 「そんなんでサンプリング音源を再現できるの!?」と驚いてしまうが、ノイズは入ってしまうものの音声などもちゃんと再生できる。

内部的に現在値が7bitで保持されていて、時間ごとにデータから1ビット取り出して+2または-2されることで波を再現する。 再生レートは16段階で指定できて、最低4.2kHzから最高33.1kHzを選択できる(後述)。 データはDMA転送でCPUがアクセスできるメモリと同じバスでプログラムROMから取得される。

データを配置できる開始アドレスは$C000~$FFC0の64バイト単位。 再生できる長さは1から16バイト刻みで4081(= 255 × 16 + 1)まで (サンプル数は×8)。

再生レートとデータサイズ

$4010レジスタの下位4ビットによるテーブル参照でカウンタの値が求まり、再生レートは次のようになる (Pitch table):

Rate 再生レート [Hz]
0 428 4,181.71 Hz
1 380 4,709.93 Hz
2 340 5,264.04 Hz
3 320 5,593.04 Hz
4 286 6,257.95 Hz
5 254 7,046.35 Hz
6 226 7,919.35 Hz
7 214 8,363.42 Hz
8 190 9,419.86 Hz
9 160 11,186.1 Hz
10 142 12,604.0 Hz
11 128 13,982.6 Hz
12 106 16,884.6 Hz
13 84 21,306.8 Hz
14 72 24,858.0 Hz
15 54 33,143.9 Hz

テーブルの値でカウンタを初期化して、CPUの動作クロック1,789,773 Hzのたびにカウンタをデクリメント (実際にはDMCは1APU=2CPUサイクルごとなので、カウンタもテーブルの値の半分)、 0になったらサンプリングデータ(1ビット)で上下させる。 例えばRate=0ではテーブルの値は428なので、再生レートは 1,789,773 / 428 = 4,181.71[Hz] となる。

しかし再生レートの音源を再現できるかというと、DMCではサンプルにつき±2しか変化させられないので、それだと表現できる音量が小さすぎる。 なので実際にはより高いレートが必要になると思う (いくつかみたところ、たいてい15を使っていた)。

1サンプルが1ビットなのでデータサイズは大まかに1秒につき再生レートHz/8バイトとなる。 例えばレート15で音源が0.1秒だったらレジスタ$4013に指定する長さは26(≒0.1×1789773/(54×8×16))で、データサイズは417(=26×16+1)バイトとなる。

サンプリングデータの取得方法

WebAudio(に限らないがオーディオのAPIがコールバックで時間ごとに呼び出される方式)で再生する場合、そのAPI側の時間進行によってDMC再生のエミュレーションを進める必要がある。 ノイズチャンネルなどの場合には周波数やボリュームだけわかっていれば処理することができるが、DMCの場合はタイミングに合わせてサンプリングデータを取得する必要がある。

実際のファミコンのハードウェアではDMA(Direct Memory Access)を使ってメモリ(プログラムROM領域)から読み込んでいる (マッパーによるバンク切り替えも反映される)。

エミュレーションする場合、ScriptableProcessorNodeを使うんであればメインスレッドで動くので、APIのコールバック内からもNESエミュレーション側のデータを見にいける。 しかしAudioWorkletの場合には別スレッドで見にいけないのでどうするか。

AudioWorkletにデータを送る

メインスレッド側のデータには直接アクセスできないので、最初にプログラムROM全体をworklet.port.postMessage()で送ってやることにした。 プログラムROMはものによっては数百キロバイトとかあるが、問題なく送れるっぽい。 第二引数にTransferable Object(例えばuint8Array.buffer)を渡すことで無駄な複製が抑えられるとか (あまり効果はわからなかった)。

AudioWorklet側でプログラムROMを参照できるようになったらあとはDMAに合わせて該当アドレスに対応するデータを自分で取得すればよい。 実際のハードウェアではDMAのたびにCPUが4サイクル程度ストールするとのことだが、現状はとくになにもしてない (厳密なエミュレーションを目指してないので)。

サンプル時の処理

サンプルを進めるタイミングはDMCのエミュレーションの周期とAPI側のサンプリングレートが異なっているので、ノイズの時の「サンプルごとの処理」と同様に。

DMAで取得したデータからカウントごとに下位1ビットを見て、内部に保持する7ビットの値を1なら+2、0なら-2する。 が0以上127以下の範囲を超える場合には更新しない。

IRQ

DMCからCPU割り込みを起こせる機能があるらしく、カウンタが0になるたびにIRQを発生させられるようだが未実装。 postMessageでメインスレッドに送った場合、レイテンシがどのくらいになるかは気になる。

リンク