[Astro] #135 Audio Converter v1 — FFmpeg WASMによるブラウザ完結型オーディオコンバーター

[Astro] #135 Audio Converter v1 — FFmpeg WASMによるブラウザ完結型オーディオコンバーター

はじめに

Video Compressorの音声版として、MP3/WAV/OGG/FLAC/AAC/Opusの6フォーマット相互変換をブラウザ完結で実行するAudio Converterを実装しました。

音声変換は「FFmpeg WASMに丸投げすれば終わる」はずでしたが、FFmpeg core 0.12.6にlibopusが含まれていない問題に遭遇。結果として、FFmpeg WASMとWeb Audio API MediaRecorderのハイブリッド構成という意図しなかったアーキテクチャが生まれました。

本記事では、FFmpeg WASMの音声パイプライン構築、フォーマット別エンコーダ設定、Opus問題の解決策、入出力波形比較UIの実装を記録します。

スクリーンショット

MP3 → WAV 変換

Audio Converter — WAV変換

MP3 → OGG 変換

Audio Converter — OGG変換

MP3 → AAC 変換

Audio Converter — aac変換

MP3 → FLAC 変換

Audio Converter — aac変換

MP3 → Opus 変換

Audio Converter — Opus変換

動画(GIF)

Audio Converter — 操作デモ

1. 全体アーキテクチャ

[ Astroページ (audio-converter.astro) ]

   ├── 左サイドバー
   │   ├─ ファイルドロップゾーン
   │   ├─ 入力ファイル情報パネル
   │   ├─ 出力設定(フォーマット/ビットレート/サンプルレート/チャンネル)
   │   ├─ アクションボタン(CONVERT / DOWNLOAD / CLEAR)
   │   └─ 出力情報パネル(サイズ/圧縮率/処理時間)

   └── 右メインエリア
       ├─ 入出力プレイヤー比較(INPUT ← → OUTPUT)
       ├─ 波形表示(Canvas)
       └─ バッチファイルリスト

エンコーダの選択は出力フォーマットに応じて自動分岐:

MP3  → FFmpeg WASM (libmp3lame)
WAV  → FFmpeg WASM (pcm_s16le)
OGG  → FFmpeg WASM (libvorbis)
FLAC → FFmpeg WASM (flac)
AAC  → FFmpeg WASM (aac)
Opus → Web Audio API + MediaRecorder (ブラウザネイティブ)

2. FFmpeg WASM — ロードとパイプライン

ロード手順

FFmpeg WASM v0.12 は UMD + ESM のハイブリッド配布です。ブラウザで使う場合、toBlobURL で WASM バイナリを Blob URL に変換してからロードする必要があります。

var FFmpeg = FFmpegWASM.FFmpeg;
var toBlobURL = FFmpegUtil.toBlobURL;
ffmpeg = new FFmpeg();

var coreBaseURL = 'https://cdn.jsdelivr.net/npm/@ffmpeg/[email protected]/dist/esm';
var ffmpegBaseURL = 'https://cdn.jsdelivr.net/npm/@ffmpeg/[email protected]/dist/umd';

await ffmpeg.load({
  coreURL:         await toBlobURL(coreBaseURL + '/ffmpeg-core.js', 'text/javascript'),
  wasmURL:         await toBlobURL(coreBaseURL + '/ffmpeg-core.wasm', 'application/wasm'),
  classWorkerURL:  await toBlobURL(ffmpegBaseURL + '/814.ffmpeg.js', 'text/javascript'),
});

重要なポイント:

  • coreURLESM版dist/esm)を指定する。UMD版を指定するとワーカー内でのimportに失敗する
  • classWorkerURL はFFmpegのワーカースクリプト。バージョンによってファイル名(814.ffmpeg.js)が変わる
  • toBlobURL でCORS制約を回避。CDNから取得したファイルをBlob URLに変換してローカルとして読み込む

変換パイプライン

// 入力ファイルをWASMファイルシステムに書き込み
var inputData = await FFmpegUtil.fetchFile(entry.file);
await ffmpeg.writeFile(inputName, inputData);

// 変換実行
await ffmpeg.exec([
  '-i', inputName,     // 入力
  '-vn',               // 映像トラックを除外(動画ファイル対応)
  '-c:a', 'libmp3lame', // エンコーダ指定
  '-b:a', '128k',      // ビットレート
  '-y', outputName      // 出力(上書き許可)
]);

// 出力ファイルを読み出し
var outputData = await ffmpeg.readFile(outputName);
var blob = new Blob([outputData.buffer], { type: 'audio/mpeg' });

// クリーンアップ
await ffmpeg.deleteFile(inputName);
await ffmpeg.deleteFile(outputName);

-vn フラグにより、MP4/MKV/WebM等の動画ファイルを入力しても音声トラックだけを抽出して変換できます。

