[Astro ] #132 OPL3 Player v1 - Nuked-OPL3 WASMとAudioWorkletで.dro (OPL3) プレイヤーをゼロから実装した記録

[Astro ] #132 OPL3 Player v1 - Nuked-OPL3 WASMとAudioWorkletで.dro (OPL3) プレイヤーをゼロから実装した記録

はじめに

前回の記事(astro-131-sid-player-rsid.mdx)では、Commodore 64のSID音源チップをWebAssembly化してブラウザで再生するプレイヤーを構築しました。

レトロPCのサウンドチップをブラウザで鳴らす楽しさに取り憑かれ、今回はPC/AT互換機のDOS時代を象徴するFM音源チップ OPL3(YMF262) の再生に挑戦します。

ターゲットにしたのは、DOSBoxなどが出力するOPLレジスタのログフォーマットである .dro (DOSBox Raw OPL) ファイルです。

「WASM化されたエミュレータコアをAudioWorkletに放り込んで、パースしたレジスタログを順次叩けば鳴るだろう」と高を括っていたのですが、実際には完全な無音、初期化のレースコンディション、128サンプルのフレーム境目での音ズレといった深刻なバグに直面し、泥沼のデバッグ作業を強いられることになりました。

本記事では、.dro ファイルの解析からNuked-OPL3のWASM統合、そしてハマったバグとその解決策まで、実装の全容を記録します。

スクリーンショット

動画(GIF)

Sample data

音声サンプルは「MSX Resource Center」様よりお借りしてます。

1. 概要と全体アーキテクチャ

システム全体の構成は以下の3レイヤーで設計しました。

[ メインスレッド (Astro / React / Three.js UI) ]

   ├─ 1. .dro ファイルのバイナリパース (ヘッダー検証, イベント配列生成)
   ├─ 2. AudioContext & AudioWorkletNode の生成
   └─ 3. postMessage({ type: 'load', events, ... }) でデータ転送


[ AudioWorklet スレッド (opl3-processor.js) ]

   ├─ 4. WASM モジュールのインスタンス化とメモリ確保
   ├─ 5. 再生ループ (process) 内でサンプル単位のイベント同期
   └─ 6. WASMからPCMデータを取得し、AudioBuffer(Float32)へ書き出し


[ WebAssembly (Nuked-OPL3 エミュレーションコア) ]
   └─ 7. レジスタ書き込み(opl3_write) & 16bit PCM生成(opl3_generate)

UI側の表示(波形描画や再生位置のシークバー)をスムーズに行うため、音声生成はすべてメインスレッドから切り離した AudioWorklet 上で実行します。


2. .dro ファイルフォーマットの解読とパース

.dro は、OPL2/OPL3チップに対するレジスタ書き込みコマンドと、次のコマンドまでの待機時間(ミリ秒)を時系列で記録した比較的シンプルなバイナリ形式です。

.dro v2 ヘッダー構造

ファイル先頭の8バイト識別子 DBRAWOPL から始まり、ヘッダー情報は以下のような仕様になっています。

  • Signature: DBRAWOPL (8 bytes)
  • Version: Major / Minor (各 2 bytes, v2.0)
  • Length Pairs: 全レジスタ書き込みペアの総数
  • Length MS: 曲の総再生時間(ミリ秒)
  • Hardware Type: 0 = OPL2, 1 = Dual OPL2, 2 = OPL3
  • Format: インデックス圧縮の有無
  • Compression Schemas: ショートカットテーブル

パース実装のポイント

JavaScriptの DataView を使ってバイナリを読み込み、待機時間(ms)をサンプリングレート(44100Hz)に応じたサンプル数(timeSample)に変換します。

