Web ブラウザ上でマイクから録音したデータを WAV データに変換し Whisper API で文字起こしするメモ

Web ブラウザ上でマイクから録音したデータを WAV データに変換し Whisper API で文字起こしするメモ

Web ブラウザ上でマイクから録音したデータを WAV データに変換し Whisper API で文字起こしするメモです。

背景

Web ブラウザ上でマイクから Web Audio API で録音した WebM データを WAV データに変換・再生するメモ がうまくいったので、Whisper API でも文字起こしてみます。

Unity からマイク録音して音データを WAV フォーマットで Whisper API に送って文字起こしするメモ の知見を軸に組み込んでみます。

実際に動くコード

このような UI です。以前の録音開始ボタン、録音停止ボタン、音データ再生プレイヤー、ステータス表示(現在は未録音)、WAV データ用の Playback UI に加えて、文字起こし後の結果表示まであります。

以下がソースです。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>録音デモ + WAV変換 + Whisper API</title>
  <style>
    body { font-family: sans-serif; margin: 20px; }
    button, audio, #status, pre { margin: 5px; vertical-align: middle; }
    pre { background: #f8f8f8; padding: 10px; border: 1px solid #ddd; }
  </style>
</head>
<body>
  <h1>録音デモ + WAV変換 + Whisper API</h1>
  <!-- 録音開始・停止ボタン -->
  <button id="recordBtn">録音開始</button>
  <button id="stopBtn" disabled>録音終了</button>
  <!-- ステータス表示 -->
  <div id="status">ステータス: 未録音</div>
  
  <!-- 録音データ再生 (WebM) -->
  <h2>録音データ再生 (WebM)</h2>
  <audio id="audioPlayback" controls></audio>
  
  <!-- WAVデータ再生 -->
  <h2>WAVデータ再生</h2>
  <audio id="wavPlayback" controls></audio>
  
  <!-- 文字起こし結果表示 (Whisper API) -->
  <h2>文字起こし結果</h2>
  <pre id="transcriptionResult">ここに文字起こし結果が表示されます</pre>
  
  <script>
    // OpenAI API キー(実際のキーに置き換えてください)
    const OPENAI_API_KEY = "OpenAI_API_KEY";

    // AudioContext(WAV変換用)
    let audioContext;
    if (window.AudioContext) {
      audioContext = new AudioContext();
    } else if (window.webkitAudioContext) {
      audioContext = new webkitAudioContext();
    } else {
      throw new Error("Web Audio API がサポートされていません。");
    }

    let mediaRecorder;
    let recordedChunks = [];
    let startTime, endTime;
    let latestWavBlob = null; // 最新のWAV Blob を保持

    // DOM 要素の取得
    const recordBtn = document.getElementById("recordBtn");
    const stopBtn = document.getElementById("stopBtn");
    const statusEl = document.getElementById("status");
    const audioPlayback = document.getElementById("audioPlayback");
    const wavPlayback = document.getElementById("wavPlayback");
    const transcriptionResultEl = document.getElementById("transcriptionResult");

    // ----- WAV 変換用関数群 -----
    async function convertToWav(blob) {
      // Blob を ArrayBuffer に変換
      const arrayBuffer = await new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onload = () => resolve(reader.result);
        reader.onerror = error => reject(error);
        reader.readAsArrayBuffer(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' });
    }

    function audioBufferToWav(buffer) {
      const numOfChannels = buffer.numberOfChannels;
      const sampleRate = buffer.sampleRate;
      const format = 1; // PCM
      const bitDepth = 16;
      let channelData;
      if (numOfChannels === 2) {
        channelData = interleave(buffer.getChannelData(0), buffer.getChannelData(1));
      } else {
        channelData = buffer.getChannelData(0);
      }
      const bufferLength = channelData.length * (bitDepth / 8);
      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);
      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;
    }

    function writeString(view, offset, string) {
      for (let i = 0; i < string.length; i++) {
        view.setUint8(offset + i, string.charCodeAt(i));
      }
    }

    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);
      }
    }

    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;
    }
    // ----- /WAV変換用関数群 -----

    // ----- Whisper API を使った文字起こし処理 -----
    async function doTranscription(wavBlob) {
      const formData = new FormData();
      formData.append("file", wavBlob, "recording.wav");
      formData.append("model", "whisper-1");

      const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${OPENAI_API_KEY}`
        },
        body: formData
      });
      if (!response.ok) {
        throw new Error(`Transcription API エラー: ${response.status}`);
      }
      return await response.json();
    }
    // ----- /Whisper API -----

    // ----- 各ボタンのクリック処理 -----
    async function clickRecordBtn() {
      try {
        // 毎回新たに MediaStream を取得して MediaRecorder を初期化する
        const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
        mediaRecorder = new MediaRecorder(stream);
        recordedChunks = [];
        startTime = Date.now();
        
        // 録音データが逐次来た時の処理
        mediaRecorder.ondataavailable = event => {
          if (event.data && event.data.size > 0) {
            recordedChunks.push(event.data);
          }
        };
        
        // 録音停止時の処理
        mediaRecorder.onstop = async () => {
          endTime = Date.now();
          const webmBlob = new Blob(recordedChunks, { type: 'audio/webm' });
          // WebM 録音データの再生
          const webmURL = URL.createObjectURL(webmBlob);
          audioPlayback.src = webmURL;
          audioPlayback.load();
          
          // WebM を WAV に変換
          try {
            const wavBlob = await convertToWav(webmBlob);
            const wavURL = URL.createObjectURL(wavBlob);
            wavPlayback.src = wavURL;
            wavPlayback.load();
            latestWavBlob = wavBlob;
          } catch (err) {
            console.error("WAV 変換に失敗しました:", err);
          }
          
          // Whisper API による文字起こし開始
          statusEl.textContent = "ステータス: 文字起こし中...";
          try {
            const transcriptionData = await doTranscription(latestWavBlob);
            transcriptionResultEl.textContent = JSON.stringify(transcriptionData, null, 2);
            statusEl.textContent = "ステータス: 文字起こし完了";
          } catch (err) {
            console.error("文字起こしエラー:", err);
            statusEl.textContent = "ステータス: 文字起こし失敗";
          }
        };
        
        mediaRecorder.start();
        statusEl.textContent = "ステータス: 録音中...";
        recordBtn.disabled = true;
        stopBtn.disabled = false;
      } catch (err) {
        console.error("録音開始エラー:", err);
        statusEl.textContent = "ステータス: 録音開始失敗";
      }
    }
    
    function clickStopBtn() {
      if (mediaRecorder && mediaRecorder.state === "recording") {
        mediaRecorder.stop();
        // 録音停止後、取得した MediaStream の各トラックを停止してリソースを解放
        mediaRecorder.stream.getTracks().forEach(track => track.stop());
        recordBtn.disabled = false;
        stopBtn.disabled = true;
        statusEl.textContent = "ステータス: 録音終了";
      }
    }
    // ----- /各ボタンのクリック処理 -----

    // イベントリスナーの登録
    recordBtn.addEventListener("click", clickRecordBtn);
    stopBtn.addEventListener("click", clickStopBtn);
  </script>
</body>
</html>

これを、どこか HTTPS で動く場所で表示します。私の場合は GitHub Codespaces だと手軽に HTTPS 環境で試せるので、よく使っています。

<script>
    // OpenAI API キー(実際のキーに置き換えてください)
    const OPENAI_API_KEY = "OpenAI_API_KEY";

OpenAI API キーは、みなさんの実際のキーに置き換えてください。

録音開始ボタンをクリックします。

話したら録音終了ボタンをクリックします。

WAV データに変換されて Whisper API でも文字起こしされた結果が表示されます!

ということで、以前 Unity でうまく言った仕組みが Web ブラウザ上でもうまくいきました!引き続き、いろいろと試してみます!