はじめに
ブラウザ上でAI音声合成を完結できれば、ユーザーの文章や編集素材をサーバーへ送信せずに済み、推論サーバーの運用コストも抑えられます。
しかし、実際に多言語TTSをプロダクトへ組み込むと、単にONNXモデルを読み込むだけでは安定しません。
オープンソースのブラウザ動画編集ツール Timeline Studio で多言語音声合成を実装した際、モデルの読み込み表示が 86%で停止する 問題に遭遇しました。生成ボタンは「生成中」のままになり、中国語では動く場合があっても、英語・ドイツ語・韓国語・タイ語・日本語へ切り替えると失敗しやすくなっていました。
コンソールに出ていた重要なエラーは次のものです。
Unable to cache file QuotaExceededError: Quota exceeded.
QuotaExceededError:
The operation failed because it would cause the application
to exceed its storage quota.
86%という数字は原因ではなく、ブラウザストレージ、複数の音声ランタイム、Service Worker、WebGPU初期化、地域別モデルミラーが組み合わさった結果でした。
本記事では、この問題をどのように切り分け、再発しにくい構成へ変更したかを紹介します。
「86%」はダウンロード残量ではなかった
ブラウザでTTSモデルを利用するまでには、少なくとも次の処理があります。
- 設定、辞書、トークナイザーなどを取得する
- ONNXモデルをダウンロードする
- Cache Storageへ保存する
- ONNX Runtimeのセッションを生成する
- WebGPUのグラフをコンパイル、またはWASMを初期化する
- ウォームアップ推論を実行する
- 入力文章から音声を生成する
当初の進捗率は、主にネットワークダウンロードだけを表していました。
モデルの取得が終わっていても、その後のキャッシュ書き込みや推論セッション作成で例外が発生すると、UIには最後に受け取った進捗値だけが残ります。それがたまたま86%でした。
つまり、画面上は「残り14%をダウンロード中」に見えても、実際には別のフェーズへ進んだ後に失敗していたのです。
そこで処理状態を以下のように分離しました。
- ローカルキャッシュを確認中
- モデルをダウンロード中
- ローカル推論エンジンを初期化中
- 選択した音声を準備中
- 音声を生成中
ダウンロード終了後は、不自然な進捗率を維持せず、初期化フェーズとして正しいメッセージを表示します。
複数のTTSランタイムが同じストレージを使っていた
Timeline Studioでは、言語ごとの品質とブラウザ互換性を考慮し、複数のTTSランタイムを使用しています。
| 言語 | 主なランタイム | 実行方式 |
|---|---|---|
| 中国語 | Piper | WebGPU優先、WASMフォールバック |
| 英語 | Kokoro Q8 | WASM |
| 一部の欧州言語 | Piper | WASM |
| 韓国語・タイ語・ベトナム語・ロシア語 | MMS | WASM |
| 日本語 | Supertonic | WASM |
これらはすべて同じオリジン上で動作します。
Cache Storage、IndexedDB、Service Workerのキャッシュは、実質的に同じサイトストレージ容量を奪い合います。ユーザーが複数言語を試すと、次のデータが蓄積していました。
- 現在使用中のモデル
- 古いリビジョン
- FP32や量子化モデルなどの別バージョン
- 国内外ミラーから取得した同一ファイル
- Service Workerが保存したレスポンス
- ランタイムごとに作られた独立キャッシュ
個々のキャッシュは正常でも、合計するとブラウザのクォータを超えてしまいます。
ダウンロードURLとモデルの識別子を分離する
大きな改善点は、URLをそのままキャッシュキーにしないことでした。
次のような設計では、同じファイルでも配信元が違えば別のキャッシュになります。
const cacheKey = modelDownloadUrl;
中国向けにはModelScope、海外向けにはHugging Faceを利用しています。両方に同一モデルを配置しても、URLが違うためブラウザは別ファイルとして保存します。
そこで、配信元に依存しない論理IDを作りました。
const cacheIdentity = [
modelFamily,
immutableRevision,
language,
voice,
quantization,
].join(":");
例えば英語のKokoro Q8なら、次のようなIDになります。
kokoro:revision-20260804:en:female:q8
ModelScopeから取得してもHugging Faceから取得しても、同じ論理IDへ保存します。
これにより、ネットワーク状況に応じて配信元を切り替えても再ダウンロードが発生せず、同一モデルの二重保存も防げます。
また、本番で利用するモデルはすべて変更不能なリビジョンへ固定しました。mainを直接参照すると、配信元の更新によってモデル内容が変化し、ある日突然ブラウザ側の実装と互換性がなくなる可能性があるためです。
国内外ミラーを切り替えてもキャッシュは共有する
初回利用時にはモデルファイルをネットワークから取得する必要があります。
実装では、中国語UIおよび国内向けセッションではModelScopeミラーを優先し、それ以外ではHugging Faceを優先します。優先配信元が失敗した場合は、自動的にもう一方を試します。
async function loadVoiceArtifact(artifact: VoiceArtifact) {
const cached = await readSharedVoiceCache(artifact.cacheIdentity);
if (cached) return cached;
for (const source of getPreferredSources()) {
try {
const bytes = await downloadArtifact(source, artifact);
await writeSharedVoiceCache(artifact.cacheIdentity, bytes);
return bytes;
} catch (error) {
reportSourceFailure(source, error);
}
}
throw new VoiceModelUnavailableError();
}
ユーザーにはブラウザ由来の Failed to fetch をそのまま見せません。
「モデルを取得できなかった」「代替配信元も試行した」「ネットワークを確認して再実行できる」といった、次の行動が分かるローカライズ済みメッセージへ変換します。
325MBのFP32より92MBのQ8を選んだ理由
英語音声では、当初およそ325MBのKokoro FP32モデルをWebGPU優先で利用していました。
ベンチマーク上は魅力的でも、実際のブラウザでは次の問題がありました。
- 初回ダウンロードが長い
- Cache Storageを大きく消費する
- WebGPUグラフのコンパイル時間が予測しにくい
- GPUやドライバごとの差が大きい
- 他言語モデルを保存する余裕が減る
そこで、英語はおよそ92MBのQ8量子化モデルとWASM実行へ切り替えました。
const session = await ort.InferenceSession.create(modelBuffer, {
executionProviders: ["wasm"],
graphOptimizationLevel: "all",
});
動画編集ツールにおける音声生成は、常時GPUを使い切る処理ではありません。
理論上の最高速度よりも、次の性質を優先しました。
- 初回生成が成功する
- 2回目以降はキャッシュを再利用する
- 言語を切り替えても既存モデルを壊さない
- 中程度の端末でも動作する
- WebGPUが利用できない場合にWASMへフォールバックできる
量子化モデルは、速度だけでなく「プロダクトとして成功する確率」を上げる選択でした。
全キャッシュ削除ではなく、古い音声モデルだけを退避する
容量不足時にすべてのキャッシュを削除すれば、一時的には直ります。しかし、ユーザーが直前に取得したモデルまで再ダウンロードすることになります。
そこで、現在使用している音声モデルを保護し、古い音声モデルから削除する方式にしました。
- 現在選択中の音声が必要とする論理IDを取得する
- そのIDを保護対象にする
- 古いモデルリビジョンを削除する
- 最近使われていない他言語モデルを削除する
- アプリ本体の静的リソースは保持する
- キャッシュ書き込みを再試行する
- 保存に失敗しても、可能ならメモリ上のモデルで今回の推論を続ける
async function ensureVoiceStorage(activeIdentity: string) {
const estimate = await navigator.storage.estimate();
if (!estimate.quota || !estimate.usage) return;
const usageRatio = estimate.usage / estimate.quota;
if (usageRatio >= 0.8) {
await evictStaleVoiceModels({
preserve: [activeIdentity],
strategy: "least-recently-used",
});
}
}
これにより、QuotaExceededErrorを回復不能なエラーではなく、リソース整理で復旧可能な状態として扱えるようになりました。
大きなモデルファイルのキャッシュ担当を一つにする
Service Workerは、JavaScript、CSS、アイコンなどの静的リソースには適しています。
一方、モデルローダーがすでに管理している巨大なONNXレスポンスをService Worker側でも複製すると、同じファイルを二重に保存する可能性があります。
そのため責任範囲を次のように整理しました。
- 大きな音声モデルはモデルマネージャーだけが管理する
- Service Workerは巨大なONNXファイルを重複保存しない
- 通常のアプリ静的リソースは引き続きService Workerが管理する
- すべてのTTSランタイムが共通のモデルマニフェストを使う
保存場所と削除担当が明確になり、クォータ使用量を予測しやすくなりました。
進捗率はファイル数ではなくバイト数で計算する
5KBの設定ファイルと92MBのONNXモデルを、同じ1ファイルとして扱うと進捗率は実態と一致しません。
ダウンロード進捗は実バイト数を基準に計算します。
const progress = loadedBytes / totalBytes;
onProgress(Math.round(progress * 100));
ネットワーク取得が終わった後は、同じ進捗バーを「推論エンジン初期化」の段階へ切り替えます。
すべてのランタイムが同じ内部進捗を返せるわけではありません。そのため、共通の上位ステージと、各アダプターが提供できる詳細進捗を分けています。
重要なのは、取得できない進捗を架空の数字で補わないことです。
重いWASM処理の前にブラウザへ1フレーム渡す
Reactで「生成中」へ状態を変更しても、その直後に重いWASM処理を開始すると、ブラウザが画面を再描画できないことがあります。
ユーザーから見ると、状態表示が変わらないまま画面がフリーズしたように見えます。
そこで、推論開始前に1フレームだけ描画タイミングを渡します。
setGenerationState({
status: "generating",
progress: 0,
});
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
await generateVoice();
推論そのものが速くなるわけではありませんが、重い処理へ入る前に正しいUI状態を表示できるため、体感品質が大きく改善します。
ランタイムの違いを共通インターフェースで隠す
Piper、Kokoro、MMS、Supertonicでは、モデル構造や前処理が異なります。
しかし、動画編集UIやタイムラインがそれぞれの実装詳細を知る必要はありません。
各ランタイムを共通インターフェースへ適合させました。
interface VoiceRuntime {
prepare(options: VoiceOptions): Promise<void>;
synthesize(text: string): Promise<AudioBuffer>;
dispose(): Promise<void>;
}
上位レイヤーは生成された音声だけを扱います。
これにより、量子化方式、実行プロバイダー、ミラー優先順位を変更しても、タイムラインや素材ライブラリを修正する必要がありません。
検証したケース
修正後は、次のケースを重点的に確認しました。
- 英語モデルの初回セットアップと再生成
- ドイツ語の連続生成
- 韓国語のローカル推論
- タイ語モデルの取得と音声合成
- 日本語Supertonicの初期化
- 中国語モデルの配信元切り替え
- ストレージ上限に近い状態での自動整理
- ページ再読み込み後のキャッシュ再利用
2回目の生成でモデルダウンロード表示が繰り返されることはなくなり、クォータ不足によってUIが86%のまま残る問題も解消しました。
まとめ
「86%で止まる」という症状は、単純なプログレスバーの不具合ではありませんでした。
根本には次の問題がありました。
- 大きなモデルの重複キャッシュ
- ブラウザストレージのクォータ超過
- 国内外ミラーで異なるキャッシュキー
- WebGPU初期化失敗時の回復経路不足
- ダウンロードと初期化を混同した進捗表示
- 重い処理によってUI描画が遅れる問題
量子化モデル、配信元に依存しないキャッシュID、国内外ミラーのフォールバック、古いモデルの自動削除、ステージ別進捗、WASMへの回復経路を導入したことで、多言語音声合成をブラウザ内で安定して利用できるようになりました。
ブラウザAIでは「一度推論できる」ことはスタート地点にすぎません。
モデル配信、バージョン固定、キャッシュ、容量不足からの回復、UI状態までを一つのシステムとして設計することが、実際のユーザー環境で動く機能につながります。