VOL.14 — FM REVIVAL

失われたFM音源を、もう一度鳴らす。
——Z80サウンドドライバの、Unityへの移植

はじめに——「設計図」から「音」へ

Vol.9「サウンド編」で、私はひとつの願いを書いた。 失われたゲーム音楽文化を「サルベージ」したい——と。 汎用のDAWではなく、ゲーム専用に設計されたFM音源ドライバMML。 その「ドライバのロジックが生む命」を、現代の技術で蘇らせたい、と。

あれは、いわば設計図の話だった。今回は、その設計図が実際に音を出しはじめた話をしたい。

題材は、1990年代の8bitパソコン時代の名作ゲームの中に積まれていた、FM音源ドライバ達だ。 ディスクイメージのなかで眠っていたZ80や68000のサウンドドライバを分析し、楽曲データをMMLとして抽出する—— そして、その音源ドライバをUnity(C#)へ移植するという挑戦を始めた。 ゲームエンジンの中で、当時のチップと同じ振る舞いで、もう一度あの音を鳴らすために。

分析とは、過去のコードを「読む」こと。
移植とは、過去のコードに、もう一度「息を吹き込む」ことだ。

第1章:なぜ「再生」ではなく「移植」なのか

抽出した楽曲を鳴らすだけなら、近道はいくらでもある。 MMLをMIDIに変換してDAWで鳴らす。あるいは近似のロジックを実装して後は手動で微調整し、そのまま再生する。

だが、それでは足りない。聴感を頼りに数値を手で補正したプレイヤーは、 「それらしく鳴る」けれど、元のドライバが本当はどう鳴らしていたのかを保証してくれない。 そして、できあがったものは、「なにかが違う」ものになる。 それを避けるため、開発計画の冒頭には、その方針をはっきり決めた。

最優先は、Unity上で「それらしく鳴る」実装ではなく、
Z80/68000 driver semantics を再現する tick-driven core を作ることである。

ゴールは、ゲームのなかで使える本物の音源エンジンだ。 当時のチップ(PSGとFM音源、PCM)のレジスタを、当時のドライバと同じ順序・同じタイミングで叩く—— その意味論(semantics)の再現を基準に据える。 「聴いて似ているか」ではなく、「ドライバとして正しく動いているか」を物差しにするのだ。

第2章:3層アーキテクチャ——コアとUnityを分ける

音源エンジンは、責務をはっきり分けた3層構造で設計した。

FM-MmlParser
  抽出 MML を event list に変換する。

FM-DriverCore
  tick ごとに channel state を進め、
  仮想 OPLL/PSG register write を発行する。

FM-AudioEngine(PSG + OPLL 合成)
  register/state から PCM を生成し、
  Unity の OnAudioFilterRead へ渡す。

パーサーは「テキストを構造に変える」だけ。ドライバコアは「時間を進めてレジスタを叩く」だけ。 合成エンジンは「レジスタの状態から波形を作る」だけ。 それぞれが隣の層のことを知りすぎないようにしてある。

この分離には、明確な狙いがある。Unity固有の処理を再生コアから切り離しておくことだ。 いまはUnityのOnAudioFilterReadへPCMを流し込んでいるが、 将来的にはこのコアを、過去記事(Vol.13)で触れたZ80 VMの上へ移植することも見据えている。 Unityに依存しているのは一番外側の層だけなので、内側のコアはそのまま運んでいける。

いま書いているのはUnityのコードだが、
本当に書いているのは「ドライバそのもの」だ。
だから、Unityの都合をコアに持ち込まない。

第3章:60Hzのtick——時間を刻む心臓

当時のサウンドドライバは、画面の垂直同期割り込み——おおむね毎秒60回——のタイミングで呼ばれ、 その1回ごとに「次に何をするか」を進めていた。音楽の時間は、この60Hzの鼓動の上に乗っている。

移植版でも、この60Hz tick駆動をそのまま基準にした。 オーディオの出力レート(48000Hzなど)とは無関係に、コアは1tick=1/60秒で時間を進める。 音を作るサンプルレートと、曲の時間を刻むtickレートを切り離すことで、 出力環境が変わってもテンポが狂わない。

各チャンネルが今どの音を鳴らし、あと何tick伸ばすのか——こうした演奏状態は、 チャンネルごとのstateとして保持する。 これは元のドライバが、チャンネルごとの作業領域(Z80のIXレジスタが指すメモリブロック)に 状態を持っていたのと、ちょうど対応している。移植の地図がそのまま引き継がれているわけだ。

第4章:MMLバイトコードの意味論——timingが命

抽出されたMMLは、人が読むための楽譜であると同時に、 元はZ80が1バイトずつ解釈していたバイトコードでもある。 移植でまず固めたのは、曲全体のタイミングを決める命令群だ。

FA xx     持続 duration seed(音の長さの種。次の音以降も持続)
0x62-0x71 ループ開始(繰り返し回数 = opcode - 0x62)
0x62      無限ループ
0x72      ループ終了 / フレーム状態の復元
0x00      ストリーム終端(休符ではない、純粋な終わり)
FF00/FF01 スラー(タイ)on / off
FE xx     ピッチ・音量エフェクトのLUT
FC xx     文脈依存の拡張命令(音色切替・PSGノイズ制御など)

ここで効いてくるのが、解析段階で何度もつまずいた知見だ。 たとえばループは、単純な「カウンタひとつ」では正しく鳴らない。 ループの中にループがある入れ子(最大4段)を、スタックとして積まないと、 内側のループに入った瞬間に外側の状態が壊れ、曲が本来より短くなってしまう。

0x00を「休符」と誤解すれば、曲尾に余計な無音が挟まる。 FAを無視すれば、長い音符がごっそり詰まってテンポが崩れる。 どれも「聴けばなんとなく合っている」ように錯覚しやすい罠であり、 だからこそ意味論を物差しにする方針が活きてくる。

音色は、間違っていてもすぐ気づく。
だがタイミングの誤差は、耳をすり抜けて忍び込む。
timingこそ、最初に固めるべき土台だ。

第5章:仮想PSG——まずは単純な方から

音源は2系統ある。矩形波を鳴らすPSG(AY-3-8910互換)と、 FM音源のOPLL(YM2413)やOPN(YM2203)だ。 実装は、構造が単純なPSGから手をつけた。ドライバコアの検証台として、ちょうどよい。

; PSG レジスタの役割
R0-R5    トーン周期(3ch分の音程)
R6       ノイズ周期
R7       ミキサー(トーン/ノイズの有効・無効)
R8-R10   各chの音量

PSGは3チャンネル。おもしろいのは、同じPSGでもチャンネルごとに「音の切り方(gate)」が違うことだ。 元ドライバを解析すると、AチャンネルとBチャンネルとCチャンネルで、 音を鳴らす長さと余韻に残す長さの配分が異なっていた。 この微妙な差が、和音の歯切れやリズムの質感を作っている。移植版でもこの差をそのまま再現する。

検証曲は、抽出済みのBGMをいくつか選んで、集中して精査した。 まずPSGだけが、正しいタイミングと音程で鳴ること——そこを最初の合格ラインにした。

第6章:仮想OPLL——emu2413をC#へ移植する

FM音源のYM2413(OPLL)は、PSGよりずっと複雑だ。 オペレータ、エンベロープ、ビブラート、リズム音源……これを一から書き起こすのは現実的でない。 そこで、まずは定評あるオープンソースのYM2413エミュレータemu2413(Mitsutaka Okazaki氏作・MITライセンス)の 合成コアを、C#へ移植して取り込むことにした。

// C# port of the core YM2413/OPLL synthesis path from emu2413 v1.5.9.
// Original: https://github.com/digital-sound-antiques/emu2413
// Copyright (C) 2001-2019 Mitsutaka Okazaki, MIT License.
public sealed class FM-Emu2413Opll
{
    public const uint MasterClock = 3579545;   // 約3.58MHz
    ...
}

ネイティブプラグインに頼らず、純粋なマネージドC#として移植したのがポイントだ。 プラットフォームを選ばず、Unityのスクリプトとしてそのまま動く。 ドライバコアは、OPLLのレジスタ(音程のf-numberblock、 key-on/off、音色と音量)をこのエミュレータへ書き込むだけで、本物のYM2413と同じ理屈で音が鳴る。

ただし計画では、最初から完璧な音色再現は狙わない。 まずレジスタ操作の順序とタイミングを正しくすること。 音色を作り込むのは、土台が安定したいちばん最後の工程と決めている。

音色を先に追い込むと、
「鳴り方が変なのは音色のせいか、タイミングのせいか」が切り分けられなくなる。
だから、美しさは最後に足す。

第7章:Unityへ——コンポーネントとして鳴らす

いちばん外側の層が、Unity統合だ。 再生はFM-MusicPlayerというMonoBehaviourが担当し、 AudioSourceとともに動く。 曲データはMMLファイルを包んだScriptableObject(FM-SongAsset)として持たせ、 コードを触らずに曲を差し替えられるようにした。

音の出口は、UnityのOnAudioFilterReadコールバックだ。 オーディオスレッドがバッファを要求するたびに、tickを進め、PSGとOPLLのサンプルを合成して流し込む。

開発を支えるために、診断用のミックスモードも仕込んだ。 インスペクタから、FMだけ・PSGだけ・リズムだけ・リズム以外、と 鳴らす要素を切り替えて聴き分けられる。

public enum FM-DiagnosticMixMode
{
    Full,             // 全部鳴らす
    OpllMelodyOnly,   // FMメロディだけ
    PsgOnly,          // PSGだけ
    RhythmOnly,       // リズムだけ
    NoRhythm,         // リズム以外
}

「全体ではなんとなく違うけれど、どのパートが原因か分からない」—— この聴き分けの仕組みが、移植のデバッグでは何より頼りになる。

第8章:耳だけで検証しない——比較ハーネス

移植がうまくいっているかは、最終的には耳で確かめる。 だが、耳だけを頼りにすると、いつのまにか「慣れ」が正解をゆがめてしまう。 そこで、機械的に答え合わせをする検証ハーネスを用意した。

ドライバコアが発行したレジスタ操作は、一つひとつログとして記録できる。 どのtickで、どのチップの、どのレジスタに、どんな値を書いたか——。 これをCSVに書き出し、解析側のPythonレンダラや、実機シミュレーションから採取した 「お手本」のレジスタ列と突き合わせる。

; レジスタ操作ログの一例
t000123 OPLL R20=15 ch=3 role=Fm0  key-on
t000123 OPLL R30=2A ch=3 role=Fm0  inst/vol
t000124 AY   R08=0F ch=0 role=PsgA  volume

合格基準はシンプルだ。レジスタ操作の並びとタイミングが、お手本から大きく外れないこと。 音色の微差より先に、この「骨格の一致」を取りにいく。 骨格さえ合っていれば、あとは音色を磨くだけで本物に近づいていける。

おわりに——設計図が、音になる

Vol.9で「サルベージしたい」と書いた構想は、いま少しずつ形になりつつある。 ディスクの底に眠っていたバイトコードを読み解き、その意味論をC#へ移し、 ゲームエンジンのなかで、当時と同じ理屈の音が鳴りはじめた。

まだ道半ばだ。FM音色の作り込み、リズム音源の安定化、表現系(ビブラートやスラー)の詰め—— やるべきことは残っている。 だが、土台となる「ドライバの意味論を基準にする」という方針は、揺らいでいない。

パーサー、60Hzのtick、チャンネルステート、仮想PSGと仮想FM音源チップ、そして比較ハーネス。 これらはすべて、Unityのためだけに作っているのではない。 いつか同じコアを、Z80 VMの上で——あるいは別のどこかで——もう一度動かすための、移植可能な「核」だ。

あの時代の音楽は、決して「古い」のではない。
まだ、正しく受け継がれていないだけだ。
——その受け継ぎ方を、いま、コードで書いている。