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

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 - Wikipedia

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 に対していろいろ行えるので技術掘り下げが楽しかったです!