[Astro ] #132 OPL3 Player v1 - Nuked-OPL3 WASMとAudioWorkletで.dro (OPL3) プレイヤーをゼロから実装した記録
はじめに
前回の記事(astro-131-sid-player-rsid.mdx)では、Commodore 64のSID音源チップをWebAssembly化してブラウザで再生するプレイヤーを構築しました。
[Astro] #131 SID Player v3.2 — RSID IRQ駆動再生対応・CIA1/VIC-II エミュレーション追加 // PROTOCOL.LAIN
SID PlayerにRSID IRQ駆動再生を追加。C64カーネル環境構築、手動IRQトリガー、CIA1/VIC-IIエミュレーション実装。playAddr=0のRSIDファイル対応。デバッグ過程の技術記録。
lain-lab.comレトロ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」様よりお借りしてます。
RoboIMF - DRO, RAW and IMF music file player | MSX Resource Center
IMF, DRO and RAW player for MSX. See this newspost for information Upload history: 20180214 ersion 1.1 20171102 version 1.0 AttachmentSizeDownloadsLast dow
www.msx.org1. 概要と全体アーキテクチャ
システム全体の構成は以下の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_init や this.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 プレイヤー実装を通して、以下の貴重な知見を得ることができました。
- WASMのエクスポート名の扱いは極めて慎重に: ビルド構成による関数の難読化に対応するため、フォールバック構成や明確なシンボル保持(
EMSCRIPTEN_KEEPALIVE)を徹底する。 - AudioWorkletの非同期ライフサイクル管理: WASMのコンパイル待ちの間に届くメッセージをキュー構造(
_pendingMsgs)で溜め込むパターンが必須。 - 高精度な音声再生にはサンプル単位の分割生成が必要: 128サンプルのブロック境界に依存せず、イベント発生タイミングに合わせてPCM生成を分割実行する。
今後は、.dro 以外のOPLフォーマット(.vgm や .imf)のサポートや、Three.js を用いた3Dビジュアライザへの波形データ転送など、表現の幅をさらに広げていく予定です。