一、从“说话”到“跑在浏览器里”
文本转语音(TTS)有三条技术主线:
- 拼接法:把录好的语料切片拼起来,音质最好,体积最大;
- 参数法:用声码器(STRAIGHT、WORLD)压缩特征,体积小,机械感重;
- 神经网络:Tacotron2、FastSpeech2、VITS 端到端,自然度逼近真人,计算量也最大。
浏览器原生只给了一个“黑盒”——speechSynthesis,底层各平台实现参差不齐:Win10 用 OneCore、macOS/iOS 用 AVSpeech、Android 用 Google TTS。好处是零成本,坏处是“能响就行”,不可定制、不可路由、不可离线。
二、Web Speech API 的“三不能”
- 不能指定输出设备:默认走系统扬声器,
<audio>的setSinkId都不给用; - 不能接进 Web Audio:
destinationNode永远收不到声音,于是“边读边录”成了幻觉; - 不能离线缓存:语音每次都是实时请求,断网就哑。
因此,做“网页配音”可以,做“严肃业务”得另想办法。
写了个示例代码,直接拿去:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>语音历史记录示例</title>
<style>
.history-container {
max-height: 300px;
overflow-y: auto;
border: 1px solid #ddd;
padding: 10px;
margin-top: 20px;
}
.history-item {
padding: 8px;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background-color 0.2s;
}
.history-item:hover {
background-color: #f0f0f0;
}
.controls {
margin: 15px 0;
}
</style>
</head>
<body>
<div class="controls">
<textarea id="textInput" rows="3" cols="50" placeholder="输入要合成的文本"></textarea>
<br>
<button onclick="speak()">播放语音</button>
<button onclick="pauseSpeech()">暂停</button>
<button onclick="resumeSpeech()">继续</button>
<button onclick="cancelSpeech()">停止</button>
</div>
<h3>语音历史记录</h3><button onclick="clearAll()">清空</button>
<div class="history-container" id="historyContainer">
<!-- 历史记录将通过JS动态生成 -->
</div>
<script>
// 语音合成控制器
const synthesis = window.speechSynthesis;
let mediaRecorder;
let audioChunks = [];
let audioContext;
let destinationNode;
// 初始化 AudioContext(用于录制合成语音)
function initAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
destinationNode = audioContext.createMediaStreamDestination();
}
}
function clearAll() {
localStorage.clear();
renderHistory()
}
// 渲染历史记录
function renderHistory() {
const container = document.getElementById('historyContainer');
container.innerHTML = '';
const recordings = JSON.parse(localStorage.getItem("speechRecordings") || "[]");
if (recordings.length === 0) {
container.innerHTML = '<div class="history-item">暂无历史记录</div>';
return;
}
recordings.forEach((recording, index) => {
const div = document.createElement('div');
div.className = 'history-item';
div.innerHTML = `
<strong>${escapeHtml(recording.text.substring(0, 30))}${recording.text.length > 30 ? '...' : ''}</strong>
<div style="font-size: 0.8em; color: #666" onclick="replaySpeech(${recording.id})">
语速: ${recording.rate}x | 音高: ${recording.pitch} | 语言: ${recording.lang}
</div>
<div>
<button onclick="deleteRecording(${recording.id})">删除</button>
</div>
`;
// 点击历史项回放
container.appendChild(div);
});
}
// 播放语音
function speak() {
const text = document.getElementById('textInput').value.trim();
if (!text) return alert('请输入要合成的文本');
initAudioContext();
audioChunks = []; // 清空之前的录音
// 创建新的语音对象
const utterance = new SpeechSynthesisUtterance(text);
// 获取当前配置(或使用默认值)
utterance.lang = document.getElementById('langSelect')?.value || 'zh-CN';
utterance.rate = parseFloat(document.getElementById('rateInput')?.value) || 1.0;
utterance.pitch = parseFloat(document.getElementById('pitchInput')?.value) || 1.0;
utterance.volume = parseFloat(document.getElementById('volumeInput')?.value) || 1.0;
// 2. 创建 MediaRecorder 录制合成后的音频,不在同一个音频通道,无法录制
// mediaRecorder = new MediaRecorder(destinationNode.stream);
// mediaRecorder.mimeType = "audio/ogg"; // 或 "audio/webm"
//
// mediaRecorder.ondataavailable = (event) => {
// if (event.data.size > 0) audioChunks.push(event.data);
// };
//
// mediaRecorder.onstop = () => {
// const audioBlob = new Blob(audioChunks, { type: "audio/ogg" });
// const audioUrl = URL.createObjectURL(audioBlob);
// // 保存到历史记录(限制最多保存20条)
// let recording ={
// text,
// lang: utterance.lang,
// rate: utterance.rate,
// pitch: utterance.pitch,
// volume: utterance.volume,
// name: `语音_${new Date().toLocaleString()}.ogg`,
// url: audioUrl,
// timestamp: new Date().toISOString()
// }
//
// // 简单存储在内存中(实际项目建议用 IndexedDB)
// let recordings = JSON.parse(localStorage.getItem("speechRecordings") || "[]");
// recordings.unshift(recording);
// localStorage.setItem("speechRecordings", JSON.stringify(recordings));
// renderHistory();
// };
// 重新渲染历史记录
renderHistory();
// 5. 语音结束时停止录制(监听 SpeechSynthesis 事件)
utterance.onend = () => {
if (mediaRecorder && mediaRecorder.state !== "inactive") {
// mediaRecorder.stop();
}
let recording ={
id:'_'+Math.random(),
text,
lang: utterance.lang,
rate: utterance.rate,
pitch: utterance.pitch,
volume: utterance.volume,
// name: `语音_${new Date().toLocaleString()}.ogg`,
// url: audioUrl,
timestamp: new Date().toISOString()
}
// 简单存储在内存中(实际项目建议用 IndexedDB)
let recordings = JSON.parse(localStorage.getItem("speechRecordings") || "[]");
recordings.unshift(recording);
localStorage.setItem("speechRecordings", JSON.stringify(recordings));
renderHistory();
};
// 4. 开始录制 + 播放语音
// mediaRecorder.start();
// 播放语音
synthesis.speak(utterance);
}
// 回放历史语音
function replaySpeech(id) {
let recordings = JSON.parse(localStorage.getItem("speechRecordings") || "[]");
const item = recordings.find((item)=>item.id ===id);
const utterance = new SpeechSynthesisUtterance(item.text);
utterance.lang = item.lang;
utterance.rate = item.rate;
utterance.pitch = item.pitch;
utterance.volume = item.volume;
synthesis.speak(utterance);
}
// 暂停/继续/停止函数(同前示例)
function pauseSpeech() { synthesis.pause(); }
function resumeSpeech() { synthesis.resume(); }
function cancelSpeech() { synthesis.cancel(); }
// 防止XSS攻击的简单转义
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// 播放录音
function playRecording(url) {
const audio = new Audio(url);
audio.play();
}
// 删除录音(仅前端,刷新后失效)
function deleteRecording(id) {
let recordings = JSON.parse(localStorage.getItem("speechRecordings") || "[]");
recordings = recordings.filter(r => r.id !== id);
localStorage.setItem("speechRecordings", JSON.stringify(recordings));
renderHistory();
}
// 初始化页面
document.addEventListener('DOMContentLoaded', () => {
// 添加语音配置控件(可选)
const controls = document.querySelector('.controls');
controls.innerHTML += `
<div style="margin-top: 10px">
<label>语言:
<select id="langSelect">
<option value="zh-CN">中文</option>
<option value="en-US">英文</option>
</select>
</label>
<label>语速: <input type="number" id="rateInput" min="0.1" max="10" step="0.1" value="1"></label>
<label>音高: <input type="number" id="pitchInput" min="0" max="2" step="0.1" value="1"></label>
<label>音量: <input type="number" id="volumeInput" min="0" max="1" step="0.1" value="1"></label>
</div>
`;
renderHistory();
});
</script>
</body>
</html>
三、前端纯离线方案:把模型搬进 WASM
1. 选型
- espeak-ng:1.2 M 体积,支持 100+ 语料,机械嗓但能用;
- onnx-tts / piper:基于 VITS,30 M 模型,自然度≈云厂商 70%,CPU 30× 实时;
- whisper-tts:结合 LLM,情感控制更强,但 100 M+,首次下载劝退。
2. 流水线
文本 → 前端分句 → 标点处理 → WASM 模型 → AudioBuffer →
├─ 直接 play()
├─ MediaRecorder 录成文件
└─ Web Audio 加混响、BGM 再输出
3. 代码骨架(espeak-ng 版)
import { EspeakNg } from 'espeak-ng-wasm';
const tts = await EspeakNg.create({ voice: 'zh' });
const buf = await tts.synthesize('你好,这是浏览器里跑出来的离线 TTS');
const src = audioCtx.createBufferSource();
src.buffer = buf;
src.connect(audioCtx.destination);
src.start();
体积 1.3 M,首屏 200 ms 内可响,移动端 Snapdragon 660 实测 10 min 文本 2× 实时合成。
四、云端方案:要啥给啥,记得省钱
- Google Cloud TTS:标准语音 4 美元/百万字符,WaveNet 16 美元;
- Azure Neural TTS:支持 140+ 语言,SSML 细调情绪;
- 国内厂商:阿里、讯飞、腾讯按次计费,长文本记得开“超长合成”接口,不然 300 字就断。
前端只需:
const res = await fetch('/api/tts', {
method: 'POST',
body: JSON.stringify({ text, voice:'zh-CN-XiaoxiaoNeural' })
});
const blob = await res.blob();
URL.createObjectURL(blob); // 直接播、下载、缓存都行
优点:自然度 90+%,缺点:走网络、有成本、需做配额与降级。
五、混合策略:把云当“高配”,把 WASM 当“保底”
- 在线时优先走云,返回 mp3 顺手写 IndexedDB 做 LRU 缓存;
- 离线或超出配额自动切到 WASM,保证功能不挂;
- 播放层统一用 Web Audio,音量淡入淡出、打断、拼接无裂缝。
六、踩坑小结
-
iOS 必须用户手势触发
AudioContext.resume(),否则静音; -
MIME 白名单:
audio/mpeg、audio/wav、audio/webm;codecs=opus,其他格式 Safari 直接拒播; -
长文本分句再合成,避免单次
AudioBuffer超过 3 min 导致内存暴涨; -
GC 陷阱:
URL.createObjectURL用完记得revoke,否则 500 次后卡死。
七、一句话收束
浏览器自带的 speechSynthesis 只能“响个声”,要音质、要离线、要路由、要特效,就把模型搬进 WASM,或者让云端帮你算好。TTS 不再是“播放按钮”,而是一条可缓存、可编辑、可混音的“音频生产线”。