开篇:数字朗读的那些坑
做客服系统的朋友都懂,最怕听到机器人把“您的余额为 12345.67 元”读成“一万二千三百四十五点六七”,用户直接懵:我到底还剩多少钱?金融报数、快递电话、验证码播报,场景不同,数字读法却必须“像人”。ChatTTS 默认把连续数字当成整数处理,结果“123”秒变“一百二十三”,和“一二三”相差十万八千里。痛点总结一句话:数字格式不对,业务直接翻车。
方案对比:三选一怎么挑
我先后试过三条路,踩坑记录如下:
直接调 API
最省事,却最不可控。ChatTTS 内部规则黑盒,数字读法随版本变,今天“123”是“一二三”,明天就可能变“一百二十三”。线上事故复盘时只能干瞪眼。正则替换
自己先把“123”换成“一二三”再喂给 ChatTTS。可控、轻量,半小时能跑通。缺点是正则写不严谨就错杀,比如把“1号线”拆成“一号线”,用户听着别扭。自定义发音词典
把数字、单位、多音字全部写进词典,ChatTTS 优先走词典, fallback 再走模型。前期工作量最大,但一次到位,后期基本不动。金融客户最认这条,宁可多花两天排词典,也不想上线后因为读错钱数被投诉。
一句话总结:
- 原型阶段→直接调 API
- 快速上线→正则替换
- 长期运营→自定义词典
核心代码:从“123”到“一二三”
下面给出可复用的 Python3.8+ 模块,开箱即用。整体思路:正则抓数字 → 转拼音 → 拼回文本 → 喂给 ChatTTS。
# num2spell.py import re from typing import Dict, List _DIGIT_MAP: Dict[str, str] = { "0": "líng", "1": "yī", "2": "èr", "3": "sān", "4": "sì", "5": "wǔ", "6": "liù", "7": "qī", "8": "bā", "9": "jiǔ" } # 预编译正则,O(n) 扫描 _RE_NUMBER = re.compile(r"\d+(?:\.\d+)?") # 匹配 123 或 123.45 def _digits2spell(match: re.Match) -> str: """把纯数字串逐字转拼音,保留小数点读‘点’""" num: str = match.group() return " ".join(_DIGIT_MAP[ch] if ch in _DIGIT_MAP else "diǎn" for ch in num) def preprocess(text: str) -> str: """入口函数,线程安全,无全局状态""" return _RE_NUMBER.sub(_digits2spell, text)与 ChatTTS 的集成示例(官方 SDK 假设为chattts):
import chattts from num2spell import preprocess def tts_with_num(text: str, out_wav: str): clean = preprocess(text) tts = chattts.TTS() tts.t2w(clean, out_wav) # text-to-wave单元测试顺手写掉,pytest 一把过:
# test_num2spell.py import pytest from num2spell import preprocess @pytest.mark.parametrize("raw,exp", [ ("验证码1234", "验证码 yī èr sān sì"), ("余额123.45元", "余额 yī èr sān diǒu sì wǔ 元"), ]) def test_preprocess(raw, exp): assert preprocess(raw) == exp时间复杂度:正则一次扫描 O(n),n 为字符数;空间复杂度:输出新字符串 O(n)。百万级文本内存占用约 2 倍原串,可接受。
性能优化:别让预处理拖垮延迟
延迟预算
实测 200 字符文本,预处理 <1 ms,ChatTTS 本身 200 ms+,占比可忽略。但正则别写贪婪回溯,一旦用(.+\d+)+这类死亡模式,CPU 直接飙升。大文本内存管理
批量合成 10 MB 文稿时,避免text += text式拼接,改用io.StringIO流式读写;正则替换开启re.DEBUG观察回溯。若仍吃内存,可切片分段,每段 5 k 字符,合成完立即落盘,GC 及时回收。
生产环境注意事项
多音字陷阱
“1月”读“yī yuè”没错,但“第一名”要读“dì yī míng”。解决思路:扩展正则,先匹配“第\d+名”“\d+月”这类固定搭配,命中就走专用函数,不走通用 digit2spell。并发线程安全
上面_RE_NUMBER.sub与_DIGIT_MAP都是只读,无共享状态,放心直接放 Web 框架线程池。若后续动态热更新词典,加threading.RLock保护 reload 即可。错误重试
ChatTTS 偶现网络 502,外层包一层 tenacity:from tenacity import retry, stop_after_attempt, wait_exponential @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, max=10)) def safe_tts(text, out_wav): tts.t2w(text, out_wav)失败自动退避重试,避免把瞬时错误抛给用户。
开放讨论:自然度 vs 准确性,怎么选?
把“110”读成“yāo yāo líng”更口语,却和“一百一十”冲突;金融场景要求一字不差,客服场景又希望越自然越好。你的业务会倾向哪一边?或者,有没有办法让模型自己学会“场景感知”,先读准再读美?欢迎留言聊聊。