ファミコンは音楽を鳴らす機能として矩形波×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
でメインスレッドに送った場合、レイテンシがどのくらいになるかは気になる。
リンク
- 作成したソース:DmcChannelProcessor、DeltaModulationSampler
- APU DMC - NESdev Wiki 動作の詳しい解説はここを見ること
- NES Sound: The DMC - Behind the Code - YouTube 動画全体よいが、特に”Double Dribble”や他のゲームもデータが誤ってバイト内で逆転してるのを発見した、というのがすごい
- WebWorkersで巨大データ転送の不思議 - Qiita