[JavaScript] reSID + fake6502をWASM化してAudioWorkletで.sidファイルを再生する ― ブラウザ完結SIDプレイヤー構築記

[JavaScript] reSID + fake6502をWASM化してAudioWorkletで.sidファイルを再生する ― ブラウザ完結SIDプレイヤー構築記

この記事の目的

Commodore 64(C64)の音源チップ「SID(MOS 6581/8580)」をブラウザ上で完全にエミュレートし、.sidファイルをリアルタイムに再生するWebプレイヤーを構築した。

本記事では、C++製のSIDチップエミュレーター「reSID」とC製の6502 CPUエミュレーター「fake6502」をEmscriptenでWASMビルドし、AudioWorkletスレッド内で直接instantiateしてリアルタイム音声合成を行うまでの実装フローと、開発中に遭遇したトラブルの解決策をまとめる。

スクリーンショット

SID WASM Player

全体アーキテクチャ

libjxl(JXL Converter)では「メインスレッド → Web Worker → WASM」の3層構造を採用したが、SIDプレイヤーではAudioWorkletの特性上、構造が異なる。AudioWorkletスレッドはWeb Workerとは別のリアルタイム音声処理専用スレッドであり、128サンプル(約[email protected])ごとにprocess()が呼ばれる厳格なタイミング制約がある。

[ Main Thread (HTML/JS UI) ]
│  1. .sidファイル読み込み(FileReader → Uint8Array)
│  2. AudioContext作成 & AudioWorkletモジュール登録
│  3. SIDバイナリをprocessorOptionsで渡してAudioWorkletNode作成
│  4. 再生/停止/曲切替のUI制御

[ AudioWorklet Thread (sid-processor.js) ]
│  5. WebAssembly.instantiate(wasmBytes) でWASMを直接ロード
│  6. _initialize() → sid_init() → sid_load_data() で初期化
│  7. 128サンプルごとにprocess()が呼ばれる

[ WebAssembly (reSID + fake6502 + PSID Parser) ]
│  8. sid_render_float(): 1サンプルごとに
│     a. SIDチップをクロック(g_sid.clock(cycles))
│     b. 50Hz間隔で6502 CPUのplayルーチンをJSR実行
│     c. 6502がSIDレジスタ($D400-$D41F)に書き込み → reSIDに転送
│     d. reSIDの出力をDCオフセット除去してfloatバッファに書き出し
└─► 9. Float32ArrayをAudioWorkletの出力チャネルにコピー → スピーカー

JXL Converterとの最大の違いは、WASMがAudioWorkletスレッド内で直接instantiateされる点。Web Workerを経由しない。AudioWorkletはリアルタイム制約があるため、postMessageによる往復通信のレイテンシーを許容できず、WASMの関数を直接呼び出す必要がある。

登場するライブラリと役割

コンポーネント言語役割
reSIDC++MOS 6581/8580 SIDチップのトランジスタレベルエミュレーション。波形生成、フィルター、エンベロープ(ADSR)を再現
fake6502CMOS 6502 CPUエミュレーター。.sidファイル内の6502マシン語を実行してSIDレジスタに書き込む
sid_wrapper.cppC++PSIDヘッダーパーサー、C64メモリマップ(64KB RAM)、6502↔reSIDの配線、レンダリングループ
fake6502_bridge.cCfake6502.hをCコンパイラで分離ビルドするためのブリッジ
sid-processor.jsJSAudioWorkletProcessor。WASM instantiate、process()内でsid_render_float()呼び出し
index.htmlJSUI。.sidファイルドロップ、PSIDヘッダー表示、再生/停止/曲切替

1. C++ ブリッジの構成 ( sid_wrapper.cpp )

1-1. .sidファイルの構造

.sidファイルはMIDIや音声データではなく、Commodore 64の6502 CPU用マシン語プログラムそのもの。先頭にPSID/RSIDヘッダーが付き、その後にC64のRAMに配置される実行バイナリが続く。

