1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

サーバーなしで音声解析とMP4書き出しを完結させる Audio Reactive 3D Visualizer のバックエンド設計

1
Last updated at Posted at 2026-06-22

はじめに

前回は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

  // ...
}

ここで重要なのは、stepSamplessampleRate / 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 をマイクロ秒単位で指定しています。

timeframe / 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は便利ですが、すべてのブラウザで同じように使えるわけではありません。

特に以下は環境差が出ます。

  • VideoEncoder
  • AudioEncoder
  • 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開発の間にあるツールを作っていきたいです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?