QVAC 文字起こしで音声質問を追加する
ブラウザで音声を録音し、@qvac/sdk を通してローカルで文字起こし、既存のチャットエンドポイントにテキストを渡す。
置く場所: 録音は UI、文字起こし呼び出しは QVAC ゲートウェイ、 両者をつなぐ薄いサーバールート 1 つ。
音声質問は、テキスト質問の前にステップを 1 つ足しただけです。QVAC には Whisper が同梱されているので、サードパーティのモデルを配線する必要は ありません。チャットと embedding を動かしている同じ SDK が、録音した 音声チャンクを文字に起こし、チャットエンドポイントが想定する質問文字列に してくれます。
公式の QVAC 文字起こしリファレンスは
docs.qvac.tether.io/sdk/examples/ai-tasks/transcription
にあります。
QVAC 文字起こしの表面
import {
loadModel,
transcribe,
unloadModel,
WHISPER_TINY,
} from "@qvac/sdk";transcribe({ modelId, audioChunk }) は、文字起こし全体を 1 つの文字列で
返します。audioChunk はファイルパスでもインメモリのバッファでも受け取れる
ので、ゲートウェイは録音した blob を文字起こし前にディスクに書き出す
必要がありません。
WHISPER_TINY は最小モデルで、音声質問には妥当なデフォルトです。短い
プロンプトには十分な精度があり、読み込み時間も速いです。多言語キャプチャ
が必要なら、QVAC リファレンスにある Parakeet TDT 定数のひとつに差し替えて
ください。
4 ステップのレシピ
1. ゲートウェイに transcribe メソッドを追加する
import {
loadModel,
transcribe,
unloadModel,
WHISPER_TINY,
} from "@qvac/sdk";
export class QvacGateway {
// existing fields…
private sttModelId: string | undefined;
private async ensureSttReady(): Promise<void> {
if (this.sttModelId) return;
this.sttModelId = await loadModel({
modelSrc: WHISPER_TINY,
modelType: "whisper",
modelConfig: { language: "en" },
});
}
async transcribe(audio: Buffer | string): Promise<string> {
await this.ensureSttReady();
return transcribe({
modelId: required(this.sttModelId, "QVAC transcription model is not loaded."),
audioChunk: audio,
});
}
}既存のチャットローダーと埋め込みローダーと同じ形です。初回利用時に
遅延読み込み、required 経由で進行中の Promise を 1 つ共有、タスクごとに
1 メソッド。文字起こしモデルはチャットモデルから独立しているので、その
読み込みは次のチャット往復を遅らせません。
2. LocalLensApp から公開する
小さな転送メソッドでゲートウェイをカプセル化したままにします。
async transcribe(audio: Buffer): Promise<string> {
return this.qvac.transcribe(audio);
}ワークフロー側の変更はこれだけです。音声質問は既存の askBrain
パイプラインを再利用するので、複数ステップの状態遷移は 1 か所に
留まります。
3. /api/transcribe ルートを追加する
if (url.pathname === "/api/transcribe" && request.method === "POST") {
const arrayBuffer = await request.arrayBuffer();
const audio = Buffer.from(arrayBuffer);
const text = await app.transcribe(audio);
return json({ text });
}既存のチャットや brain エンドポイントと同じく、薄い素通しです。
LocalLensApp.transcribe が throw したエラーは、共通の errorResponse
ヘルパーを変更なしで流れます。AppError はステータスコードを保ち、
それ以外は 500 として表面化します。
4. UI から録音して送信する
キャプチャには標準の MediaRecorder API を使います。
async function startRecording() {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
const chunks = [];
recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.start();
return {
stop: () =>
new Promise((resolve) => {
recorder.onstop = () => {
stream.getTracks().forEach((t) => t.stop());
resolve(new Blob(chunks, { type: "audio/wav" }));
};
recorder.stop();
}),
};
}チャット入力の隣にマイクボタンを追加してください。録音が終わったら、
blob を /api/transcribe に POST し、返ってきたテキストをチャット入力に
入れて、既存のチャットフォームを送信します。
const recorder = await startRecording();
// … wait for the user to release the button …
const audio = await recorder.stop();
const { text } = await fetch("/api/transcribe", {
method: "POST",
body: audio,
}).then((r) => r.json());
elements.chatInput.value = text;
elements.chatForm.requestSubmit();チャットフォームはすでに /api/brains/:id/chat の呼び出し方を知って
いるので、残りの往復は変わりません。ユーザーには自分の発話が質問として
現れ、回答は同じ流れでストリーミングされてきます。
Whisper は 16 kHz の WAV を想定
MediaRecorder のデフォルトのコンテナは、Whisper が望む形式と一致しない
ことがあります。QVAC のドキュメントには audio_format: "f32le" の
ツマミがあり、16 kHz のオーディオが推奨されています。文字起こしの品質が
悪い場合、transcribe を呼ぶ前にクライアント側(またはサーバー側)で
リサンプリングしてください。
変えなくて良いもの
rag.ts— プロンプトビルダーは同じ。- チャットエンドポイント — JSON ボディもレスポンスの形も同じ。
store.ts— 新しいフィールドは不要。domain.ts— チャットパスがすでに必要としている以上の新しい型は不要。
これが音声を既存のチャットパスにルーティングする効用です。回答側に入る 改善は、音声質問にも無料でついてきます。