[Astro] #138 WASM × Web Audio API で FM音源(OPL3/OPN2)を鳴らそうとして地雷を踏み抜いた話 — 音が出ない4大原因と実装全記録

[Astro] #138 WASM × Web Audio API で FM音源(OPL3/OPN2)を鳴らそうとして地雷を踏み抜いた話 — 音が出ない4大原因と実装全記録

はじめに

Sound Blaster 16 や PC-98、メガドライブ等で親しまれた FM 音源(YMF262/OPL3 や YM2612/OPN2)独特のシャープで金属的なサウンド。あの音色をブラウザ上でリアルタイム合成して鳴らすべく、C/C++ で記述された音源合成コア(libADLMIDI / libOPNMIDI)を Emscripten で WebAssembly (WASM) へビルドし、Web Audio API の AudioWorklet 上で駆動させるプレイヤーの開発を試みました。

しかし、ビルド自体はエラーなく通るものの**「ブラウザ上で音が一切鳴らない」**という深い沼にはまり、半日〜数日の時間が一瞬で溶け落ちることになりました。

本記事では、WASM × Web Audio API による FM 音源実装の全体パイプラインと、開発中に遭遇した「音が出ない 4 大原因(ハマりポイント)」、そしてその打開策とデバッグログを余すことなく記録します。

スクリーンショット

[Astro] #138 WASM FM Synth — エミュレータUIとAudioWorkletステータス

1. 全体アーキテクチャとパイプライン

FM 音源は波形サンプルデータを持たず、数式演算によってリアルタイムに波形を合成するため WASM との相性が非常に良く、数 megabytes 規模のライブラリに比べてモジュールサイズも百数十 KB 程度に収まる利点があります。


[ メインスレッド (UI / Astro) ]
│  ├─ WebAssembly.compile() / WASMバイナリのロード
│  └─ AudioContext の初期化 (ユーザーアクション契機)
│        │ (MessageChannel / postMessage)
▼        ▼
[ AudioWorklet スレッド (AudioWorkletProcessor) ]
│  ├─ WASM モジュールのインスタンス化 (WebAssembly.instantiate)
│  ├─ C++ 側 `adl_render()` の定周期呼び出し (128 samples / quantum)
│  └─ C++ ヒープ (HEAPF32) から PCM データを抽出
│        │
▼        ▼
[ Web Audio API (AudioWorkletNode Output) ]
└─ スピーカー出力 ([L, R] デインターリーブ書き込み)

2. 音が出ない 4 大原因(地雷原の検証)

ビルドエラーが出ず、console.log にも明確な Exception が吐かれない状態で無音が続く場合、以下の 4 つの地雷のいずれか(あるいは複数)を踏み抜いています。

番号地雷カテゴリ主な症状根本原因
1Embedded Bank 未読み込みレンダー関数は回るが全出力が 0.0楽器定義(バンクデータ)がメモリ上に存在しない
2AudioWorklet スコープ制限Fetch エラー / WASM インスタンス化失敗AudioWorkletGlobalScope での相対パス解決不可
3HEAPF32 ポインタ計算ミスノイズ / 無音 / メモリアクセス違反バイトオフセットと 32bit Float 配列インデックスの混同
4サンプルレート & チャンネル混同音程外れ / 速度異常 / 片チャンネル無音Interleaved 配置のまま output[0] に流し込んでいる

地雷 1: 内蔵音色バンク(Embedded Bank)の未読み込み

libADLMIDIlibOPNMIDI は、レジスタ操作や合成演算の計算式だけでなく、音色パラメータ(FM オペレータのアルゴリズム、FB、倍率、EG 等)を保持したバンクデータを要求します。

  • 原因: CMake ビルド時に -DWITH_EMBEDDED_BANKS=ON を指定していない、または初期化時に C++ コード側で adl_openBankData()adl_setBank() を明示的に呼んでいない場合、演算は実行されても出力バッファがすべてゼロ(完全な無音)になります。
// C++ Wrapper 側の修正例
#include "adlmidi.h"

extern "C" {
  EMSCRIPTEN_KEEPALIVE
  struct ADL_MIDIPlayer* init_synth(int sample_rate) {
      struct ADL_MIDIPlayer* player = adl_init(sample_rate);
      // 内蔵バンクを明示的に適用しないと音が出ない
      adl_setBank(player, 0);
      return player;
  }
}

地雷 2: AudioWorklet スコープ内での WASM 読み込み失敗

UI スレッドを停止させないために AudioWorkletProcessor 内部で WASM を実行しようとすると、AudioWorkletGlobalScope のコンテキスト制限に引っかかります。

  • 原因: AudioWorklet スコープ内では window オブジェクトが存在せず、相対パスでの fetch() が意図しないオリジンを参照して失敗します。
  • 解決策: メインスレッド側で WASM ファイルを fetch して WebAssembly.compile() まで済ませ、作成した WebAssembly.ModulepostMessage で Worklet ポートへ転送してインスタンス化します。
