[Astro] #154 Tracker Player — libopenmpt WASMでMOD/XM/S3M/ITをブラウザ再生、パターンをリアルタイム表示

[Astro] #154 Tracker Player — libopenmpt WASMでMOD/XM/S3M/ITをブラウザ再生、パターンをリアルタイム表示

はじめに

SID Player(C64)、OPL3 Player(Sound Blaster)、FluidSynth Web Player(SoundFont / MIDI)に続く、ブラウザ音源ツールの4本目です。

今回はAmigaやDOSの時代に使われた トラッカー音楽(MOD / XM / S3M / IT)を再生する「Tracker Player」を作りました。

スクリーンショット

Tracker Playerの動作画面

動画

Tracker Playerの動作画面

Tracker Player

👉 Tracker Player を開く

ファイルをドロップするだけで再生が始まり、画面中央には曲の「楽譜」にあたるパターンデータが、再生位置に合わせて流れていきます。サーバーへのアップロードはなく、すべてブラウザの中で完結します。

トラッカー音楽とは

トラッカーは、1987年のAmiga用ソフト「Ultimate Soundtracker」から始まった作曲ソフトの系統です。

MIDIが「どの楽器で、どの音を鳴らすか」という命令だけを記録するのに対し、トラッカーのファイル(モジュール)は楽器の波形(サンプル)そのものと楽譜を1ファイルに同梱しています。そのため、どの環境で鳴らしても作者が意図した音がそのまま出ます。

楽譜は縦に流れる表の形をしています。

行  ch1           ch2           ch3
00  C-5 01 v64 ...  ... .. ... ...  E-5 03 ... C20
01  ... .. ... ...  ... .. ... ...  ... .. ... ...
02  D-5 01 ... ...  G-4 02 ... A0F  ... .. ... ...
列意味
C-5音程(ド・オクターブ5)
01楽器(サンプル)番号
v64音量
C20 / A0Fエフェクト(音量設定、ボリュームスライドなど)

この表の1ブロックをパターン、パターンを演奏する順番を並べたものをオーダーリストと呼びます。同じパターンを何度も使い回して1曲を組み立てるのが基本です。


機能

  • 対応形式:MOD / XM / S3M / IT / MPTM ほか、libopenmptが読める形式すべて
  • パターン表示:現在行を中心に、前後のパターンまで途切れなくスクロール。音程・楽器・音量・エフェクトを色分け
  • 文字サイズ自動調整:チャンネル数に合わせて、画面幅いっぱいに表示
  • チャンネルVUメーター:チャンネルごとの音量をリアルタイム表示
  • ミュート / ソロ:チャンネル名クリックでミュート、右クリックでソロ(数字キー 1〜0 でも操作可)
  • オーダーリスト:クリックでその位置へジャンプ
  • レンダリング設定:ステレオ幅、補間方式、Amigaのフィルター再現(A500 / A1200)、テンポ・ピッチ
  • 全画面表示:F キーでパターン表示を全画面に
  • プレイリスト、シーク、ループ、曲メッセージ表示

チャンネルを1本ずつミュートしていくと、ベース・ドラム・メロディがどう組み上がっているのかを耳で分解できます。これはトラッカーならではの楽しみ方です。

ch1以外をミュートしたソロ表示

曲の入手先

ページ左の「▶ Load demo.mod」で、動作確認用のデモ曲も再生できます(Pythonで波形と譜面を生成したオリジナルの4ch MODです)。

構成

src/pages/tracker-player.astro          UI(3ペイン構成 + Canvas2Dのパターンビュー)
public/libs/openmpt/
  ├─ libopenmpt.worklet.js             libopenmpt 0.8.9 の Emscripten ビルド(wasm埋め込み)
  └─ tracker-worklet.js                自前の AudioWorkletProcessor
public/tracker-player/demo.mod          デモ曲

libopenmptのWASMビルドは、npmパッケージ chiptune3 に同梱されているものをそのまま使っています。AudioWorkletから import できる形にビルドされていて、wasmもJSの中にbase64で埋め込まれているため、ファイル1つで完結します。

ただし、chiptune3付属のワークレットは使わず、自前で書き直しました。理由は3つです。

  • 再生位置の通知が128フレーム(約2.7ms)ごとで、多すぎる
  • チャンネルごとのVUがコメントアウトされていて取れない
  • 停止時に解放するポインタの変数名が間違っていて、曲を切り替えるたびにメモリがリークする

AudioWorklet:libopenmptで直接レンダリング

AudioWorkletの process() は128フレームずつ呼ばれます。その都度libopenmptにステレオ波形を書かせて、出力バッファへコピーするだけです。

process(_inputs, outputs) {
  if (!lib || !this.mod || this.paused || this.ended) return true;
  const L = outputs[0][0], R = outputs[0][1];

  // libopenmpt に 128 フレーム分を float で書かせる
  const n = lib._openmpt_module_read_float_stereo(
    this.mod, sampleRate, L.length, this.lPtr, this.rPtr);
  if (n === 0) {                      // 曲の終端
    this.ended = true;
    this.port.postMessage({ cmd: 'end' });
    return true;
  }
  const heap = lib.HEAPF32;
  L.set(heap.subarray(this.lPtr >> 2, (this.lPtr >> 2) + n));
  R.set(heap.subarray(this.rPtr >> 2, (this.rPtr >> 2) + n));

  // 3 ブロックに 1 回 (約 8ms) 再生位置と VU を UI へ
  if (++this.blockCount % 3 === 0) {
    const vu = [];
    for (let c = 0; c < this.numCh; c++)
      vu[c] = lib._openmpt_module_get_current_channel_vu_mono(this.mod, c);
    this.port.postMessage({
      cmd: 'pos',
      order:   lib._openmpt_module_get_current_order(this.mod),
      pattern: lib._openmpt_module_get_current_pattern(this.mod),
      row:     lib._openmpt_module_get_current_row(this.mod),
      vu,
    });
  }
  return true;
}

CS講座 #10 では、音源チップの波形を自前で合成してAudioWorkletで鳴らしました。今回は合成をlibopenmptに任せていますが、「process() の中でサンプルを作って出力バッファに詰める」という骨組みはまったく同じです。

パターン表示:libopenmptに整形させる

トラッカーのエフェクト表記はフォーマットごとに違います。MODの C20 とITの v32 はどちらも「音量」ですが、書き方が違います。これを自前で変換するのは大仕事です。

幸い、libopenmptには トラッカーと同じ見た目の文字列を返すAPI があります。

// 1セル分の表示文字列 (例: "C-5 01 v64 A0F")
const text = takeStr(lib._openmpt_module_format_pattern_row_channel(m, pat, row, ch, 0, 1));
// 同じ長さの「種類」文字列 (例: "nnn ii uuu eff")
const hl   = takeStr(lib._openmpt_module_highlight_pattern_row_channel(m, pat, row, ch, 0, 1));

highlight が返すのは、各文字が何を表すかの記号列です(n=音程、i=楽器、u/v=音量、e/f=エフェクト)。これを色にマップするだけで、フォーマットを問わず色分け表示ができます。

パターンは曲全体を一度に送らず、UIが必要になったパターンだけをワークレットに要求します。100パターン×64行×32チャンネルのIT曲を丸ごと文字列化すると、それだけで数十万回の呼び出しになるためです。

前後のパターンをつなげて流す

最初は現在のパターンだけを描いていました。しかし1パターンが32行の曲だと、画面の上下がガラガラになってしまいます。

そこで、オーダーリストをたどって前後のパターンを連結した行リストを作るようにしました。

// 現在行から下へ half 行ぶん、パターンをまたいで行を集める
let o = this.order, pd = cur, r = this.row;
for (let i = 1; i <= half; i++) {
  r++;
  if (r >= pd.rows.length) {         // パターンの終わり → 次のオーダーへ
    o = this.nextOrd(o);             // 0xFE (skip) は飛ばし、0xFF (end) で終了
    if (o < 0) break;
    pd = this.patAt(o);
    if (!pd) break;                  // まだ届いていないパターン
    r = 0;
  }
  lines.push({ i, o, pd, r });
}

再生位置のオーダーが変わるたびに前後数オーダー分のパターンを先読みしておくので、境目でも途切れずに流れます。

なお、パターンを途中で打ち切るエフェクト(Dxx / Bxx)がある曲では、先読みした行と実際に再生される行がずれることがあります。その場合は、行が変わった瞬間に正しい位置へ表示が飛びます。


ミュート / ソロ:Emscriptenが隠した関数テーブルを拾う

チャンネルのミュートは、libopenmptの通常のAPIにはありません。拡張API(openmpt_module_ext)の interactive インターフェース にあります。

このインターフェースの取り出し方が少し特殊で、関数ポインタが並んだ構造体として受け取ります。

