【Webフロント】マイクで録音した Blob データを、form アクション経由で Web サーバーに送信する
概要
背景
Web で、OpenAI の API の Speech-to-Text を叩きたかったのですが、file の渡し方が公式ドキュメントで python と curl しかなく、情報がどこにもなくて少し苦労したので、記事に残します。
フロントのフレームワーク以外のパッケージは使いません。
この記事のゴール
- ブラウザのマイクから音声を抽出
- 音声ファイルを Blob に変換
- form アクションで、Blob を Web サーバーに送信
ソースコード
Svelte 製です。
デモアプリなので、console.log 周りが散りばめられてますが、気になさらず。
実装方法
主に、この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()
という、録音状態を操作する関数を提供してくれます。
// 他の関数からも触れるような場所に置いておく
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
です。
FileList
をそのまま入れればいいのですが、FileList
はコンストラクタがなぜかプライベートで new FileList()
ができないので、DataTransfer
を利用します。
new DataTransfer()
でインスタンスを取得すれば、空の FileList
インスタンスが取得できます。
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
を上手く送信できなかったので、必須の設定です。
- <form>
+ <form enctype="multipart/form-data">
<input name="voice" style="display: none;" type="file" bind:this={inputElement} />
</form>
8. api 側で取得する
この辺は、sveltekit 以外の書き方知らないので、各々頑張って取得してください。
リクエストのformData
から、name 属性経由でデータをバイナリデータで取得できれば、多分それが音声ファイルです。
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