ブラウザで動く自動VJシステムを作った。
ブラウザだけで動く完全自動VJシステムを作った技術の話
TL;DR
- ブラウザ完結(Vanilla JS、フレームワークなし、ビルドステップなし)で動く自動VJシステムを作った
- Web Audio API でビート検知・スペクトラム解析 → 映像エフェクトをリアルタイム連動
- 音声フィンガープリントAPIで楽曲をリアルタイム認識 → ジャンル判定 → 最適な背景映像を自動選択
- 同期歌詞を取得してLyric VJを完全自動化(プロVJでも事前準備なしには不可能な領域)
- Canvas compositing + CSS filter + WebGL でレイヤー合成
- LLMで数千本のVJ映像を自動分類してジャンル別DBを構築
なぜ作ったのか
クラブでVJをやっている。VDMXでレイヤー組んで、MIDIコンで叩いて、曲に合わせて映像を切り替える——というスタンダードなVJだ。
ある日、VJとして呼ばれていないイベントに遊びに行った。プロジェクターに映っていたのはYouTubeの「Free VJ Loop 4K 60min」。音と一切同期していない。ジャンルもバラバラ。たまに広告が入る。
しかしフロアの誰も気にしていなかった。
日本のクラブシーンの大半——平日の小箱、DJバー、地方のクラブ、オープンデッキ系——は「VJなし」で運営されている。VJを呼ぶ予算はないが、真っ黒の壁は寂しい。YouTube垂れ流しは安っぽい。
この「間」を埋めるプロダクトが存在しなかった。
技術スタック
フロントエンド: Vanilla JS(フレームワークなし)
映像ソース: 複数のロイヤリティフリー映像API + 自前フッテージ
音声解析: Web Audio API(AnalyserNode + FFT)
レイヤー合成: Canvas 2D / WebGL / CSS compositing
シェーダー: GLSL(WebGL 2.0)
楽曲認識: 音声フィンガープリントAPI(複数プロバイダー冗長構成)
歌詞取得: 同期歌詞API
端末間通信: WebSocket + フォールバック
ホスティング: エッジ配信
フレームワークなし。サーバーサイドは認証と楽曲認識プロキシのみ。ほぼ全ての処理がブラウザ内で完結する。
アーキテクチャ概要
┌─────────────────────────────────────┐
│ 出力ディスプレイ │
│ ┌─────────────────────────────────┐│
│ │ Layer N: テキスト(歌詞/DJ名) ││
│ │ Layer ..: GLSLシェーダー ││
│ │ Layer ..: エフェクトフィルター ││
│ │ Layer 1: 背景映像 ││
│ └─────────────────────────────────┘│
│ compositing engine │
└──────────────┬──────────────────────┘
│
┌──────────┼──────────┐
↓ ↓ ↓
[音声解析] [楽曲認識] [リモコン]
レイヤーの枚数や合成方法の詳細はここでは省略する。
音声解析:ビート検知の実装
なぜ単純な音量検知では不十分か
最初はRMS(Root Mean Square)のスパイク検出だけでビートを検出していた。静かな環境では動く。しかしクラブは常に大音量が鳴り続けている環境だ。RMSの平均値が定常的に高く、スパイクとの差分が縮まり、ビートが検出できなくなる。
複合ビート検出エンジン
最終的に複数の解析経路を組み合わせたアンサンブル方式にした。
class BeatDetector {
constructor(config) {
this.analysers = [
new TemporalFluxAnalyser(config.temporal),
new SpectralContrastAnalyser(config.spectral),
new SubbandOnsetAnalyser(config.subband),
new PhaseDeviationAnalyser(config.phase),
];
this.fusionWeights = config.fusionWeights;
this.adaptiveThreshold = new AdaptiveThreshold(config.adaptation);
}
detect(frequencyData, timeDomainData) {
const signals = this.analysers.map((a, i) => ({
value: a.process(frequencyData, timeDomainData),
weight: this.fusionWeights[i],
}));
const fused = signals.reduce((sum, s) => sum + s.value * s.weight, 0)
/ signals.reduce((sum, s) => sum + s.weight, 0);
const threshold = this.adaptiveThreshold.update(fused);
return {
beat: fused > threshold,
confidence: Math.min(1, fused / threshold),
dominantBand: this._classifyBand(frequencyData),
};
}
}
各Analyserの内部実装は割愛するが、ポイントは**適応的閾値(AdaptiveThreshold)**を入れていること。環境の音量が変わっても追従する。実装にはexponential moving averageの変種を使っているが、decay rateのチューニングに一番時間がかかった。クラブの環境ノイズは想像以上に非定常的で、教科書通りの値では全く動かなかった。
周波数帯域分解
const decompose = (spectrum, bands) => {
const result = {};
for (const [name, [lo, hi]] of Object.entries(bands)) {
const slice = spectrum.subarray(lo, hi);
result[name] = {
energy: norm(slice, 2),
centroid: spectralCentroid(slice, lo),
flux: spectralFlux(slice, this._prevBands[name]),
};
}
this._prevBands = result;
return result;
};
各帯域からenergy、centroid、fluxの3つの特徴量を抽出し、エフェクトエンジンに渡す。エフェクト側はどの特徴量をどう使うかを自由に選べる。この分離が重要で、解析とレンダリングが疎結合になる。
レイヤー合成
詳細は省略するが、Resolumeなどのプロ向けVJソフトがGPUで行っているレイヤー合成を、ブラウザのネイティブ機能(Canvas composite operation、CSS compositing、WebGL framebuffer)を組み合わせて再現している。
ブレンドモードの選択にはコツがある。screen は加算合成に近く、暗い部分が透過する。各レイヤーを黒背景で描画すれば、明るい部分だけが自然に重なる。逆に multiply や overlay を使うと下のレイヤーが潰れる。この制約に気づくまでに結構な時間を使った。
A/Bクロスフェード
映像の切り替えにはダブルバッファリングを使っている。2系統の映像パイプラインを保持し、不透明度の補間でクロスフェードする。実装自体はシンプルだが、映像のプリロードタイミングとGCのタイミングが噛み合わないと、フェード中にフレームドロップが発生する。requestVideoFrameCallback が使える環境ではそれを活用している。
楽曲認識 → 自動映像選択パイプライン
リアルタイム楽曲認識
マイクから音声を取得し、音声フィンガープリントAPIに送信する。レスポンスには曲名、アーティスト、ジャンル等のメタデータが含まれる。
楽曲認識プロバイダーの選定には紆余曲折があった。精度、レイテンシ、コスト、日本の楽曲カバレッジ(特にアニソン)を比較検証した結果、複数プロバイダーを冗長構成で使い分ける設計に落ち着いた。具体的なプロバイダー名と構成は割愛する。
スマート認識: APIコール最適化
固定間隔でAPIを叩くと従量課金が膨らむ。楽曲の切り替わりを推定して、必要なタイミングだけ認識を走らせる。
class RecognitionScheduler {
shouldFire(audioFeatures) {
// 直近の認識から十分な時間が経過していなければスキップ
if (this._inCooldown()) return false;
// 音響特徴量の変化度合いを計算
const divergence = this._computeDivergence(
audioFeatures,
this._lastFeatureSnapshot
);
// 変化度合いが閾値を超えたら「曲が変わった可能性」
if (divergence > this.changeThreshold) {
this._lastFeatureSnapshot = audioFeatures;
return true;
}
// 定期的なフォールバックチェック
return this._periodicCheckDue();
}
}
_computeDivergence の中身はKLダイバージェンス……と言いたいところだが、実際にはもっと泥臭い特徴量比較をやっている。スペクトル形状の変化、RMSの急変、低音エネルギーの断絶を複合的に見ている。
この最適化で、固定間隔方式と比べてAPIコールを約1/10に削減できた。
ジャンル → 映像マッチング
認識結果からジャンル、エネルギーレベル、BPM帯を推定し、タグ付きDBから最適な映像を選出する。
スコアリングの重み付けは試行錯誤の結果だが、最も重要な知見はこれ:ジャンル一致よりもエネルギーレベルの一致の方が体感的に「合ってる」と感じる。テクノにカラフルな幾何学模様はまぁ許せるが、テクノにスローな自然映像は明らかに違和感がある。
この知見はプロVJの暗黙知そのもので、コードで再現するのに一番苦労した部分でもある。重み付けの具体的な数値はプロダクトの競争優位に直結するので非公開とする。
自動Lyric VJ
なぜこれがキラー機能なのか
プロVJでも歌詞表示には事前にセットリストをもらって歌詞データを仕込む作業が必要だ。DJは基本的にセトリを事前共有しない。つまり人間VJでもほぼ不可能な領域。
実装の概要
マイクで音声取得
→ 楽曲認識API(曲名 + timecode取得)
→ 同期歌詞DB問い合わせ
→ timecodeで現在のフレーズを特定
→ テキストレンダリングエンジンで表示
同期歌詞(タイムスタンプ付き歌詞データ)の取得元は複数ソースをフォールバックで切り替えている。カバレッジに差があるため、単一ソースでは日本の楽曲(特にアニソン)に対応しきれない。
DJプレイ特有の問題
ピッチ変更: DJはBPMを合わせるためにピッチを変える。楽曲認識のtimecodeと実際の再生位置にズレが生じる。
対策: ここが設計上の勝負どころだった。カラオケのようなジャスト同期を目指すと「ズレた時にダサい」。逆にLyric Video風のゆるいタイポグラフィにすれば、数秒ズレても演出として成立する。技術で精度を上げるよりも、デザインで許容範囲を広げるアプローチを選んだ。
2曲同時再生: ミックス中は2曲が重なる。認識精度が落ちる。
対策: 認識の信頼度スコアに閾値を設け、低スコア時は歌詞表示をスキップ。中途半端な表示よりも「出さない」方がマシ。
VJ映像の自動分類パイプライン
LLMによるメタデータ分類
映像素材のメタデータ(タイトル、説明文、タグ、サムネイル)をLLMに渡して自動分類した。分類軸は以下の6つ:
-
visual_type: トンネル、幾何学、パーティクル、自然、都市、宇宙、グリッチ… -
color_mood: ダーク、ネオン、暖色、寒色、カラフル、モノクロ… -
energy_level: high / medium / low / static -
music_genres: 適合する音楽ジャンル(複数選択) -
bpm_range: 適合するBPM帯 -
club_suitability: クラブで使えるか(1-5)
NGフィルタリング
映像素材のソースによっては、VJ用途に適さないコンテンツが混在する。解説動画、ゲーム実況、Vlog等を排除するフィルタを実装した。
const REJECT_PATTERNS = [
/\b(tutorial|how\s*to|explained|解説|実況)\b/i,
/\b(reaction|review|unboxing|vlog)\b/i,
/\b(gameplay|commentary|podcast)\b/i,
// ... 他にもあるが省略
];
さらにホワイトリスト方式でVJループ専門チャンネル・ソースのみを信頼する運用にしている。これにより誤検出率はほぼゼロになった。
マルチディスプレイ対応
操作画面と出力画面を分離している。プロジェクターにはフルスクリーンの映像のみ、手元のスマホやPCでは操作UIを表示する。
同一ブラウザ内の複数ウィンドウ間通信にはいくつかの方式を検討した。最終的にはWebSocketベースの軽量ブリッジを採用しているが、同一オリジンの場合はブラウザネイティブのチャネルAPI系にフォールバックする。レイテンシは実測で1ms未満。VJ用途では十分すぎる。
リモコン操作はスマホブラウザからも可能で、DJがフロアに出てビール飲みながら映像を微調整できる。VJがブースに縛られる時代は終わりにしたかった。
パフォーマンス最適化
レンダリングパイプライン
60fpsを維持するためにやったこと:
- Retinaスケーリング無効化: Canvas描画のpixelDensityを1に固定。GPU負荷が1/4になる。VJ映像は多少荒くても誰も気にしない
- オブジェクトプールの徹底: パーティクル等の生成・破棄をゼロアロケーションで実装。GCスパイクを排除
- Typed Array直接操作: Float32Arrayでスペクトラムデータを管理。配列コピーを一切しない
- Web Workerでの音声解析: メインスレッドのフレームバジェットを確保
最適化は地味だが効果は劇的で、M1 MacBook Airのようなファンレス機でも安定して60fpsが出る。
メモリ管理
映像素材のプリロードとGCのタイミング制御が一番厄介だった。ブラウザのメディアキャッシュの挙動はブラックボックスで、明示的にコントロールできない部分が多い。結局、映像のプリロードキューを自前で管理し、メモリ使用量を監視しながら古い映像を明示的に解放する仕組みを入れた。
本番投入の結果
小箱のイベントで投入した。
- 「これどうなってんの?」「公式映像かと思った」
- 「歌詞出てる!同期してる!」
- 客が写真を撮りまくり → SNSに投稿された
- オーナーから「今度ウチでも使える?」→ ファーストカスタマー候補獲得
- M1 MacBook Pro内蔵マイクで動作OK。トラブルほぼなし
学んだこと
Web Audio APIは想像以上に使える
FFTのサイズ、smoothingTimeConstant、minDecibels/maxDecibelsの調整だけで、クラブの大音量環境でもまともなスペクトラム解析ができる。ただしブラウザ間の差異は地獄。特にSafariのAudioContextの挙動はChromeと微妙に異なり、ユーザージェスチャー起点の初期化が必須。
CSSの合成機能は過小評価されている
mix-blend-mode、filter、backdrop-filter を組み合わせるだけで、それなりのVJミキサーが再現できる。GPUアクセラレーションが効くので、Canvas 2Dで自前描画するよりパフォーマンスが良い場面も多い。
技術よりドメイン知識が価値
音声解析もレイヤー合成も、技術的にはそこまで高度なことはしていない。Web標準APIの組み合わせだ。
差別化は**「クラブで何が映えるか」を知っていること**にある。スコアリングの重み、映像のキュレーション、エフェクトのパラメータ——全て現場経験に基づいている。同じコードベースをフォークしても、クラブに行ったことがない人には調整できない。
足りないのは技術ではなく、「現場で何が必要か」を知っていることだった。
今後の展望
- iOS ネイティブのコンパニオンアプリで楽曲認識コストの最適化
- Android TV / Fire TV 対応でハードウェアレベルのプラグアンドプレイ体験
- ユーザーのフィードバックループによる映像選択精度の継続的改善
- 自前映像ライブラリの拡充(AI動画生成の活用を検討中)
β版公開中です。フィードバック歓迎。