[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してリアルタイム音声合成を行うまでの実装フローと、開発中に遭遇したトラブルの解決策をまとめる。
スクリーンショット
全体アーキテクチャ
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の関数を直接呼び出す必要がある。
登場するライブラリと役割
| コンポーネント | 言語 | 役割 |
|---|---|---|
| reSID | C++ | MOS 6581/8580 SIDチップのトランジスタレベルエミュレーション。波形生成、フィルター、エンベロープ(ADSR)を再現 |
| fake6502 | C | MOS 6502 CPUエミュレーター。.sidファイル内の6502マシン語を実行してSIDレジスタに書き込む |
| sid_wrapper.cpp | C++ | PSIDヘッダーパーサー、C64メモリマップ(64KB RAM)、6502↔reSIDの配線、レンダリングループ |
| fake6502_bridge.c | C | fake6502.hをCコンパイラで分離ビルドするためのブリッジ |
| sid-processor.js | JS | AudioWorkletProcessor。WASM instantiate、process()内でsid_render_float()呼び出し |
| index.html | JS | UI。.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]に配置 │
└──────────────────────────────────────┘
再生の流れ:
- ヘッダーからinit/playアドレスを取得
- バイナリをC64の64KB RAMの指定アドレスに配置
- 6502 CPUでinitアドレスをJSR(サブルーチンコール)→ 音源初期化
- 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の設定によって変わる。memory、b、memなど予測不能。
対策
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 undefined | WASMメモリのエクスポート名が不定 | Object.keys(exports)で確認、exports.memoryを使用 |
| fake6502 C++コンパイルエラー | and/or等がC++予約語、配列前方宣言の重複 | Cブリッジファイルで分離コンパイル |
| リンク時signature mismatch | step6502()の戻り値型の不一致 | 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再生に必要な最小構成で十分に動作する。