// Main Thread (JS)
const response = await fetch('/wasm/adlmidi.wasm');
const bytes = await response.arrayBuffer();
const wasmModule = await WebAssembly.compile(bytes);

// AudioWorkletNode へ compiled module を送信
audioWorkletNode.port.postMessage({ type: 'INIT_WASM', module: wasmModule });

地雷 3: C++ ヒープメモリ(HEAPF32)のポインタ参照ズレ

C++ 側の adl_render で PCM(Float32 配列)を生成した後、JavaScript 側で Emscripten のメモリ空間(HEAPF32)から波形データを引き出す際のインデックス計算ミスです。

  • 原因: Module._malloc(bytes) が返すポインタ値はバイト単位のオフセットです。一方、HEAPF32Float32Array)の要素アクセスは 4 バイト単位であるため、そのままインデックスとして使うと 4 倍の位置を参照してしまい、メモリの領域外またはゴミデータを読み出すことになります。
// AudioWorkletProcessor 内での正しい参照方法
const samplesPerFrame = 128; // Web Audio API の 1 クォンタム
const numChannels = 2;
const byteSize = samplesPerFrame * numChannels * 4; // Float32 (4 bytes)

// C++ 側のメモリ確保
const ptr = wasmInstance.exports.malloc(byteSize);

// レンダリング実行
wasmInstance.exports.adl_render(playerPtr, samplesPerFrame, ptr);

// 【重要】バイトアドレスを 4 で割る(または 右シフト >> 2)
const floatOffset = ptr >> 2;
const pcmBuffer = wasmInstance.exports.HEAPF32.subarray(
  floatOffset,
  floatOffset + (samplesPerFrame * numChannels)
);

地雷 4: サンプルレートの不一致と Interleaved 配置

FM 合成エンジンの出力フォーマットと、Web Audio API の出力バッファ構造のズレです。

  • インターリーブ処理: adl_render が出力するデータは通常 [L, R, L, R, L, R...] のインターリーブ構造です。
  • Web Audio API: AudioWorkletProcessor.process()outputsoutput[0](Lch 用配列)output[1](Rch 用配列) に完全に分離(デインターリーブ)して書き込む必要があります。
// process() 内でのデインターリーブ書き込み
process(inputs, outputs, parameters) {
  const output = outputs[0];
  const leftChannel = output[0];
  const rightChannel = output[1];

  // pcmBuffer から L/R へ分離抽出
  for (let i = 0; i < 128; i++) {
    leftChannel[i]  = pcmBuffer[i * 2 + 0]; // L
    rightChannel[i] = pcmBuffer[i * 2 + 1]; // R
  }
  return true;
}

3. 実装とデバッグテクニック

無音状態から抜け出すために最も有効だったデバッグ手法は、AudioWorkletProcessor.process() 内で C++ 側のバッファに非ゼロ値が入っているかを直接チェックすることでした。

// デバッグログ仕込み
let nonZeroCount = 0;
for (let i = 0; i < pcmBuffer.length; i++) {
  if (pcmBuffer[i] !== 0) nonZeroCount++;
}

if (nonZeroCount > 0) {
  // PCM データが正しく生成されている
  console.log(`[WASM Synth] Generated ${nonZeroCount} active PCM samples.`);
} else {
  // バイク未読み込み、または MIDI イベントが到達していない
  console.warn('[WASM Synth] Buffer is completely silent (all zeros).');
}

このチェックを入れることで、「WASM と AudioWorklet の通信ミス(データが渡っていない)」のか「C++ 音源エンジン内部で波形が生成されていない(バンク未適用やノートオン失敗)」のかを明確に切り分けることが可能になります。


4. まとめ

  • Emscripten ビルド時のバンク埋め込み: -DWITH_EMBEDDED_BANKS=ON 等のフラグ確認が必須。
  • AudioWorklet との分離設計: メインスレッドで WebAssembly.compile() し、postMessageWebAssembly.Module を転送するのが最もしがらみが少ない。
  • TypedArray の型とオフセット計算: malloc のバイトポインタを HEAPF32 で扱う際は ptr >> 2 のシフト演算を忘れない。
  • インターリーブ解除: C++ 側の [L, R, L, R] データを Web Audio API のマルチチャンネル配列(output[0], output[1])へ展開する。

私は、AudioWorklet と Emscripten の地雷原で、MIDI(FM 音源再生)をやめました(´・ω・`)

……というのは冗談(例の禁煙パイポのオチ)ですが、エラーログが一切吐かれないまま無音が続く WASM × Web Audio API 開発の難所は、仕組みさえ整理できれば非常に強力なWebパフォーマンスを発揮してくれます。

同じ地雷を踏んで半日を溶かしている開発者の助けになれば幸いです。