#Web Audio#フロントエンド#音声処理#TypeScript

Web Audioだけで仕様準拠WAV(16bit/モノラル/非圧縮)を録音する


ブラウザでマイク録音、と言われて最初に思いつくのは MediaRecorder でしょう。数行で録音できて手軽です。ところが録音した音声を機械学習の前処理に回す——声質変換や特徴抽出のような用途になると、MediaRecorder は途端に不都合になります。

MediaRecorder が出力するのはたいてい webm/opus、つまり非可逆圧縮です。人間の耳には十分でも、前処理の前段で微細な声質情報がすでに削られている。「クリーンな原音」が欲しい場面で、入口で劣化させてしまうわけです。しかも下流のパイプラインが求めるのは、多くの場合きっちり仕様の決まった WAV——16bit PCM・モノラル・非圧縮です。

そこでこの記事では、MediaRecorder を使わず、Web Audio で生の PCM を収録し、WAV を自前でエンコードする実装を紹介します。バイナリを1バイトずつ書く地味な作業ですが、一度書けば劣化ゼロの録音が手に入ります。

前提:なぜ生PCMなのか

Web Audio API では、マイク入力を AudioContext のグラフに流し込み、途中で**生のサンプル(Float32、-1〜1)**を横取りできます。圧縮コーデックを一切通さないので、取れるのはマイクが拾ったそのままの信号です。これを自分の手で 16bit の WAV に詰め直せば、劣化のない仕様準拠ファイルになります。

方針は2段階です。

  1. 収録:マイクから Float32 の生サンプルを集める(Web Audio)
  2. エンコード:集めた Float32 を 16bit PCM WAV のバイト列に変換する

ステップ1:クリーンな設定でマイクを開く

getUserMedia でマイクを取得しますが、ここでブラウザの音声処理を全部切るのが重要です。エコーキャンセル・ノイズ抑制・自動ゲイン調整は、通話には有用でも、原音を保ちたい録音では勝手に信号を加工する余計なお世話になります。

this.stream = await navigator.mediaDevices.getUserMedia({
  audio: { channelCount: 1, echoCancellation: false,
           noiseSuppression: false, autoGainControl: false },
});

channelCount: 1 でモノラルを要求します。仕様として「生の声質を保つクリーン収録」を目指すなら、これらのフラグを明示的に false にしておくことが第一歩です。

ステップ2:ScriptProcessor で生サンプルを集める

マイクの MediaStreamSource を作り、そこから流れてくるサンプルを拾います。ここでは ScriptProcessorNode を使います。

this.ctx = new AudioContext();
this.sampleRate = this.ctx.sampleRate;       // 通常 48000(≧44.1kHz)
this.source = this.ctx.createMediaStreamSource(this.stream);
this.node = this.ctx.createScriptProcessor(4096, 1, 1);  // buffer, in=1, out=1
this.chunks = [];
this.node.onaudioprocess = (e) => {
  // getChannelData はバッファを使い回すので、必ずコピーして退避する
  this.chunks.push(new Float32Array(e.inputBuffer.getChannelData(0)));
};
this.source.connect(this.node);
this.node.connect(this.ctx.destination);     // 一部ブラウザで発火に必要

いくつか実装上の急所があります。

  • サンプルはコピーして貯めるgetChannelData(0) が返す Float32Array は内部バッファの使い回しで、次のコールバックで上書きされます。new Float32Array(...) でコピーしてから配列に push しないと、録音全体が最後のフレームで塗りつぶされます。
  • destination に繋ぐScriptProcessorNode は出力先に接続されていないと onaudioprocess が発火しないブラウザがあります。音を鳴らす必要はなくても、ctx.destination に繋いでおきます。
  • サンプルレートはデバイス既定ctx.sampleRate は環境依存で、多くのデバイスで 48000(=44.1kHz 以上)です。ここで実際のレートを記録しておき、後段のヘッダに正しく書き込みます。録音した実レートをそのままヘッダに反映することが仕様準拠の肝で、勝手なリサンプルで辻褄合わせをしないのが正攻法です。

補足:ScriptProcessorNode は非推奨で、後継は AudioWorklet です。ただし今回のように「サンプルを集めるだけ」の用途なら ScriptProcessor でも要件を満たせます。低レイテンシや重い DSP をメインスレッド外で回すなら AudioWorklet への移行を検討してください。

停止時は、貯めたチャンクを1本の Float32Array に連結し、マイクと AudioContext を解放してから、次のエンコードに渡します。

