[Astro] #143 FluidSynth WASM と Web Audio API で構築する SoundFont / MIDI プレイヤー — Piano Roll ビジュアライザー実装記録

[Astro] #143 FluidSynth WASM と Web Audio API で構築する SoundFont / MIDI プレイヤー — Piano Roll ビジュアライザー実装記録

はじめに

ブラウザ標準の Web Audio API と、WebAssembly(WASM)環境で動作する SoundFont レンダラー FluidSynth 2.4.6 を組み合わせ、外部サーバーを介さずに .sf2 / .sf3 音源と MIDI (.mid) ファイルをレンダリング再生する Web アプリケーション「FluidSynth Web Player」を構築しました。

自作の軽量 Standard MIDI File (SMF) パーサーによるノート解析をはじめ、Synthesia 風の Canvas2D ピアノロールビジュアライザー、AudioContext と同期するスムーズな Tick 補間描画、大容量 SoundFont の IndexedDB ローカルキャッシュ、そして Reverb / Chorus などの DSP パラメータリアルタイム制御機能を統合しています。

モジュール No.モジュール名主要機能・処理内容
01SYNTH ENGINEWASM FluidSynth 2.4.6, AudioWorklet / ScriptProcessor Node 接続, 16ch 独立設定
02MIDI PARSER自作 SMF バイナリパーサー, VLQ 読み込み, ノート・テンポ・プログラムチェンジ解析
03VISUALIZERSynthesia 風 2D ピアノロール, 音高・チャンネル別色分け, 鍵盤アクティブ発光エフェクト
04SYNC ENGINEretrievePlayerCurrentTickAudioContext.currentTime による高精度 Tick 補間 (Smooth Scroll)
05SF CACHE30MB 級の SoundFont (GeneralUser-GS.sf2) の IndexedDB 自動キャッシュ・永続化
06CONTROL UIReverb / Chorus / Polyphony / Gain リアルタイム制御, 16ch レベルメーター & 楽器名表示

スクリーンショット

[Astro] #143 FluidSynth WASM と Web Audio API で構築する SoundFont / MIDI プレイヤー — Piano Roll ビジュアライザー実装記録

動画

[Astro] #143 FluidSynth WASM と Web Audio API で構築する SoundFont / MIDI プレイヤー — Piano Roll ビジュアライザー実装記録

1. 全体アーキテクチャと処理フロー

FluidSynth を WASM 経由でブラウザにロードし、Web Audio API の音声コンテキストへ PCM ストリームを送り出すパイプラインを構築しました。

データフロー & ノード構造


[ MIDI File (.mid) ] ────────┐

[ SoundFont (.sf2) ] ──► [ FluidSynth WASM ] ──► [ AudioNode (4096 Samples) ] ──► [ AudioContext Destination ]

├─► retrievePlayerCurrentTick() (50ms Interval)
│           │
│           ▼
└─► [ Smooth Tick Interpolator ]


[ Canvas2D Piano Roll ] (60fps)

再生時、FluidSynth のプレイヤー機能(playPlayer)に MIDI バイナリを投入し、再生位置(Tick)を 50ms 周期のタイマーで取得しつつ、requestAnimationFrame 内で音声時間との差分を補間描画します。


2. 軽量 Standard MIDI File (SMF) パーサーの実装

ビジュアライザーへのノート配置やタイムライン計算のため、外部ライブラリを使用せず純粋な DataView / Uint8Array のみで動作する SMF パーサーを実装しました。

可変長数値 (VLQ) のデコードとイベント解析

MIDI バイナリ特有の可変長数値 (Variable Length Quantity) や Meta イベント (0xFF)、Note On / Note Off 情報を読み取り、再生全体の notes 配列および tempos テーブルを生成します。

class MidiParser {
  static parse(buf) {
    const arrayBuf = buf instanceof ArrayBuffer ? buf : buf.buffer;
    const d = new DataView(arrayBuf);
    const u8 = new Uint8Array(arrayBuf);
    let pos = 0;

    const r32 = () => { const v = d.getUint32(pos); pos += 4; return v; };
    const r16 = () => { const v = d.getUint16(pos); pos += 2; return v; };
    const r8  = () => u8[pos++];

    // 可変長数値 (VLQ) のデコード
    const readVLQ = () => {
      let v = 0;
      for (let i = 0; i < 4; i++) {
        if (pos >= u8.length) break;
        const b = r8();
        v = (v << 7) | (b & 0x7F);
        if (!(b & 0x80)) break;
      }
      return v;
    };

    // Header MThd の検証
    if (r32() !== 0x4D546864) throw new Error('Not a MIDI file');
    const headerLen = r32();
    const format = r16();
    const ntracks = r16();
    const ticksPerBeat = r16();
    if (headerLen > 6) pos += (headerLen - 6);

    const notes = [];
    const tempos = [];
    const pending = {};

    // トラック解析処理 ...
    // Note On (0x90), Note Off (0x80), Set Tempo (0x51) 等をパース

    return { format, ntracks, ticksPerBeat, notes, tempos, totalTicks: maxTick };
  }