┌──────────────────────────────────────┐
│ PSIDヘッダー($00-$75, 最小$7C bytes)│
│  $00-$03: マジック ('PSID' or 'RSID')│
│  $04-$05: バージョン (Big Endian)     │
│  $06-$07: データオフセット            │
│  $08-$09: ロードアドレス              │
│  $0A-$0B: initアドレス               │
│  $0C-$0D: playアドレス               │
│  $0E-$0F: 曲数                       │
│  $10-$11: 開始曲番号                  │
│  $12-$15: スピードフラグ              │
│  $16-$35: タイトル (32 bytes)         │
│  $36-$55: 作者 (32 bytes)             │
│  $56-$75: コピーライト (32 bytes)     │
├──────────────────────────────────────┤
│ 6502マシン語バイナリ                  │
│  → g_ram[load_address]に配置          │
└──────────────────────────────────────┘

再生の流れ:

  1. ヘッダーからinit/playアドレスを取得
  2. バイナリをC64の64KB RAMの指定アドレスに配置
  3. 6502 CPUでinitアドレスをJSR(サブルーチンコール)→ 音源初期化
  4. 50Hz(PAL)または60Hz(NTSC)間隔でplayアドレスをJSR → SIDレジスタに書き込み → 音が出る

1-2. C64メモリマップとSIDレジスタフック

.sidファイル内の6502コードは、C64実機と同じメモリアドレスにSIDレジスタ値を書き込む。write6502関数内で$D400-$D41Fへの書き込みをフックし、reSIDに転送する。

static SID g_sid;                    // reSIDインスタンス
static uint8_t g_ram[65536];         // C64 64KB RAM

// fake6502が呼ぶコールバック
extern "C" {
uint8_t read6502(uint16_t address) {
    return g_ram[address];
}

void write6502(uint16_t address, uint8_t value) {
    g_ram[address] = value;

    // SIDレジスタ ($D400-$D41F) への書き込みをキャッチ
    if (address >= 0xD400 && address <= 0xD41F) {
        uint8_t reg = address & 0x1F;
        g_sid.write(reg, value);     // reSIDに転送
    }
}
}

これだけで、6502コードがSIDレジスタに書いた値がreSIDの音声合成に反映される。C64実機のバスアーキテクチャを数十行で再現している。

1-3. 6502サブルーチン実行(JSR)

.sidファイルのinit/playルーチンは6502のサブルーチンとして呼び出す。fake6502にはjsr命令を直接呼ぶAPIがないため、スタックにトラップ用のリターンアドレスを積んでstep6502()ループで実行する。

static void jsr6502(uint16_t addr) {
    // $FFFFにRTS命令を配置(トラップ)
    g_ram[0xFFFF] = 0x60; // RTS opcode
    // スタックにリターンアドレス($FFFE)をプッシュ
    g_ram[0x01FE] = 0xFF;
    g_ram[0x01FD] = 0xFE;
    sp = 0xFC;
    pc = addr;

    // RTS実行でPC=$FFFFに戻るまでステップ実行
    int max_cycles = 1000000; // 無限ループ防止
    int cycles_run = 0;
    while (cycles_run < max_cycles) {
        step6502();
        cycles_run += clockticks6502;
        clockticks6502 = 0;
        if (pc == 0xFFFF || pc == 0x0000) break;
    }
}

1-4. レンダリングループ(サンプル精度の同期)

sid_render_float()は128サンプル分の音声をfloatバッファに書き出す。1サンプルごとにSIDチップをクロックし、PAL 50Hz間隔(約19,705 CPUサイクルごと)でplayルーチンをJSR実行する。

