このドキュメントはgba-hq-mixerのREADMEを日本語に翻訳したものです。 まだ翻訳が終わっていない部分もあります。
このリポジトリには、m4a/mp2kサウンドドライバを使用するGBAゲーム用の、いわゆるHQミキサー(High Quality mixer)が含まれています。
m4a/mp2k: 任天堂によって提供されたGBAのSDKに添付された、ゲームで音楽や効果音を鳴らすためのサウンドドライバライブラリ。 詳細はこちらを参照。 量子化誤差: アナログ信号をデジタル信号に変換する際に生じる誤差の一種で、元の信号レベルとデジタル化した後の数値の間に生じる誤差のこと。
任天堂SDKのm4a/mp2kサウンドドライバを使用したゲームの多くは、音がかなりうるさいという共通の問題を抱えています。これは、GBAにはPCMのハードウェアチャンネルが2つしかないため(通常は左右のステレオ出力に使用)、PCMサウンドをすべてCPUで生成しなければならないためです。
任天堂のサウンドドライバは、8bitのPCMサンプルデータを処理し、エンジンの出力レートにリサンプリングし、ボリューム・スケーリングとリバーブ(エコー)をかけます。
これらの処理はすべて、DMAを介してハードウェアチャンネルに使用されるのと同じバッファを使って行われます。
このバッファの解像度は8bitなので、このバッファで直接ミキシングを行うと、処理された音が増えるたびに量子化誤差が発生するため、ノイズの問題が発生します。
ボイスの数が少ないゲームの方が、ボイスの数が多いゲームよりもノイズが少ないのもこのためです。
GBAゲームのハック/MODを作るとき、より忠実な音楽のために、より多くのボイスを使用することが望ましいことがよくありますが、その場合、音楽は本質的にとてもノイズが多くなります。
この問題を解決するために、私は2014年に新しいサウンド処理ルーチンの開発を始めました。
その後、何度か改良を重ねていますので、当時のコードベースをお使いの方は、新しいコードベースに興味を持たれるかもしれません。
このコードの一部は、任天堂のサウンドドライバをリバースエンジニアリングした『黄金の太陽』のサウンドドライバのコードにヒントを得ています。
黄金の太陽は、GBAの市販ゲームでリリースされた中で最高品質の音楽を搭載していることでよく知られています。このコードは、他のゲームに比べてはるかにノイズが少ないので、私は注目しました。
このノイズを避けるための解決策は、すべての処理においてより高い解像度(ビット幅)のバッファを使用することです。
私のコードでは、内部的に16bitのPCMバッファを使用しています。16bitよりも高い解像度を使用することもできますが、RAMを浪費するだけであまりメリットはありません。
より高い解像度のバッファを使用することで、量子化エラーは、私が「ダウンサンプリング」と呼ぶ段階で一度だけ発生します。
このコードでは、主要な処理が終わった後、16bitのオーディオを8bitに変換するという余分なステップが必要になります。
最初は直感的ではないかもしれませんが、1回だけ量子化を導入することで、(再生されるすべての音声に量子化を行うのとは逆に)実質的に可聴ノイズを大幅に減らすことができます。
実際にコードを書いてみると、処理速度というもうひとつのメリットが見えてきました。
任天堂のコードは、標準的なプログラミングの視点から見て、非効率なことをしていたわけではありません。しかしそれでも、いくつかのトリックを使用すれば、音の処理をもっと高速化することができます。その詳細は以下の通りです。
後述のトリックを導入することにより、ハックや改造でより多くのボイスを使用したり、より忠実な音楽のために高いサンプレートを使用したりすることができるようになります。
- 任天堂の従来のコードからさらにノイズを軽減
- 高速な処理速度を実現(高いサンプルレートのときに顕著です)
- その他にもさまざまなメリットを実現(キャメロットによる楽器のシンセサイズやポケモンのサンプルデータの圧縮など)
HQミキサーには様々な機能があり、バージョンも異なります。
コンフィグとは、コードの動作に影響を与える、コード内の異なる.equ
ディレクティブのことです。
バージョンとは、このコードの異なるgitブランチのことです。利用可能なバージョンはブランチのリストで見ることができます。(現在は1種類のみ)
HQミキサーを使用する前に、コンフィグとバージョンを選択する必要があります。
バージョン
- master: 大抵の用途の場合はこれで問題ないでしょう (GitHubのデフォルトブランチです。)
将来的には、ユーザーのニーズに応じて、さまざまな機能を備えたバージョンをご用意する予定です。今のところ、単一のメインバージョンのみが利用可能です。
コンフィグ
アセンブリプログラムの冒頭には、次のような.equ
ディレクティブを含む小さなセクションがありますので、ケースに合わせて正しく選択してください。
hq_buffer_ptr
: バッファの定義場所です。バッファはIWRAM(0x03000000..03007FFF
)内に定義することを推奨します。バッファのサイズはサンプルレートに依存しており、samples-per-frame * 4
バイトとなります。POKE_CHN_INIT
: ポケモンのゲームではPCMチャンネルの初期化が少し違うので、ポケモンのゲームで使用する場合は1
を選択してください。その他のゲームでは0
を選択してください。ENABLE_STEREO
: 将来的には、モノラルサウンドしか出力されないゲームでもこのコードを使用できるようにすることを目的としています。メモリを破壊することなく、モノラルゲームに標準のステレオバージョンを単純に使用することはできません。ENABLE_REVERB
: ハック前のゲームと同様のエコー効果を得たい場合は、1
に設定します。エコー効果が不要な場合は、0
に設定することで処理速度を上げることができます。ENABLE_DMA
: DMAは、ROMからのデータのコピーを高速化するために使用できます。これは、コードが小さくなるというメリットもあります。しかし、DMAレジスタが変化することを想定していないゲームでは、クラッシュする可能性があります。また、CPUの一時停止時間が長くなるため、HBlankの効果が損なわれる可能性があります。
このアセンブリプログラムは自己完結型なので、次のように単純にコンパイルしてバイナリ形式にすることができます。
arm-none-eabi-as m4a_hq_mixer.s -o m4a_hq_mixer.o
arm-none-eabi-objcopy -O binary m4a_hq_mixer.o m4a_hq_mixer.bin
そして、そのバイナリファイルを、必要な場所にコピーすることができます。コードは位置に依存しないので、リンクする必要はありません。もちろん、アセンブリプログラムをソースコードレベルのプロジェクトに統合することもできます。その場合には、独自の統合方法を考えなければなりません。
ROMハックでHQミキサーのコードを挿入するのは、試みるゲームによってはかなり難しい場合もあるかもしれません。
任天堂のm4a/mp2kドライバにはSoundMain
という関数があり、毎フレーム実行されてすべてのサウンド処理を行います。すべてのシーケンスが処理された後、PCMチャンネルの処理が行われます。
SoundMain
はタイミングの重要な処理なのでパフォーマンスを最大化するために、今回のHQミキサーコードはIWRAMとランタイムにコピーされます。
このため、ROM内の関数を単純に置き換えることはできないので、関数の置き換えには多少の困難が伴います。
ほとんどのゲームでは、IWRAMの空き容量はかなり限られており(合計32KiB)、どの領域が空いているのかは必ずしも明らかではありません。
オリジナルのサウンド処理のコードもIWRAMに存在するので、新しいコードは単に古いコードの代わりになると考えるかもしれません。
残念ながら、私のコードは一般的なサウンド処理のコードより、もう少しサイズが大きいので、これはほとんどの場合、うまくいきません。
そのため、コード用の追加スペースを確保する必要があります。最終的に必要なサイズは設定に依存するので、上記のようにファイルを組み立て、実際に必要なバイト数を確認する必要があります。
新しいミキサーの利点の一つは、より高いビット深度のミックスバッファです。このバッファは、IWRAMにも配置する必要があり、hq_buffer_ptr
の定義で設定できます。
これらを整理して、すべてのIWRAMアドレスの場所を確保したら、挿入作業に入ります。
HQミキサーのコードをアセンブルしたプログラムをROMのどこかに配置します。
これだけでは、コードが呼び出されたり、IWRAMにコピーされたりしないので、当然不十分です。基本的に以下の3つのポインタを新たに設定してやる必要があります。
- IWRAMのミキサーコードのポインタ。ミキサーコードを実行するときはcallでここに飛ぶ
- ミキサーコードをCpuSetでIWRAMにコピーする際のコピー先のIWRAMのアドレスのポインタ
- ミキサーコードをCpuSetでIWRAMにコピーする際のコピー元のROMのアドレスのポインタ
さて、新しいミキサーコードのコピーのためにIWRAMとROMのポインタを指定しなければならないことは、直感的に理解できるかもしれません。
問題は、ミキサーコードのポインタ変数がグローバル変数として1つの場所に配置されているわけではなく、複数の場所に現れることです。
そのような場所の一つが、ROMからIWRAMへの最初のコピーです。
これは通常、m4aSoundInit
という関数から開始され、BIOSのCpuSetを使ってROMからIWRAMにコードをコピーします。コピーには実質的に必要ありませんが、一般的にROMのポインタにはThumbビットが設定されており、IWRAMのポインタには設定されていません。
初期化の後、コードを実行する必要があります。これは通常、SoundMain
という関数から行われ、2つ目のIWRAMポインタを読み込みます。このIWRAMポインタは、m4aSoundInit
で指定されたものと同じですが、コードのエントリポイントがThumb命令で始まるため、このIWRAMポインタにはThumbビットが設定されています。
この3つのポインタを変更すれば、あなたの新しいコードが呼び出され、問題なく動作するはずです。しかし、IWRAMを解放するために、より高度なメモリの再配置を行うことができ、そのためにはさらにポインタを変更する必要があるかもしれません。これは使用するゲームに依存するので、ここでは何をすべきかについての詳しい説明はできません。
このセクションでは、GBAのARM CPUの性能を最大限に引き出すために、私のHQミキサーがどのような工夫をしているかを簡単に説明します。
GBAのARM7TDMI CPUは、技術的にはベクトル化をサポートしていませんが、いくつかのトリックを適用することで、そのように動作させることができます。
ミキサーのコードでよく行われる処理は、符号付き8ビットのPCMサンプルを取得し、左右のスピーカーの音量調整を行い、その結果をバッファに保存するというものです。以下のコードを簡単に見てみましょう。
@ R10 = left volume (0-127, unsigned)
@ R11 = right volume (0-127, unsigned)
@ R12 = sign-extended 8 bit PCM sample (-128 to 127)
@ R5 = 16 bit destination buffer (left, right interleaved)
LDRSH R0, [R5] @ load mix buffer left sample
MUL R1, R12, R10 @ multiply PCM sample with left volume
ADD R0, R0, R1 @ ... add that to our sample from mixer buffer
STRH R0, [R5], #2 @ save value back to mix buffer and step to next sample
LDRSH R0, [R5] @ same procedure for right sample
MUL R1, R12, R11
ADD R0, R0, R1
STRH R0, [R5], #2
@ execution time: 16 cycles
さて、最適化できる点は2つあります。
まず、MULとADDを明らかにMLAに置き換えることができます。これは高速ではありませんが、コードサイズの点で1命令分の節約になります。次に、これを見てください。
@ R10 = 0x00RR00LL where LL corresponds to left volume, RR to right volume
@ R12 = sign-extended 8 bit PCM sample (-128 to 127)
@ R5 = 16 bit destination buffer (left, right interleaved)
LDR R0, [R5]
MLA R0, R10, R12, R0
STR R0, [R5], #4
@ execution time: 8 cycles
さて、ここで何が起こったのでしょうか?
突然、ミックスバッファから4バイトの値を1つだけ読み書きすることになりました。
そのため、R0にはミックスバッファの左と右の16ビットの値が入ります。
リトルエンディアン形式なので左の値は下位16ビット、右の値は上位16ビットになります。
まず、乗算部では何が起こるのでしょうか。R10には左右のボリュームが入っているため、スカラー値との掛け算は、レジスタ内の位置で両方の値を掛け合わせることになります。
次の例を見てみましょう。
R10 = 0x00400020 @ right volume: 64 (=0x40), left volume: 32 (=0x20)
R12 = 0x6E @ sample: 110 (=0x6E)
temp = R10 * R12 @ temp = 0x1B800DC0
left = lower16(temp) @ left = 0x0DC0 (3520)
right = higher16(temp) @ right = 0x1B80 (7040)
たった今、2つの掛け算を1つの掛け算で効果的に行ったことがわかりますか?かっこいいですよね。
しかし、これには問題があります。負のサンプル値でこの例をやり直してみましょう。
R10 = 0x00400020 @ right volume: 64 (=0x40), left volume: 32 (=0x20)
R12 = 0xFFFFFF92 @ sample: -110 (=0xFFFFFF92)
temp = R10 * R12 @ temp = 0xE47FF240
left = lower16(temp) @ left = 0xF240 (-3520)
right = higher16(temp) @ right = 0xE47F (-7041)
つまり、今回は正しいサンプルに対する掛け算が正しくないということです。おそらく、サンプルがマイナスであれば、正しい結果から誤って1を引いてしまうことがわかると思います。
幸いなことに、これは致命的な問題ではありません。すぐにはわからないかもしれませんが、この計算ミスは非常に些細なもので、実際には(少なくともGBA的には)聴感上の違いはありません。
しかし、結果的にその部分の実行速度は2倍になります。
コンパイラに詳しい方ならご存知かもしれませんね。
アンローリングとは、通常、ジャンプの実行回数を減らすために、ループコードを重複させることを指します。
ARMにはLDMやSTMといった命令があるため、ジャンプ回数が減るだけでなく、ロードやストアも同時に高速化することができるのです。
LDMIA R5, {R0-R3}
MLA R0, R10, R4, R0
MLA R1, R10, R5, R1
MLA R2, R10, R6, R2
MLA R3, R10, R7, R3
STMIA R5!, {R0-R3}
@ execution time: 19 cycles
実際には1回のMLAよりも少し多くのコードを実行しますが、この例では上のサンプルを4回実行しましたが、実行時間はおよそ2.5倍にしかなりませんでした。
ループアンローリングで問題になるのは、コードのチャンクサイズが大きくなることです。ループの周りにif/elseを配置したい場合、コードのわずかな部分しか変わらないにもかかわらず、巨大なループを複製しなければなりません。ここで、実行時にコードがIWRAMに存在することを覚えておかなければなりません。IWRAMのサイズはかなり制限されているので、コードはできるだけ小さくした方がいいでしょう。ですから、大きなコードブロックを複製する代わりに、ループを実行する直前にループ本体の一部を変更すればよいのです。
これが、私のミキサープログラムが自己修正コードを多用して、コードサイズを爆発させずにパフォーマンスを可能な限り高く保つ理由です。コードの中には、実行時に実際の命令で上書きされるNOPのプレースホルダー命令があるので、これらのセクションを見つけることができるかもしれません。
このセクションはあまりエキサイティングではありませんが、ARMの命令セットは本当に面白いことができます。正しい使い方をすれば、1つの命令に多くの機能を詰め込むことができます。
以前、LDM
とSTM
について説明しましたが、私がコードに使ったこの怪しげなものを見てください。
LDMEQFD SP!, {R0, R2-R5, PC} @ only if zero flag is set, cleanup stack and return
また、バレルシフタとロード命令を組み合わせた例もあります。
LDRNE R0, [R5, -R8, LSL#2]
特に、次のようなものを思いついたことは誇りに思っています。
@ assume LR = 0xC0000000
ADDS R4, R12, R12
EORVS R12, LR, R4, ASR#31
上のコードは何をしているのでしょうか?
R12の符号付き32ビットの入力値を、有効な31ビットの範囲にクランプしています。
これは特にダウンサンプリングの段階で有用で、オーバーフローポップアーチファクトを引き起こす代わりに、出力レンジをクランプするために行います。以前のコードはこうでした。
CMP R12, #0x3FFFFFFF
MOVGT R12, #0x3FFFFFFF
CMP R12, #-0x40000000
MOVLT R12, #-0x40000000
最後に、GBAバスと読み書きの速度について触れておきましょう。これは、リードとライトが実際にどのくらいの速さなのかを示した小さな表です。
Instruction | Memory | Clock Cycles |
---|---|---|
LDR(S)B | ROM | 6 |
LDR(S)B | IWRAM | 3 |
LDR(S)B | EWRAM | 5 |
LDR(S)H | ROM | 6 |
LDR(S)H | IWRAM | 3 |
LDR(S)H | EWRAM | 5 |
LDR | ROM | 8 |
LDR | IWRAM | 3 |
LDR | EWRAM | 8 |
STRB | ROM | N/A |
STRB | IWRAM | 2 |
STRB | EWRAM | 4 |
STRH | ROM | N/A |
STRH | IWRAM | 2 |
STRH | EWRAM | 4 |
STR | ROM | 8 |
STR | IWRAM | 2 |
STR | EWRAM | 7 |
このように、どの命令を選んで何をするかは非常に重要です。例えば、ROMから8ビットの値を4つ読み出す場合、LDRSBを4回行うか、LDRを1回行い、その後に符号拡張を行うか。ROMから4回のLDRSBを行うと24クロックサイクル、1回のLDRを行うと8クロックサイクルで済みますが、その後、手動で符号拡張を行う必要があります。
実際には、後者の方が効率的ですが、より複雑なコードが必要になります。
私のコードの一部では、この方法ではコードが非常に複雑になってしまいます。
そこで、LDRSBがROMよりもIWRAM上で高速に動作することを利用しています。
しかし、データは最初にIWRAMに到達しなければならないので(ストレージにはメインスタックを使用します)、この余分なコピーがコードを遅くするのではないでしょうか?
256サンプルの塊をROMからDMA3(さらに高速なシーケンシャル転送が可能)でIWRAMにロードした場合の計算をしてみます。
DMA転送に使用されるサイクル数は以下の通りです。
- non-sequential read from source (ROM): 6
- sequential read from source (ROM): 254
- all writes to destination (IWRAM): 64
- sample fetching (IWRAM): 768
Which adds up to 1092 cycles.
Now, reading the samples from ROM directly is much simpler and only has one step:
- non-sequential read from source (ROM): 1536
大きな問題の一つは、ROMから非連続のバイトを読み出すと、実際には常に必要以上の1ワードを取り込んでしまうことですが、ROMバスの仕組み上、これを避けることはできません。
このように、バッファリング技術を使うことで、処理時間が⅔で済むことがわかります。