把 TTS 搬进浏览器:kokoroi-rs 的 WASM 实践
摘要:本文深入解析 kokoroi-rs 项目的 WASM 模块,展示如何将 Kokoro TTS 模型编译为 WebAssembly,在浏览器中实现纯前端、零服务器的高质量中文语音合成。文章涵盖整体架构(G2P 引擎 + ONNX 推理)、两种推理后端(ONNX Runtime Web 与纯 Rust oxionnx)的深度对比、JavaScript API 设计、构建与 Demo 演示,以及隐私安全、性能表现与未来方向。适合关注 Rust + WASM + AI 的前端开发者、隐私敏感应用及边缘计算场景。
关键词:TTS, WebAssembly, WASM, Rust, 语音合成, 浏览器, kokoroi-rs, ONNX, 隐私安全, 前端AI
纯前端运行,隐私数据不出设备,一个 3MB 的 WASM 模块实现高质量中文语音合成
引子:云 TTS 的隐忧与 WASM 的机遇
语音合成(TTS)服务已经非常普及,但大多数解决方案依赖云端 API:用户把文本传到服务器,服务器生成音频后返回。这个过程带来了几个绕不开的问题:
- 隐私风险:文本内容会经过第三方服务器,对于涉及个人隐私或商业机密的场景,这是一个硬伤。
- 网络依赖:需要稳定的互联网连接,弱网环境下体验打折扣。
- 成本:商用 API 按字符计费,大规模使用不是小数目。
那么,有没有可能让 TTS 完全在浏览器本地运行?WebAssembly 的出现让这种想法成为可能。kokoroi-rs 的 WASM 模块正是这样一个尝试------将 Kokoro TTS 的核心能力编译为 WebAssembly,让高质量语音合成直接运行在用户的浏览器中,无需任何后端服务器。
本文将深入拆解 kokoroi-rs 的 WASM 架构、两种推理后端、JavaScript API 设计以及实际使用中的性能表现。
一、整体架构:WASM 模块 + 推理后端
kokoroi-rs 的 WASM 模块将 Rust 代码编译为 wasm32-unknown-unknown 目标,通过 wasm-bindgen 生成 JavaScript 胶水代码,最终交付一个约 3MB 的 WASM 二进制文件。
整个系统的核心功能分两层:
- G2P 引擎:将中文文本转换为 Bopomofo(注音符号)音素序列------这是模型输入的前置步骤,完全在 Rust 中实现。
- ONNX 推理引擎:将音素序列 + 发音人风格嵌入 → PCM 音频采样。
推理部分提供了两种后端选择,开发者可以根据需求灵活切换:
graph TB subgraph Browser"浏览器环境" JS"JavaScript 调用层" WASM"Rust WASM 模块\
(kokoros_bg.wasm)" subgraph WASM"WASM 内部功能" G2P"ChineseG2P\
字素转音素" WAV"WAV 编码器" OX"oxionnx 推理\
(纯 Rust)" end ORT"ONNX Runtime Web\
(CDN 加载)" end subgraph Data"数据文件" MODEL"ONNX 模型文件\
(\~80MB)" STYLE"发音人嵌入\
(\*.bin)" end JS -->|init| WASM JS -->|phonemize| G2P JS -->|synthesize| OX JS -->|InferenceSession| ORT G2P -->|Bopomofo| OX G2P -->|Bopomofo| ORT OX -->|PCM| WAV ORT -->|PCM| WAV WAV -->|WAV Uint8| JS OX -.->|加载| MODEL ORT -.->|加载| MODEL JS -.->|fetch| STYLE
这两个后端各有侧重:ONNX Runtime Web 是微软官方方案,稳定性好、优化成熟;而 oxionnx 是纯 Rust 实现,与 WASM 集成更紧密,无需额外加载推理库。
二、G2P 引擎:中文处理的基石
文本到音素的转换是 TTS 的第一步,也是中文场景中最复杂的环节之一。WASM 模块中的 ChineseG2P 引擎与 Native 版本共享同一套代码,保证了处理逻辑的一致性和准确性。
核心流程如下:
flowchart TD TEXT输入中文文本 --> NORM文本规范化 NORM --> SEGjieba-rs 分词 + POS 标注 SEG --> DISAMB多音字消歧\
基于上下文规则 DISAMB --> SANDHI变调处理\
三声变调 / 一不变调 SANDHI --> MAP拼音 → Bopomofo 映射 MAP --> OUT输出音素序列
几个关键点:
- jieba-rs 分词:WASM 版本同样使用 jieba-rs,词典数据以压缩形式嵌入 WASM 二进制中,首次调用时解压(约数百毫秒一次性开销),之后常驻内存。
- 多音字消歧 :
PolyphonicDisambiguator维护了一套基于规则的上文消歧表,例如"了"在"了解"中读liǎo,在"好了"中读 `le"。 - 变调处理:实现了经典的三声变调规则(两个三声相连,前变二声)以及"一"、"不"的变调规律。
最终输出 Bopomofo(注音符号)序列,这是 Kokoro 模型原生训练使用的音素表示,准确度优于 IPA。
三、JavaScript API:简洁易用
WASM 模块通过 wasm-bindgen 导出清晰的 JavaScript 接口,开发者可以像使用普通 JS 库一样调用。
初始化
javascript
import init, { KokoroWASM, pcm_samples_to_wav_data } from './wasm-pkg/kokoros.js';
// 加载 WASM 运行时
await init();
// 创建 TTS 实例
const kokoro = new KokoroWASM({ usePolyphonic: true });
实战示例:从文本输入到音频播放/下载
下面是一个完整的 HTML 页面示例,展示如何将文本合成为语音并播放或下载,包含完善的错误处理和进度提示:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>kokoroi-rs TTS 演示</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
textarea { width: 100%; height: 100px; margin-bottom: 1rem; }
button { padding: 0.5rem 1rem; margin-right: 0.5rem; cursor: pointer; }
.status { margin-top: 1rem; padding: 0.5rem; border-radius: 4px; }
.status.loading { background: #fff3cd; color: #856404; }
.status.success { background: #d4edda; color: #155724; }
.status.error { background: #f8d7da; color: #721c24; }
progress { width: 100%; margin-top: 0.5rem; }
</style>
</head>
<body>
<h1>kokoroi-rs 浏览器 TTS</h1>
<textarea id="textInput" placeholder="请输入要合成语音的中文文本...">你好,欢迎体验浏览器端语音合成。</textarea>
<button id="playBtn">🔊 播放</button>
<button id="downloadBtn">⬇️ 下载 WAV</button>
<progress id="progressBar" value="0" max="100" style="display:none;"></progress>
<div id="status" class="status">就绪</div>
<script type="module">
import init, { KokoroWASM, pcm_samples_to_wav_data } from './wasm-pkg/kokoros.js';
const textInput = document.getElementById('textInput');
const playBtn = document.getElementById('playBtn');
const downloadBtn = document.getElementById('downloadBtn');
const progressBar = document.getElementById('progressBar');
const statusDiv = document.getElementById('status');
let kokoro = null;
let lastAudioBuffer = null; // 缓存最近一次合成的音频
function setStatus(msg, type = '') {
statusDiv.textContent = msg;
statusDiv.className = 'status' + (type ? ' ' + type : '');
}
function setProgress(pct) {
progressBar.style.display = pct >= 0 ? 'block' : 'none';
progressBar.value = Math.max(0, Math.min(100, pct));
}
// 初始化 WASM 模块
async function initTTS() {
try {
setStatus('正在加载 WASM 模块...', 'loading');
setProgress(20);
await init();
setProgress(50);
setStatus('正在初始化 TTS 引擎...', 'loading');
kokoro = new KokoroWASM({ usePolyphonic: true });
setProgress(80);
// 加载 ONNX 模型(以 oxionnx 模式为例)
setStatus('正在加载语音模型(约 80MB)...', 'loading');
const modelResp = await fetch('/models/kokoro.onnx');
if (!modelResp.ok) throw new Error(`模型下载失败:HTTP ${modelResp.status}`);
const modelBytes = new Uint8Array(await modelResp.arrayBuffer());
await kokoro.loadModel(modelBytes);
setProgress(100);
setStatus('✅ 就绪,可以开始合成语音', 'success');
playBtn.disabled = false;
downloadBtn.disabled = false;
} catch (err) {
setStatus(`❌ 初始化失败:${err.message}`, 'error');
playBtn.disabled = true;
downloadBtn.disabled = true;
console.error('TTS init error:', err);
} finally {
setProgress(-1); // 隐藏进度条
}
}
// 合成语音
async function synthesize(text) {
if (!kokoro) throw new Error('TTS 引擎未初始化');
if (!text.trim()) throw new Error('请输入文本');
setStatus('正在合成语音...', 'loading');
setProgress(30);
// 获取默认发音人
const voices = await kokoro.getVoices();
if (!voices.length) throw new Error('未找到可用发音人');
const defaultVoice = voices[0];
setProgress(50);
// 执行合成
const result = await kokoro.synthesize(text, defaultVoice.id, 1.0);
setProgress(80);
// 转换为 WAV
const wavBytes = pcm_samples_to_wav_data(result.audio);
setProgress(100);
lastAudioBuffer = wavBytes;
setStatus(`✅ 合成完成(音素:${result.phonemesDisplay})`, 'success');
setProgress(-1);
return { wavBytes, result };
}
// 播放音频
async function handlePlay() {
try {
const { wavBytes } = await synthesize(textInput.value);
const blob = new Blob([wavBytes], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.onended = () => URL.revokeObjectURL(url);
await audio.play();
} catch (err) {
setStatus(`❌ 播放失败:${err.message}`, 'error');
setProgress(-1);
}
}
// 下载音频
async function handleDownload() {
try {
const { wavBytes } = await synthesize(textInput.value);
const blob = new Blob([wavBytes], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `tts-${Date.now()}.wav`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setStatus('✅ 下载已开始', 'success');
} catch (err) {
setStatus(`❌ 下载失败:${err.message}`, 'error');
setProgress(-1);
}
}
// 绑定事件
playBtn.addEventListener('click', handlePlay);
downloadBtn.addEventListener('click', handleDownload);
// 启动初始化
playBtn.disabled = true;
downloadBtn.disabled = true;
initTTS();
</script>
</body>
</html>
代码说明:
- 初始化阶段 :通过
init()加载 WASM 运行时,创建KokoroWASM实例,然后fetch下载 ONNX 模型并调用loadModel()加载。每一步都有进度提示和错误捕获。 - 合成流程 :
synthesize()函数先获取默认发音人,再调用synthesize(text, voiceId, speed)执行推理,最后用pcm_samples_to_wav_data()将 PCM 数据编码为 WAV 格式。 - 播放与下载 :
handlePlay()将 WAV 转为 Blob URL 后通过Audio播放;handleDownload()创建<a>标签触发下载。 - 错误处理 :网络错误(
fetch失败)、模型加载失败、无可用发音人等场景均有try/catch捕获并显示友好提示。 - 进度提示 :使用
<progress>元素和状态栏实时反馈加载与合成进度。
核心方法一览
| 方法 | 说明 | 返回值 |
|---|---|---|
phonemize(text) |
文本 → Bopomofo 音素 | Promise<string> |
phonemizeIPA(text) |
文本 → IPA 音素 | Promise<string> |
tokenize(phonemes) |
音素 → token ID 数组 | Promise<Uint32Array> |
loadModel(bytes) |
加载 ONNX 模型(oxionnx 模式) | Promise<void> |
synthesize(text, style, speed) |
文本 → 语音(oxionnx 模式) | Promise<SynthesisResult> |
getVoices() |
获取内置发音人列表 | Promise<VoiceInfo[]> |
类型安全
项目提供了完整的 TypeScript 类型声明(kokoros.d.ts),在 IDE 中可以获得智能提示:
typescript
interface SynthesisResult {
phonemes: string; // Bopomofo 音素序列
phonemesDisplay: string; // 可读注音(带声调符号)
text: string; // 原始文本
audio: Float32Array; // PCM 音频数据(24000Hz 单声道)
sampleRate: number; // 固定 24000
}
辅助函数 pcm_samples_to_wav_data(samples) 将 Float32Array 转换为标准 WAV 格式的 Uint8Array,方便播放或下载。
四、两种推理模式深度对比
WASM 模块最有趣的设计是提供了两套推理路径,开发者可根据场景选择。
模式一:ONNX Runtime Web(混合架构)
sequenceDiagram participant JS participant WASM as Rust WASM (G2P) participant ORT as ONNX Runtime Web JS->>WASM: phonemize(text) WASM-->>JS: Bopomofo 音素 JS->>JS: tokenize + 构造 Tensor JS->>ORT: InferenceSession.run(feeds) ORT-->>JS: PCM Float32Array JS->>JS: 编码为 WAV
- G2P 部分由 Rust WASM 完成(高效、小巧)
- 模型推理 使用微软官方的
onnxruntime-web库,从 CDN 加载ort.min.js及其 WASM 后端 - 发音人嵌入由 JavaScript 通过
fetch加载后传入
适合场景:对推理稳定性和算子覆盖度要求较高的生产应用。
模式二:纯 Rust WASM + oxionnx(全栈 Rust)
sequenceDiagram participant JS participant WASM as Rust WASM (G2P + oxionnx) JS->>WASM: loadModel(modelBytes) JS->>WASM: synthesize(text, style, speed) WASM->>WASM: G2P → Tokenize → oxionnx 推理 WASM-->>JS: PCM Float32Array + 音素信息 JS->>JS: 编码为 WAV
- 全部逻辑(G2P、推理、WAV 编码)都在 Rust WASM 内部完成
- 推理使用 oxionnx------一个纯 Rust 实现的 ONNX 推理库
- 无需加载任何外部 JavaScript 推理库,零 CDN 依赖
适合场景:离线应用、对隐私要求极高、希望最小化外部依赖的场景。
| 对比维度 | ONNX Runtime Web | oxionnx(纯 Rust) |
|---|---|---|
| 推理库来源 | 微软官方 CDN | 编译进 WASM |
| WASM 体积 | ~3MB(仅 G2P) | ~3MB(含推理) |
| 额外 JS 加载 | ort.min.js (~200KB) |
无 |
| 算子覆盖度 | 广泛 | 核心算子(持续完善中) |
| 优化选项 | 丰富(图优化、量化等) | 基础 |
| 硬件加速 | WebGL / WASM SIMD | WASM SIMD |
| 离线使用 | 需缓存 CDN 资源 | 完全离线可用 |
五、构建与 Demo:从源码到浏览器
构建 WASM 模块
bash
# 安装 WASM 目标
rustup target add wasm32-unknown-unknown
# 安装 wasm-pack
cargo install wasm-pack
# 一键构建(推荐)
./scripts/build_wasm.sh
# 或手动构建
wasm-pack build crates/kokoros-core \
--target web \
--out-dir ../../static/wasm-pkg \
-- \
--features wasm \
--no-default-features
构建产物位于 static/wasm-pkg/ 目录下:
static/wasm-pkg/
├── kokoros_bg.wasm # WASM 二进制 (~3MB)
├── kokoros.js # 胶水代码 (~28KB)
├── kokoros.d.ts # TypeScript 类型声明
└── package.json
Demo 页面
项目提供了三个可直接运行的演示页面,位于 static/ 目录:
wasm_demo.html--- 使用 ONNX Runtime Web 后端,展示完整的 G2P + 推理流程。browser_demo.html--- 流式合成体验,更接近实时对话场景。rust_wasm_demo.html--- 纯 Rust WASM(oxionnx)后端,展示零外部依赖的全离线方案。
运行方式:
bash
cd static
python3 -m http.server 8080
# 或
npx serve .
打开浏览器访问 http://localhost:8080/wasm_demo.html 即可体验。
六、隐私与安全:数据不出设备
这是 WASM 版本最吸引人的特性之一。对比云 TTS 方案:
传统云 TTS:
用户文本 ──(网络)──→ 云服务器 ──(网络)──→ 返回音频
文本离开用户设备,存在隐私泄露风险
kokoroi-rs WASM:
用户文本 ──(本地)──→ WASM 模块 ──(本地)──→ 播放音频
文本始终在浏览器沙箱内,永不离开用户设备
对于一些敏感场景(如医疗咨询、金融信息、个人日记等),本地 TTS 有着天然的优势。同时,由于无需网络请求,弱网环境下也能稳定工作。
七、性能与局限
性能表现
| 维度 | 数据 |
|---|---|
| G2P 处理速度 | < 1ms / 字符 |
| 模型加载(80MB) | 首次约 1-3 秒(取决于网络) |
| 推理实时率 | 约 1-2 倍实时(受 WASM 引擎限制) |
| 内存占用 | 约 100-200MB |
| WASM 体积 | ~3MB(含 G2P + oxionnx) |
当前局限
- 单线程推理 :WASM 不支持
std::thread,无法利用多核并行处理长文本分片。对于超长文本,目前的方案是整体推理,可能会造成 UI 卡顿(可通过 Web Worker 缓解)。 - 模型下载体积:ONNX 模型约 80MB,发音人嵌入约 150MB,首次加载需要一定时间。后续可以利用浏览器缓存或 OPFS 减少重复下载。
- 仅 WAV 输出:受限于 WASM 下的音频编码库支持,目前只支持 WAV 格式(PCM 16-bit)。MP3/Opus 等压缩格式暂不支持。
- 浏览器兼容性:需要支持 WebAssembly 和 SharedArrayBuffer(部分旧浏览器不支持)。
八、未来方向
WASM 模块的潜力远不止于此,团队已经在规划几个有意思的方向:
- Web Worker 并行:利用多个 Web Worker 各加载一个 WASM 实例,实现分片并行推理,弥补单线程的不足。
- OPFS 模型缓存:利用 Origin Private File System 将下载的模型持久化在本地,第二次访问时几乎秒开。
- 流式合成:将 Native 版本的 SSE 流式机制移植到 WASM,实现"边合成边播放"的低延迟体验。
- 混合模式:G2P 在本地 WASM 执行,推理通过 WebSocket 连接私有推理服务器------兼顾隐私和性能。
结语
kokoroi-rs 的 WASM 模块展示了如何将 AI 模型"搬进浏览器"------通过 Rust + WebAssembly,我们可以在不牺牲质量的前提下,让 TTS 服务脱离云端,真正属于用户自己。
对于前端开发者、隐私敏感应用以及边缘计算场景,这套方案提供了一条全新的技术路径。如果你也关注 Rust + WASM + AI 这个方向,不妨去 GitHub 仓库看一看,跑一跑 Demo,甚至参与进来一起完善。
本文基于 kokoroi-rs 项目 WASM 模块的文档和源码整理,欢迎交流讨论。