Whisper WASMで7.5分の音声を食わせたらフリーズした — ブラウザのリソース制約と戦った記録
はじめに
Whisper WASMをブラウザで動かせば、サーバー不要・月額0円で文字起こし環境が手に入る。
実際に試してみると、3分程度の音声なら問題なく動く。しかし7.5分の音声を食わせたらブラウザがフリーズした。
問題はWhisperの精度ではなく、ブラウザのマシンリソースの使い方にあった。
この記事では、ブラウザ上でWhisper WASMを使って長時間音声を処理するために試した方法と、実測ベンチマークの結果を共有する。
この記事で分かること:
- ブラウザ WASM アプリがフリーズする原因(LinearMemory の制約)
- 音声チャンク分割の実装方法と最適なチャンクサイズ(実測データ付き)
- VAD(Voice Activity Detection)による賢い分割とその限界
- ブラウザ上の重量級 WASM 処理で使える UX パターン
なぜブラウザ完結にこだわるのか
- サーバー不要: GitHub Pages / Cloudflare Pages で無料ホスティングできる
- プライバシー: 音声データがサーバーに送られない。すべてローカルで完結する
- 課金不要: otter.ai、notta 等の文字起こしサービスへの依存をやめたい
- 技術的に可能になった: @xenova/transformers v2.17.0(transformers.js)が Whisper の WASM 実行をサポートしている
フリーズの原因
WASMのメモリモデル
WASMはLinearMemory(連続的なメモリ領域)を使う。重要な制約として:
- メモリは拡張はできるが縮小はできない
- Chrome では最大 約4GB、Firefox では 256MB 程度が上限
- 音声データ全体 + モデルの重み(whisper-base で約 150MB)をメモリに載せる必要がある
メインスレッドのブロック
WASM の推論処理は同期的に実行される。つまりUIスレッドを占有し、推論が終わるまでブラウザが一切応答しなくなる。
7.5分の音声(16kHz mono で約 7.2MB)+ Whisper base モデル(~150MB)を組み合わせると、メモリとCPUの両方がボトルネックになり、ブラウザがフリーズまたはクラッシュする。
解決策: 音声チャンク分割
長い音声を一度に処理するのではなく、短いチャンクに分割して逐次処理する。
実装の流れ
音声ファイル読み込み
↓
AudioContext でデコード → Float32Array(PCM)
↓
チャンクに分割(例: 15秒ずつ、オーバーラップ付き)
↓
各チャンクを Whisper に渡して推論
↓
結果を結合
コード例: AudioContext でデコード → チャンク分割
// 音声ファイルを読み込んでPCMに変換
async function decodeAudio(file) {
const audioCtx = new AudioContext({ sampleRate: 16000 });
const arrayBuf = await file.arrayBuffer();
const audioBuf = await audioCtx.decodeAudioData(arrayBuf);
return audioBuf.getChannelData(0); // モノラル Float32Array
}
// チャンク分割(オーバーラップ付き)
function splitIntoChunks(audioData, sampleRate, chunkSec, overlapSec) {
const chunkSize = chunkSec * sampleRate;
const stepSize = (chunkSec - overlapSec) * sampleRate;
const chunks = [];
for (let offset = 0; offset < audioData.length; offset += stepSize) {
const end = Math.min(offset + chunkSize, audioData.length);
chunks.push({
data: audioData.slice(offset, end),
startSec: offset / sampleRate,
endSec: end / sampleRate,
});
if (end >= audioData.length) break;
}
return chunks;
}
コード例: チャンクごとに Whisper で推論
// npm install @xenova/transformers@2.17.0
// ※ @xenova/transformers は @huggingface/transformers に移行済み。
// 新規プロジェクトでは @huggingface/transformers を使うこと。
// API は互換性があるが、import パスが変わる点に注意。
// ※ ブラウザで直接使う場合は CDN から ESM で読み込むか、Vite 等のバンドラーを使用
import { pipeline } from '@xenova/transformers';
const transcriber = await pipeline(
'automatic-speech-recognition',
'Xenova/whisper-base'
);
async function transcribeChunks(chunks) {
const results = [];
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const result = await transcriber(chunk.data, {
language: 'ja',
task: 'transcribe',
});
results.push({
text: result.text,
startSec: chunk.startSec,
endSec: chunk.endSec,
});
console.log(`[${i + 1}/${chunks.length}] ${chunk.startSec.toFixed(1)}s - ${chunk.endSec.toFixed(1)}s`);
}
return results.map(r => r.text).join('');
}
ベンチマーク: チャンクサイズの比較
7.5分の日本語音声(16kHz mono)を whisper-base モデルで処理した実測結果:
| モード | 処理時間 | チャンク数 | ハルシネーション | 結果 |
|---|---|---|---|---|
| チャンクなし(従来) | 116.6秒 | 1 | — | ✅ 動作したが7.5分が限界※ |
| 5秒チャンク | — | — | — | ❌ クラッシュ |
| 10秒チャンク | 803.8秒 | 85 | 1回 | △ 遅すぎて実用外 |
| 15秒チャンク | 609.0秒 | 43 | 0回 | ✅ 最適バランス |
| 30秒チャンク | 595.4秒 | 17 | 1回 | △ 3:20-4:35に内容欠損 |
※ 今回の環境(16GB RAM, Chrome)では7.5分がギリギリ動作した境界線。再現テストでも同じ音声で2回成功・メモリ警告あり。10分の音声ではクラッシュした。この上限はマシンスペックやブラウザに依存するため、安全に動かすならチャンク分割が必須。
測定環境: Chrome, Windows 11, 16GB RAM
分かったこと
- 5秒は短すぎる — Whisper はコンテキストが短すぎるとまともに推論できずクラッシュする
- 10秒は遅すぎる — チャンク数が多すぎてオーバーヘッドが支配的になる
- 15秒が最適 — ハルシネーション 0 回、処理時間も許容範囲
- 30秒は大きすぎる — メモリ消費が増え、内容欠損が発生した
なお、Whisper のハルシネーション問題については Calm-Whisper の研究が参考になる。デコーダの自己注意ヘッド 20 個のうちわずか 3 個が非音声区間でのハルシネーションの 75% 以上を引き起こしているという分析で、チャンク分割時に短すぎるセグメント(無音のみ等)を避けるべき理由を裏付けている。
チャンクなし vs 15秒チャンク: なぜチャンクの方が遅いのか
チャンクなし(116.6秒)の方が15秒チャンク(609.0秒)より速い。これは一見矛盾するが、理由は明確:
- チャンクなしでは Whisper が音声全体を一度に処理するため、モデルの読み込みは1回で済む
- チャンク処理では43回の推論を逐次実行するため、推論のオーバーヘッドが積み重なる
つまりチャンク分割は速度のための手法ではなく、メモリ制約を回避するための手法。7.5分では動いた「チャンクなし」も、それ以上の長さでは確実にフリーズする。
VAD(Voice Activity Detection)で賢くチャンク分割する
固定長チャンクの問題は、発話の途中で切れること。これを改善するために、音声の無音区間を検出して自然な切れ目で分割する VAD を試した。
RMS エネルギーベースの VAD
最もシンプルな方式。各フレームの RMS(二乗平均平方根)エネルギーを計算し、閾値以下を無音と判定する。
function detectSilence(audioData, sampleRate, frameSizeSec = 0.03, threshold = 0.01) {
const frameSize = Math.round(frameSizeSec * sampleRate);
const frames = [];
for (let i = 0; i < audioData.length; i += frameSize) {
const frame = audioData.slice(i, i + frameSize);
const rms = Math.sqrt(frame.reduce((sum, v) => sum + v * v, 0) / frame.length);
frames.push({ offset: i, isSpeech: rms > threshold });
}
return frames;
}
利点: 計算コストがほぼゼロ、追加ライブラリ不要
限界: 背景ノイズが多い音声では精度が低い
ML ベースの VAD: Silero VAD
Silero VAD は ONNX 形式の軽量モデル(約 1.8MB)で、ブラウザ上でも ONNX Runtime Web 経由で動作する。RMS より遥かに高精度で、ノイズ環境でも発話区間を正確に検出できる。
実際に試したところ、Silero VAD による動的チャンク分割では 387.1秒(15秒固定チャンクの609秒から36%高速化) かつ ハルシネーション 0 回 という結果が出た。
| モード | 処理時間 | チャンク数 | ハルシネーション |
|---|---|---|---|
| 15秒固定チャンク | 609.0秒 | 43 | 0回 |
| Silero VAD 動的チャンク | 387.1秒 | 46 | 0回 |
発話区間のみを処理対象にすることで、無音部分のWhisper推論をスキップできたのが高速化の主因。
ブラウザ上の Silero VAD の課題: WASM メモリリーク
ただし、Silero VAD をブラウザで動かすには注意点がある。
ONNX Runtime Web は WASM バックエンドで動作するが、WASM の LinearMemory は縮小できない。7.5分の音声に対して数千回の推論を回すと、dispose() でテンソルを解放しても内部のメモリフラグメンテーションが蓄積し、最終的にブラウザがクラッシュする。
実際に何度もクラッシュを経験し、以下の対策を試みた:
- ウィンドウサイズを 512 → 1536 に変更(推論回数を 1/3 に削減)
- 毎推論後にテンソルを
dispose() - Web Worker に隔離して WASM メモリをメインスレッドから分離
Web Worker 隔離が理論的には最も有効だが、ブラウザの Worker 内での ONNX Runtime 読み込みには制約がある(importScripts の CORS 制限、ESM の Blob URL 制約等)。単一 HTML ファイルで完結させる構成では、ORTスクリプトをメインスレッドで fetch してテキストとして取得し、Worker コードと結合して Blob URL から Classic Worker を生成する方式が必要になる。
結論: Silero VAD は精度面では優れているが、ブラウザの WASM メモリ制約との相性が悪い。 本番で使うなら、別途 Web Worker ファイルを用意するか、サーバーサイドで VAD を実行して結果だけブラウザに返す設計が現実的。
UXの工夫: 「遅い」より「フリーズ」が最悪
チャンク処理によって処理時間は伸びるが、フリーズよりも「遅いけど動いている」方がユーザー体験として圧倒的にマシ。
実装すべきUX要素:
- プログレスバー: 処理済みチャンク数 / 全チャンク数
- リアルタイム文字起こし表示: チャンクごとに結果を逐次表示
- 推定残り時間: 1チャンクあたりの平均処理時間 × 残りチャンク数
これだけで「壊れたのかな?」という不安を解消できる。
まとめ
| 課題 | 解決策 | 効果 |
|---|---|---|
| メモリ不足でフリーズ | 音声チャンク分割(15秒推奨) | 長時間音声の処理が可能に |
| 発話途中での切断 | VAD で自然な切れ目を検出 | 精度向上 + 無音スキップで高速化 |
| UIフリーズ | Web Worker + プログレス表示 | ユーザー体験の改善 |
| WASM メモリリーク | Web Worker 隔離 / サーバーサイド VAD | 根本的な解決にはアーキテクチャ分離が必要 |
ブラウザ WASM のリソース制約は、チャンク分割 + VAD + UX の工夫 で実用レベルまで攻略できる。
完璧ではないが、サーバー無しで月額0円の文字起こし環境は十分に構築可能だ。「フリーズするくらいなら遅くていい」——この設計判断が、ブラウザ上の重量級WASM処理では重要になる。
補足: OPFS(Origin Private File System)
モデルファイルのキャッシュには OPFS が有効。IndexedDB より 3〜4 倍高速な読み書きが可能で、Web Worker 内では同期アクセスハンドルも使える。whisper.cpp の Issue #825 でも WASM + 大容量ファイル処理での活用が議論されている。今回の実験では主題から外れるため深掘りしなかったが、本番環境ではモデルの再ダウンロード回避に活用すべき技術。
今後の展望
- WebGPU 対応: 推論速度の劇的改善が期待される。ただし tiny/base モデルでは GPU オーバーヘッドの方が大きくなるケースも
- Distil-Whisper: 6倍高速・49%小型で WER 差は 1% 以内。ただし現時点では英語のみ
- AudioWorklet: リアルタイムストリーミング処理への発展(さらに先の話)
参考リソース
- whisper.cpp WASM example — C++ 実装の WASM ビルド
- @xenova/transformers (transformers.js) — 本記事で使用
- Silero VAD — ML ベースの音声活性検出
- @ricky0123/vad-web — Silero VAD のブラウザ実装
- Calm-Whisper — 注意ヘッド 3/20 がハルシネーションの 75% を発生。非音声区間の処理回避の根拠
- OPFS vs IndexedDB (whisper.cpp #825) — WASM + 大容量ファイル処理
- OPFS MDN