EMSCRIPTEN_KEEPALIVE
void sid_render_float(float* out_buffer, int num_samples) {
    for (int i = 0; i < num_samples; ++i) {
        // 1サンプル分のCPUサイクル数を計算
        g_cycle_accumulator += g_cycles_per_sample; // 985248/44100 ≈ 22.34
        cycle_count cycles = (cycle_count)g_cycle_accumulator;
        g_cycle_accumulator -= (double)cycles;

        // SIDチップをクロック進行
        g_sid.clock(cycles);

        // 50Hz間隔でplayルーチンを呼び出し
        if (g_sid_loaded && g_play_addr != 0) {
            g_play_cycle_acc += g_cycles_per_sample;
            if (g_play_cycle_acc >= g_play_cycles_per_call) {
                g_play_cycle_acc -= g_play_cycles_per_call;
                jsr6502(g_play_addr); // 6502でplay実行 → SIDレジスタ更新
            }
        }

        // reSIDの16bit出力をfloat正規化
        int raw_sample = g_sid.output();
        float in_val = (float)raw_sample / 32768.0f;

        // DCオフセット除去(ハイパスフィルタ)
        float out_val = 0.995f * (g_dc_prev_out + in_val - g_dc_prev_in);
        g_dc_prev_in = in_val;
        g_dc_prev_out = out_val;

        out_buffer[i] = out_val;
    }
}

DCオフセット除去は、SIDチップの出力に含まれるDC成分(一定電圧のずれ)をハイパスフィルタで除去するもの。これがないとスピーカーに不要なDC電圧がかかり、音がクリップしたり再生開始時に「ブッ」というノイズが入る。


2. C/C++ 分離コンパイル

fake6502.hはC言語で書かれており、C++の予約語(and, or, not, xor)と衝突する。また、配列の前方宣言がC++ではredefinitionエラーになる。

2-1. fake6502_bridge.c(Cコンパイル用ブリッジ)

// fake6502_bridge.c — Cコンパイラで別途コンパイル
#include <stdint.h>

// sid_wrapper.cpp側で定義されるコールバック
extern uint8_t read6502(uint16_t address);
extern void write6502(uint16_t address, uint8_t value);

#define UNDOCUMENTED
#define FAKE6502_NOT_STATIC
#include "fake6502.h"

2-2. C++予約語の回避

fake6502.h内の関数名and, or, not, xorはC++の代替演算子トークン(&&, ||, !, ^のエイリアス)と衝突する。sedで一括リネーム:

sed -i 's/\band\b/op_and/g; s/\bor\b/op_or/g; s/\bnot\b/op_not/g; s/\bxor\b/op_xor/g' fake6502.h

2-3. ビルドコマンド

Cとしてfake6502_bridge.cをオブジェクトファイルにコンパイルし、C++のsid_wrapper.cppとreSIDのソースファイルと合わせてリンクする。

# Step 1: fake6502をCとしてコンパイル
emcc fake6502_bridge.c -O3 -c -o fake6502_bridge.o

