Web ブラウザ上でマイクから Web Audio API で録音した WebM データを WAV データに変換・再生するメモ
Web ブラウザ上でマイクから Web Audio API で録音した WebM データを WAV データに変換・再生するメモです。
背景

Web ブラウザ上でマイクからシンプルに Web Audio API で録音・再生するメモ – 1ft-seabass.jp.MEMO
こちらで、うまくデータ取得でうまくいったので、録音の生データは WebM データ形式なので、文字起こし系の API にイイ感じに送るために WAV データに変換します。
ちなみに WebM のフォーマットはこんなのです。
WebM(ウェブエム)は、Googleが開発しているオープンでロイヤリティフリーな動画のコンテナフォーマット。ウェブに親和的なオープンなフォーマットであると共に、軽量さと高品質を両立することを目標としている。
姉妹プロジェクトとして技術を応用した静止画フォーマットのWebPも開発されている。
情報収集と変換の流れの整理

Unity からマイク録音して音データを WAV フォーマットに保存するメモ – 1ft-seabass.jp.MEMO
以前、Unity での音声データを WAV データに変換する仕組みあったので、これでデータのフォーマットが分かるので JavaScript に書き換えていきます。こまかなフォーマット違いの調査や JavaScript でのバイナリ系のデータの扱いをChatGPT にも並走してもらいながらやってみました。

WebM データを格納した Blob から ArrayBuffer を取り出してから AudioContext を使って ArrayBuffer を AudioBuffer にします。ここまでできると AudioBuffer から Unity と(だいたい)同じように WAV データに変換できるので DataView を経由して Blob に audio/wav で戻してあげると WAV 用の Playback UI に割り当てられます。
とサラッと書いていますが、WebM データまわりの変換について BaseAudioContext: decodeAudioData() メソッド - Web API | MDN の記事が見つかって理解が結構進んだので、いろいろ調べておいてよかったです。
実際に動くコード

