[Astro] #160 WINAMP X2 — トップページに置いたWINAMP風プレイヤーと、VRMのダンス連動(自作.vrmaにも対応)
はじめに
前回(#159)は、.wasm の中身をバイト単位で覗く WASM Inspector でした。
[Astro] #159 WASM Inspector — .wasmの中身をバイト単位で覗く(WAT・hex・DWARFソース行の3方向連動) // PROTOCOL.LAIN
.wasm ファイルの中身を、WAT・hex・セクション構造の3つの見方で同時に表示し、どこにカーソルを乗せても他の2つの対応する場所が光るツールを作りました。
lain-lab.com今回は、トップページに WINAMP 風のプレイヤーを作りました。
このサイトの音楽系のツール(SID、OPL3、FluidSynth、トラッカーなど)は、これまでにいくつも作っています。プレイヤー自体は珍しくないので、この記事は要点だけにします。
作ったきっかけは、ターミナルの play 1 でした。
play 1 と打つと曲が流れて、トップの VRM アバターが踊ります。でも、コマンドを知らないと誰も気づきません。「音楽プレイヤーを置けば伝わる」と考えて、WINAMP を作ることにしました。
要点は 2 つです。
- WINAMP の再現:独立した 4 枚の窓、吸着、スペクトラム、EQ、スキン
- VRM のダンス機能との連携:曲ごとのダンスの割り当てと、自作の .vrma への対応
実装は 1 日で終わっています。目玉は 2 つめです。
完成画面
スタートメニューから起動
LAIN スキン(既定)と AQUA スキン
VRM がダンスする
操作動画
できることは次のとおりです。
- スタートメニューとターミナルの
winampから、トップページの中に表示 - メイン・EQ・プレイリスト・VIDEO の 4 枚の窓を、タイトルバーのドラッグで自由に移動
- 窓同士が近づくと吸着し、メインを動かすとくっついている窓も一緒に動く
- VIDEO 窓のリサイズ、位置・サイズ・開閉の保存
- スペクトラムアナライザー、10 バンド EQ(プリセットと、名前を付けた保存)
- DEFAULT / USER の 2 つのプレイリスト。ドロップで追加、Delete キーで削除、ドラッグで並べ替え
- 連続再生、リピート(OFF → LIST → ONE)、シャッフル
- スキンの切替(LAIN / AQUA)
- 曲ごとのダンス割り当てと、自作 .vrma の追加
構成
ページは Astro、プレイヤーは src/components/winamp/ に置いた素の JS です。React 側(WiredScene)には組み込まず、独立したモジュールにしました。
src/
├─ lib/nativeApps.ts ページ内アプリの登録(1 行足すだけで増やせる)
└─ components/winamp/
├─ app.js 本体(約 680 行)
├─ windows.js 4 枚の窓の配置・ドラッグ・吸着・保存(約 300 行)
├─ library.js 曲とダンスのデータ層(IndexedDB)
├─ audio.js Web Audio の EQ とアナライザー
├─ spectrum.js スペクトラムの描画
├─ window.html 窓のマークアップ
├─ style.css 見た目(色は CSS 変数に集約)
└─ skins/aqua.css macOS 風スキン
ページ内に出す仕組み
最初は、スタートメニューから開くと別ウインドウ(iframe)で出てしまいました。ページ内に出すために、アプリを登録して、必要なときに生成する仕組みにしています。
// nativeApps.ts(一部)
const loaders = {
winamp: async () => {
const { WinampApp } = await import('../components/winamp/app.js');
return new WinampApp({ visible: false });
},
};
loaders に 1 行足すだけで、同じ形のアプリを増やせます。コードは初回に開くまで読み込まれません。
WINAMP を再現する
4 枚の窓
窓は position: absolute の独立した要素です。親は画面全体を覆う透明なレイヤー(pointer-events: none)にして、窓の部分だけ pointer-events: auto にしています。窓の外のクリックは、そのままトップページ(VRM や 3D シーン)に届きます。
移動は Pointer Events と setPointerCapture です。タイトルバーを掴んでいる間は、マウスが窓の外に出ても追従します。
吸着と一体移動
WINAMP らしさの大半は、ここにあります。仕組みは、ドラッグ中に矩形同士の距離を測って、近ければ補正する、というものです。
// windows.js(snapDelta の要点)
// 動かしている窓の辺と、他の窓の辺の距離を測る
const cands = [
t.x + t.w - r.x, // 相手の右辺に、自分の左辺を合わせる
t.x - (r.x + r.w), // 相手の左辺に、自分の右辺を合わせる
t.x - r.x, // 左辺同士を揃える
t.x + t.w - (r.x + r.w), // 右辺同士を揃える
];
// しきい値(12px)以内で、最も近いものを採用
上の 4 通りを縦方向にも同じように計算します。くっついた状態で横にスライドすると、辺が揃う位置でも吸着するのは、「隣に付ける」と「辺を揃える」を同じ処理で扱っているからです。画面の端にも吸着します。
くっついているかどうかは、辺が 3px 以内で、もう一方の軸で重なっているかで判定します。ドラッグを始めるときに、くっついている窓を数珠つなぎに集めておき、まとめて動かします。
- メインを動かす:くっついている窓が全部一緒に動く
- それ以外を動かす:その窓と、そこにくっついている窓(メインは含まない)が動く。メインから引き離すと切り離される
- 閉じている窓:判定の対象外
スペクトラムと EQ
音の経路は次のとおりです。
<video> → EQ(10 バンドの BiquadFilter)→ AnalyserNode → 出力
スペクトラムは、EQ を通した後の音を AnalyserNode で分析しています。周波数のビンを対数的に 19 本にまとめて、緑から黄、赤のグラデーションと、ゆっくり落ちるピークキャップで描いています。
EQ は 10 バンドで、プリセット(FLAT / ROCK / POP / DANCE など)と、名前を付けて保存する USER プリセットを持たせました。現在の値は、リロードしても残ります。
プレイリストと連続再生
曲のデータは、プロジェクトで共通の IndexedDB(WiredAssetCache)を使っています。
| リスト | 中身 |
|---|---|
| DEFAULT | audio.json の曲。play コマンドと同じキャッシュを共有する |
| USER | ドロップした曲。db.audios に LOCAL_AUDIO_ のキーで保存 |
連続再生では、再生中のリスト(queue)を、表示しているタブとは別に持つようにしました。再生中に DEFAULT と USER のタブを切り替えても、次に流れる曲は再生中のリストから選ばれます。
リピートは、ボタンを押すたびに OFF → LIST → ONE と切り替わる形にしました。シャッフルは別ボタンで、前の曲に戻るための履歴を持っています。⏮ は、3 秒以上再生していたら頭出し、それ以内なら前の曲です。WINAMP と同じです。
スキン
見た目の色・枠・フォントを、すべて CSS 変数にまとめました。
.winamp-wrapper {
--wa-bg: #22262e;
--wa-title-bg: linear-gradient(90deg, #0a192f, #1e3a8a);
--wa-btn-bg: linear-gradient(180deg, #444, #111);
--wa-spec-low: #00ff66; /* スペクトラムの色もここ */
/* ... */
}
スキンは、変数の値を上書きするだけの CSS です。
.winamp-wrapper[data-skin="aqua"] {
--wa-bg: linear-gradient(180deg, #f4f4f4, #dedede);
--wa-btn-bg: linear-gradient(180deg, #f0f6fd 0%, #b7d3f3 48%, #6ea6e2 52%, #b9dbf8 100%);
--wa-btn-radius: 10px;
/* ... */
}
タイトルバーの ◐ で順に切り替わり、選んだスキンは保存されます。macOS 風の AQUA は、Winamp 2.9 系の Aqua ベースのスキン「Winamp X2」(DeeLight 氏と Andy Couch 氏の作)の配色を参考にして、CSS で独自に作りました。画像は使っていません。
窓のサイズやレイアウトは、スキンでは変えないことにしました。窓の大きさは CSS と windows.js の両方にあるので、スキンで形まで変わると破綻するからです。
なお、本物の Winamp のスキン(.wsz)を読み込める Webamp というオープンソースのプレイヤーもあります。今回は、窓の管理と VRM との連携を自分で持ちたかったので、使っていません。
VRM のダンスと連携する
ここが今回の目玉です。
既存のイベントをそのまま使う
play 1 で VRM が踊る仕組みは、すでにありました。
play 1 → WiredAudio が曲を再生 → wired-dance-sync を発火
{ action: 'play', dance: 'House Dancing.vrma' }
→ アバター側(useWiredAnimation)が受け取って、そのモーションを再生
曲ごとの dance は audio.json に書いてあります。WINAMP も、同じイベントを同じ形式で発火するだけにしました。アバター側に新しいイベントは足していません。
// app.js
window.dispatchEvent(new CustomEvent('wired-dance-sync', {
detail: { action: 'play', dance: this.currentDance },
}));
動画の play / pause / ended に合わせて play と stop を発火します。止まるときは、踊っていないのに stop を飛ばさないようにフラグ(danceActive)を持っています。
方針:ダンスは曲ごとに決める
- DEFAULT の曲は、
audio.jsonのdanceで踊る - USER の曲は、初期状態ではダンスなし
- ダンスの有無は
DANCE ON/OFFボタンでも切り替えられる
ダンスのない曲は、play を発火しません。アバター側は「dance なしの play」を停止として扱うので、何も足さなくて済みました。最初の実装でダンスしなかったのは、この「dance が空のまま play を送っていた」ためでした。
USER の曲にダンスを割り当てる
プレイリストの下に選択欄を置きました。USER の曲を選ぶと、ダンスを割り当てられます。
- 選択肢は、DEFAULT のダンス(
audio.jsonに出てくるもの)と、自分で追加した .vrma - 割り当てたダンスは、曲のデータ(
db.audiosの行)にdanceとして保存 - ダンスが付いた曲は、名前の後ろに
◆が付く - 再生中の曲のダンスを変えると、その場で踊りが切り替わる(「ダンスなし」で停止)
自分の .vrma で踊らせる
ここが一番やりたかった部分です。
.vrma を WINAMP の画面にドロップすると、ダンスとして登録されます。 曲リストには入らず、ダンスの選択肢に追加されます。
保存先は、アニメーション用の IndexedDB(db.animations)です。曲の dance には、ファイル名の代わりに local: で始まる ID を入れます。
// library.js
'House Dancing.vrma' // 従来のダンス(/animations/ にあるファイル)
'local:LOCAL_ANIM_xxx.vrma_123' // ユーザーが追加した .vrma(IndexedDB)
再生の直前に、local: の ID を IndexedDB から読んで、blob URL に変換します。
export async function resolveDance(dance) {
if (!dance) return null;
if (!dance.startsWith('local:')) return dance; // 従来のファイル名はそのまま
const row = await db.animations.get(dance.slice('local:'.length));
return row ? URL.createObjectURL(row.data) : null;
}
アバター側(useWiredAnimation.ts)は、受け取ったダンスが blob: なら、そのまま読み込むように変えただけです。
// handleDanceSync
if (action === 'play' && dance) {
isDancingRef.current = true;
setAnimPath(dance.startsWith('blob:') ? dance : `/animations/${dance}`);
}
もともとアバター側の読み込みは、パスが Animations.json に登録されていなければ、受け取った URL をそのまま GLTFLoader に渡す作りでした。blob URL もそのまま読めたので、これで動きました。壊れた .vrma を渡されたときのために、アニメーションが入っていなければ読み込みを諦めるガードも入れています。
アバター側の変更は、これだけです。 イベントの形を変えなかったので、既存のコマンドやアバターの挙動には影響がありません。
追加した .vrma は、選択欄の横の ✕ ボタンで削除できます。そのダンスを使っていた曲は、「ダンスなし」に戻ります。
play コマンドとの二重再生を防ぐ
play 1(WiredAudio)と WINAMP は、別々に音を鳴らします。同時に鳴らすと重なるので、お互いに止め合うようにしました。
// 鳴り始めたら通知する
window.dispatchEvent(new CustomEvent('wired-media-play', { detail: { source: 'winamp' } }));
// 相手が鳴り始めたら、自分は黙って止まる
window.addEventListener('wired-media-play', (e) => {
if (e.detail?.source === 'winamp') return; // 自分のイベントは無視
video.pause();
});
WiredAudio 側にも同じ形のリスナーを足しました。source で発火元を区別して、自分のイベントでは止まらないようにしています。
ハマりどころ
1. スタートメニューから開くと 404 になる
「別ウインドウではなくページ内に出す」はずが、スタートメニューをクリックすると /winamp に遷移して 404 になりました。
原因は、start_menu.json のトップレベルの項目に "app": "winamp" と書いたことでした。フッターの描画は、トップレベルの項目に app があると <a href> を作ります。子メニューなら data-app になってクリックを横取りできますが、トップレベルは普通のリンクとして遷移します。"action": "winamp" に変えて直りました。
2. 再生前に動かした EQ が効かなかった
ブラウザの自動再生の制限のために、AudioContext は最初の再生で作ります。ところが、EQ のフィルターもそのとき初めて作られるので、再生前にスライダーを動かしても、値が捨てられていました。
AudioEngine が値を覚えておいて、フィルターを作るときに反映するようにして直しています。リロードで復元した EQ の値も、これで最初の再生から効きます。
3. 再生中にダンスを変えても反映されなかった
最初の実装では、選んだダンスは「次に再生するとき」から効く形でした。別の曲に移って戻ると動くので気づきました。選んだ曲が再生中なら、その場でダンスを切り替える処理を足しています。
4. 窓の大きさを変えたら、窓が重なった
窓のサイズは、CSS と windows.js の両方に書いています。サイズを変えたあと、保存済みの位置のままだと窓が重なって、「くっついている」判定も外れました。窓の高さを変えたら、保存済みのレイアウトを消す、という手順になっています。
localStorage.removeItem('winamp_layout');
まだ入れていないこと
本家の WINAMP は、もっと多機能です。次のものは入れていません。
.wszの本物のスキン(ビットマップ)の読み込み- バランス、モノ / ステレオ表示、ウィンドウシェード(窓の折りたたみ)
- プレイリストの保存と読み込み(
.m3u) - 曲の切り替わりで、ダンスが一瞬アイドルに戻る(
endedで止めて、次の曲の再生で始めるため)
おわりに
もともとは、「play 1 の仕掛けが伝わらない」という悩みから始めた WINAMP でした。
作ってみると、すでにあったダンスの仕組みに、同じイベントを送るだけで繋がりました。いちばん手間がかかったのは、ダンスの連携ではなく、WINAMP の窓の動きのほうです。
自作の .vrma を踊らせるところまで、IndexedDB に入れて blob URL にするだけで届きました。VRM アバターと音楽プレイヤーが、プロジェクトの共通のイベントとデータで繋がっていると、機能を足すときも小さな変更で済みます。
次にやるとしたら、プレイリストの保存・読み込みと、ダンスを曲のビートに合わせる仕組みです。