# Step 2: 全体をリンク
em++ sid_wrapper.cpp resid/src/*.cc fake6502_bridge.o -O3 \
  -I./resid/src \
  --no-entry \
  -s STANDALONE_WASM=1 \
  -s EXPORTED_FUNCTIONS='["_malloc","_free","_sid_init","_sid_load_data","_sid_render_float","_sid_write_reg","_sid_get_registers","_sid_get_init_addr","_sid_get_play_addr","_sid_get_load_addr","_sid_get_num_songs","_sid_set_song"]' \
  -o sid_core.wasm

STANDALONE_WASM=1を指定することで、Emscriptenのグルーコード(.jsファイル)なしでWASM単体を生成する。これにより、AudioWorkletスレッド内でWebAssembly.instantiate()を直接使ってロードできる。


3. AudioWorklet実装 ( sid-processor.js )

AudioWorkletはWeb Workerとは異なるリアルタイム音声処理専用スレッドで動作する。通常のWorkerと違い、importScripts()でEmscriptenのグルーコードを読み込む代わりに、WASMバイナリを直接WebAssembly.instantiate()する。

class SidAudioProcessor extends AudioWorkletProcessor {
  constructor(options) {
    super();
    this.initialized = false;
    const opts = options.processorOptions || {};
    if (opts.wasmBytes) {
      this.initWasm(opts.wasmBytes, opts.sidData, opts.songNum || 0);
    }

    // 曲切替メッセージ受信
    this.port.onmessage = (e) => {
      if (e.data.type === 'set_song' && this.initialized) {
        this.exports.sid_set_song(e.data.song);
      }
    };
  }

  async initWasm(wasmBytes, sidData, songNum) {
    const { instance } = await WebAssembly.instantiate(wasmBytes);
    this.exports = instance.exports;
    this.memory = instance.exports.memory;

    // STANDALONE_WASMではC++グローバルコンストラクタの実行が必要
    this.exports._initialize();

    // SIDエンジン初期化(44100Hzサンプリング)
    this.exports.sid_init(sampleRate);

    // .sidデータをWASMメモリにコピーしてロード
    if (sidData && sidData.length > 0) {
      const dataPtr = this.exports.malloc(sidData.length);
      const heap = new Uint8Array(this.memory.buffer);
      heap.set(sidData, dataPtr);
      this.exports.sid_load_data(dataPtr, sidData.length);
      this.exports.free(dataPtr);
    }

    // 出力バッファ確保(128サンプル × 4bytes/float)
    this.bufferPtr = this.exports.malloc(128 * Float32Array.BYTES_PER_ELEMENT);
    this.initialized = true;
  }

  process(inputs, outputs, parameters) {
    if (!this.initialized) return true;

    const output = outputs[0];
    const numSamples = output[0].length; // 通常128サンプル

    // WASM側で音声波形を生成
    this.exports.sid_render_float(this.bufferPtr, numSamples);

    // WASMメモリからFloat32Arrayとして読み出し
    const rendered = new Float32Array(this.memory.buffer, this.bufferPtr, numSamples);

    // 全出力チャネルにコピー(モノラル→ステレオ)
    for (let ch = 0; ch < output.length; ch++) {
      output[ch].set(rendered);
    }

    return true; // trueを返し続ける限り再生が続く
  }
}

registerProcessor('sid-audio-processor', SidAudioProcessor);

AudioWorkletでのWASMロードのポイント

  • processorOptionsでWASMバイナリ(ArrayBuffer)とSIDデータを渡す。AudioWorkletスレッドからはfetch()が使えないため、メインスレッドで事前にfetchしたバイナリを渡す
  • STANDALONE_WASMビルドでは_initialize()を最初に呼ぶ必要がある。これはC++のグローバル変数(static SID g_sid)のコンストラクタを実行するため
  • process()は128サンプル(約2.9ms)ごとに呼ばれるため、内部処理がこの時間を超えるとバッファアンダーランが発生して音が途切れる

4. メインスレッド ( index.html )

UI側では、.sidファイルのドロップ→PSIDヘッダー解析→AudioWorkletNode作成の流れで再生を開始する。

async function startPlayback() {
  // AudioContext作成
  const audioCtx = new AudioContext({ sampleRate: 44100 });
  if (audioCtx.state === 'suspended') await audioCtx.resume();

  // WASMバイナリをfetch
  const wasmBytes = await (await fetch('sid_core.wasm')).arrayBuffer();

  // AudioWorkletモジュール登録
  await audioCtx.audioWorklet.addModule('sid-processor.js');

  // AudioWorkletNode作成(WASMとSIDデータを渡す)
  const sidNode = new AudioWorkletNode(audioCtx, 'sid-audio-processor', {
    processorOptions: {
      wasmBytes: wasmBytes,
      sidData: Array.from(sidFileData), // Uint8Array → Array
      songNum: currentSong
    }
  });

  // スピーカーへ接続
  sidNode.connect(audioCtx.destination);
}

PSIDヘッダーからタイトル・作者・曲数を表示し、port.postMessageで曲切替メッセージを送信する。


トラブルシューティング & 実装のハマりどころ

1. AudioWorklet内でWASMが動かない(_initializeの欠落)

症状

WASMのinstantiateは成功するが、sid_init()呼び出し後にsid_render_float()が正常な波形を返さない。「ブッ」という一瞬のノイズだけが出る。

原因

STANDALONE_WASM=1でビルドすると、WASMのエクスポートに_initialize関数が含まれる。これはC++のグローバル変数のコンストラクタ(static SID g_sidのコンストラクタ)を実行するもので、sid_init()の前に呼ばないとreSIDが初期化されない。

対策

this.exports._initialize();  // これがないとC++のstaticオブジェクトが初期化されない
this.exports.sid_init(sampleRate);

2. WASMメモリのエクスポート名が不明

症状

this.exports.memory.bufferにアクセスするとCannot read properties of undefinedエラー。

原因

EmscriptenのSTANDALONE_WASMビルドでは、WASMメモリのエクスポート名がminifyの設定によって変わる。memorybmemなど予測不能。

対策

Object.keys(instance.exports)でエクスポート名一覧を出力し、WebAssembly.Memoryインスタンスを特定する。今回の環境では"memory"だった。

console.log('WASM exports:', Object.keys(instance.exports));
// → ['memory', 'sid_init', 'sid_write_reg', 'sid_render_float', ...]

3. fake6502.hがC++でコンパイルエラー

症状

and(), or(), xor()関数でSyntaxError。配列の前方宣言でredefinitionエラー。

原因

  • C++ではand, or, not, xorが予約語(&&, ||, !, ^のエイリアス)
  • C++ではstatic void (*addrtable[256])();が定義(ゼロ初期化)として扱われ、後の初期化付き定義と衝突

対策

fake6502.hを直接C++に含めず、Cブリッジファイル(fake6502_bridge.c)を作成して分離コンパイル。emccでCとしてオブジェクトファイルを生成し、em++のリンク時に結合する。

4. step6502の戻り値型不一致

症状

リンク時にfunction signature mismatch: step6502の警告。

原因

fake6502のstep6502()intを返すが、sid_wrapper.cpp側のextern宣言がvoidになっていた。

対策

extern "C" {
    extern int step6502();   // void ではなく int
}

トラブルまとめ

現象・問題原因解決策
音が出ない(ブッと一瞬だけ)_initialize()未呼び出しでC++ staticオブジェクト未初期化sid_init()の前に_initialize()を呼ぶ
memory undefinedWASMメモリのエクスポート名が不定Object.keys(exports)で確認、exports.memoryを使用
fake6502 C++コンパイルエラーand/or等がC++予約語、配列前方宣言の重複Cブリッジファイルで分離コンパイル
リンク時signature mismatchstep6502()の戻り値型の不一致extern宣言をintに修正

まとめ

  • STANDALONE_WASMの_initialize()は必須: C++のグローバル変数コンストラクタを実行するために、WASM初期化直後に呼ぶ。これを忘れるとstaticオブジェクトが未初期化のまま動作し、音が出ないという致命的な問題が発生する。
  • AudioWorklet内でのWASM直接instantiate: Web Workerと異なり、AudioWorkletスレッドではEmscriptenグルーコードなしでWASMを直接ロードする。processorOptionsでArrayBufferを渡すパターンが最もシンプル。
  • C/C++分離コンパイル: CライブラリをC++プロジェクトに組み込む際、予約語衝突や配列宣言の意味の違い(Cのtentative definition vs C++の定義)に注意。ブリッジファイルで分離コンパイルする。
  • 6502 → SIDの配線は数十行: read6502/write6502のコールバック関数で$D400-$D41Fへの書き込みをフックするだけで、C64のバスアーキテクチャ全体をエミュレートできる。完全なC64エミュレーションは不要で、SID再生に必要な最小構成で十分に動作する。