在浏览器里运行 AI 配音,真正困难的往往不是“把 ONNX 模型加载起来”,而是如何同时处理模型体积、网络环境、浏览器存储配额、多个推理后端,以及用户能够理解的进度反馈。
最近我在开源浏览器视频编辑器 Timeline Studio 中,集中修复了一轮多语种 AI 配音问题。最初的现象非常典型:英文配音长时间停在 86%,切换德语、韩语、泰语或日语后,控制台又出现 QuotaExceededError。模型看似下载完成,界面却一直显示“正在生成”。
这篇文章记录问题背后的原因,以及如何把 Kokoro、Piper、MMS 和 Supertonic 四套浏览器语音运行时统一到一套可控的模型下载、缓存与进度体系中。
项目地址:https://github.com/MartinDelophy/ai-video-editor
在线体验:https://video-editor.ai-creator.top/
Timeline Studio 的配音全部在浏览器本地生成。不同语言使用的模型和运行时并不相同:
语言 | 模型/运行时 | 推理后端 |
|---|---|---|
中文 | Piper/VITS ONNX | WebGPU 优先,WASM 回退 |
英文 | Kokoro 82M q8 | WASM |
德语、西班牙语、法语、意大利语、葡萄牙语 | Piper/VITS ONNX | WASM |
韩语、越南语、俄语、泰语 | MMS VITS ONNX | WASM |
日语 | Supertonic 3 ONNX | WASM |
这些运行时最初各自管理缓存:
它们在代码上互相独立,但在浏览器里共享同一个站点存储配额。于是出现了一个典型的系统性问题:每个运行时都认为自己只占了一点空间,所有模型相加后却把整个 origin 的 quota 用满了。
控制台中的错误通常类似:
QuotaExceededError: The operation failed because it would cause
the application to exceed its storage quota.更麻烦的是,缓存写入失败不一定代表推理失败。很多时候模型字节仍然已经在内存中,本次生成本可以继续,但旧实现会暴露原始异常、错误清理当前模型,甚至再次下载同一个文件。
调查后发现,86% 并不是模型真实下载到了 86%。
Transformers.js 会为多个文件分别上报进度,例如:
config.json 100%
tokenizer_config.json 100%
tokenizer.json 100%
onnx/model_quantized.onnx 37%旧逻辑只要收到任意文件的 progress: 100,就会把整体进度推到预设上限 86%。一个只有几十 KB 的 JSON 文件很快完成,界面便提前显示 86%;真正几十或几百 MB 的 ONNX 模型其实还在下载或初始化。
修复方式是只使用主 ONNX 文件计算模型阶段进度:
const modelProgress = (event) => {
if (!String(event?.file || "").endsWith(".onnx")) return;
if (!Number.isFinite(event?.progress)) return;
const progress = 10
+ Math.max(0, Math.min(100, event.progress)) * 0.78;
onProgress({ ...event, progress });
};现在整个生成过程被拆成了清晰的阶段:
在开始主线程 WASM 推理前,还需要主动让 React 完成一次渲染。否则状态虽然已经在代码里变成“正在使用 WASM 生成配音”,浏览器却会因为后续同步计算繁忙,继续显示上一个“加载模型”文案:
onProgress({ backend: "wasm", progress: 92 });
await new Promise((resolve) => {
if (globalThis.requestAnimationFrame) {
requestAnimationFrame(resolve);
} else {
setTimeout(resolve, 0);
}
});这个小处理不会缩短推理时间,但会显著改善用户对当前阶段的理解。
英文 Kokoro 原先会先尝试加载约 325MB 的 fp32 WebGPU 模型。如果 WebGPU 会话在初始化或首次推理时长时间不返回,Promise 既不成功也不抛错,后续 WASM 回退就永远不会执行。
同时,项目文档和产品定位本来使用的就是 Kokoro q8 WASM。继续保留 fp32 WebGPU 首选路径不仅增加了约三倍下载量,也更容易触发浏览器配额与显存压力。
因此这次直接统一为稳定路径:
KokoroTTS.from_pretrained(modelId, {
dtype: "q8",
device: "wasm",
progress_callback: modelProgress,
});升级后还会定向删除废弃的 fp32 Kokoro 缓存,但不会清理项目素材或其他用户数据。模型从约 325MB 降到约 92MB,现代 Chromium 浏览器不再依赖 WebGPU 也能生成英文语音。
运行时 Promise 被保留在模块作用域中,同一页面重复生成会直接复用已经初始化的模型:
let runtimePromise;
async function loadRuntime() {
runtimePromise ??= createKokoroRuntime();
return runtimePromise;
}仅仅在每个引擎内部调用一次“清空缓存”并不能解决问题。真正需要的是一个跨运行时的存储协调层。
新实现会先读取浏览器对当前 origin 的用量估算:
const { usage = 0, quota = 0 } = await navigator.storage.estimate();
const storageIsTight = quota > 0 && (
usage / quota > 0.85
|| quota - usage < requiredBytes
);只有在使用率超过 85%,或者剩余空间不足以容纳即将加载的模型时,才启动清理。
清理遵循三个原则:
如果用户正在使用德语 Thorsten,已经完整缓存的 Thorsten 模型不会被删除。只淘汰其他 Piper 声音,避免重复生成时再次下载相同的 30~64MB 文件。
for (const storedVoice of storedVoices) {
if (storedVoice === selectedVoiceId) continue;
await tts.remove(storedVoice);
}共享的 Service Worker 缓存中还可能存在字幕、视觉、AI 音乐等模型。语音生成空间不足时,优先清理 timeline-studio-voice-models 下的其他语音家族,而不是删除用户素材或无关运行时。
持久化是性能优化,不应该成为推理成功的前置条件。如果 Piper 模型已经下载到内存,只是写入 OPFS 时配额不足,本次生成仍然继续。界面不再向普通用户显示原始浏览器异常。
这让缓存从“生成必需条件”变成了真正的“可选加速层”。
Timeline Studio 使用 Service Worker 为自有模型仓库做 cache-first 缓存,并为 Hugging Face 与 ModelScope 的镜像 URL生成统一缓存身份。
当 cache.put() 遇到 QuotaExceededError 时,Service Worker 不会阻塞当前网络响应,也不会为了填满缓存重新下载一次大模型。它只清理其他语音家族,为后续文件或下一次访问释放空间:
event.waitUntil(
cache.put(cacheRequest, response.clone()).catch(async (error) => {
if (error?.name !== "QuotaExceededError") throw error;
const staleVoiceKeys = keys.filter((key) =>
isVoiceModel(key)
&& modelFamily(key) !== currentFamily
);
await Promise.all(
staleVoiceKeys.map((key) => cache.delete(key))
);
}),
);这里有一个容易忽略的细节:不能为了失败重试预先创建多个 response.clone()。对于数百 MB 的流式响应,一个无人消费的 clone 可能导致浏览器缓存整条流,反而制造新的内存压力。因此当前请求始终优先服务推理,持久化失败留给后续请求自然恢复。
浏览器本地推理不代表模型无需下载。为了兼顾国内和海外网络,所有编辑器语音模型都放在项目自有的双镜像仓库中:
haixin/timeline-studio-voice-models;martindelophy/timeline-studio-voice-models。中文和国内环境优先 ModelScope,失败后回退 Hugging Face;海外环境使用相反顺序。两个来源都固定到不可变 revision,并映射为同一个缓存身份,因此切换提供商不会重复保存同一份模型。
路由可以抽象为:
中文/国内会话:ModelScope ──失败──► Hugging Face
海外会话: Hugging Face ──失败──► ModelScope
│
▼
统一模型缓存身份Kokoro 的音色文件原本在依赖库中写死了外部 URL。这次通过精确拦截音色请求,把它路由到自有镜像;其他网络请求仍使用原始 fetch,避免对应用全局请求造成影响。
统一缓存策略并不意味着所有模型使用同一种进度算法。
Piper 下载单个主要 ONNX 文件,可以按 loaded / total 显示真实字节进度。已经缓存时则直接进入初始化和推理。
Transformers.js 会同时报告 JSON、Tokenizer 与 ONNX 文件,因此只追踪 .onnx 主模型,避免小文件让总体进度提前完成。
日语 Supertonic 包含 Duration Predictor、Text Encoder、Vector Estimator 和 Vocoder 四个会话。旧实现是在“开始创建会话”之前更新进度,导致 100% 时最后一个模型仍未初始化。
现在改为每个会话成功创建后才提交阶段进度:
for (let i = 0; i < modelPaths.length; i += 1) {
const session = await loadOnnx(modelPaths[i].path, options);
sessions.push(session);
reportProgress(modelPaths[i].name, i + 1, modelPaths.length);
}用户看到的 25%、50%、75%、100%,分别对应真正已经就绪的子模型,而不是刚开始下载的任务数量。
这次修改不仅做了构建检查,也在实际浏览器中逐条运行了不同引擎:
工程检查包括:
npx eslint src/hooks/useVoiceGeneration.js \
src/lib/voiceModelStorage.js \
src/lib/piperVoiceRuntime.js \
src/lib/mmsVoiceRuntime.js \
src/lib/supertonicVoiceRuntime.js
npm run build
git diff --check模型能运行,只代表完成了第一步。真正可用的产品还需要同时处理下载源、缓存身份、存储配额、内存生命周期、推理后端、进度阶段和失败语义。
Cache Storage 和 OPFS 应该让第二次运行更快,而不是决定第一次能否成功。如果模型字节已经在内存中,缓存写入失败应该降级为“本次不持久化”,而不是让生成失败。
显示一个漂亮但不真实的 86%,比不显示进度更令人困惑。多文件模型必须区分“小配置已完成”和“主模型已完成”,多会话模型则应在会话真正可用后再更新阶段。
简单准备两个下载 URL 并不够。如果两个来源产生两份缓存,用户切换网络后会重复下载。Provider-independent cache identity 是双镜像方案真正落地的关键。
浏览器已经可以承载相当完整的本地 AI 媒体工作流,但大型模型带来的存储和资源压力不会自动消失。与其在报错后让用户手动清空所有网站数据,更合理的做法是让应用理解自己的模型家族、当前任务和缓存价值,主动做有边界的治理。
Timeline Studio 仍在持续迭代。如果你也在研究浏览器端 ONNX、WebAssembly、WebGPU、多轨视频编辑或本地优先 AI 应用,欢迎体验项目、提交 Issue 或参与贡献。
项目地址:https://github.com/MartinDelophy/ai-video-editor
在线体验:https://video-editor.ai-creator.top/
如果项目对你有帮助,也欢迎在 GitHub 点一个 Star。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。