はじめに
前回はReactで音声解析・3D描画・MP4書き出しをつなぐ Audio Reactive 3D Visualizer のUI設計を書きました。
今回はそのバックエンド編です。
ただし、最初に書いておくと、このアプリには一般的な意味でのバックエンドサーバーはありません。
Node.jsのAPIサーバーも、DBも、アップロード用のストレージも使っていません。
では何をバックエンド編として書くのかというと、ブラウザ内で動いている「裏側の処理」です。
このアプリでは、以下の処理をすべてブラウザ内で行っています。
- 音源ファイルの読み込み
-
AudioBufferへのデコード - BPM検出
- RMS / 簡易LUFS計算
- 波形生成
- スペクトログラム生成
- トランジェント検出
- ステレオ幅計算
- 3D / 2D描画用の解析データ生成
- 1920x1080 / 30fps のMP4書き出し
- WebCodecsが使えない場合のffmpeg.wasmフォールバック
つまり、サーバーを立てずに、ブラウザそのものを処理基盤として使っています。
この記事では、Audio Reactive 3D Visualizer の裏側で動いている音声解析・映像生成・MP4書き出しの設計について書きます。
リポジトリはこちらです。
https://github.com/7g3n/phase-viz
なぜサーバーを使わないのか
このアプリは、音楽制作者が自分の音源を読み込んで、すぐにビジュアル化できるツールとして作りました。
そのため、最初からかなり強く意識していたことがあります。
制作中の音源を外部サーバーにアップロードさせたくない
音楽制作では、まだ公開していない曲、依頼曲、コンペ用の音源、ラフミックスなどを扱うことがあります。
それを毎回どこかのサーバーへアップロードする設計にすると、使う側として心理的なハードルが上がります。
なので、このアプリでは以下の方針にしました。
音源はローカルで読み込む
解析もブラウザ内で行う
映像生成もブラウザ内で行う
MP4書き出しもブラウザ内で行う
サーバーがないことで、以下のメリットがあります。
- 音源をアップロードしなくていい
- APIサーバーの運用が不要
- ストレージ費用がかからない
- 待ち時間が比較的少ない
- 静的ホスティングで配信できる
- ユーザーのファイルがアプリ側に残らない
もちろん、ブラウザ内処理なので重い処理には限界があります。
ただ、個人制作ツールとしては、この方針の方が自然だと思いました。
全体構成
裏側の処理に関係する主なディレクトリは以下です。
src/
audio/
analyze.ts
bpm.ts
fft.ts
export/
recorder.ts
webcodecs.ts
ffmpeg.ts
download.ts
visual/
scene.ts
particles.ts
presets.ts
ui/
VisualizerCanvas.tsx
WaveVisualizer.tsx
ImageFXVisualizer.tsx
役割をざっくり分けると、こんな感じです。
| ファイル | 役割 |
|---|---|
audio/analyze.ts |
音声解析の入口 |
audio/bpm.ts |
BPM検出 |
audio/fft.ts |
FFT、スペクトログラム、波形生成 |
export/webcodecs.ts |
WebCodecsによるMP4書き出し |
export/ffmpeg.ts |
ffmpeg.wasmによるフォールバック書き出し |
export/recorder.ts |
Canvasフレーム記録用 |
ui/VisualizerCanvas.tsx |
3D描画と書き出しフレーム生成 |
ui/WaveVisualizer.tsx |
波形描画と書き出しフレーム生成 |
ui/ImageFXVisualizer.tsx |
画像エフェクト描画と書き出しフレーム生成 |
通常のWebアプリであれば、重い処理はサーバー側に逃がすことも多いと思います。
でもこのアプリでは、ブラウザ側に以下のような処理レイヤーを作っています。
User File
↓
Audio Decode
↓
Audio Analysis
↓
Visualizer Rendering
↓
Frame Export
↓
MP4 Encode
↓
Download
これが、このアプリにおける「バックエンド的な部分」です。
音源読み込みの流れ
音源読み込みは、ユーザーが選択した File から始まります。
流れは以下です。
File
↓
ArrayBuffer
↓
AudioContext.decodeAudioData()
↓
AudioBuffer
↓
analyzeAudio()
↓
AudioAnalysis
実装イメージはこんな感じです。
const arrayBuffer = await file.arrayBuffer();
const ctx = new AudioContext();
const buffer = await ctx.decodeAudioData(arrayBuffer);
setAudioBuffer(buffer);
setDuration(buffer.duration);
const analysis = await analyzeAudio(buffer);
setAnalysis(analysis);
ここで得られる AudioBuffer は、後続の処理で何度も使います。
- リアルタイム再生
- 波形生成
- スペクトログラム生成
- MP4書き出し時の音声エンコード
- 解析済みデータからのフレーム再現
つまり、このアプリの裏側では AudioBuffer が中心になります。
AudioAnalysisという中間データ
解析結果は AudioAnalysis としてまとめています。
export interface AudioAnalysis {
bpm: number;
loudness: number;
waveform: Float32Array;
spectrum: Float32Array[];
transientMap: number[];
stereoWidth: number;
mood: MoodId;
energy: EnergyLevel;
duration: number;
}
これは、音声そのものを直接Visualizerに渡すのではなく、映像側で扱いやすい形に変換した中間データです。
AudioBuffer
↓
AudioAnalysis
↓
3D / Wave / Image FX
音源の生データはかなり大きく、そのまま描画側に渡すと扱いにくいです。
そこで、ビジュアルに必要な情報だけを取り出します。
このアプリでは主に以下を使います。
- BPM
- 音量感
- 周波数分布
- 波形
- トランジェント
- ステレオ感
- 曲のエネルギー
- 曲のムード
厳密な音楽解析というより、ビジュアル表現に使いやすい解析データを作る方針です。
解析の入口 analyzeAudio()
音声解析の入口は analyzeAudio() です。
ざっくり書くと、以下のような処理をしています。
export async function analyzeAudio(buffer: AudioBuffer): Promise<AudioAnalysis> {
const left = buffer.getChannelData(0);
const right = buffer.numberOfChannels > 1
? buffer.getChannelData(1)
: left;
const sampleRate = buffer.sampleRate;
const bpm = detectBPM(left, sampleRate);
let rmsSum = 0;
for (let i = 0; i < left.length; i++) {
rmsSum += left[i] ** 2;
}
const rms = Math.sqrt(rmsSum / left.length);
const lufs = 20 * Math.log10(Math.max(rms, 1e-10));
const waveform = computeWaveform(left, 512);
const spectrum = computeSpectrogram(left, sampleRate);
const transientMap = detectTransients(left, sampleRate);
const stereoWidth = computeStereoWidth(left, right);
const energy = classifyEnergy(rms);
const mood = classifyMood(bpm, rms, stereoWidth);
return {
bpm,
loudness: lufs,
waveform,
spectrum,
transientMap,
stereoWidth,
mood,
energy,
duration: buffer.duration,
};
}
ここでやっていることは大きく分けて以下です。
1. 左右チャンネルを取得
2. BPMを検出
3. RMSから簡易的なラウドネスを計算
4. 波形を生成
5. スペクトログラムを生成
6. トランジェントを検出
7. ステレオ幅を計算
8. energy / mood を分類
この関数が、音源をビジュアル用データに変換する入口です。
RMSと簡易LUFS
音量感はRMSを使って計算しています。
let rmsSum = 0;
for (let i = 0; i < left.length; i++) {
rmsSum += left[i] ** 2;
}
const rms = Math.sqrt(rmsSum / left.length);
const lufs = 20 * Math.log10(Math.max(rms, 1e-10));
ここで出している lufs は、厳密なEBU R128準拠のLUFSではありません。
RMSをもとにした簡易的なラウドネス値です。
このアプリでは、音楽解析ツールとしての正確な測定よりも、ビジュアルの反応に使える指標を優先しています。
つまり、
正確なマスタリング計測値
ではなく、
この曲はどれくらい強く鳴っているか
を見るための値です。
BPM検出
BPM検出は bpm.ts に分けています。
方針はシンプルで、エネルギーのピークからオンセットを検出し、その間隔からBPMを推定します。
流れは以下です。
音声を10ms単位に分割
↓
各windowのエネルギーを計算
↓
局所平均より大きいピークをonsetとして検出
↓
onset間隔を計算
↓
中央値からBPMを推定
↓
60〜200程度に補正
実装イメージです。
const windowSize = Math.floor(sampleRate * 0.01); // 10ms windows
const energies: number[] = [];
for (let i = 0; i < channelData.length - windowSize; i += windowSize) {
let energy = 0;
for (let j = 0; j < windowSize; j++) {
energy += channelData[i + j] ** 2;
}
energies.push(energy / windowSize);
}
まず10msごとにエネルギーを計算します。
次に、局所平均より強い部分をonsetとして扱います。
const onsets: number[] = [];
const historyLen = 43;
for (let i = historyLen; i < energies.length; i++) {
const avg = rollingEnergy / historyLen;
if (energies[i] > avg * 1.5 && energies[i] > 0.001) {
onsets.push(i);
}
rollingEnergy += energies[i] - energies[i - historyLen];
}
その後、onset間の時間差を計算します。
const intervals: number[] = [];
for (let i = 1; i < onsets.length; i++) {
const diff = (onsets[i] - onsets[i - 1]) * windowSize / sampleRate;
if (diff > 0.2 && diff < 2.0) {
intervals.push(diff);
}
}
最後に中央値からBPMを出します。
intervals.sort((a, b) => a - b);
const median = intervals[Math.floor(intervals.length / 2)];
const bpm = Math.round(60 / median);
このBPM検出は完璧ではありません。
複雑なブレイクビーツ、アンビエント、倍テン・半テンの曲ではズレることがあります。
ただ、このアプリではBPMを厳密に当てることよりも、ビジュアルの時間感を作るための値として使っています。
そのため、取れなかった場合は 120 を返すようにしています。
if (onsets.length < 2) return 120;
if (intervals.length === 0) return 120;
FFTとスペクトログラム
周波数解析は fft.ts にあります。
FFTサイズは 2048 です。
export const FFT_SIZE = 2048;
export const HOP_SIZE = 512;
スペクトログラム生成では、音声を一定間隔で切り出してFFTします。
export function computeSpectrogram(
channelData: Float32Array,
sampleRate: number,
): Float32Array[] {
const frames: Float32Array[] = [];
const windowFn = hanningWindow(FFT_SIZE);
const stepSamples = Math.floor(sampleRate / 30); // 30fps aligned
// ...
}
ここで重要なのは、stepSamples を sampleRate / 30 にしているところです。
const stepSamples = Math.floor(sampleRate / 30);
これは映像の30fpsに合わせるためです。
つまり、音声解析のフレームと映像のフレームを対応させやすくしています。
音声解析フレーム
≒
映像フレーム
MP4書き出し時にも、30fpsで各時刻の解析データを参照するので、この考え方が効いてきます。
Hanning window
FFTの前にはHanning windowをかけています。
function hanningWindow(size: number): Float32Array {
const w = new Float32Array(size);
for (let i = 0; i < size; i++) {
w[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (size - 1)));
}
return w;
}
音声をそのままぶつ切りにしてFFTすると、フレームの端で不自然な成分が出やすくなります。
そのため、窓関数で端をなだらかにしてからFFTします。
このあたりは、音声解析というより信号処理の基本に近い部分です。
FFT本体
FFTは自前実装しています。
まずbit reversal用のテーブルを作ります。
function createBitReversalTable(size: number): Uint16Array {
const table = new Uint16Array(size);
let j = 0;
for (let i = 0; i < size; i++) {
table[i] = j;
let bit = size >> 1;
for (; j & bit; bit >>= 1) j ^= bit;
j ^= bit;
}
return table;
}
その後、in-placeでFFTを行います。
function fftInPlace(
real: Float32Array,
imag: Float32Array,
bitReversal: Uint16Array,
) {
const n = real.length;
// bit reversal
for (let i = 0; i < n; i++) {
const j = bitReversal[i];
if (i < j) {
const realTmp = real[i];
real[i] = real[j];
real[j] = realTmp;
const imagTmp = imag[i];
imag[i] = imag[j];
imag[j] = imagTmp;
}
}
// FFT butterfly
for (let len = 2; len <= n; len <<= 1) {
const ang = (2 * Math.PI) / len;
const wReal = Math.cos(ang);
const wImag = -Math.sin(ang);
for (let i = 0; i < n; i += len) {
let curReal = 1;
let curImag = 0;
for (let k = 0; k < len / 2; k++) {
const uR = real[i + k];
const uI = imag[i + k];
const vR =
real[i + k + len / 2] * curReal -
imag[i + k + len / 2] * curImag;
const vI =
real[i + k + len / 2] * curImag +
imag[i + k + len / 2] * curReal;
real[i + k] = uR + vR;
imag[i + k] = uI + vI;
real[i + k + len / 2] = uR - vR;
imag[i + k + len / 2] = uI - vI;
const newCurReal = curReal * wReal - curImag * wImag;
curImag = curReal * wImag + curImag * wReal;
curReal = newCurReal;
}
}
}
}
そして、複素数の実部・虚部から振幅を計算します。
for (let i = 0; i < FFT_SIZE / 2; i++) {
const r = real[i];
const im = imag[i];
frame[i] = Math.sqrt(r * r + im * im) / FFT_SIZE;
}
これで、各フレームごとの周波数成分が得られます。
波形生成
波形は、音声全体を一定数のブロックに分け、それぞれのピークを取っています。
export function computeWaveform(
channelData: Float32Array,
points = 512,
): Float32Array {
const waveform = new Float32Array(points);
const blockSize = Math.floor(channelData.length / points);
for (let i = 0; i < points; i++) {
let peak = 0;
for (let j = 0; j < blockSize; j++) {
const v = Math.abs(channelData[i * blockSize + j]);
if (v > peak) peak = v;
}
waveform[i] = peak;
}
return waveform;
}
この波形は、UI上のミニ波形やWave Visualizerに使えます。
ここでは細かいサンプル単位の波形というより、全体の形を見るための波形です。
トランジェント検出
トランジェントは、音の立ち上がりです。
キック、スネア、強いアタックなどに反応させるために使います。
このアプリでは、30fps単位でエネルギーを計算し、前フレームとの差分を見ています。
function detectTransients(
channelData: Float32Array,
sampleRate: number,
): number[] {
const frameSize = Math.floor(sampleRate / 30);
const numFrames = Math.floor(channelData.length / frameSize);
const energies = new Float32Array(numFrames);
for (let i = 0; i < numFrames; i++) {
let e = 0;
for (let j = 0; j < frameSize; j++) {
e += channelData[i * frameSize + j] ** 2;
}
energies[i] = e / frameSize;
}
const transients: number[] = [];
let maxTransient = 0.001;
for (let i = 1; i < numFrames; i++) {
const delta = energies[i] - energies[i - 1];
const transient = Math.max(0, delta * 20);
if (transient > maxTransient) {
maxTransient = transient;
}
transients.push(transient);
}
transients.unshift(0);
return transients.map((v) => v / maxTransient);
}
ここでも30fpsを意識しています。
映像側で使う値なので、映像フレーム単位で扱いやすいようにしています。
ステレオ幅
ステレオ幅は、左右チャンネルの差分を簡易的に見ています。
function computeStereoWidth(
left: Float32Array,
right: Float32Array,
): number {
const len = Math.min(left.length, right.length, 44100);
let sum = 0;
for (let i = 0; i < len; i++) {
sum += Math.abs(left[i] - right[i]);
}
return Math.min(1, sum / len / 0.5);
}
最初の1秒程度を見て、左右差が大きければステレオ感が強いと判断します。
これも厳密な相関計算というより、ビジュアル用の特徴量です。
たとえば、ステレオ幅が広い曲では明るい・広い印象のプリセットやムード判定に使いやすくなります。
energyとmoodの分類
解析した値から、ざっくりした曲のエネルギーとムードも分類しています。
function classifyEnergy(rms: number): EnergyLevel {
if (rms < 0.05) return 'low';
if (rms < 0.2) return 'medium';
return 'high';
}
ムードは、BPM、RMS、ステレオ幅から決めています。
function classifyMood(
bpm: number,
rms: number,
stereoWidth: number,
): MoodId {
if (bpm > 140 && rms > 0.15) return 'aggressive';
if (bpm < 90 && rms < 0.08) return 'calm';
if (stereoWidth > 0.7 && bpm > 100) return 'bright';
if (bpm < 100 && rms < 0.12) return 'emotional';
return 'dark';
}
これはAI的な高度な分類ではありません。
ただ、音源を読み込んだ時に「この曲は高エネルギー」「これはcalm寄り」みたいなラベルがあると、UI上でも扱いやすくなります。
また、将来的にはプリセット推薦にも使えます。
リアルタイム解析と事前解析を分ける
このアプリでは、音源読み込み時の事前解析と、再生中のリアルタイム解析を分けています。
事前解析
└─ analyzeAudio()
├─ BPM
├─ waveform
├─ spectrum
├─ transientMap
└─ mood / energy
リアルタイム解析
└─ AnalyserNode
├─ frequency data
├─ time domain data
├─ bass
├─ mid
└─ high
事前解析は、MP4書き出しや停止中プレビューで使います。
リアルタイム解析は、再生中にその瞬間の音へ反応させるために使います。
この2つを分けた理由は、用途が違うからです。
事前解析
事前解析は、時間に対して再現性のあるデータを作るために使います。
10秒地点の解析結果
20秒地点の解析結果
30秒地点の解析結果
のように、任意の時刻で参照できます。
これはMP4書き出しで重要です。
リアルタイム解析
リアルタイム解析は、実際に音を再生しながら、今鳴っている音に反応するために使います。
AnalyserNode から周波数データを取り、低域・中域・高域に分けます。
analyser.getByteFrequencyData(freqData);
const binCount = freqData.length;
const bassEnd = Math.max(1, Math.floor(binCount * 0.05));
const midEnd = Math.max(bassEnd + 1, Math.floor(binCount * 0.35));
そして、それぞれを0〜1へ正規化します。
bass = bass / bassEnd / 255;
mid = mid / (midEnd - bassEnd) / 255;
high = high / (binCount - midEnd) / 255;
この値を3DシーンやCanvas描画に渡します。
MP4書き出しの全体像
MP4書き出しは、このアプリでかなり重い処理です。
大きな流れは以下です。
Export button
↓
App.tsx
↓
現在表示中のVisualizerのexportRendererを呼ぶ
↓
各Visualizerが指定時刻のフレームを描画
↓
WebCodecsでエンコード
↓
失敗したらffmpeg.wasmへフォールバック
↓
Blobを生成
↓
Object URLを作成
↓
Download
React側では、現在表示しているVisualizerの書き出し関数を exportRendererRef で受け取っています。
const exportRendererRef = useRef<ExportFrameRenderer | null>(null);
書き出し時は、その関数を呼ぶだけです。
const blob = await exportRendererRef.current({
duration: analysis.duration,
fps,
signal: abortController.signal,
onProgress: reportProgress,
onStatus: setExportStatus,
});
この設計によって、App側は現在のモードが3Dなのか、Waveなのか、Image FXなのかを意識しなくて済みます。
App
↓
ExportFrameRenderer
↓
3D / Wave / Image FX
各Visualizerが、自分の描画方式でフレームを生成します。
ExportFrameRendererという共通インターフェース
書き出し処理の共通インターフェースは以下です。
export interface ExportRenderOptions {
duration: number;
fps: number;
onProgress: (progress: number) => void;
onStatus?: (status: string) => void;
signal?: AbortSignal;
}
export type ExportFrameRenderer = (
options: ExportRenderOptions
) => Promise<Blob>;
重要なのは、返り値が Promise<Blob> であることです。
つまり、どのモードであっても最終的にはMP4のBlobを返すようにしています。
3D Visualizer export
┐
Wave Visualizer export
├─ Promise<Blob>
Image FX export
┘
こうしておくと、UI側は共通の処理でダウンロードまで持っていけます。
AbortControllerで中断できるようにする
MP4書き出しは長くなることがあります。
そのため、AbortController を使ってキャンセルできるようにしています。
const abortController = new AbortController();
exportAbortRef.current = abortController;
書き出し関数には signal を渡します。
const blob = await exportRendererRef.current({
duration: analysis.duration,
fps,
signal: abortController.signal,
onProgress: reportProgress,
onStatus: setExportStatus,
});
キャンセル時は以下です。
const handleCancelExport = () => {
exportAbortRef.current?.abort();
};
各エクスポート処理の中では、定期的に throwIfAborted() を呼びます。
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) {
throw new DOMException('Export was canceled', 'AbortError');
}
}
重いループ処理では、こういう中断ポイントを入れておくことがかなり大事です。
WebCodecsでの高速書き出し
まず優先するのはWebCodecsです。
WebCodecsが使えるかどうかは、ブラウザAPIの存在で判定しています。
export function canUseWebCodecsMP4() {
return typeof VideoEncoder !== 'undefined'
&& typeof VideoFrame !== 'undefined'
&& typeof AudioEncoder !== 'undefined'
&& typeof AudioData !== 'undefined';
}
使える場合は、Canvasから VideoFrame を作って VideoEncoder へ渡します。
const videoFrame = new VideoFrame(canvas, {
visibleRect: { x: 0, y: 0, width, height },
displayWidth: width,
displayHeight: height,
timestamp: Math.round(time * 1_000_000),
duration: Math.round(1_000_000 / fps),
});
videoEncoder.encode(videoFrame, {
keyFrame: frame === 0 || frame % (fps * 10) === 0,
});
videoFrame.close();
ここでは timestamp をマイクロ秒単位で指定しています。
time は frame / fps で計算します。
const time = frame / fps;
renderFrame(time, frame);
つまり、
frame 0 → 0.000秒
frame 1 → 0.033秒
frame 2 → 0.066秒
...
のように、各フレームの時刻を明示して描画します。
これにより、リアルタイム再生のタイミングに依存せず、決まった時刻の映像を生成できます。
mp4-muxerで映像と音声をまとめる
WebCodecsは映像や音声のエンコードはできますが、それだけでMP4ファイルになるわけではありません。
そこで mp4-muxer を使って、映像チャンクと音声チャンクをMP4へまとめています。
const target = new ArrayBufferTarget();
const muxer = new Muxer({
target,
video: {
codec: 'avc',
width,
height,
frameRate: fps,
},
audio: {
codec: 'aac',
numberOfChannels: audioBuffer.numberOfChannels,
sampleRate: audioBuffer.sampleRate,
},
fastStart: {
expectedVideoChunks: totalFrames,
expectedAudioChunks: Math.ceil(audioBuffer.length / AUDIO_CHUNK_SIZE),
},
});
映像エンコードの出力は addVideoChunk() へ渡します。
const videoEncoder = new VideoEncoder({
output: (chunk, meta) => {
muxer.addVideoChunk(chunk, meta);
},
error: setEncoderError,
});
音声エンコードの出力は addAudioChunk() へ渡します。
const audioEncoder = new AudioEncoder({
output: (chunk, meta) => {
muxer.addAudioChunk(chunk, meta);
},
error: setEncoderError,
});
最後に finalize() してBlobを作ります。
muxer.finalize();
return new Blob([target.buffer], {
type: 'video/mp4',
});
音声のエンコード
音声は AudioBuffer からチャンネルデータを取り出し、一定サイズごとに AudioData に変換します。
const channels = buffer.numberOfChannels;
const sampleRate = buffer.sampleRate;
const channelData = Array.from(
{ length: channels },
(_, ch) => buffer.getChannelData(ch),
);
チャンクごとに Float32Array を作ります。
for (let start = 0; start < buffer.length; start += AUDIO_CHUNK_SIZE) {
const frames = Math.min(AUDIO_CHUNK_SIZE, buffer.length - start);
const data = new Float32Array(frames * channels);
for (let ch = 0; ch < channels; ch++) {
data.set(channelData[ch].subarray(start, start + frames), ch * frames);
}
const audioData = new AudioData({
format: 'f32-planar',
sampleRate,
numberOfFrames: frames,
numberOfChannels: channels,
timestamp: Math.round(start / sampleRate * 1_000_000),
data,
});
encoder.encode(audioData);
audioData.close();
}
ここでも timestamp を指定します。
映像と音声を同期させるために、時間情報が重要です。
エンコードキューの制御
WebCodecsで大量のフレームを投げると、エンコードキューが詰まることがあります。
そのため、キューサイズを見て待つ処理を入れています。
if (videoEncoder.encodeQueueSize > MAX_VIDEO_QUEUE_SIZE) {
await waitForVideoQueue(
videoEncoder,
Math.floor(MAX_VIDEO_QUEUE_SIZE * 0.65),
throwEncoderError,
signal,
);
}
waitForVideoQueue() では、キューサイズが下がるまで待ちます。
while (encoder.encodeQueueSize > targetSize) {
throwIfAborted(signal);
throwEncoderError();
if (encoder.encodeQueueSize < lastQueueSize) {
lastQueueSize = encoder.encodeQueueSize;
lastQueueChangeAt = performance.now();
} else if (performance.now() - lastQueueChangeAt > 15_000) {
throw new Error(
`Video encoder stalled with ${encoder.encodeQueueSize} frames queued`,
);
}
await yieldToBrowser();
}
ここで yieldToBrowser() を挟むことで、ブラウザに処理を返します。
function yieldToBrowser(): Promise<void> {
const scheduler = (globalThis as {
scheduler?: { yield?: () => Promise<void> };
}).scheduler;
return scheduler?.yield?.() ?? new Promise((resolve) => setTimeout(resolve, 0));
}
こうしないと、長い書き出し中にUIが固まりやすくなります。
ffmpeg.wasmフォールバック
WebCodecsが使えない場合や、途中で失敗した場合はffmpeg.wasmへフォールバックします。
基本の流れは以下です。
各フレームをCanvasに描画
↓
JPEGとして保存
↓
ffmpeg.wasmの仮想FSに書き込む
↓
AudioBufferをWAVに変換
↓
ffmpeg.wasmに書き込む
↓
画像列 + WAV をMP4に変換
↓
MP4をBlobとして取り出す
WebCodecsより重いですが、対応ブラウザを広げるために入れています。
ffmpeg coreの読み込み
ffmpeg.wasmは重いので、最初からメインバンドルに含めないようにしています。
実行時に動的importします。
const { FFmpeg } = await import('@ffmpeg/ffmpeg');
const ffmpeg = new FFmpeg();
さらに、coreファイルはまずローカルの /vendor を見に行きます。
const LOCAL_CORE = '/vendor/ffmpeg-core.js';
const LOCAL_WASM = '/vendor/ffmpeg-core.wasm';
ローカルに使えるファイルがあればそれを使います。
if (await hasUsableLocalCore(signal)) {
return createLocalCoreURLs();
}
使えない場合はCDNへフォールバックします。
const CDN_BASE =
`https://cdn.jsdelivr.net/npm/@ffmpeg/core@${FFMPEG_CORE_VERSION}/dist/esm`;
このあたりは、デプロイ環境でwasmファイルがうまく配信できないケースを考慮しています。
CanvasフレームをJPEGとして保存する
ffmpeg.wasmフォールバックでは、各フレームを画像として保存します。
for (let frame = 0; frame < totalFrames; frame++) {
throwIfAborted(signal);
const time = frame / fps;
renderFrame(time, frame);
const bytes = await captureCanvasJpeg(
canvas,
time * 1000,
captureCanvas,
);
const frameFile = frameFiles[frame];
pendingWrites.push(
ffmpegPromise.then(async (ffmpeg) => {
await ffmpeg.writeFile(frameFile, bytes, { signal });
}),
);
}
captureCanvasJpeg() では、Canvasを別のCanvasに描画してJPEG化します。
ctx.drawImage(sourceCanvas, 0, 0, width, height);
captureCanvas.toBlob(
finish,
'image/jpeg',
FALLBACK_FRAME_JPEG_QUALITY,
);
toBlob() が失敗した場合のために、toDataURL() へのフォールバックも入れています。
resolve(
dataURLToBytes(
captureCanvas.toDataURL('image/jpeg', FALLBACK_FRAME_JPEG_QUALITY),
),
);
AudioBufferをWAVに変換する
ffmpeg.wasmへ音声を渡すために、AudioBuffer をWAVに変換しています。
function audioBufferToWav(buffer: AudioBuffer): ArrayBuffer {
const numChannels = buffer.numberOfChannels;
const sampleRate = buffer.sampleRate;
const length = buffer.length;
const arrayBuffer = new ArrayBuffer(44 + length * numChannels * 2);
const view = new DataView(arrayBuffer);
// RIFF / WAVE headerを書き込む
let offset = 44;
for (let i = 0; i < length; i++) {
for (let ch = 0; ch < numChannels; ch++) {
const sample = Math.max(-1, Math.min(1, buffer.getChannelData(ch)[i]));
view.setInt16(offset, sample * 0x7fff, true);
offset += 2;
}
}
return arrayBuffer;
}
WAVは構造が比較的シンプルなので、ブラウザ内で生成しやすいです。
これをffmpeg.wasmに渡して、画像列と結合します。
ffmpegでMP4化する
最終的には、画像列とWAVを入力にしてMP4を作ります。
return [
'-hide_banner',
'-loglevel', 'warning',
'-framerate', String(fps),
'-start_number', '0',
'-i', `${exportId}-%06d.jpg`,
'-i', audioFile,
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p',
'-frames:v', String(totalFrames),
...videoCodecArgs,
'-c:a', 'aac',
'-b:a', '192k',
'-shortest',
'-movflags', '+faststart',
'-y',
outputFile,
];
ここで重要なのは、yuv420p にしているところです。
format=yuv420p
MP4の再生互換性を考えると、ここはかなり大事です。
また、+faststart も付けています。
-movflags +faststart
Web上で扱いやすいMP4にするためです。
エンコードはまずH.264を試します。
{
label: 'h264',
args: createEncodeArgs(options, [
'-c:v', 'libx264',
'-preset', 'ultrafast',
'-crf', '23',
]),
}
それが失敗した場合は、mpeg4へフォールバックします。
{
label: 'mpeg4',
args: createEncodeArgs(options, [
'-c:v', 'mpeg4',
'-q:v', '3',
]),
}
ブラウザ上のffmpeg.wasmは環境差が出やすいので、複数パターンを試すようにしています。
一時ファイルの削除
ffmpeg.wasmでは仮想ファイルシステムにフレーム画像や音声ファイルを書き込みます。
書き出し後は、それらを削除します。
await Promise.allSettled([
...frameFiles.map((file) => ffmpeg.deleteFile(file)),
ffmpeg.deleteFile(audioFile),
ffmpeg.deleteFile(outputFile),
]);
長い曲だとフレーム数がかなり増えます。
たとえば3分の曲を30fpsで書き出すと、
180秒 × 30fps = 5400フレーム
になります。
これを放置するとメモリを使い続けるので、後片付けは必須です。
MP4の検証
書き出しが終わっても、空のBlobや壊れたデータが返る可能性があります。
そのため、最低限の検証を入れています。
function assertValidMP4(rawData: Uint8Array | string) {
const uint8 = toUint8Array(rawData);
if (
uint8.byteLength < 12 ||
uint8[4] !== 0x66 ||
uint8[5] !== 0x74 ||
uint8[6] !== 0x79 ||
uint8[7] !== 0x70
) {
throw new Error(
`FFmpeg finished without a valid MP4 payload (${uint8.byteLength} bytes)`,
);
}
}
MP4には ftyp boxがあるので、そこを簡易チェックしています。
完璧な検証ではありませんが、明らかに壊れたデータをそのままダウンロードさせるよりは安全です。
ダウンロード処理
最終的にMP4は Blob として返ってきます。
const blob = await exportRendererRef.current({
duration: analysis.duration,
fps,
signal: abortController.signal,
onProgress: reportProgress,
onStatus: setExportStatus,
});
Blobが正常なら、Object URLを作ってダウンロードします。
const url = URL.createObjectURL(blob);
lastExportUrlRef.current = url;
setExportDownload(url, DEFAULT_EXPORT_FILE_NAME);
triggerBlobDownload(url, DEFAULT_EXPORT_FILE_NAME);
使い終わったObject URLは URL.revokeObjectURL() で解放します。
if (lastExportUrlRef.current) {
URL.revokeObjectURL(lastExportUrlRef.current);
}
ブラウザ内で大きな動画Blobを扱うので、この解放処理も大事です。
ブラウザ内バックエンドの難しさ
サーバーを使わない構成はシンプルに見えます。
でも、実際には別の難しさがあります。
1. メモリ管理が難しい
音源、解析結果、Canvas、画像フレーム、MP4 Blobをすべてブラウザ内で扱います。
特にffmpeg.wasmフォールバックでは、フレーム画像を大量に生成するため、長い曲ほどメモリを使います。
そのため、
- フレーム書き込みを溜めすぎない
- 一時ファイルを消す
- Object URLを解放する
- エクスポート中はCanvasサイズを固定する
- 長時間処理では中断できるようにする
といった工夫が必要でした。
2. ブラウザ差が大きい
WebCodecsは便利ですが、すべてのブラウザで同じように使えるわけではありません。
特に以下は環境差が出ます。
VideoEncoderAudioEncoder- H.264の対応
- AACの対応
- ハードウェアエンコード
- WebAssemblyの実行性能
- Canvasの扱い
- ファイルデコード
そのため、このアプリでは、
WebCodecsを試す
↓
失敗したらffmpeg.wasmへフォールバック
という二段構えにしています。
3. UIを固めない工夫が必要
MP4書き出しは重い処理です。
単純にfor文で全フレームを処理すると、UIが固まりやすくなります。
そのため、処理の途中でブラウザへ制御を返しています。
await yieldToBrowser();
また、進捗も毎フレーム更新すると逆に重くなるので、一定間隔で更新しています。
if (
progress <= 0 ||
progress >= 1 ||
progress - lastProgress >= 0.01 ||
now - lastProgressAt >= 120
) {
setExportProgress(progress);
}
こういう細かい部分が、ブラウザ内で重い処理をする時にはかなり効きます。
4. リアルタイム表示と書き出しで処理を分ける必要がある
リアルタイム表示では、今鳴っている音に反応すればよいです。
一方、MP4書き出しでは、各フレームの時刻に対応した映像を正確に生成する必要があります。
リアルタイム表示
└─ 現在のAnalyserNodeの値を見る
MP4書き出し
└─ frame / fps の時刻で描画する
この違いを意識しないと、再生中は良く見えるのに、書き出すとズレるということが起きます。
なので、書き出しでは renderFrame(time, frame) のように、時刻を明示して描画する構成にしました。
改善したいところ
今後、バックエンド的な処理で改善したいところもあります。
- Web Worker化
- AudioWorkletの活用
- 解析処理の高速化
- BPM検出精度の改善
- 長時間音源のメモリ削減
- ffmpeg.wasmフォールバックの軽量化
- エクスポート前の推定メモリ表示
- 解析結果のキャッシュ
- プリセットと解析結果の結びつけ
- 書き出し品質設定の追加
- ブラウザ互換性マトリクスの整備
特に、今の構成では重い処理がメインスレッドに寄りがちです。
将来的には、音声解析やエンコード補助をWorkerに逃がして、UIの応答性を上げたいです。
まとめ
今回は、Audio Reactive 3D Visualizer のバックエンド的な処理について書きました。
このアプリには、一般的なAPIサーバーはありません。
それでも裏側では、
- 音声デコード
- BPM検出
- FFT
- 波形生成
- トランジェント検出
- ステレオ幅計算
- 解析データ生成
- Canvasフレーム生成
- WebCodecsエンコード
- ffmpeg.wasmフォールバック
- MP4 Blob生成
- ローカルダウンロード
といった処理が動いています。
自分の中では、このアプリのバックエンドはサーバーではなく、ブラウザ内にあります。
サーバーに投げるのではなく、
ユーザーのブラウザの中で完結させる。
音楽制作者向けのツールとして、これはかなり大事な設計でした。
音源を外に出さず、ブラウザだけで解析し、映像を書き出せる。
Web技術だけでここまでできるのは、かなり面白いと思います。
今後も、音楽制作とWeb開発の間にあるツールを作っていきたいです。