「MP3をWAVにしたい」という要件は、DAWや動画編集ソフトへの取り込み、音声認識APIへの入力などでよく出てきます。ブラウザだけで実装しようとすると、decodeAudioData でデコードしてPCMをWAVヘッダー付きで書き出す、という流れになります。シンプルに見えて、いくつかハマりどころがありました。
1. decodeAudioDataは勝手にリサンプリングする
AudioContext はデバイスの出力サンプルレート(48kHzが多い)で作られます。そして decodeAudioData は、元ファイルが44.1kHzでもコンテキストのサンプルレートに変換して返します。
const ctx = new AudioContext(); // 48000Hz の環境が多い
const buf = await ctx.decodeAudioData(arrayBuffer);
console.log(buf.sampleRate); // 44100 ではなく 48000
元のサンプルレートを保ちたい場合は、事前にMP3のフレームヘッダーからサンプルレートを読み、そのレートで OfflineAudioContext を作ってデコードします。
const off = new OfflineAudioContext(2, 1, srcSampleRate);
const buf = await off.decodeAudioData(arrayBuffer);
2. 先頭と末尾の無音(エンコーダーディレイ)
MP3には仕様上、先頭に数十ms程度の無音(エンコーダーディレイ)と末尾のパディングがあります。LAMEでエンコードされたファイルなら、XingヘッダーのLAMEタグに正確なサンプル数が書かれているので、それを読んでトリミングできます。
ループ素材やギャップレス再生が必要な音源では、これを放置すると「つなぎ目でプチッと鳴る」原因になります。
3. ビット深度とクリッピング
AudioBuffer の中身は-1.0〜1.0のFloat32です。16bit PCMに変換するときは必ずクランプします。
for (let i = 0; i < src.length; i++) {
const s = Math.max(-1, Math.min(1, src[i]));
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
offset += 2;
}
MP3は音圧の高い曲だとデコード後に1.0を超えるサンプルが出ることがあります。クランプしないと、オーバーフローして大きなノイズになります。用途によっては32bit floatのWAV(フォーマットコード3)で書き出すのも選択肢です。
4. 大きなファイルとメモリ
1時間のステレオ音声を48kHz・Float32で保持すると約1.4GBになります。モバイルでは落ちます。長尺ファイルは、デコードをチャンク単位で行い、WAVのデータチャンクをストリームで書き出す方が安全です。
5. 「MP3→WAVで音質は良くならない」
最後に仕様ではなく説明の話です。ユーザーには「WAVにしても失われた情報は戻らない。編集ソフトに取り込みやすくなるだけ」と明記しておくと、問い合わせが減ります。
実装の確認用に
自分の実装の出力と比較するのに、ブラウザ内で変換が完結するNibboのMP3→WAV変換を使っています。ファイルをアップロードしないので、手元の音源で気軽に試せます。
まとめ
-
decodeAudioDataはコンテキストのレートにリサンプリングする - エンコーダーディレイを考慮する
- Float32→Int16はクランプ必須
- 長尺はストリーム処理
— Vitalii