このような UI です。以前の録音開始ボタン、録音停止ボタン、音データ再生プレイヤー、ステータス表示(現在は未録音)に加えて、WAV データ用の Playback UI と WAV のダウンロードボタンがあります。
以下がソースです。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>録音デモ + WAV変換</title>
<style>
body { font-family: sans-serif; margin: 20px; }
button, audio, #status { margin: 5px; vertical-align: middle; }
</style>
</head>
<body>
<h1>録音デモ</h1>
<!-- 録音開始・停止ボタン -->
<button id="recordBtn">録音開始</button>
<button id="stopBtn" disabled>録音停止</button>
<!-- WebM形式の録音データ再生用 -->
<audio id="audioPlayback" controls></audio>
<!-- 録音状態表示 -->
<div id="status">未録音</div>
<h2>WAVデータ</h2>
<!-- WAVデータ再生用 -->
<audio id="wavPlayback" controls></audio>
<!-- WAVファイルダウンロード用ボタン -->
<button id="downloadWavBtn" disabled>WAVダウンロード</button>
<script>
// AudioContext を生成(WAV変換処理に利用)
let audioContext;
if (window.AudioContext) {
audioContext = new AudioContext();
} else if (window.webkitAudioContext) {
audioContext = new webkitAudioContext();
} else {
// Web Audio API がサポートされていない処理
throw new Error("Web Audio API がサポートされていません。");
}
// 最新の WAV データ Blob を保持する変数
let latestWavBlob = null;
// Blob を ArrayBuffer に変換する非同期関数(async/await を利用)
async function blobToArrayBuffer(blob) {
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = error => reject(error);
reader.readAsArrayBuffer(blob);
});
}
// Blob を WAV データに変換する非同期関数
async function convertToWav(blob) {
// Blob を ArrayBuffer に変換する
const arrayBuffer = await blobToArrayBuffer(blob);
// ArrayBuffer を AudioBuffer にデコードする
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
// AudioBuffer から WAV データ(DataView)を作成する
const wavDataView = audioBufferToWav(audioBuffer);
// DataView を Blob に変換して返す。MIME タイプは audio/wav とする。
return new Blob([wavDataView], { type: 'audio/wav' });
}
// AudioBuffer を WAV データに変換する関数
// PCM形式 16ビット
function audioBufferToWav(buffer) {
const numOfChannels = buffer.numberOfChannels;
const sampleRate = buffer.sampleRate;
const format = 1; // PCM形式
const bitDepth = 16; // 16ビット
// チャンネル数が2の場合、左右のデータを交互に配置(インターリーブ)する
let channelData;
if (numOfChannels === 2) {
channelData = interleave(buffer.getChannelData(0), buffer.getChannelData(1));
} else {
channelData = buffer.getChannelData(0);
}
// WAV のデータ部分のサイズを計算
const bufferLength = channelData.length * (bitDepth / 8);
// 44バイトのヘッダー + 音声データ分の ArrayBuffer を用意
const wavBuffer = new ArrayBuffer(44 + bufferLength);
const view = new DataView(wavBuffer);
// RIFFヘッダーの作成
writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + bufferLength, true);
writeString(view, 8, 'WAVE');
// fmt サブチャンクの作成
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true); // サブチャンクサイズ
view.setUint16(20, format, true); // フォーマット(1 = PCM)
view.setUint16(22, numOfChannels, true); // チャンネル数
view.setUint32(24, sampleRate, true); // サンプルレート
view.setUint32(28, sampleRate * numOfChannels * (bitDepth / 8), true); // バイトレート
view.setUint16(32, numOfChannels * (bitDepth / 8), true); // ブロックサイズ
view.setUint16(34, bitDepth, true); // ビット深度
// data サブチャンクの作成
writeString(view, 36, 'data');
view.setUint32(40, bufferLength, true);
// AudioBuffer のデータを 16ビット PCM に変換し、DataView に書き込む
floatTo16BitPCM(view, 44, channelData);
return view;
}
// DataView に文字列を書き込む補助関数
function writeString(view, offset, string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
// 浮動小数点数のオーディオデータを 16ビット PCM に変換する関数
function floatTo16BitPCM(output, offset, input) {
for (let i = 0; i < input.length; i++, offset += 2) {
let s = Math.max(-1, Math.min(1, input[i]));
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
}
// 2チャンネルの場合、左右のデータを交互に配置する関数
function interleave(leftChannel, rightChannel) {
const length = leftChannel.length + rightChannel.length;
const result = new Float32Array(length);
let inputIndex = 0;
for (let index = 0; index < length;) {
result[index++] = leftChannel[inputIndex];
result[index++] = rightChannel[inputIndex];
inputIndex++;
}
return result;
}
// MediaRecorder 関連の変数を定義
let mediaRecorder;
let stream;
let recordedChunks = [];
// HTML の各要素を取得
const recordBtn = document.getElementById("recordBtn");
const stopBtn = document.getElementById("stopBtn");
const audioPlayback = document.getElementById("audioPlayback");
const statusDiv = document.getElementById("status");
const wavPlayback = document.getElementById("wavPlayback");
const downloadWavBtn = document.getElementById("downloadWavBtn");
// 録音開始ボタンがクリックされたときの処理
async function startRecording() {
try {
// ユーザーのマイクからオーディオのみの MediaStream を取得
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// MediaRecorder を初期化
mediaRecorder = new MediaRecorder(stream);
// 録音データを一旦集める配列を初期化
recordedChunks = [];
// 録音データがつどつど来た時の処理
mediaRecorder.ondataavailable = event => {
if (event.data && event.data.size > 0) {
recordedChunks.push(event.data);
}
};
// 録音停止時の処理
mediaRecorder.onstop = async () => {
// WebM形式のBlobにまとめ、audioPlayback で再生できるようにする
const webmBlob = new Blob(recordedChunks, { type: "audio/webm" });
audioPlayback.src = URL.createObjectURL(webmBlob);
// WebMデータをWAVデータに変換する
try {
const wavBlob = await convertToWav(webmBlob);
// WAVデータを再生するために wavPlayback に設定
wavPlayback.src = URL.createObjectURL(wavBlob);
// 最新の WAV Blob を保存
latestWavBlob = wavBlob;
// WAVダウンロードボタンを有効にする
downloadWavBtn.disabled = false;
} catch (err) {
console.error("WAV変換に失敗しました:", err);
}
// ステータス表示を更新
statusDiv.textContent = "録音終了";
};
// 録音開始
mediaRecorder.start();
// UI の状態を更新
recordBtn.disabled = true;
stopBtn.disabled = false;
downloadWavBtn.disabled = true;
statusDiv.textContent = "録音中...";
} catch (error) {
// エラー処理
console.error("録音開始に失敗しました:", error);
statusDiv.textContent = "録音開始失敗";
}
}
// 録音停止ボタンがクリックされたときの処理
function stopRecording() {
if (mediaRecorder && mediaRecorder.state === "recording") {
// MediaRecorder の録音を停止
mediaRecorder.stop();
// MediaStream 内の全トラックを停止して、デバイスリソースを解放
stream.getTracks().forEach(track => track.stop());
// UI の状態を更新
recordBtn.disabled = false;
stopBtn.disabled = true;
}
}
// WAVダウンロードボタンがクリックされたときの処理(clickDownloadWavBtn 関数)
function clickDownloadWavBtn() {
if (latestWavBlob) {
const a = document.createElement("a");
a.href = URL.createObjectURL(latestWavBlob);
a.download = "recording.wav";
a.click();
}
}
// 各ボタンにクリックイベントを登録
recordBtn.addEventListener("click", startRecording);
stopBtn.addEventListener("click", stopRecording);
downloadWavBtn.addEventListener("click", clickDownloadWavBtn);
</script>
</body>
</html>
これを、どこか HTTPS で動く場所で表示します。私の場合は GitHub Codespaces だと手軽に HTTPS 環境で試せるので、よく使っています。

録音開始ボタンをクリックしてマイクから録音して、録音停止ボタンをクリックして録音停止します。ここまでは以前の Unity からマイク録音して音データを WAV フォーマットに保存するメモ と同じ。

録音終了すると、下部の WAV データの Playback UI で WAV データ変換された音声が再生できます。正直、私の聴力では WebM も WAV も区別つきませんでした。

そして、WAV ダウンロードボタンで WAV データが直接ダウンロードできます。本来はこのあと WAV データをそのまま文字起こし系の API に送るのでダウンロードはしないのですが、いまは、うまくいっているかの確認としての機能です。途中の開発ではチェック大事ですね~。
ということで、この仕組みができると WAV データを必要とする API に対していろいろ行えるので技術掘り下げが楽しかったです!