実際にやってみて分かったこと
genai-web はもともと AWS 前提の構成ですが、API 境界をうまく残すと、文字起こしもローカル実行に寄せられます。
今回のポイントは、「フロントエンドを大きく作り替えない」ことです。
ブラウザ側は今まで通り、
- 音声ファイルを選択する
- アップロード URL を取得する
- 音声をアップロードする
- 文字起こしジョブを開始する
- 結果をポーリングする
という流れを使います。
変えたのは、その先です。AWS Transcribe ではなく、Local API が ffmpeg と whisper.cpp を呼び出します。
まず結論
ローカルの文字起こしは、次の構成で動きます。
Browser
|
| /transcribe/url
| /transcribe/start
| /transcribe/result/:jobName
v
Local API
|
| ffmpeg: 16kHz mono WAV へ変換
v
whisper.cpp
|
| JSON 出力
v
Transcript[]
重要なのは、whisper.cpp を直接フロントエンドから呼ばないことです。
ブラウザは既存の /transcribe/* API だけを見ます。
Local API の中で、音声変換、CLI 実行、JSON パースをまとめて吸収します。
1. 文字起こし機能を有効化する
ローカル起動はいつも通りです。
npm run local:dev
このとき scripts/local-dev.mjs では、ローカルモード用の環境変数がまとめて設定されます。
VITE_APP_LOCAL_MODE: 'true',
VITE_APP_API_ENDPOINT: `http://127.0.0.1:${port}`,
VITE_APP_HIDDEN_USE_CASES: JSON.stringify({ image: true }),
LOCAL_TRANSCRIBE_ENGINE: process.env.LOCAL_TRANSCRIBE_ENGINE ?? 'whisper',
ここで VITE_APP_HIDDEN_USE_CASES に transcribe が含まれていないため、文字起こし画面は表示されます。
ルート側もシンプルです。
isUseCaseEnabled('transcribe')
? { path: 'transcribe', element: lazyElement(<TranscribePage />) }
: null
ヘッダーも同じ判定を使っています。
{isUseCaseEnabled('transcribe') && (
<GlobalMenuLink to='/transcribe'>文字起こし</GlobalMenuLink>
)}
つまり、文字起こしを出すかどうかは isUseCaseEnabled('transcribe') が決めています。
2. whisper.cpp を使うための環境変数
whisper.cpp はリポジトリには含めません。
実行ファイル、モデル、ffmpeg はローカルに置き、環境変数で渡します。
LOCAL_TRANSCRIBE_ENGINE=whisper
LOCAL_WHISPER_CPP_CPU_BIN=C:\path\to\whisper-cli.exe
LOCAL_WHISPER_CPP_CUDA_BIN=C:\path\to\whisper-cli.exe
LOCAL_WHISPER_CPP_MODEL=C:\path\to\ggml-large-v3-turbo.bin
LOCAL_WHISPER_BACKEND=auto
LOCAL_FFMPEG_BIN=ffmpeg
npm run local:dev
LOCAL_WHISPER_BACKEND は auto / cpu / cuda を指定できます。
auto の場合、Windows で NVIDIA GPU が見つかり、CUDA 版の whisper-cli.exe が設定されていれば CUDA を優先します。失敗したら CPU にフォールバックします。
3. Local API 側の入口
Local API では、既存の /transcribe/* API をそのまま受けます。
if (segments[0] === 'transcribe') {
if (segments[1] === 'capabilities') {
sendJson(res, 200, transcribe.getCapabilities());
}
if (segments[1] === 'url') {
sendJson(res, 200, transcribe.createUploadUrl(body, origin));
}
if (segments[1] === 'start') {
sendJson(res, 200, transcribe.start(body));
}
if (segments[1] === 'result') {
sendJson(res, 200, transcribe.get(jobName));
}
}
フロントエンドから見ると、Cloud でも Local でも API の形はほぼ同じです。
このおかげで、画面側の変更を小さくできます。
4. 音声ファイルを whisper.cpp に渡す前処理
アップロードされた音声は、そのまま whisper.cpp に渡しません。
まず ffmpeg で 16kHz / mono / PCM WAV に変換します。
await this.commandRunner(ffmpegBin, [
'-y',
'-i', inputPath,
'-ar', '16000',
'-ac', '1',
'-c:a', 'pcm_s16le',
wavPath,
]);
この変換を Local API 側に置いたことで、ブラウザは MP3 / MP4 / WAV / FLAC / OGG / AMR / WebM / M4A をアップロードできます。
5. whisper.cpp の実行部分
実際に whisper.cpp を呼んでいる中心はここです。
const args = [
'-m', modelPath,
'-f', wavPath,
'-oj',
'-of', outputBase,
'-np',
'-l', 'auto',
];
if (candidate.backend === 'cpu') {
args.push('--no-gpu');
}
const result = await this.commandRunner(candidate.bin, args);
ポイントは -oj です。
whisper.cpp の出力を JSON にして、後段で Transcript[] に変換しています。
また、CPU 実行時は --no-gpu を明示しています。
CUDA 版と CPU 版の両方を扱えるようにするためです。
6. JSON をアプリ共通の Transcript に変換する
whisper.cpp の JSON は、そのまま画面には返しません。
アプリ側の共通型に寄せます。
const rawSegments =
output.transcription ||
output.segments ||
output.results?.audio_segments ||
[];
const transcripts = rawSegments
.map((segment) => ({
transcript: segment.text?.trim() ?? '',
speakerLabel: segment.speaker ?? segment.speaker_label,
}))
.filter((segment) => segment.transcript);
最終的には次の形になります。
type Transcript = {
speakerLabel?: string;
transcript: string;
};
これで、フロントエンドはエンジンの違いをほとんど意識しなくて済みます。
7. 話者認識は whisper.cpp では出さない
この実装では、whisper.cpp エンジンの話者認識は無効です。
getCapabilities() {
return {
engine: this.config.transcribeEngine,
supportsSpeakerDiarization: this.config.transcribeEngine === 'funasr',
};
}
画面側もこの値を見ています。
{supportsSpeakerDiarization && (
<Switch label='話者認識' checked={speakerLabel} onSwitch={setSpeakerLabel} />
)}
つまり、LOCAL_TRANSCRIBE_ENGINE=whisper のときは通常の文字起こしだけ。
話者認識が必要な場合は funasr エンジンを使う、という整理です。
まとめ
今回の実装で大事だったのは、whisper.cpp を無理に画面へ露出させないことでした。
フロントエンドは既存の文字起こし API を呼ぶだけ。
Local API が ffmpeg、whisper.cpp、JSON パース、ジョブ管理を受け持ちます。
この形にすると、
- AWS Transcribe からローカル ASR に差し替えやすい
- 画面側の変更が小さい
- CPU / CUDA の切り替えをバックエンドに閉じ込められる
- 将来 FunASR など別エンジンも追加しやすい
というメリットがあります。
個人的には、ローカル AI 対応では「モデルを動かすこと」よりも、「既存アプリの境界にどう自然に入れるか」の方が大事だと感じました。
whisper.cpp は CLI としては単体で便利ですが、Web アプリに組み込むなら、今回のように Local API の中に閉じ込める方が扱いやすいです。
参考にした公開ページ:
- whisper.cpp README: https://github.com/ggml-org/whisper.cpp/blob/master/README.md
- whisper.cpp models README: https://github.com/ggml-org/whisper.cpp/blob/master/models/README.md