浏览器端多语音合成为何卡在86%
浏览器端TTS加载停滞并非单一进度条故障,而是大模型文件、存储配额、Service Worker缓存、WebGPU初始化及区域镜像共同作用的结果。文章结合实际项目介绍提升多语言推理管线可靠性的架构调整。
完整中文译文
完全在浏览器中运行文本转语音听起来很简单:下载一个 ONNX 模型、创建推理会话,然后在不把用户文本或媒体发送到服务器的情况下合成音频。
但到了生产环境,事情要困难得多。
在为 Timeline Studio 构建多语言语音生成功能时,我们反复遇到同一种故障:模型加载界面停在 86%,Generate 按钮一直显示忙碌状态;而当用户从中文切换到英语、德语、韩语、泰语或日语时,这个问题更容易发生。
控制台里最有用的提示信息是:
Unable to cache file QuotaExceededError: Quota exceeded.
QuotaExceededError:
The operation failed because it would cause the application
to exceed its storage quota.
进度条只是表象。真正的问题来自多个因素之间的相互作用:大型模型文件、多套 TTS runtime、浏览器存储配额、Service Worker 缓存、WebGPU 初始化,以及不同地区使用的模型镜像。
本文将介绍我们对架构所做的调整,以及这些调整如何让多语言处理流水线变得可靠。
项目:https://github.com/MartinDelophy/ai-video-editor
在线演示:https://video-editor.ai-creator.top/
浏览器实际执行的工作比进度条显示的更多
浏览器中的 TTS 模型通常需要经历以下几个阶段:
- 下载配置、phonemizer、tokenizer 和词表文件。
- 下载一个或多个 ONNX 模型文件。
- 将模型产物存入 Cache Storage。
- 创建 ONNX Runtime 会话。
- 为 WebGPU 编译计算图,或者初始化 WASM。
- 执行一次预热推理。
- 最后合成用户请求的语音。
我们最初的进度计算主要反映网络下载状态。如果文件已经下载完成,但写入缓存或创建会话时失败,界面就会停留在最后一次上报的进度值——通常是 86%。
因此,浏览器未必仍在下载任何内容。它可能已经进入了一个进度条没有覆盖的阶段,并且在状态机进入成功状态或有效错误状态之前抛出了异常。
修复工作首先从把模型准备过程当成真正的多阶段操作开始:
- 检查本地模型缓存
- 下载模型产物
- 初始化本地推理引擎
- 准备当前选中的语音
现在,初始化期间的界面会如实显示正在发生什么,而不是假装另一个文件仍在下载。
同一个 origin,多套相互竞争的缓存
Timeline Studio 支持多套在浏览器本地运行的语音 runtime,因为没有任何一个模型家族能成为所有语言的最佳选择。
这些 runtime 都运行在同一个浏览器 origin 下。因此,Cache Storage、IndexedDB 和 Service Worker 缓存会争夺同一份站点存储配额。
用户尝试过多种语音后,这个 origin 中可能会积累:
- 较旧的模型修订版本
- 多种量化版本
- 来自不同镜像的重复文件
- Service Worker 保存的响应副本
- 各个 runtime 自己的缓存条目
单独看,每个缓存似乎都很合理;但把它们加在一起,就可能超过浏览器允许的存储空间。
将模型身份与下载 URL 分离
缓存方面最重要的改动,是不再把模型 URL 当作模型的唯一身份。
const cacheKey = modelDownloadUrl;
同一个不可变产物,在中国可以由 ModelScope 提供,在其他地区则可以由 Hugging Face 提供。如果使用完整 URL 作为缓存键,那么来自两个提供方、内容完全相同的字节,就会占据两个彼此独立的缓存条目。
我们改为生成一个与提供方无关的身份标识:
const cacheIdentity = [
modelFamily,
immutableRevision,
language,
voice,
quantization,
].join(":");
kokoro:revision-20260804:en:female:q8
两个镜像 URL 都会解析到同一个逻辑条目。这样,即使更换提供方,也不会被迫重新下载,更不会重复存储数百 MB 的内容。
这也让缓存迁移变得更可预测:产物修订版本是明确的,而新的修订版本自然会获得新的身份标识。
使用区域镜像,但不让缓存碎片化
浏览器本地推理在首次使用某个语音时,仍然需要网络连接。
对于中文语言环境和中国境内的会话,Timeline Studio 会优先尝试我们维护的 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();
}
所有生产环境中的模型产物,都固定到提供方的某个不可变修订版本。这样可以避免远程 main 分支悄然改变模型字节,或导致浏览器 runtime 无法正常工作。
产品也绝不会直接向用户显示原始的 Failed to fetch 信息。用户会看到本地化的说明,得知模型无法下载,并且系统已经尝试过备用来源。
更小的模型可能才是更快的产品决策
英语处理流水线最初使用一个约 325 MB 的 FP32 Kokoro 模型,并优先走 WebGPU 路径。
这套配置在基准测试中看起来很有吸引力,但在真实环境中造成了几个问题:
- 给 Cache Storage 带来很大压力
- WebGPU 计算图编译时间不可预测
- 出现与驱动程序相关的故障
- 与其他语言模型争夺更多资源
我们把英语处理路径切换到了约 92 MB 的 Q8 模型,并使用稳定的 WASM execution provider:
const session = await ort.InferenceSession.create(modelBuffer, {
executionProviders: ["wasm"],
graphOptimizationLevel: "all",
});
对编辑器来说,语音生成通常只是偶尔执行的操作,并不是一项持续满负荷运行的推理工作。一个体积更小、能在更多设备上可靠加载的量化模型,比一条理论上更快、却经常在推理开始前就失败的 GPU 路径更能带来良好的用户体验。
我们的设计目标变成了:
- 首次生成能够成功
- 重复生成能够复用缓存
- 切换语言不会破坏已有语音
- 继续支持中端设备
- 在适当情况下,为 WebGPU 提供有边界的 WASM fallback
淘汰过期语音模型,而不是删除整个应用缓存
存储空间不足时删除所有缓存很容易,但这会让用户付出代价,因为他们刚刚下载的模型也会被一并删除。
因此,缓存管理器会保护当前正在使用的语音,并优先清理过期的语音产物:
- 确定当前语音需要的缓存身份标识。
- 将对应条目标记为受保护状态。
- 删除过时的模型修订版本。
- 按最近最少使用策略,淘汰未激活的语音模型。
- 保留应用资源和当前语音。
- 重试缓存写入。
- 如果持久化存储仍然失败,只要条件允许,就让当前推理继续使用内存中的模型。
简化后的配额检查如下:
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",
});
}
}
这样一来,原本会直接导致失败的配额问题,就变成了一次可以恢复的资源管理事件。
让大型模型文件只有一个缓存负责人
Service Worker 非常适合缓存 JavaScript bundle、样式、图标和普通静态资源。但如果大型 ONNX 响应已经由模型加载器负责管理,而 Service Worker 又独立缓存一份,它就会变得很危险。
如果两个层级都克隆并缓存同一份响应,存储占用可能会在不知不觉中翻倍。
我们确立了一条单一职责规则:
- 大型模型产物由语音模型管理器负责。
- Service Worker 不重复缓存大型 ONNX 文件。
- 普通应用资源仍然由 Service Worker 管理。
- 所有 TTS runtime 共用同一份语音产物清单。
这样一来,存储占用变得可以测量,模型清理也变得确定可控。
进度应按字节加权,并感知不同处理阶段
一个 5 KB 的配置文件和一个 92 MB 的模型,不应该对进度产生同等影响。
下载器现在会根据实际字节数上报进度:
const progress = loadedBytes / totalBytes;
onProgress(Math.round(progress * 100));
网络阶段结束后,界面会切换并重置到初始化阶段,而不是停留在某个随意的下载百分比上。
并非每一种 runtime 都能暴露完全相同的内部进度信息。因此,应用采用了一套共享的高层接口,同时允许各个适配器提供自己能获取到的最佳信号。即使计算图编译本身无法提供字节级进度,界面也仍能如实反映当前状态。
在执行繁重的 WASM 工作前,让浏览器先完成一次绘制
我们还发现了另一个看似细小、但非常重要的问题。
React 状态更新是异步的。如果设置完生成状态后,立即开始同步执行或执行 CPU 密集型 WASM 工作,主线程可能来不及绘制新的状态。对用户来说,页面看起来就像在显示任何有效信息之前已经卡死。
现在,我们会在开始推理前主动让出一帧:
setGenerationState({
status: "generating",
progress: 0,
});
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
await generateVoice();
这并不会让推理变快,但会让产品显得更有响应,因为浏览器可以在进入繁重任务之前,先把状态变化显示出来。
用统一接口隐藏不同 runtime 之间的差异
多语言产品不应该迫使编辑器 UI 理解每一个模型家族。
每个适配器都实现同一套接口:
interface VoiceRuntime {
prepare(options: VoiceOptions): Promise<void>;
synthesize(text: string): Promise<AudioBuffer>;
dispose(): Promise<void>;
}
无论音频来自 Piper、Kokoro、MMS 还是 Supertonic,时间线、素材库和导出流水线处理的都是最终生成的音频。
这种分层还意味着,我们可以调整量化方式、execution provider 或镜像路由,而不必重写产品层面的编辑功能。
完成这些改动后,我们测试了:
- 首次设置英语模型,以及后续的重复生成
- 重复生成德语语音
- 韩语本地推理
- 泰语模型下载与语音合成
- 日语 Supertonic 初始化
- 中文镜像优先级与提供方 fallback
- 浏览器存储接近配额上限时的自动清理
- 页面刷新后的缓存复用
重复生成时,界面不再把它表现成又一次完整的模型下载;配额故障也不会再让界面永久卡在 86%。
浏览器 AI 应用带来的经验
最大的经验是:模型能在本地原型中成功运行一次,并不等于它已经是一项可靠的浏览器 AI 功能。
生产级实现应该能够回答以下所有问题:
- 模型文件是否固定到了不可变修订版本?
- 区域镜像是否共享同一个缓存身份标识?
- Service Worker 是否可能重复缓存大型产物?
- 持久化存储即将耗尽时会发生什么?
- 进度是否反映真实字节数和真实处理阶段?
- 在执行繁重推理前,UI 是否有机会完成绘制?
- WebGPU 不可用时,是否存在稳定的 fallback?
- 不再使用的语言模型最终是否会被淘汰?
最初,“卡在 86%”看起来像是一个进度条 bug。实际上,它暴露的是一个横跨存储、网络、推理和 UI 调度的架构问题。
通过引入量化模型、共享产物身份标识、区域镜像 fallback、感知配额的淘汰机制、感知处理阶段的进度,以及稳定的 WASM 路径,多语言语音生成在不同浏览器和不同地区中的表现变得更加可预测。
如果你正在浏览器中构建 local-first AI,就应该把模型分发和存储当作一等基础设施。成功完成推理只是开始;真正让它成为产品的,是面对不同设备、网络和存储条件时仍能可靠恢复的能力。
开源项目:https://github.com/MartinDelophy/ai-video-editor
在线体验:https://video-editor.ai-creator.top/
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。