フォーマット別エンコーダ設定

各フォーマットのFFmpeg引数をオブジェクトで管理しています。

var FORMAT_CONFIG = {
  mp3:  { ext: 'mp3',  mime: 'audio/mpeg',
    args: function(br,sr,ch) {
      var a = ['-c:a','libmp3lame','-b:a',br+'k'];
      if (sr !== '0') a.push('-ar', sr);
      if (ch !== '0') a.push('-ac', ch);
      return a;
    }},
  wav:  { ext: 'wav',  mime: 'audio/wav',
    args: function(br,sr,ch) {
      var a = ['-c:a','pcm_s16le'];  // ビットレート不要
      // ...
    }},
  ogg:  { ext: 'ogg',  args: /* libvorbis */ },
  flac: { ext: 'flac', args: /* flac */ },
  aac:  { ext: 'm4a',  args: /* aac */ },
};

WAVとFLACはロスレス形式のためビットレート設定が不要です。UIでも hasBitrate: false のフォーマットを選択するとビットレートセレクトが自動で非表示になります。

進捗表示

ffmpeg.on('progress', function(ev) {
  if (ev.progress >= 0 && ev.progress <= 1) {
    showProgress('Converting...', ev.progress);
  }
});

FFmpegのprogressイベントで変換の進捗をリアルタイム取得し、プログレスバーに反映します。


3. Opus問題 — FFmpeg core にlibopusが無い

問題

FFmpeg WASM core 0.12.6 で Opus エンコードを試みると RuntimeError: memory access out of bounds が発生しました。

原因は、FFmpeg core 0.12.6 のWASMビルドに libopus が含まれていないためです。FFmpegは libopus をオプショナル依存として扱っており、ビルド時に明示的に有効化しないと含まれません。CDN配布されているプリビルド版には含まれていませんでした。

解決策:MediaRecorderによるネイティブOpusエンコード

FFmpegを使わず、ブラウザの Web Audio API + MediaRecorder を使ってOpusエンコードを行います。

async function convertOpusNative(file, bitrate) {
  // 1. AudioContextで入力ファイルをデコード
  var arrayBuffer = await file.arrayBuffer();
  var audioCtx = new AudioContext();
  var decoded = await audioCtx.decodeAudioData(arrayBuffer);

  // 2. OfflineAudioContextでレンダリング
  var offlineCtx = new OfflineAudioContext(
    decoded.numberOfChannels,
    decoded.length,
    decoded.sampleRate
  );
  var source = offlineCtx.createBufferSource();
  source.buffer = decoded;
  source.connect(offlineCtx.destination);
  source.start();
  var rendered = await offlineCtx.startRendering();

  // 3. MediaRecorderでOpus/WebMにエンコード
  var mediaStreamDest = new AudioContext().createMediaStreamDestination();
  var bufferSource = mediaStreamDest.context.createBufferSource();
  bufferSource.buffer = rendered;
  bufferSource.connect(mediaStreamDest);

  var recorder = new MediaRecorder(mediaStreamDest.stream, {
    mimeType: 'audio/webm;codecs=opus',
    audioBitsPerSecond: bitrate * 1000
  });

  var chunks = [];
  recorder.ondataavailable = function(e) { chunks.push(e.data); };

  return new Promise(function(resolve) {
    recorder.onstop = function() {
      resolve(new Blob(chunks, { type: 'audio/webm' }));
    };
    recorder.start();
    bufferSource.start();
    bufferSource.onended = function() { recorder.stop(); };
  });
}

MediaRecorderは最新ブラウザで audio/webm;codecs=opus をネイティブサポートしています。WASMのlibopusビルドに頼ることなく、ブラウザ内蔵のOpusエンコーダを利用できます。

変換関数でのルーティング

async function convert(idx) {
  var fmt = outFormat.value;

  if (fmt === 'opus') {
    // MediaRecorder ネイティブ Opus エンコード
    var blob = await convertOpusNative(entry.file, bitrate);
    entry.outputBlob = blob;
  } else {
    // FFmpeg WASM パイプライン
    await ffmpeg.exec(args);
    var outputData = await ffmpeg.readFile(outputName);
    entry.outputBlob = new Blob([outputData.buffer], { type: cfg.mime });
  }
}

Opusのみ別パスを通し、それ以外はFFmpeg WASMを使用します。ユーザーから見ると同じUIで6フォーマットすべてが同じように変換できます。


4. 入出力比較UI

INPUT / OUTPUT 並列プレイヤー

メインエリアに入力と出力のオーディオプレイヤーを横並びに配置し、変換前後を直接比較できます。

<div class="audio-section">
  <div class="audio-label">INPUT</div>
  <audio id="audioInput" controls></audio>
  <div class="waveform-container" id="waveform-input"></div>
