3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Whisper WASMで7.5分の音声を食わせたらフリーズした — ブラウザのリソース制約と戦った記録

3
Posted at

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

分かったこと

  1. 5秒は短すぎる — Whisper はコンテキストが短すぎるとまともに推論できずクラッシュする
  2. 10秒は遅すぎる — チャンク数が多すぎてオーバーヘッドが支配的になる
  3. 15秒が最適 — ハルシネーション 0 回、処理時間も許容範囲
  4. 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() でテンソルを解放しても内部のメモリフラグメンテーションが蓄積し、最終的にブラウザがクラッシュする。

実際に何度もクラッシュを経験し、以下の対策を試みた:

  1. ウィンドウサイズを 512 → 1536 に変更(推論回数を 1/3 に削減)
  2. 毎推論後にテンソルを dispose()
  3. 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: リアルタイムストリーミング処理への発展(さらに先の話)

参考リソース

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?