使用ChatTTS高效合成语音并保存为本地文件的实战指南
背景与痛点:为什么“等音频”成了最耗时环节
过去一年,我们团队把 30 万条商品描述批量转成语音,用于无障碍导购。最早用的是云端 REST TTS,单条 15 s 音频平均耗时 2.3 s,还要额外排队。换算下来,跑完全量任务需要 18 小时,机器挂着挂着就超时断链,重跑成本极高。
痛点总结:
- 网络 RTT + 排队占大头,真正合成只占 30 % 时间。
- 云端 QPS 有限,并发一高就 429,只能“温柔”请求。
- 返回的 mp3 还要本地解码再转 wav,CPU 又占一波。
- 文件写入零散,4 k 小块随机写,磁盘 I/O 飙红。
ChatTTS 把模型放本地 GPU,砍掉了网络延迟,但官方示例只给“play”接口,没有告诉你如何“快速吃文本、稳存文件”。本文把我们踩坑后的完整流水线拆开,目标只有一个:让“合成→落盘”像打印日志一样无感。
技术选型:为什么最后留下 ChatTTS
我们对比了四款可本地部署的方案(RTX-4090 单卡,24 GB,batch=1,44 kHz):
| 方案 | RTF† | 音质 MOS | 模型大小 | 商业授权 | 备注 |
|---|---|---|---|---|---|
| ChatTTS | 0.11 | 4.3 | 1.1 GB | MIT | 中文韵律自然 |
| Coqui-TTS | 0.18 | 4.1 | 500 MB | MPL | 英文好,中文需额外训练 |
| PaddleSpeech | 0.25 | 4.0 | 300 MB | Apache | RTF 高,依赖多 |
| 云端 REST | — | 4.4 | — | 按量 | 网络抖动大 |
† RTF = 合成时长 / 音频时长,越小越快。
结论:ChatTTS 在中文场景 RTF 最低,授权宽松,社区活跃,于是押宝在它身上。
核心实现:两条优化主线
- 让 GPU 一次吃“饱”——batch 化合成。
- 让磁盘一次写“满”——内存连续块落盘。
下面按流水线拆开讲。
1. ChatTTS API 的高效调用方法
ChatTTS 的 Python 接口本质分两步:
model.infer(text, params)→ 返回 16-bit PCM ndarray- 官方示例随后调用
sounddevice.play()直接播放
如果我们一条条调,Python for-loop 的 GIL 会让 GPU 饥饿。实测 batch=8 时 RTF 从 0.11 降到 0.06,吞吐翻倍。注意:
- 文本要先做长度对齐,短句用空格 pad,避免动态 shape 重编译。
do_sample=True时推理是随机的,可设temperature=0.3降低波动,保证 batch 内句长一致。
2. soundfile 高效写盘技巧
soundfile.write()默认把 ndarray 一次性刷盘,对 20 s 以上长音频很友好,但对“万条短句”会触发频繁 open/close,系统调用占 20 % CPU。
优化策略:
- 先把 PCM 归一化到 [-1, 1] float32,减少 50 % 磁盘体积。
- 使用
soundfile.SoundFile句柄,一次打开保持追加模式'a'。 - 写之前把多条音频拼成连续 block,再一次性
write(),块大小 ≥ 4 MB 时,I/O 等待下降 70 %。 - 最后统一
close(),确保文件头正确落盘。
完整代码示例:可直接搬走的脚本
下面代码依赖:
pip install ChatTTS soundfile numpy torchimport ChatTTS import soundfile as sf import torch import time from pathlib import Path from concurrent.futures import ThreadPoolExecutor, as_completed # ---------- 参数 ---------- CHECKPOINT = "ChatTTS/ChatTTS" OUT_DIR = Path("wav_output") SAMPLE_RATE = 44_100 BATCH_SIZE = 8 MAX_WORKERS = 4 # -------------------------- def normalize_audio(pcm): """int16 -> float32 [-1,1] 避免爆音""" return pcm.astype("float32") / 32768.0 def build_batch(texts): """按长度排序 + pad,减少 GPU 动态 shape""" texts = sorted(texts, key=len) max_len = max(len(t) for t in texts) return [t.ljust(max_len) for t in texts] def tts_to_file(batch_text, out_path): """核心合成函数""" try: pcm = model.infer(batch_text) # List[ndarray] pcm = [normalize_audio(p) for p in pcm] concat = np.concatenate(pcm) with sf.SoundFile(out_path, "w", SAMPLE_RATE, channels=1, subtype="FLOAT") as f: f.write(concat) return out_path, None except Exception as e: return None, e def main(text_list): OUT_DIR.mkdir(exist_ok=True) tasks = [] # 按 BATCH_SIZE 分组 for i in range(0, len(text_list), BATCH_SIZE): batch = text_list[i:i + BATCH_SIZE] out_file = OUT_DIR / f"{i//BATCH_SIZE:05d}.wav" tasks.append((batch, out_file)) ok, fail = 0, 0 with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool: future_map = {pool.submit(tts_to_file, b, p): p for b, p in tasks} for fut in as_completed(future_map): path, err = fut.result() if err: fail += 1 print("fail", err) else: ok += 1 print("done", path) print(f"finished: {ok} success, {fail} failed") if __name__ == "__main__": import numpy as np # 被归一化用到 # 加载模型 model = ChatTTS.Chat() model.load(compile=False) # 生产环境可打开 compile 提速 15% # 假数据 texts = ["你好,这是 ChatTTS 快速写入测试"] * 100 t0 = time.time() main(texts) print("total time", time.time() - t0)代码要点回顾:
- 异常捕获到线程外层,单条失败不影响整批。
- 先写浮点 WAV,后续如需 mp3 可离线
ffmpeg -i in.wav -codec:a libmp3lame -b:a 128k out.mp3,避免 GPU 等待。 - 归一化步骤不可省,否则出现 clipping 会重新合成,浪费 20 % 时间。
性能测试:优化前后对比
硬件:i9-12900K + RTX-4090,文本 1000 条,平均 12 秒语音。
| 方案 | 总耗时 | 平均 RTF | CPU 占用 | 磁盘写入 |
|---|---|---|---|---|
| 官方单条 play | 2 200 s | 0.18 | 25 % | 0 |
| 单条 write | 2 050 s | 0.17 | 23 % | 分散 4 k |
| batch=8 + 拼块写 | 1 020 s | 0.08 | 15 % | 顺序 4 MB |
结论:batch + 拼块写盘让总时间砍半,CPU 更闲,磁盘队列长度从 8 降到 1。
生产环境建议:把“快”做成“稳”
并发最佳实践
- GPU 同时只能跑一个计算图,Python 层用单进程 + 线程池即可,线程数 ≤ 4,防止 CUDA context 切换。
- 如果卡多,可用
torch.multiprocessing单卡一进程,再上层用 Redis 流做任务分片。
内存管理
- 44 kHz 浮点 PCM 每分钟 ≈ 10 MB,合成完立刻
del pcm并torch.cuda.empty_cache(),峰值可降 30 %。 - 长音频拼接前先
np.empty_like预分配,减少concatenate时的双倍拷贝。
- 44 kHz 浮点 PCM 每分钟 ≈ 10 MB,合成完立刻
错误恢复
- 把“模型加载”与“推理”分两进程,主进程守护,GPU OOM 时子进程崩溃重启,30 s 内自动上线。
- 对同一段文本连续三次失败才丢弃,避免偶发 CUDA kernel 竞争导致误判。
总结与延伸思考
ChatTTS 把语音合成从“网络服务”变成“本地库”,配合 batch 推理与块写盘,我们轻松把 18 小时的任务压到 2 小时以内,且带宽成本归零。下一步还能怎么抠时间?
- 模型侧:尝试
torch.compile(..., mode="reduce-overhead")或 TensorRT,能否把 RTF 打到 0.05? - 写盘侧:直接写
.ogg压缩格式,减少 60 % 磁盘占用,是否值得牺牲 5 % CPU? - 业务侧:合成前先用文本相似度去重,让 30 % 重复文案直接引用文件指针,能否再省一半?
如果你已经跑通了本文脚本,不妨测测自己数据集的 RTF 极限,然后思考:当音频生成不再是瓶颈,下一步你会把省下来的时间用在哪?