🎙️

【Webフロント】マイクで録音した Blob データを、form アクション経由で Web サーバーに送信する

2023/11/23に公開

概要

背景

Web で、OpenAI の API の Speech-to-Text を叩きたかったのですが、file の渡し方が公式ドキュメントで python と curl しかなく、情報がどこにもなくて少し苦労したので、記事に残します。
フロントのフレームワーク以外のパッケージは使いません。

この記事のゴール

  • ブラウザのマイクから音声を抽出
  • 音声ファイルを Blob に変換
  • form アクションで、Blob を Web サーバーに送信

ソースコード

Svelte 製です。
デモアプリなので、console.log 周りが散りばめられてますが、気になさらず。
https://github.com/Zudah228/browser_media_stream

実装方法

主に、この2つのクラスを使います。

svelte 以外あんまり分からないので、svelte x sveltekit 前提で書きますが、できるだけ普遍的に伝わるように書きます。

1. デバイスの取得

const recordStart = async () => {
  // ブラウザで使用できる、MediaDeviceInfo を取得
  //
  // MediaDeviceInfo
  // https://developer.mozilla.org/ja/docs/Web/API/MediaDeviceInfo
  const devices:MediaDeviceInfo[]  = await navigator.mediaDevices.enumerateDevices()

  // 本番アプリでは、ここを選択可能にしったりすると思いますが、デモアプリなので、とりあえず先頭のもの
  const selectedDevice: MediaDeviceInfo = devices[0]

  const stream: MediaStream = await navigator.mediaDevices.getUserMedia({
    audio: true,
    peerIdentity: device.deviceId
  });
}

2. マイク起動/停止を操作するクラスを生成

MediaStream を起点に、MediaRecorder を取得。
.start().stop() という、録音状態を操作する関数を提供してくれます。
https://developer.mozilla.org/ja/docs/Web/API/MediaRecorder

// 他の関数からも触れるような場所に置いておく
let mediaRecorder: MediaRecorder | undefined

const recordStart = async () => {
  // ...略
  const stream: MediaStream = //
  
  mediaRecorder = new MediaRecorder(stream);
}

3. 音声データを取得する準備

MediaRecorder から、Blob に変換します。
Blob のコンストラクタの第2引数を、{ type: 'audio/ogg; codecs=opus'} にすることで、ogg ファイルにできるっぽい。
MDN に書いてたからこうしたけど、ぶっちゃけよく分からんので、なぜか明確になったら追記します。

let mediaRecorder: MediaRecorder | undefined
// 取得した Blob を受け取る変数
let file: Blob | null

const recordStart = async () => {
  // ...略
  mediaRecorder = new MediaRecorder(stream);
  
  // 取得した音声データを貯めておく
  // ここでしか使わない変数なので、スコープはこの関数の中だけで問題ない
  const chunks: Blob[] = [];
  
  // addListener みたいなもの、データ取得するたびに発火
  // 取得したデータを、配列に貯めておく
  mediaRecorder.ondataavailable = (e) => {
    chunks.push(e.data);
  };
  
  // 録音を止めたら、Blob として保存する
  mediaRecorder.onstop = (_) => {
    file = new Blob([...chunks], { type: 'audio/ogg; codecs=opus'});
  };
}

4. 録音の開始/停止を動作させる

const recordStart = async () => {
  // ...略
  mediaRecorder.onstop = (_) => {
    file = new Blob([...chunks], { type: 'audio/ogg; codecs=opus'});
  };
  
  await mediaRecorder.start();
}

const recordStop = async () => {
  await mediaRecorder.stop();
  mediaRecorder = undefined;
}
<button on:click={recordStart}>録音開始</button>
<button on:click={recordStop}>停止</button>

5. input タグとバインドさせる

この辺から、少し厄介になっていきます。
form アクションの使用がこの記事のゴールなので、form タグの中の input に、値を持たせなければいけません。
まずは、とりあえず、input タグの HTMLElement にいつでもアクセスできるようにします。
バインド方法は document.getElementById("hoge") とか、フレームワークによって色々あると思うのでおまかせで。

+ let inputElement: HTMLInputElement;
- let file: Blob | null
<form>
  <input name="voice" style="display: none;" type="file" bind:this={inputElement} />
  <button type="submit">送信</button>
</form>

6. inputElement にデータをぶち込む

<input type="file"> が保持する値のクラスは、FileList です。
https://developer.mozilla.org/ja/docs/Web/API/FileList

FileList をそのまま入れればいいのですが、FileList はコンストラクタがなぜかプライベートで new FileList() ができないので、DataTransfer を利用します。
new DataTransfer() でインスタンスを取得すれば、空の FileList インスタンスが取得できます。
https://developer.mozilla.org/ja/docs/Web/API/DataTransfer

let inputElement: HTMLInputElement;

const recordStart = async () => {
  // ...略
  mediaRecorder.onstop = (_) => {
-   file = new Blob([...chunks], { type: 'audio/ogg; codecs=opus'});
+   const transfer = new DataTransfer();
+   const file = new File(
+     [...chunks],
+     'filename.ogg', // File の生成には、ファイル名が必須。多分なんでもいい。
+     { type: 'audio/ogg; codecs=opus'}
+   );
+   transfer.files.add(file);
+
+   // 参照が重複するのは気持ち悪いが、
+   // transfer 変数のスコープはこの関数だけなので、一応問題ない。
+   inputElement.files = transfer.files;
}

7. form タグに enctype を設定

ここで一番ハマりました。
form タグの entctype に multipart/form-data を付与しないと、file を上手く送信できなかったので、必須の設定です。

https://developer.mozilla.org/ja/docs/Web/API/HTMLFormElement/enctype

- <form>
+ <form enctype="multipart/form-data">
  <input name="voice" style="display: none;" type="file" bind:this={inputElement} />
</form>

8. api 側で取得する

この辺は、sveltekit 以外の書き方知らないので、各々頑張って取得してください。
リクエストのformData から、name 属性経由でデータをバイナリデータで取得できれば、多分それが音声ファイルです。

+page.server.ts
export const actions = {
  send: async (event: RequestEvent) => {
    try {
      const data = await event.request.formData();
      const file = data.get("voice");
 
      const transcriptions = await openAI.audio.transcriptions.create({
        model: 'whisper-1',
        file: new File([file], 'file.ogg', { type: 'audio/ogg; codecs=opus' }),
        language: "ja"
      })

完成

できた。

参考リンク

Discussion