背景痛点:在线 TTS 的“三座大山”
很多团队最初都直接调用云端 TTS,几行代码就能出声,看似省心,却很快撞上三堵墙:
- 延迟高:公网链路动辄 200 ms+,遇上晚高峰还抖动,实时对话场景里“你说完 1 秒我才答”的体验直接劝退用户。
- 隐私差:语音数据必须明文上传,医疗、金融客户一听就摇头,合规审计过不了。
- 成本高:按字符计费,量一大账单就飙升;为了省字把提示词砍得面目全非,结果音质又下降。
把模型搬回本地,一次性买断 GPU 就能无限调用,延迟压到 30 ms 以内,数据还留在自家硬盘,这三座大山瞬间被削平。
技术选型:TensorFlowTTS / VITS / Piper 谁更适合你?
开源圈能打的 TTS 框架不少,先给一张“速查表”:
| 框架 | 语言支持 | 音质 MOS | 显存占用 (FP32) | 特点 | 缺点 |
|---|---|---|---|---|---|
| TensorFlowTTS | 中/英/日 | 4.1 | 1.8 GB | 模型库丰富,文档全 | 依赖重,推理代码冗长 |
| VITS | 中/英/韩 | 4.3 | 2.1 GB | 端到端,音色克隆强 | 训练门槛高,ONNX 转换易踩坑 |
| Piper | 英(社区扩展中文) | 3.9 | 0.9 GB | 量化模型 200 MB,超轻 | 中文韵律模型少,需自己转音素 |
结论
- 服务器有 RTX 3060 以上显存,直接上 VITS,MOS 最高。
- 边缘盒子只有 4 GB 内存,选 Piper,量化后 CPU 也能跑 2× 实时。
- 需要多语种快速 Demo,TensorFlowTTS 的预训练模型最全,先跑通再迭代。
核心实现:30 分钟搭一套可并发 REST 服务
1. 环境骨架
# Ubuntu 22.04 + Python 3.10 pip install fastapi uvicorn onnxruntime-gpu phonemizer2. ONNX 量化模型加载(以 VITS 为例)
# model_loader.py import onnxruntime as ort from typing import List import numpy as np class VitsTTS: def __init__(self, model_path: str, device_id: int = 0): # 注册 CUDA 提供器,开启 FP16 providers = [ ("CUDAExecutionProvider", { "device_id": device_id, "arena_extend_strategy": "kSameAsRequested", }), "CPUExecutionProvider", ] self.session = ort.InferenceSession(model_path, providers=providers) self.sample_rate = 22050 def synthesize(self, phonemes: List[str]) -> np.ndarray: """ phonemes: 已转换的音素 ID 序列 返回: 16-bit PCM,[-1, 1] """ seq = np.expand_dims(np.array(phonemes, dtype=np.int64), 0) seq_len = np.array([seq.shape[1]], dtype=np.int64) noise = np.random.randn(1, 256).astype(np.float32) # VITS 噪声输入 audio = self.session.run( None, {"input": seq, "input_lengths": seq_len, "noise": noise} )[0].squeeze() return audio3. FastAPI 暴露接口
# main.py from fastapi import FastAPI, Response from model_loader import VitsTTS import io import soundfile as sf app = FastAPI() tts = VitsTTS("vits_zh_q.onnx") @app.post("/tts") def text_to_speech(text: str, response: Response): phonemes = text_to_phoneme(text) # 自定义音素转换 pcm = tts.synthesize(phonemes) # 动态头部,告诉浏览器是音频流 response.headers["Content-Type"] = "audio/wav" buf = io.BytesIO() sf.write(buf, pcm, tts.sample_rate, format="WAV") return Response(content=buf.getvalue(), media_type="audio/wav")4. 流式生成 + 缓冲区管理
# streamer.py import queue import threading import numpy as np class StreamBuffer: def __init__(self, chunk_size: int = 1024): self.q = queue.Queue() self.chunk_size = chunk_size self.closed = False def write(self, data: np.ndarray): """模型回调,整段音频写入队列""" for i in range(0, len(data), self.chunk_size): self.q.put(data[i : i + self.chunk_size]) self.q.put(None) # 结束标志 def read(self): """FastAPI 迭代器,逐块返回给前端""" while True: chunk = self.q.get() if chunk is None: break yield chunk.tobytes()FastAPI 端点改为StreamingResponse,media_type="audio/pcm",前端 Web 拿到audio/x-wav流即可边下边播,延迟再降 40%。
性能优化:把 RTF 压到 0.05 以下
基准测试
测试文本:“你好,欢迎使用本地化 TTS。”
硬件:i5-12400 / RTX 3060 / 32 GB RAM精度 平均延迟 RTF FP32 82 ms 0.18 FP16 38 ms 0.08 INT8 量化 22 ms 0.05 模型预热
服务启动时先跑一条 dummy 文本,CUDA kernel 与显存分配完毕,首条真实请求不再额外编译,P99 延迟降低 30%。内存驻留
将onnxruntime的graph_optimization_level设为ORT_ENABLE_ALL,并开启trt_fp16_enable,显存占用略增 150 MB,换来 20% 吞吐提升。动态批处理
对并发场景实现长度对齐 + 动态 batch:- 缓存 50 ms 内的请求
- 按音素长度分组,最大 batch=4
- 合并后一次推理,再切片返回
实测 QPS 从 35 → 110,延迟仅增加 6 ms。
避坑指南:中文音素与日志监控
音素遗漏
中文多音字“行”在“银行”里读hang2,在“步行”里读xing2。直接用开源phonemizer会漏调,需要接入pypinyin的严格模式,再手动校正姓氏词表。批大小过大
动态 batch 别一味求高,显存峰值 = 最大批大小 × 最大序列长度 × 嵌入维度 × 4 Byte。压爆显存会触发 OOM,推理进程重启,客户端收到 502。建议设置上限为显存 70%。日志监控
用prometheus-client暴露tts_inference_duration_seconds直方图,搭配 Grafana 面板,RTF>0.1 就报警。日志里再打印音素长度与 batch 大小,方便复现异常。
延伸思考:再往前走一步就是离线语音对话
TTS 只是“说”,要让设备“听懂”还得接 ASR。整个链路可以全部离线:
- ASR:用 faster-whisper 或 WeNet 中文模型,RTF 0.06,普通 CPU 可跑。
- LLM:7B 量化模型 + llama.cpp,单卡 RTX 4090 能 30 token/s。
- TTS:本文方案。
三段式管道:麦克风 → ASR → LLM → TTS → 扬声器
全部在本地,延迟 600 ms 以内,隐私零外泄。把三套服务封装进一个 Docker Compose,再配个 WebSocket 网关,就是一台“离线语音对话小主机”。
动手试试:从 0 打造个人豆包实时通话 AI
如果看完想亲手跑一遍端到端链路,包括 ASR→LLM→TTS 的完整闭环,可以打开火山引擎的从0打造个人豆包实时通话AI动手实验。教程把模型申请、环境镜像、流式代码都打包好了,照着敲命令就能在浏览器里跟“豆包”实时唠嗑。实测本地笔记本 3060 也能 30 ms 内回声返回,小白跟着步骤走完全没压力,值得一试。