// libopenmpt_ext.h(抜粋)
typedef struct openmpt_module_ext_interface_interactive {
  int (*set_current_speed)(openmpt_module_ext*, int32_t);
  ...
  int (*set_channel_mute_status)(openmpt_module_ext*, int32_t channel, int mute);  // 11番目
  int (*get_channel_mute_status)(openmpt_module_ext*, int32_t channel);
  ...
} openmpt_module_ext_interface_interactive;

WASMの世界では、関数ポインタは関数テーブル(WebAssembly.Table)のインデックスです。JSから呼ぶにはテーブル本体が必要になります。ところが、このEmscriptenビルドは wasmTable を外に公開していません。

そこで、WASMがインスタンス化される瞬間に一度だけ割り込んで、exportsの中からTableを拾うことにしました。

// ライブラリの初期化前に WebAssembly.Instance を差し替える
const OrigInstance = WebAssembly.Instance;
WebAssembly.Instance = function (mod, imports) {
  const inst = new OrigInstance(mod, imports);
  wasmTable = Object.values(inst.exports)
    .find((v) => v instanceof WebAssembly.Table);   // 名前は minify されているので型で探す
  return inst;
};

libopenmptFactory().then((m) => {
  WebAssembly.Instance = OrigInstance;               // すぐ元に戻す
  lib = m;
});

このビルドは同期版の new WebAssembly.Instance() でwasmを起動しているため、この方法で捕まえられます。ライブラリ本体のファイルには一切手を入れていないので、chiptune3のバージョンを上げてもそのまま使えます。

あとは、ext 版の関数でモジュールを作り、interactiveインターフェースを取り出して、テーブルから関数を引くだけです。

// ext 版でモジュールを作る
this.ext = lib._openmpt_module_ext_create_from_memory(ptr, size, 0, 0, 0, 0, 0, 0, 0);
this.mod = lib._openmpt_module_ext_get_module(this.ext);

// 関数ポインタ 16 個 (wasm32 なので 4byte × 16) を受け取る
const ip = lib._malloc(64);
lib._openmpt_module_ext_get_interface(this.ext, cstr('interactive'), ip, 64);
const ptrs = new Uint32Array(lib.HEAPU8.buffer, ip, 16);
const fns  = Array.from(ptrs, (p) => wasmTable.get(p));

// ミュート
fns[10](this.ext, channel, 1);

動作はNode上でレンダリング結果の音量を計って確認しました。4チャンネル全部をミュートすると音量が0、1チャンネルだけ残すと約6割になります。


Astroのscoped CSSがinnerHTMLに当たらない

UIでは一つ地味にハマりました。オーダーリストやプレイリストのように、JSの innerHTML で後から作った要素にCSSが当たらなかったのです。

Astroの <style> はscoped(スコープ付き)で、ビルド時にHTMLの各要素へ data-astro-cid-xxxx という属性を付け、その属性に対してだけルールを当てます。innerHTML で作った要素にはこの属性が付かないので、ルールがマッチしません。

:global() で包む方法でもビルドでは動きました。ただ、devサーバーで何度もCSSを差し替えているうちに古いスタイルが残り、効いたり効かなかったりしました。最終的には、動的に作る要素のCSSだけを is:inline に分けて落ち着きました。

<!-- JS で生成する要素用: Astro の処理を通さず素の CSS として出力 -->
<style is:inline>
  .tracker-app .ord { background: #111; border: 1px solid #1a1a1a; }
  .tracker-app .ord.cur { background: var(--acc); color: #000; }
</style>

<!-- それ以外は通常の scoped style -->
<style>
  .tracker-app .panel-left { width: 280px; }
</style>

is:inline は書いたCSSがそのままHTMLに出力されます。スコープにもViteのキャッシュにも左右されません。セレクタを .tracker-app 配下に限定しておけば、サイトのほかの部分にも影響しません。


ライセンス

  • libopenmpt:BSD 3-Clause License(OpenMPT)
  • chiptune3(WASMビルドの配布元):MIT License(DrSnuggles/chiptune)
  • 再生する楽曲の著作権は各作者にあります。The Mod Archiveの曲は、それぞれのライセンスに従ってください。

おわりに

SID(C64)、OPL3(Sound Blaster)、FluidSynth(MIDI)、そしてトラッカー。これでブラウザ音源ツールが4本そろいました。

SIDとOPL3は「チップのレジスタに書き込んで音を出す」方式、FluidSynthは「命令と音色を別々に持つ」方式、トラッカーは「サンプルと楽譜を1ファイルに同梱する」方式です。同じ「昔のコンピューターの音楽」でも、音の作り方がそれぞれ違います。4本を並べて聴き比べてみてください。

👉 Tracker Player を開く