浏览器端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 模型通常需要经历以下几个阶段:
我们最初的进度计算主要反映网络下载状态。如果文件已经下载完成,但写入缓存或创建会话时失败,界面就会停留在最后一次上报的进度值——通常是 86%。
因此,浏览器未必仍在下载任何内容。它可能已经进入了一个进度条没有覆盖的阶段,并且在状态机进入成功状态或有效错误状态之前抛出了异常。
修复工作首先从把模型准备过程当成真正的多阶段操作开始:
现在,初始化期间的界面会如实显示正在发生什么,而不是假装另一个文件仍在下载。
Timeline Studio 支持多套在浏览器本地运行的语音 runtime,因为没有任何一个模型家族能成为所有语言的最佳选择。
这些 runtime 都运行在同一个浏览器 origin 下。因此,Cache Storage、IndexedDB 和 Service Worker 缓存会争夺同一份站点存储配额。
用户尝试过多种语音后,这个 origin 中可能会积累:
单独看,每个缓存似乎都很合理;但把它们加在一起,就可能超过浏览器允许的存储空间。
缓存方面最重要的改动,是不再把模型 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 路径。
这套配置在基准测试中看起来很有吸引力,但在真实环境中造成了几个问题:
我们把英语处理路径切换到了约 92 MB 的 Q8 模型,并使用稳定的 WASM execution provider:
const session = await ort.InferenceSession.create(modelBuffer, {
executionProviders: ["wasm"],
graphOptimizationLevel: "all",
});
对编辑器来说,语音生成通常只是偶尔执行的操作,并不是一项持续满负荷运行的推理工作。一个体积更小、能在更多设备上可靠加载的量化模型,比一条理论上更快、却经常在推理开始前就失败的 GPU 路径更能带来良好的用户体验。
我们的设计目标变成了:
存储空间不足时删除所有缓存很容易,但这会让用户付出代价,因为他们刚刚下载的模型也会被一并删除。
因此,缓存管理器会保护当前正在使用的语音,并优先清理过期的语音产物:
简化后的配额检查如下:
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 又独立缓存一份,它就会变得很危险。
如果两个层级都克隆并缓存同一份响应,存储占用可能会在不知不觉中翻倍。
我们确立了一条单一职责规则:
这样一来,存储占用变得可以测量,模型清理也变得确定可控。
一个 5 KB 的配置文件和一个 92 MB 的模型,不应该对进度产生同等影响。
下载器现在会根据实际字节数上报进度:
const progress = loadedBytes / totalBytes;
onProgress(Math.round(progress * 100));
网络阶段结束后,界面会切换并重置到初始化阶段,而不是停留在某个随意的下载百分比上。
并非每一种 runtime 都能暴露完全相同的内部进度信息。因此,应用采用了一套共享的高层接口,同时允许各个适配器提供自己能获取到的最佳信号。即使计算图编译本身无法提供字节级进度,界面也仍能如实反映当前状态。
我们还发现了另一个看似细小、但非常重要的问题。
React 状态更新是异步的。如果设置完生成状态后,立即开始同步执行或执行 CPU 密集型 WASM 工作,主线程可能来不及绘制新的状态。对用户来说,页面看起来就像在显示任何有效信息之前已经卡死。
现在,我们会在开始推理前主动让出一帧:
setGenerationState({
status: "generating",
progress: 0,
});
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
await generateVoice();
这并不会让推理变快,但会让产品显得更有响应,因为浏览器可以在进入繁重任务之前,先把状态变化显示出来。
多语言产品不应该迫使编辑器 UI 理解每一个模型家族。
每个适配器都实现同一套接口:
interface VoiceRuntime {
prepare(options: VoiceOptions): Promise<void>;
synthesize(text: string): Promise<AudioBuffer>;
dispose(): Promise<void>;
}
无论音频来自 Piper、Kokoro、MMS 还是 Supertonic,时间线、素材库和导出流水线处理的都是最终生成的音频。
这种分层还意味着,我们可以调整量化方式、execution provider 或镜像路由,而不必重写产品层面的编辑功能。
完成这些改动后,我们测试了:
重复生成时,界面不再把它表现成又一次完整的模型下载;配额故障也不会再让界面永久卡在 86%。
最大的经验是:模型能在本地原型中成功运行一次,并不等于它已经是一项可靠的浏览器 AI 功能。
生产级实现应该能够回答以下所有问题:
最初,“卡在 86%”看起来像是一个进度条 bug。实际上,它暴露的是一个横跨存储、网络、推理和 UI 调度的架构问题。
通过引入量化模型、共享产物身份标识、区域镜像 fallback、感知配额的淘汰机制、感知处理阶段的进度,以及稳定的 WASM 路径,多语言语音生成在不同浏览器和不同地区中的表现变得更加可预测。
如果你正在浏览器中构建 local-first AI,就应该把模型分发和存储当作一等基础设施。成功完成推理只是开始;真正让它成为产品的,是面对不同设备、网络和存储条件时仍能可靠恢复的能力。
开源项目:https://github.com/MartinDelophy/ai-video-editor
在线体验:https://video-editor.ai-creator.top/
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。