不用后端、不调 API,只靠 JS 和 100MB 的模型,在你浏览器里说出“Hello World”!
前言:我为什么要在浏览器里“造人声”?
作为一个 React 初学者,我一直以为 AI 都是云端的神明——高高在上、遥不可及。直到某天刷到 Hugging Face 的开源社区,发现居然有人把 文本转语音(TTS)模型打包成 JavaScript 库,还能直接跑在浏览器里!
那一刻,我的中二之魂燃了🔥:
“我要在我的网页里,养一个会说话的小 AI!”
于是,就有了这篇记录——不仅是一次技术实践,更是一场与 Web Worker、Promise.all、单例模式 和 内存爆炸 的斗智斗勇。
项目目标:让 AI 用不同声音念出“我爱掘金”
- ✅ 纯前端实现,无需后端 API
- ✅ 支持多个“说话人”(比如男声、女声、机器人声)
- ✅ 模型懒加载 + 进度条反馈
- ✅ 用 React 写得像模像样(至少看起来是)
- ❌ 不要卡死用户电脑(差点就翻车了)
技术栈:不是魔法,但接近
| 工具 | 作用 |
|---|---|
@xenova/transformers | Hugging Face 官方 JS 库,把 PyTorch 模型搬到浏览器 |
SpeechT5ForTextToSpeech | 文本 → 语音特征向量 |
SpeechT5HifiGan | 特征向量 → 听得懂的人声(wav) |
Web Worker | 防止主线程被大模型干趴下 |
Tailwind CSS | 让 UI 不那么丑(且几乎不用写 CSS) |
React Hooks | 状态管理,假装自己很专业 |
难点一:模型太大,不能每次点按钮都重新下载!
想象一下:你点一次“生成语音”,浏览器就要下载 3 个 30MB+ 的模型文件……用户早跑了。
解决方案:单例模式 + Web Worker
我把模型初始化逻辑封装在一个叫 MyTextToSpeechPipeline 的类里:
static async getInstance(progress_callback = null) {
if (this.tokenizer_instance === null) {
this.tokenizer = AutoTokenizer.from_pretrained(this.model_id, { progress_callback });
}
// model 和 vocoder 同理...
}
关键点:所有实例都是
static,整个 Worker 生命周期只加载一次。
第二次点击?直接复用,快如闪电⚡️。
难点二:主线程不能卡!否则页面变“冰雕”
模型推理非常吃 CPU,如果直接在 React 主线程跑,页面会直接冻结。
解决方案:Web Worker 异步处理
// App.jsx
useEffect(() => {
worker.current = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
}, []);
所有 TTS 任务都扔给 Worker,主线程只负责:
- 显示进度条 📊
- 播放音频 🎧
- 装作很忙的样子 😎
Worker 通过 postMessage 和主线程通信:
// worker.js
self.onmessage = async (e) => {
const [tokenizer, model, vocoder] = await MyTextToSpeechPipeline.getInstance(...);
// ...处理 TTS
self.postMessage({ status: 'complete', output: audioBlob });
};
难点三:音色怎么换?靠“说话人嵌入”!
这个项目最酷的地方:支持不同角色的声音!
Hugging Face 提供了预提取的说话人特征向量(.bin 文件),每个 512 维:
static async getSpeakerEmbeddings(speaker_id) {
const url = `${this.BASE_URL}${speaker_id}.bin`;
const data = new Float32Array(await (await fetch(url)).arrayBuffer());
return new Tensor('float32', data, [1, 512]);
}
我建了个 Map 缓存已下载的音色,避免重复请求:
const speaker_embeddings_cache = new Map();
用户选“女声 Clara”?去下载 clb.bin;选“男声 Bob”?下载 bdl.bin。
效果:同一个句子,不同人念,感情都不一样(虽然有点机械,但够用了)!
难点四:进度条怎么显示?
模型下载时,用户不能干等。@xenova/transformers 提供了 progress_callback:
AutoTokenizer.from_pretrained(model_id, {
progress_callback: (data) => self.postMessage(data)
});
回调数据包含 file、progress 等字段,我在 React 里用 useState 管理进度数组:
case 'progress':
setProgressItems(prev =>
prev.map(item =>
item.file === e.data.file ? { ...item, progress: e.data.progress } : item
)
);
配合 Tailwind 写个动态宽度的进度条,瞬间高级感拉满👇
<div className="w-full bg-gray-200 rounded">
<div className="bg-blue-500 h-4 rounded" style={{ width: `${percentage}%` }} />
</div>
成果展示:你的浏览器,现在是个播音室!
(此处应有图:一个简洁的输入框 + 下拉选择音色 + “Generate”按钮 + 音频播放器)
输入:“稀土掘金真香!”
选择音色:“女声 Sally”
点击按钮 → 2 秒后,浏览器里传出甜美(略带电子感)的声音:“Xī tǔ jué jīn zhēn xiāng!”
全程无网络请求(除了首次下载模型) ,完全离线运行!
避坑指南(血泪经验)
-
别在开发环境狂点按钮
模型没加载完就多次触发?内存直接爆。记得加disabled状态。 -
Worker 事件监听要手动移除
否则组件卸载后还会收消息,导致内存泄漏:return () => { worker.current?.removeEventListener('message', onMessageReceived); }; -
模型默认用 fp16,但浏览器可能不支持
强制指定dtype: 'fp32'更稳妥。 -
音频 Blob 要用
URL.createObjectURL
直接传给<audio>标签才能播放。
结语:前端的边界,正在被 AI 重新定义
以前我们觉得“AI 是后端的事”,但现在,一个 React 组件 + 一个 Worker + 几个开源模型,就能在浏览器里实现 TTS、图像识别、甚至本地聊天机器人。
这不仅是技术玩具,更是未来的趋势:隐私友好、离线可用、零服务器成本。