概要
クライアントのブラウザで動画をMP3にエンコードする処理を作成しました。
作成した理由ですが、Whisperで文字起こしする会議の議事録作成には下記の課題がありました。
- 録画した動画のファイルサイズが大きいので音声だけサーバーにアップロードしたい
- Windowsで動画から音声抽出するソフトがデフォルトでない。インストールが必要
- サーバーでAACを扱うと特許のライセンス問題がある
解決方法として、WebAudio API(decodeAudioData)で音声抽出を行い、MP3でエンコードしてからサーバーにアップロードを実施としました。
MP3は特許の期限が切れたので利便性が良いですね。
公開されているWebAssemblyのMP3エンコーダーの課題
幾つか公開されているWebAssemblyのMP3エンコーダーを試したのですが下記の課題がありました。
- 長時間のエンコードに対応していない
- 高音質にするためかVBR(可変長ビットレート)で圧縮しており時間が変化する
- 処理時間が長めなので進捗表示が欲しい
会議の文字起こしに使いたいので、長時間のエンコードに対応しており、ファイルの時間は変化しない、進捗状況の表示が必要条件です。
ffmpeg.wasmが要件を満たしているのですが、様々なライブラリを含んでいるので業務利用で安心して利用できるのか、利用するライブラリに限定してビルドが必要なのかがよくわかりません。
頑張ってバラすよりも必要な機能だけを作ってしまうのが楽そうなので作ってみました。
開発環境
- M1 MacBookAir
- Docker Image: emscripten/emsdk:3.1.69
- Podman
出来上がったもの
- 2時間程度の動画であれば音声抽出してのMP3エンコードが可能
- CBR圧縮にしたので時間に変化はない
- Wasmのサイズが500KB以内
- ffmpeg.wasmは30MBなので1/60サイズ
- Vanilla JSで実装
- テストコードはないのでとってもレガシー
工夫した点
- JSとWasmの連携周り
- WebAssembly FileSystemを用いたデータの受け渡し
- オーバーヘッドの回避
- シークしながら少しずつのデータ読み込み
- PCM音源がステレオの場合はファイルシステムに別々に保存
- JS側でひとつにまとめるとデータ長の都合でハングするのを回避(2時間までは確認)
- 処理の進捗をJSに返す対応
- WebAssembly FileSystemを用いたデータの受け渡し
- 開発環境はDockerではなくPodmanを利用
Dockerを利用しないのは大人の事情です。
ライセンスとか
LAME
引用:LAMEは、MP3 エンコーディングの学習に使用する教育ツールです。
MP3の特許が切れたのでLGPLのライセンス下で自由に使えると考えています。
WASMに動的ファイルとしてリンクしているので今回作成したコードのライセンスには影響はないでしょう。
参考にしたコード: pprmnt
ビルド周りの処理を変更しながら利用しています。
pprmntのライセンスがGPLでしたのでGPLで配布します。
実装のポイントなど
JS <-> Wasmでのやり取り
ファイルを登録しますと、WebAudio API(decodeAudioData)で音声抽出を行い、
File System APIにデコードしたデータを書き込みます。
音声データはUint8で書き込めばC++と連携ができました。
Module.FS.writeFile("audioBufferL",
new Uint8Array(audioBuffer.getChannelData(0).buffer));
Wasm側では少しずつバッファを読みながらmp3にエンコードしてUint8で保存。
最後にJS側でFile System APIを読み込んでUint8をBlobで処理します。
const mp3bin = Module.FS.readFile("output.mp3");
let audioBlob = URL.createObjectURL(new Blob([mp3bin], { type: 'audio/mpeg' }));
処理の進捗表示
処理の進捗を画面に表示するのにEM_ASM
でC++のコード内にJSを記述しています。
EM_ASM
にはC++の値を渡せますのでDOM要素に直接書き込めます。
EM_ASM({
const progress = $0;
const elm = document.getElementsByClassName('progress-bar').item(0);
if (elm){
const pcg = Math.round(progress * 100);
elm.setAttribute('aria-valuenow',pcg);
elm.setAttribute('style','width:'+Number(pcg)+'%');
elm.setAttribute('class','progress-bar progress-bar-striped progress-bar-animated');
elm.innerText = Number(pcg)+'%';
}
}, float(chunkEnd) / float(nsamples));
これを実装したことでJSからWasmへのコールをawaitで書く必要がありました。
const ret = await Instance.encode(
audioBuffer.sampleRate,
audioBuffer.numberOfChannels,
bitrate, vbr, audioBuffer.length);
更に事前にJS側にcwrapで関数を定義する必要があり、中々手間でした。
var Module = {
onRuntimeInitialized: async function () {
Instance = {
encode: Module.cwrap('encode', "number", ["number","number","number","number","number"], {async: true})
}
}
}
もう少しスマートなやり方もあるのでしょうが、ハードコーディングで実装しています。
サンプルとコード
GitHubページで動いてますのでお試しできます。
コードはこちら
おわりに
EmscriptenをPodmanでサクッと動かせたのでコーディングに集中できました。
ちょうど良いビルドのコードがGPLライセンスでしたので、今回はGPLで配布としてます。
次回はビルド環境も自前で書くようにしてGPLライセンスを解消したいですね。