  // Tick 位置から演奏時間(秒)へ変換するヘルパー関数
  static tickToSec(tick, tempos, tpb) {
    let sec = 0, prevTick = 0, uspqn = 500000;
    for (const t of tempos) {
      if (t.tick > tick) break;
      sec += ((t.tick - prevTick) / tpb) * (uspqn / 1000000);
      prevTick = t.tick;
      uspqn = t.uspqn;
    }
    sec += ((tick - prevTick) / tpb) * (uspqn / 1000000);
    return sec;
  }
}

3. Canvas2D ピアノロール & 高精度 Smooth Tick 補間

WASM から非同期(retrievePlayerCurrentTick)で取得する Tick 値は ポーリング間隔(50ms程度)によりコマ落ちが生じるため、そのまま Canvas 描画に使うとノートの落下スクロールがカクついて見えます。

これを解決するため、AudioContext.currentTime の経過時間 (dtdt) と現在の BPM から正確な経過 Tick 量をフレーム単位で計算・補間する getSmoothTick() メソッドを設計しました。

スムーススクロール補間アルゴリズム

class PianoRoll {
  // FluidSynth から Tick を受領した際に時刻と一緒に記録
  updateSync(tick, audioTime) {
    if (tick !== this.lastTick) {
      this.lastTick = tick;
      this.lastAudioTime = audioTime;
    }
  }

  // 描画フレームごとの高精度な Tick 補間計算
  getSmoothTick() {
    if (!this.playing || !this.audioCtx || !this.midi) return this.lastTick;

    // 前回の更新からの経過時間 (秒)
    const dt = this.audioCtx.currentTime - this.lastAudioTime;
    if (dt < 0 || dt > 1) return this.lastTick;

    // 現在位置の BPM を取得
    let bpm = 120;
    const tempos = this.midi.tempos;
    if (tempos && tempos.length > 0) {
      for (let i = tempos.length - 1; i >= 0; i--) {
        if (tempos[i].tick <= this.lastTick) {
          bpm = tempos[i].bpm;
          break;
        }
      }
    }

    // 1秒あたりの Tick 数から微少経過 Tick を加算
    const ticksPerSec = (bpm / 60) * this.midi.ticksPerBeat;
    return this.lastTick + (dt * ticksPerSec);
  }

  draw() {
    // 補間後の最新 Tick を取得して 60fps 描画
    const curTick = this.getSmoothTick();
    const tickToY = (t) => keyY - ((t - curTick) / visibleTicks) * keyY;

    // 鍵盤およびノートバーの描画処理...
  }
}

4. IndexedDB を利用した大容量 SoundFont キャッシュ

GM音源(GeneralUser-GS.sf2 等)はファイルサイズが 30MB 近くに及ぶため、ページ移動やリロード毎に再ダウンロードが発生すると UX を大きく損ないます。

ブラウザの IndexedDB オブジェクトストアを利用して Blob / ArrayBuffer を永続化し、2回目以降のアクセス時はローカルキャッシュから即座にロードする構成を作成しました。

const SF_DB_NAME = 'fluidsynth-sf-cache';
const SF_DB_STORE = 'soundfonts';

class SFCache {
  static open() {
    return new Promise((resolve, reject) => {
      const req = indexedDB.open(SF_DB_NAME, 1);
      req.onupgradeneeded = () => req.result.createObjectStore(SF_DB_STORE);
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error);
    });
  }

  static async get(key) {
    const db = await this.open();
    return new Promise((resolve, reject) => {
      const tx = db.transaction(SF_DB_STORE, 'readonly');
      const req = tx.objectStore(SF_DB_STORE).get(key);
      req.onsuccess = () => resolve(req.result || null);
      req.onerror = () => reject(req.error);
    });
  }

  static async put(key, value) {
    const db = await this.open();
    return new Promise((resolve, reject) => {
      const tx = db.transaction(SF_DB_STORE, 'readwrite');
      tx.objectStore(SF_DB_STORE).put(value, key);
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    });
  }
}

5. デバッグ記録: 同期メソッドの欠損とメソッド重複定義

開発の最終段階で、TypeError: this.pianoRoll.updateSync is not a function の例外が発生し、タイマー駆動の同期ループが停止するトラブルがありました。

原因と対策

  1. クラス内でのメソッド定義漏れ: メインアプリ(FluidSynthApp)側で this.pianoRoll.updateSync(curTick, ...) を呼び出していたのに対し、PianoRoll クラスに updateSync メソッドが定義されていませんでした。
  2. setTick の重複定義による上書き: リファクタリングの過程で setTick() メソッドがクラス内で2個記述されており、下側の古いシグネチャによって意図した挙動が上書きされていました。

updateSync の新規実装と setTick の役割整理(直接シーク時用と毎フレーム補間用)を行うことで、完璧に 60fps スムーススクロールが動くようになりました。


6. まとめ

  • WASM × Web Audio の高精度レンダリング: ブラウザ上で音切れなく 16 チャンネルの General MIDI 音源と SoundFont をリアルタイム合成可能。
  • Smooth Tick 補間による滑らかな描画: AudioContext.currentTime を基準とした補間アルゴリズムにより、60fps の滑らかな Synthesia 風ビジュアライザーを実現。
  • ローカルキャッシュによる高速化: IndexedDB に SoundFont バイナリを保持することで、通信量を大幅に削減し即座に試聴可能。

デスクトップのレトロゲーム SE/BGM 試聴ツールや Web DAW のベースラインとして、今後も拡張を続けていきます。