</div>
<div class="audio-divider">→</div>
<div class="audio-section">
  <div class="audio-label">OUTPUT</div>
  <audio id="audioOutput" controls></audio>
  <div class="waveform-container" id="waveform-output"></div>
</div>

OUTPUT ラベルは未変換時はグレー(#555)、変換完了後にグリーン(#00ff88)に変わります。

波形描画

入力と出力それぞれにCanvasで波形を描画します。Text Extractorで実装した波形描画コードを再利用。

async function drawWaveform(source, containerId) {
  var arrayBuffer = await source.arrayBuffer();
  var audioCtx = new AudioContext();
  var decoded = await audioCtx.decodeAudioData(arrayBuffer);
  var data = decoded.getChannelData(0);

  var canvas = document.createElement('canvas');
  var ctx = canvas.getContext('2d');
  var step = Math.ceil(data.length / canvas.width);
  ctx.strokeStyle = '#00ff88';
  ctx.beginPath();
  for (var i = 0; i < canvas.width; i++) {
    var min = 1, max = -1;
    for (var j = 0; j < step; j++) {
      var val = data[i * step + j] || 0;
      if (val < min) min = val;
      if (val > max) max = val;
    }
    ctx.moveTo(i, (1 + min) * canvas.height / 2);
    ctx.lineTo(i, (1 + max) * canvas.height / 2);
  }
  ctx.stroke();
}

入出力の波形を並べることで、圧縮による波形の変化を視覚的に確認できます。ロスレス変換(FLAC/WAV)では波形が完全に一致し、非可逆圧縮(MP3/OGG/AAC)では高周波成分の削減が波形に表れます。


5. OUTPUT INFO — サイズ比較と圧縮率

変換後に出力ファイルの詳細を表示します。

document.getElementById('out-info-format').textContent = cfg.ext.toUpperCase();
document.getElementById('out-info-size').textContent = formatBytes(entry.outputBlob.size);

var reduction = ((1 - entry.outputBlob.size / entry.file.size) * 100);
document.getElementById('out-info-reduction').textContent =
  (reduction >= 0 ? '-' : '+') + Math.abs(reduction).toFixed(1) + '%';

document.getElementById('out-info-time').textContent = (elapsed / 1000).toFixed(2) + 's';

実測結果(入力: MP3 128kbps 3.9MB 4:17):

出力サイズ変化率処理時間
WAV43.3 MB+1002.5%0.73s
OGG 128k3.7 MB-5.7%5.31s
FLAC47.0 MB+1098.8%1.65s
AAC 128k4.0 MB+2.9%6.27s
Opus 128k4.1 MB+3.2%2.24s

MP3→WAV/FLACでサイズが大幅に増加するのは、非圧縮/ロスレスに展開されるため。MP3→OGGで微減するのはVorbisのエンコード効率がわずかに上回るためです。


6. 動画からの音声抽出

-vn フラグを付けているため、入力が動画ファイル(MP4/MKV/WebM等)でも映像トラックを無視して音声だけを抽出・変換します。

var args = ['-i', inputName, '-vn'].concat(cfg.args(br, sr, ch)).concat(['-y', outputName]);

ファイル選択の accept にも動画MIMEタイプと拡張子を含めてあります。

<input type="file" accept="audio/*,video/*,.mp3,.wav,.ogg,.flac,.aac,.opus,.m4a,.wma,.aiff,.ape,.webm,.mp4,.mkv">

「動画から音声だけ取り出したい」という需要にもこのツール1つで対応できます。


7. ローカルに必要なファイル

ゼロ

  • @ffmpeg/ffmpeg → jsDelivr CDN(UMD)
  • @ffmpeg/core → jsDelivr CDN(ESM、toBlobURLでBlob化)
  • @ffmpeg/util → jsDelivr CDN

/public にファイルを置く必要は一切ありません。Astroページ1ファイルで完結します。


8. まとめ

  • FFmpeg WASM: 5フォーマット(MP3/WAV/OGG/FLAC/AAC)の変換をカバー。toBlobURL + ESM core の組み合わせがブラウザ環境での正しいロード手順
  • Opus問題と解決: FFmpeg core 0.12.6にlibopusが未収録。Web Audio API + MediaRecorderによるブラウザネイティブOpusエンコードで解決
  • 入出力比較UI: INPUT/OUTPUTのプレイヤーと波形を並列表示し、変換結果を直感的に比較
  • 動画音声抽出: -vn フラグで動画ファイルからの音声抽出にも対応

「単にFFmpegに丸投げ」で終わるはずが、Opus問題によってFFmpeg WASMとブラウザネイティブAPIのハイブリッド構成になりました。結果として、WASMの限界をブラウザAPIで補完するパターンの実践例になっています。