[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
ファイルをドロップするだけで再生が始まり、画面中央には曲の「楽譜」にあたるパターンデータが、再生位置に合わせて流れていきます。サーバーへのアップロードはなく、すべてブラウザの中で完結します。
トラッカー音楽とは
トラッカーは、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本ずつミュートしていくと、ベース・ドラム・メロディがどう組み上がっているのかを耳で分解できます。これはトラッカーならではの楽しみ方です。
曲の入手先
The Mod Archive
数十万曲のトラッカーモジュールを公開しているアーカイブ。1曲ずつダウンロード可能。
modarchive.orgページ左の「▶ 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() の中でサンプルを作って出力バッファに詰める」という骨組みはまったく同じです。
[CS講座 #10] 音源チップの波形合成 — レトロゲーム音をWeb Audio APIで発声させる // PROTOCOL.LAIN
離散信号とサンプリング、周波数計算式(f = Clock / (32×N) / (16×N))、矩形波・LFSRノイズ・2dB刻み減衰テーブル、Game Gearステレオ、SCC/WSG波形メモリ、エイリアシング対策、CPUサイクル付きレジスタキューによるAudioWorklet再生をJavaScriptコード付きで徹底解説。
lain-lab.comパターン表示: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本を並べて聴き比べてみてください。