听到「一句话断在半截」,先别急着改切分脚本,也别立刻换音色。SparkAudio 1.0 这类 TTS 链路里,断句通常来自三处:单段合成返回的音频尾部本身就被截断、请求参数与写进容器的采样率或声道不一致、以及切分和拼接时把尾巴裁掉或补了过长静音。可行的排查顺序是先用一句完整短句单独合成并原样保存返回文件,再用 ffprobe 读容器参数,最后才去调切分粒度。
判断顺序建议是:单段完整性 → 容器格式 → 切分粒度 → 拼接裁切。每一步只改一个变量,并保留原始返回文件做对照。如果单段合成就不完整,问题在请求侧或模型输出,调切分粒度基本无效;如果单段完整,断点才可能出在自己写的那段拼接代码里。下面的命令和阈值需结合本机 ffmpeg 版本、音色配置和文本长度确认。
先确认单段返回的音频本身是否完整
先准备一句 15 到 25 字、带标点的完整短句,只合成这一句,不要附带上下文。保存返回时要拿到原始字节,不要在中间做解码、重采样或重写 WAV 头,否则后面无法区分是服务端截断还是本地处理截断。下面是一段通用骨架,语音调用部分按你实际的 SDK 或 HTTP 请求替换:
# 只保存服务端返回的原始字节,先不解码、不重采样
resp = client.synthesize(text=single_sentence, voice=VOICE, sample_rate=REQ_SR, format="wav")
raw = getattr(resp, "audio_bytes", None) or resp.content
open("single_raw.wav", "wb").write(raw)接着核对时长。中文播报语速通常在每秒四到六个字之间,可以拿这个区间做粗比:把 ffprobe 读出的时长除以字数,若结果明显低于这个区间,就要怀疑尾部被裁;若明显偏高,更可能是采样率被写错导致整段被拉长。这一步只是粗比,不作为判定标准,真正的依据还是听最后半秒有没有突然静音或削字。
ffprobe -v error -show_entries format=duration -of csv=p=0 single_raw.wav用命令行读出声卡参数与文件真实采样率
很多时候请求里写的是 24000 Hz,而服务端或本地写进容器的却是别的值,听感上就表现为语句被「压扁」或某个音节消失,容易被误判成切分太碎。用下面的命令一次读出容器、采样率、声道、码率和时长:
ffprobe -v error -select_streams a:0 -show_entries stream=codec_name,sample_rate,channels,bit_rate,duration -of default=noprint_wrappers=1 single_raw.wav把输出结果和你发出请求时的参数逐项对照,建议按下面这张表记录一次:
| 对照项 | 请求参数 | 容器读出 | 不一致时的常见听感 |
|---|---|---|---|
| 采样率 | 如 24000 | 如 16000 或 44100 | 语速整体变慢或变快,句中像吞字,时长与字数之比失真 |
| 声道数 | 单声道 | 双声道 | 拼接后左右声道错位,听起来像两次发音叠在一起 |
| 容器格式 | wav | mp3 或带编码头的包装 | 段尾自带 padding 静音,拼起来断点处空白偏长 |
| 时长 | 按字数估算 | 明显偏短 | 最后半句被削掉,常见于长文本单次请求 |
若采样率和声道都对得上,断点又只在长文本里出现,问题范围就基本落到切分和拼接这一侧了。
把切分粒度调粗再看断点是否消失
这一步的目的是验证切得太碎是否切断了语义。取同一段 300 到 500 字的文稿,做两组对照:A 组优先按句号、问号等句末标点切,单段上限放宽;B 组按逗号或固定字符数切,单段上限收紧。其余参数(音色、采样率、输出格式)保持完全一致,分别合成后拼接,再逐段听。
记录表建议至少包含断点位置前后各三个字,这样下次出现同样听感时可以直接比对,而不是凭印象说「这里断了」:
| 组别 | 切分规则 | 段数 | 断点前三个字 | 断点后三个字 | 断点处听感 | 是否复现 |
|---|---|---|---|---|---|---|
| A | 按句末标点,单段上限放宽 | 待填 | 待填 | 待填 | 待填 | 待填 |
| B | 按逗号或固定字数 | 待填 | 待填 | 待填 | 待填 | 待填 |
如果 A 组断点消失、B 组集中在逗号处出现断口,说明这份文稿确实被切得过碎,尤其是把「但是」「因此」这类连接词切到段首时更容易听出截断感。需要注意的是,这个结论只针对本次测试文本,换成长句多、数字多的稿件需要重新做一次对照,不能直接套用。
检查拼接处的静音长度与重叠裁切
如果单段文件单独播放完整,拼起来才有断口,问题就在拼接脚本。两类常见错误:一是段间补的静音过长,让正常停顿变成空白;二是切分时为了保上下文让相邻段重叠了若干字,拼接时却没有裁掉重叠部分,导致重复发音或音节错位。下面是一段可替换的拼接骨架:
from pydub import AudioSegment
GAP_MS = 120 # 段间静音,先用小值试,不要一上来就补 500ms
OVERLAP_MS = 0 # 切分时保留的重叠时长,没有重叠就保持 0
out = AudioSegment.empty()
for i, path in enumerate(seg_files):
seg = AudioSegment.from_file(path)
if OVERLAP_MS and i > 0:
seg = seg[OVERLAP_MS:] # 裁掉与前一段重叠的部分
out += seg
if i != len(seg_files) - 1:
out += AudioSegment.silent(duration=GAP_MS)
out.export("joined.wav", format="wav")试听对比建议分三轮:第一轮正常速度整体听一遍,第二轮用 0.75 倍速播放重点听断点,第三轮把断点前后两段单独播放,确认单段尾部本身没有缺字。三轮结论不一致时,以单段播放结果为准,因为拼接会掩盖段内问题。段间静音一般可以先从 80 到 150 毫秒试起,句子之间的停顿靠文本标点表达,不要靠补静音硬凑。
把定位到的那一步固化成默认参数
定位到具体环节后,把参数写回配置,而不是每次出问题再手改脚本。至少要固化下面几项:
- 切分阈值:优先按句末标点切,单段字符上限固定,遇到超长句再按逗号兜底;
- 段间静音长度:写成一个常量,与重叠裁切时长分开配置;
- 采样率与声道:请求参数和导出参数保持同一值,不要一处 24000 一处 44100;
- 输出格式:统一为 wav 或统一为同一种编码,避免不同容器尾部 padding 长度不一致。
固化后需要一条能重复运行的验证命令,用来检查产物是否都符合预期,可以放在每次生成后执行:
for f in out/seg_*.wav out/joined.wav; do
ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate,channels,duration -of csv=p=0 "$f" | sed "s|^|$f,|"
done回归时比对的文件建议固定为三份:单段原始返回 single_raw.wav、拼接产物 joined.wav、以及上面那张记录断点前后三个字的对照表。下次同一类文稿再出现断在半句,先用这三份文件对照,通常几分钟内就能判断是切分阈值不合适,还是拼接处的静音与重叠没对齐。