// メインスレッド側のパース処理抜粋
export function parseDRO(buffer: ArrayBuffer, targetSampleRate = 44100) {
  const view = new DataView(buffer);

  // シグネチャ確認
  const sig = String.fromCharCode(...new Uint8Array(buffer, 0, 8));
  if (sig !== 'DBRAWOPL') throw new Error('Invalid DRO signature');

  const lengthPairs = view.getUint32(12, true);
  const totalDurationMs = view.getUint32(16, true);
  const hardwareType = view.getUint8(20);

  // ショートカットテーブルの取得 (Codemap)
  const codeMap = [view.getUint8(23), view.getUint8(24)];

  let offset = 26; // ヘッダ以降のデータ開始位置
  let currentSample = 0;
  const events = [];

  for (let i = 0; i < lengthPairs; i++) {
    const cmd = view.getUint8(offset++);
    const val = view.getUint8(offset++);

    if (cmd === codeMap[0]) {
      // 1バイトの遅延 (1〜256 ms)
      currentSample += Math.round(((val + 1) * targetSampleRate) / 1000);
    } else if (cmd === codeMap[1]) {
      // 2バイトの遅延
      const delayMs = (val + 1) << 8;
      currentSample += Math.round((delayMs * targetSampleRate) / 1000);
    } else {
      // レジスタ書き込みコマンド
      // OPL3のポート1/2判定処理
      const regHigh = (cmd & 0x80) ? 0x100 : 0x000;
      const reg = (cmd & 0x7F) | regHigh;
      events.push({ reg, val, timeSample: currentSample });
    }
  }

  return { events, totalDurationMs };
}

生成された events 配列({ reg, val, timeSample } のリスト)を AudioWorkletNode.port.postMessage でスレッドに送信します。


3. WASMコアの用意(Nuked-OPL3)

OPL3エミュレータには最高峰の精度を誇る Nuked-OPL3 (C言語) を採用しました。これを Emscripten でスタンドアロンな WASM モジュールとしてビルドします。

C側のインターフェースとして、以下のラッパーを用意しました。

// opl3_wasm.c
#include "opl3.h"
#include <stdlib.h>

static opl3_chip chip;

void opl3_init(uint32_t samplerate) {
    OPL3_Reset(&chip, samplerate);
}

void opl3_write(uint16_t reg, uint8_t val) {
    OPL3_WriteRegBuffered(&chip, reg, val);
}

void opl3_generate(int16_t *buffer, uint32_t samples) {
    OPL3_GenerateStream(&chip, buffer, samples);
}

int16_t* create_buffer(uint32_t samples) {
    return (int16_t*)malloc(samples * 2 * sizeof(int16_t));
}

4. 苦闘のデバッグ:直面した3つの巨大な壁

コードを一通り組み上げて再生ボタンを押したものの、画面の波形はピクリとも動かず完全な無音。ここから長時間の試行錯誤が始まりました。

悲劇1:WASM関数の難読化(Minification)とメモリ無効化

デバッグ中、最も困難だったのは「一切のエラーを吐かずに無音になる」現象でした。

調査を進めると、Emscriptenのビルドフラグや最適化によって、WASMがエクスポートする関数名やプロパティ名が b, c, d, e, f, g という1文字のアルファベットに圧縮されていた ことが判明しました。

JS側で this.w.opl3_initthis.w.memory と指定していたため、すべて undefined と評価されて参照エラーが発生し、process() 内のPCM計算がサイレントにスキップされていたのです。

解決策

正式名と圧縮名(アルファベット)のフォールバックマッピング構造を構築し、確実に呼び出せるように改修しました。

// エクスポート関数の動的マッピング
this.mem = this.w.memory || this.w.b;                 // WebAssembly.Memory
this.fnCtors = this.w.__wasm_call_ctors || this.w.c;  // 初期化関数
this.fnInit = this.w.opl3_init || this.w.d;           // opl3_init
this.fnWrite = this.w.opl3_write || this.w.e;         // opl3_write
this.fnGenerate = this.w.opl3_generate || this.w.f;   // opl3_generate
this.fnCreateBuffer = this.w.create_buffer || this.w.g; // create_buffer

これで this.mem.buffer から正確なPCM領域(Int16Array)を参照できるようになりました。

悲劇2:非同期ロードとメッセージのレースコンディション

WASMモジュールのインスタンス化(WebAssembly.instantiate)は非同期で実行されます。しかし、メインスレッドはUIからファイルが読み込まれると、WASMの準備を待たずに即座に load 命令を postMessage していました。

この結果、WASMが初期化される前に届いた曲データ(events)が破棄され、初期状態のまま放置されていました。

解決策

AudioWorklet内にメッセージメッセージ退避用のキュー(_pendingMsgs)を設置しました。

// 初期化前のメッセージを溜め込む
_onMsg(msg) {
  if (!this.wasmReady) {
    this._pendingMsgs.push(msg);
    return;
  }
  // ...メッセージ処理
}