ステップ3:16bit PCM WAV を手で組み立てる

いよいよ本題、WAV バイト列の生成です。WAV は 44 バイトのヘッダ + PCM データ本体という素直な構造なので、ArrayBufferDataView で1フィールドずつ書けます。

export function encodeWav(samples: Float32Array, sampleRate: number): Blob {
  const buffer = new ArrayBuffer(44 + samples.length * 2);  // 16bit = 2byte/sample
  const view = new DataView(buffer);
  const writeStr = (o: number, s: string) => {
    for (let i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i));
  };
  writeStr(0, 'RIFF');
  view.setUint32(4, 36 + samples.length * 2, true);  // ファイルサイズ - 8
  writeStr(8, 'WAVE');
  writeStr(12, 'fmt ');
  view.setUint32(16, 16, true);            // fmt チャンクサイズ
  view.setUint16(20, 1, true);             // フォーマット = 1 (PCM 非圧縮)
  view.setUint16(22, 1, true);             // チャンネル数 = モノラル
  view.setUint32(24, sampleRate, true);    // サンプルレート
  view.setUint32(28, sampleRate * 2, true);// バイト/秒 = sr * blockAlign
  view.setUint16(32, 2, true);             // blockAlign = mono * 16bit/8
  view.setUint16(34, 16, true);            // 量子化ビット数 = 16
  writeStr(36, 'data');
  view.setUint32(40, samples.length * 2, true);       // データ長
  // ...本体...
}

ヘッダのポイントは3つ。**フォーマット番号 1 が「PCM=非圧縮」**を意味します。マルチバイト値はリトルエンディアンなので、DataView の書き込みは第3引数 true を必ず付けます。そして blockAlignbyteRate はチャンネル数とビット深度から算出します(モノラル 16bit なら blockAlign=2、byteRate=sampleRate×2)。ここに実際のサンプルレートを入れることで、48kHz で録れば 48kHz として、正しい速さで再生される WAV になります。

本体は Float32(-1〜1)を 16bit 整数に量子化します。負値と正値でスケールが違う点に注意です。

let off = 44;
for (let i = 0; i < samples.length; i++) {
  const s = Math.max(-1, Math.min(1, samples[i]));         // 範囲外をクランプ
  view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7fff, true); // 非対称スケール
  off += 2;
}
return new Blob([view], { type: 'audio/wav' });

16bit signed の範囲は -32768〜+32767 と非対称です。負側は 0x8000(32768)、正側は 0x7fff(32767)を掛けることで、フルスケールを正しく使い切ります。両方に同じ 0x7fff を掛ける実装も見かけますが、厳密には負側のダイナミックレンジを1段損します。細部ですが、仕様準拠を名乗るなら合わせておきたいところです。クランプで -1〜1 に収めてから量子化するのも、オーバーフローを防ぐために忘れずに。

最後に Blobtype: 'audio/wav')にまとめれば完成です。これを URL.createObjectURL に渡して <a download> を叩けばダウンロードでき、FormData に載せればそのままサーバへ送れます。

落とし穴・学び

  • MediaRecorder は前処理用途に向かない。webm/opus は非可逆で、原音がほしい機械学習の入口では避ける。生 PCM を Web Audio で取るのが根本解決です。
  • getChannelData はコピー必須。使い回しバッファを push すると録音が壊れます。最初にハマりやすい罠です。
  • ScriptProcessor は destination 接続で発火。音を出さなくても繋いでおく。
  • 実サンプルレートをヘッダに正直に書く。デバイス既定(多くは 48kHz)をそのまま反映し、勝手なリサンプルで辻褄を合わせない。
  • リトルエンディアンと非対称量子化DataViewtrue を付け、16bit 変換は負 0x8000 / 正 0x7fff で行う。

まとめ

  • 前処理用のクリーンな録音には、非可逆な MediaRecorder ではなく Web Audio で生 PCM を収録する
  • getUserMediaechoCancellation / noiseSuppression / autoGainControl を全て無効化し、モノラルを要求する
  • ScriptProcessorNodeonaudioprocess で Float32 サンプルをコピーして貯め、destination に接続して発火させる
  • WAV は 44 バイトヘッダ + 16bit PCM 本体DataView で組み立てる。PCM(=1)・モノラル・実サンプルレートを正しく書く
  • 量子化はリトルエンディアンかつ負 0x8000 / 正 0x7fff の非対称スケールで、劣化のない仕様準拠 WAV を得る
← 記事一覧へ