在浏览器中直接运行 AI 配音模型,可以避免把用户的文本、声音和项目素材上传到服务器,同时也能降低后端推理成本。
但将语音模型真正放进浏览器后,我们遇到了一个非常典型的问题:模型加载进度经常停在 86%,生成按钮一直显示“生成中”。中文偶尔可以使用,切换到英文、德语、韩语、泰语或日语后,失败概率却明显增加。
开发者控制台中最关键的错误是:
Unable to cache file QuotaExceededError: Quota exceeded.
QuotaExceededError:
The operation failed because it would cause the application
to exceed its storage quota.
这次修复并不只是调整一个进度数字,而是对浏览器端配音架构进行了一次完整治理,包括模型体积、运行时选择、缓存策略、国内外下载源、加载进度以及异常恢复。
项目地址:
https://github.com/MartinDelophy/ai-video-editor
在线体验:
https://video-editor.ai-creator.top/
一、为什么模型总是停在 86%
页面显示“86%”,并不意味着模型真的只剩下 14% 没有下载。
浏览器端模型通常需要经历下面几个阶段:
- 下载配置文件、词典和分词资源;
- 下载 ONNX 模型;
- 将文件写入 Cache Storage;
- 创建 ONNX Runtime 推理会话;
- 为 WebGPU 编译计算图;
- 执行第一次预热推理。
原来的进度计算只覆盖了部分下载过程。当模型已经下载完成,却在写入缓存或者创建推理会话时失败,界面就会永远停留在最后一次收到的进度,例如 86%。
因此,真正的问题不是“进度条卡住”,而是后续任务抛出异常后,状态机没有正常结束。
二、浏览器存储配额才是主要故障来源
浏览器中的 Cache Storage、IndexedDB 和 Service Worker 缓存,通常共享同一个站点存储配额。
项目使用了多套语音模型运行时:
- 中文主要使用 Piper;
- 英文使用 Kokoro;
- 部分欧洲语言使用 Piper;
- 韩语、越南语、俄语和泰语使用 MMS;
- 日语使用 Supertonic。
如果每一种语言都维护自己的缓存规则,用户切换几次语言后,浏览器中可能同时存在:
- 已经过期的模型;
- 同一文件的多个镜像副本;
- 不同量化版本的 ONNX 模型;
- Service Worker 的响应缓存;
- 推理模块自己的资源缓存。
这些文件单独看都不算异常,但累积之后很容易触发 QuotaExceededError。
更隐蔽的问题是:如果同一个模型分别从国内镜像和海外镜像下载,而缓存键直接使用下载 URL,浏览器会把它们视为两个完全不同的文件。
也就是说,内容完全相同的模型可能被保存两次。
三、统一模型缓存身份
修复的关键之一,是将“模型的逻辑身份”和“模型的下载地址”分开。
错误的缓存方式类似:
const cacheKey = modelDownloadUrl;
这种方式会导致 ModelScope 和 Hugging Face 上的同一模型产生两份缓存。
调整后,每个模型使用稳定的逻辑标识:
const cacheIdentity = [
modelFamily,
modelRevision,
language,
voice,
quantization
].join(":");
例如:
kokoro:revision-20260804:en:female:q8
无论文件最终来自 ModelScope 还是 Hugging Face,都写入同一个缓存身份。
这样既能支持国内外下载源切换,也能避免重复占用浏览器存储空间。
四、国内与海外使用不同下载源
浏览器本地推理并不代表完全不依赖网络。用户第一次使用某种语言时,仍然需要下载模型文件。
为了提高不同网络环境下的可用性,我们采用了双镜像方案:
- 中文界面和国内环境优先从 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 file = await downloadArtifact(source, artifact);
await writeSharedVoiceCache(artifact.cacheIdentity, file);
return file;
} catch (error) {
reportSourceFailure(source, error);
}
}
throw new VoiceModelUnavailableError();
}
这里还有一个重要的产品细节:不能把浏览器底层的 Failed to fetch 直接显示给用户。
用户需要看到的是可操作的信息,例如:
配音模型暂时无法下载,请检查网络后重试,系统已经自动尝试备用下载源。
五、避免超大模型挤爆缓存
英文配音原来使用的 Kokoro FP32 模型体积约为 325MB,并且优先创建 WebGPU 会话。
这种方案理论性能较强,但在真实浏览器环境中存在几个问题:
- 初次下载时间长;
- Cache Storage 占用高;
- WebGPU 图编译时间不可预测;
- 部分显卡驱动兼容性不稳定;
- 容易与其他语言模型争夺存储空间。
修复后,英文配音改为约 92MB 的 Q8 量化模型,并使用更稳定的 WASM 推理。
const session = await ort.InferenceSession.create(modelBuffer, {
executionProviders: ["wasm"],
graphOptimizationLevel: "all"
});
量化模型的体积下降后,首次加载速度和缓存成功率都有明显改善。
在视频编辑器场景中,配音推理通常不是持续高频任务。相比追求理论上的最高 GPU 性能,更重要的是:
- 用户第一次能成功生成;
- 再次使用时可以直接读取缓存;
- 切换语言后不会破坏已有模型;
- 中低配置设备也能稳定运行。
六、缓存不足时只清理过期资源
简单调用 caches.delete() 清空所有缓存虽然容易实现,但会导致用户重新下载刚刚使用过的模型。
因此,我们增加了模型缓存治理策略:
- 计算当前语言和音色所需的模型身份;
- 将正在使用的模型标记为受保护资源;
- 优先删除旧版本模型;
- 清理最近最少使用的其他语音模型;
- 保留应用静态资源和当前活跃模型;
- 重新尝试写入缓存;
- 如果仍然失败,允许在内存中继续完成本次推理。
伪代码如下:
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) {
return;
}
await evictStaleVoiceModels({
preserve: [activeIdentity],
strategy: "least-recently-used"
});
}
这样能够在不破坏用户当前任务的前提下,主动降低存储配额异常的概率。
七、进度条必须反映真实阶段
不同模型运行时能够提供的进度信息并不完全一致。
如果直接把所有资源数量平均分配,5KB 的配置文件和 90MB 的 ONNX 模型就会占用相同的进度比例,结果自然不准确。
我们调整为按模型字节数计算下载进度,并将加载过程拆成几个明确阶段:
- 检查本地缓存;
- 下载模型资源;
- 初始化推理运行时;
- 准备配音;
- 生成语音。
其中,下载阶段根据真实字节数更新:
const progress = loadedBytes / totalBytes;
onProgress(Math.round(progress * 100));
进入推理会话创建阶段后,不再假装仍然处于下载状态,而是切换提示文案:
正在初始化本地配音引擎
这样即使 WebGPU 编译或 WASM 初始化需要一定时间,用户也知道系统正在执行什么操作。
八、为什么需要先让浏览器完成一次渲染
即使状态已经更新,React 的界面也不一定会立刻显示。
如果设置“生成中”状态后马上执行同步或高负载 WASM 推理,浏览器主线程可能来不及绘制页面,用户看到的仍然是旧界面。
因此,在开始重任务前,我们主动让出一次渲染时机:
setGenerationState({
status: "generating",
progress: 0
});
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
await generateVoice();
这个改动看起来很小,但对于浏览器端 AI 应用非常重要。
用户先看到明确的状态反馈,然后模型才开始执行推理,可以显著减少“页面卡死”的感受。
九、Service Worker 不应该重复保存大模型
Service Worker 很适合缓存 JavaScript、CSS、图标和普通静态资源,但不适合在没有统一策略的情况下自动缓存大型 ONNX 文件。
如果模型加载模块已经缓存了一份响应,而 Service Worker 又克隆响应并再次写入自己的缓存,就可能产生两份大文件。
因此,我们对大型语音模型采用单一缓存责任:
- 模型管理器负责模型文件缓存;
- Service Worker 不再重复缓存大型 ONNX 响应;
- 静态应用资源继续由 Service Worker 管理;
- 所有语音运行时共享统一的模型缓存清单。
这让缓存结构更加可预测,也更容易进行版本迁移和空间回收。
十、不同语言不应该强行使用同一套运行时
多语种配音系统并不是“一个模型加一个语言参数”这么简单。
不同模型在音色、体积、浏览器兼容性和推理速度方面各有特点。因此,本项目保留了多运行时架构:
| 语言类型 | 主要运行时 | 执行方式 |
|---|---|---|
| 中文 | Piper | WebGPU 优先,WASM 回退 |
| 英文 | Kokoro Q8 | WASM |
| 部分欧洲语言 | Piper | WASM |
| 韩语、泰语、越南语、俄语 | MMS | WASM |
| 日语 | Supertonic | WASM |
上层业务不直接关心模型内部实现,而是通过统一接口调用:
interface VoiceRuntime {
prepare(options: VoiceOptions): Promise<void>;
synthesize(text: string): Promise<AudioBuffer>;
dispose(): Promise<void>;
}
这样新增语言时,只需要实现对应的运行时适配,不需要修改时间线、素材库和导出流程。
十一、修复后的验证结果
完成架构调整后,我们重点验证了以下场景:
- 英文首次加载和重复生成;
- 德语连续生成两次;
- 韩语本地推理;
- 泰语模型下载与合成;
- 日语 Supertonic 初始化;
- 中文模型国内外下载源切换;
- 浏览器缓存接近配额上限时的自动清理;
- 页面刷新后的模型缓存复用。
修复后,多语言模型均能正常完成加载和生成,重复生成不会再次显示完整模型下载过程,控制台中也不再出现未处理的 QuotaExceededError。
十二、这次修复带来的工程经验
这次问题说明,浏览器端 AI 应用不能只关注“模型能不能运行”,还需要把模型当成完整的产品资源进行管理。
需要重点关注:
- 模型文件是否固定到了不可变版本;
- 国内外下载源是否具有相同缓存身份;
- Service Worker 是否重复缓存大文件;
- 浏览器配额不足时是否能够自动恢复;
- 进度条是否反映真实下载字节和执行阶段;
- 重任务开始前,界面是否获得了渲染机会;
- WebGPU 不可用时,是否存在稳定的 WASM 回退;
- 切换语言后,旧模型是否会无限累积。
浏览器端 AI 的真正难点,往往不在某一次推理,而在下载、缓存、版本、运行时和交互状态能否长期稳定地协同工作。
总结
“模型卡在 86%”只是表面现象。
背后的真实问题是:
- 大模型重复缓存;
- 浏览器存储配额耗尽;
- 国内外镜像缓存身份不一致;
- WebGPU 初始化异常缺少回退;
- 进度状态与真实执行阶段脱节;
- 主线程繁忙导致界面无法及时刷新。
通过引入量化模型、统一缓存身份、双镜像下载、缓存淘汰策略、WASM 回退和分阶段进度提示,我们最终让中文、英文、日语、韩语、泰语以及多种欧洲语言都能在浏览器中稳定完成本地配音。
如果你也在开发浏览器端 AI 应用,不妨把模型下载和缓存系统视为一项独立的基础设施。模型能够成功运行只是起点,能够在不同设备、网络和存储状态下稳定恢复,才是真正面向用户的实现。
项目地址:
https://github.com/MartinDelophy/ai-video-editor
在线体验: