[Astro] #160 WINAMP X2 — トップページに置いたWINAMP風プレイヤーと、VRMのダンス連動(自作.vrmaにも対応)

[Astro] #160 WINAMP X2 — トップページに置いたWINAMP風プレイヤーと、VRMのダンス連動(自作.vrmaにも対応)

はじめに

前回(#159)は、.wasm の中身をバイト単位で覗く WASM Inspector でした。

今回は、トップページに WINAMP 風のプレイヤーを作りました。

このサイトの音楽系のツール(SID、OPL3、FluidSynth、トラッカーなど)は、これまでにいくつも作っています。プレイヤー自体は珍しくないので、この記事は要点だけにします。

作ったきっかけは、ターミナルの play 1 でした。

play 1 と打つと曲が流れて、トップの VRM アバターが踊ります。でも、コマンドを知らないと誰も気づきません。「音楽プレイヤーを置けば伝わる」と考えて、WINAMP を作ることにしました。

要点は 2 つです。

  1. WINAMP の再現:独立した 4 枚の窓、吸着、スペクトラム、EQ、スキン
  2. VRM のダンス機能との連携:曲ごとのダンスの割り当てと、自作の .vrma への対応

実装は 1 日で終わっています。目玉は 2 つめです。

完成画面

WINAMP X2 — 起動画面

スタートメニューから起動

WINAMP X2 — スタートメニューから起動

LAIN スキン(既定)と AQUA スキン

WINAMP X2 — LAIN スキン WINAMP X2 — AQUA スキン

VRM がダンスする

曲に合わせて 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)を使っています。

リスト中身
DEFAULTaudio.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 アバターと音楽プレイヤーが、プロジェクトの共通のイベントとデータで繋がっていると、機能を足すときも小さな変更で済みます。

次にやるとしたら、プレイリストの保存・読み込みと、ダンスを曲のビートに合わせる仕組みです。