// WASMの初期化完了後にまとめて消化 (Drain)
async _initWasm(bytes) {
  // ...WASMインスタンス化処理...
  this.wasmReady = true;
  this.port.postMessage({ type: 'ready' });

  for (const msg of this._pendingMsgs) {
    this._onMsg(msg);
  }
  this._pendingMsgs = [];
}

悲 tragedy 3:128サンプル境界でのイベント量子化ズレ

WebAudio APIの AudioWorklet は、常に固定サイズである 128サンプル単位 で process() を呼び出します。

単純に「128サンプルごとに、その時点までのイベントをまとめて処理する」という大雑把な実装にしていたため、短い発音(アルペジオやドラム)のタイミングが128サンプルのフレーム境界に量子化されてずれ、楽曲のテンポが不自然に揺らぐ問題が発生しました。

解決策

128サンプルのブロック内をさらに細かく分割し、「次のイベントが起きるサンプルまでPCMを生成」→「レジスタ書き込み」→「残りのPCMを生成」というインターリーブ処理を組み込みました。

process(inputs, outputs) {
  if (!this.wasmReady || !this.isPlaying) return true;

  const left = outputs[0][0];
  const right = outputs[0][1] || left;
  let offset = 0;
  const blockSize = 128;

  while (offset < blockSize) {
    let samplesToGen = blockSize - offset;

    // 次のイベント境界を検索
    if (this.eventIndex < this.events.length) {
      const evt = this.events[this.eventIndex];
      const evtSample = Math.round(evt.timeSample / this.speed);

      // イベントの発生タイミングに到達した場合
      if (evtSample <= this.sampleCounter) {
        this.w.e(evt.reg, evt.val); // opl3_write
        this.eventIndex++;
        continue;
      }

      // 次のイベントまでのサンプル数を計算
      const samplesUntilEvt = evtSample - this.sampleCounter;
      if (samplesUntilEvt < samplesToGen) {
        samplesToGen = samplesUntilEvt;
      }
    }

    if (samplesToGen <= 0) samplesToGen = 1;

    // 細分化されたサンプル数だけPCMを生成
    this.w.f(this.bufPtr, samplesToGen); // opl3_generate
    const pcm = new Int16Array(this.mem.buffer, this.bufPtr, samplesToGen * 2);

    for (let i = 0; i < samplesToGen; i++) {
      left[offset + i]  = pcm[i * 2]     / 32768.0;
      right[offset + i] = pcm[i * 2 + 1] / 32768.0;
    }

    offset += samplesToGen;
    this.sampleCounter += samplesToGen;
  }

  // 再生位置をメインスレッドに定期的(約100ms毎)に通知
  if (this.sampleCounter % (Math.round(sampleRate / 4)) < blockSize) {
    this.port.postMessage({
      type: 'position',
      timeMs: (this.sampleCounter / sampleRate) * 1000 * this.speed
    });
  }

  return true;
}

5. 動いた瞬間とUI・ビジュアライザとの同期

すべての修正を適用し、ブラウザの再生ボタンを押した瞬間、スピーカーから90年代のDOSゲーム特有の金属的で重厚なFMサウンドが響き渡りました。

DevToolsコンソールには position イベントが約秒間4回のペースで流れ続け、メインスレッド側の AnalyserNode を介してオシロスコープに波形がリアルタイム描画されました。

ダークテーマのグラフィカルUIに、オレンジ色の繊細な音声波形がシンクロして描画される光景は、ここまでの地道なデバッグ作業を一瞬で報いてくれるものでした。


6. まとめと今後の展望

今回の .dro プレイヤー実装を通して、以下の貴重な知見を得ることができました。

  1. WASMのエクスポート名の扱いは極めて慎重に: ビルド構成による関数の難読化に対応するため、フォールバック構成や明確なシンボル保持(EMSCRIPTEN_KEEPALIVE)を徹底する。
  2. AudioWorkletの非同期ライフサイクル管理: WASMのコンパイル待ちの間に届くメッセージをキュー構造(_pendingMsgs)で溜め込むパターンが必須。
  3. 高精度な音声再生にはサンプル単位の分割生成が必要: 128サンプルのブロック境界に依存せず、イベント発生タイミングに合わせてPCM生成を分割実行する。

今後は、.dro 以外のOPLフォーマット(.vgm.imf)のサポートや、Three.js を用いた3Dビジュアライザへの波形データ転送など、表現の幅をさらに広げていく予定です。