LocalLens
拡張

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 メソッドを追加する

src/qvac.ts
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 から公開する

小さな転送メソッドでゲートウェイをカプセル化したままにします。

src/locallens.ts
async transcribe(audio: Buffer): Promise<string> {
  return this.qvac.transcribe(audio);
}

ワークフロー側の変更はこれだけです。音声質問は既存の askBrain パイプラインを再利用するので、複数ステップの状態遷移は 1 か所に 留まります。

3. /api/transcribe ルートを追加する

src/server.ts
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 を使います。

src/ui/app.js
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 — チャットパスがすでに必要としている以上の新しい型は不要。

これが音声を既存のチャットパスにルーティングする効用です。回答側に入る 改善は、音声質問にも無料でついてきます。

外部リファレンス

On this page