一次「听起来只是多了一个语气词」的报障,最后落成一段带三道安全阀的输出侧裁切逻辑。
环境:Fun-CosyVoice3-0.5B · RTX 4060 Ti 16G · worker 分支 5.0.0 · 2026-10-29
TL;DR
| 项目 | 结论 |
|---|---|
| 现象 | 克隆音色「温柔姐9s」合成的语音,正文前多出一个约 0.2s 的「嗯」哼声,中间隔一小段静音 |
| 排除项 | 参考文本与参考音频不一致(CosyVoice 的经典坑)------ 转写逐字比对完全一致 |
| 根因 | 该音色的确定性模型行为:跨文本、跨随机种子、跨参考音频预处理都稳定存在 |
| 修复 | 输出侧按特征签名裁切(_trim_leading_blip),并升 TTS 缓存版本让旧成品失效 |
| 效果 | 伪影裁掉 0.33s,正文从 0.05~0.1s 直接开始;ASR 转写内容无缺失 |
真正有价值的部分不是那段裁剪代码(不到 40 行),而是怎么把一个听感问题变成可证伪的实验 ,以及为什么最终没有改输入端。
1. 现象
用户在测试页合成一句话,反馈「开头多了一个无关的音节」。
单听很难描述成「嗯」,更像一声含糊的起音。这一单对应的合成记录是 125735(17:44),音频长度正常、正文完整------只是开头多了一点东西。
先做的第一件事不是看代码,而是确认现场:把那条记录下载下来,切开开头 1 秒单独分析。
2. 先排除最像的那个原因:音文不一致
CosyVoice 的 zero_shot 模式有一个已知坑,也是本项目 踩过的:当 prompt_text 与实际参考音频不一致时,模型会把参考文本吐在成片开头。症状同样是「开头多了一段不是正文的话」。
所以第一步是最小成本的证伪:
- 从库里取出这个音色的
ref_audio与ref_text; - 下载
ref_audio,用 FunASR 转写; - 与库里的
ref_text逐字比对。
结果:9.775 秒,内容逐字一致。
这条线直接断掉。注意这一步的价值------它花了不到两分钟,却排除了一个「改后端数据就能修好」的方向,避免后续所有分析都建立在错误假设上。
顺带确认了合成模式:标准中文 + 有参考文本 → 走 zero_shot(不是 instruct2),单 chunk。
3. 把「听感」变成「可测量的量」
「开头多了一个音节」不足以定位。需要三级观测,逐级提高语义分辨率:
第一级:ASR 转写整条音频 ------ 结果是干净的。
这是关键的一次「观测工具失效」:ASR 主动吞掉了那个短音节(语音段检测从 0.15s 才开始)。不能因为转写干净就认为没问题,用户听到的是波形,不是转写文本。
第二级:能量包络 ------ 真相出现。
对开头 1 秒做逐帧 RMS:
0.00 ~ 0.20s 有明确人声能量(rms 峰值 8860)
0.20 ~ 0.35s 静音
0.35s 之后 正文「左边克隆...」开始
也就是说,音频开头确实多了一段有能量的人声 ,而不是静音、不是噪声、不是淡入残留。这也解释了为什么项目里现成的 _normalize_pacing(只裁首尾静音)完全没拦住它------哼声是有声的,不在静音判据的射程内。
第三级:切片识别 ------ 确认它是什么。
把 0~0.2s 切出来、复制拼接若干次后单独送 ASR,识别结果是一个 「嗯」。
既不是参考文本,也不是正文内容------是模型自己生成的一个语气起音。
4. 三组对照实验:证明「输入端没有可修的东西」
到这里已经可以猜「这是模型伪影」,但「猜」不能作为改架构的依据。需要证明:参考侧的所有可控变量都不是原因。
| # | 变量 | 操作 | 结果 |
|---|---|---|---|
| 1 | 参考音频起始状态 | 给参考音频加前置静音后再合成 | 开头 0~0.15s 仍有哼声(rms 7392),只是后面的静音变长了 |
| 2 | 随机性 | 种子 1234 → 42 | 开头仍有哼声(服务端种子固定,1234 下为确定性复现) |
| 3 | 参考音频自身起音 | 裁掉参考音频开头 0.05~0.1s 的低电平起音(rms 1008,ASR 都忽略的气声段) | 开头仍有 0~0.25s 的哼声 |
第 3 组实验来自一个具体假设:参考音频自己开头就有一段低电平气声,模型可能把它「学」进去并在生成时复现。实验结果是否掉------这个假设很自然,但错了。
再加一组横向证据(不做新推理,直接翻历史):
| 记录 | 文本 | 开头签名 |
|---|---|---|
125735 |
左边克隆一个音色... | 0~0.15s 有声 → 0.2~0.5s 静音 → 正文 |
125652 |
完全不同的文本 | 0~0.15s 有声 → 0.2~0.5s 静音 → 正文 |
同音色、不同文本、相同签名 → 伪影与文本无关,是这个音色的确定性模型行为。
结论
Fun-CosyVoice3 的 zero_shot 对部分克隆音色会在正文前稳定哼一个短音节。参考文本、参考音频内容、参考音频起始状态、随机种子,全都不影响它。
也就是说:输入端没有 bug 可修。要么换模型,要么在输出侧裁掉。
5. 修复:按「特征签名」裁切,而不是按「静音」裁切
5.1 签名定义
伪影的可观测特征是稳定的三段结构:
[短有声段 ≤0.35s] [静音间隙 ≥0.08s] [正文...]
只有三者同时成立才动手。这就是「误伤面小」的来源------真人朗读几乎不会以「短音节 + 明显停顿」开场。
5.2 三道安全阀
裁剪逻辑本身很短,但它必须宁可不动,也不能把话吃掉:
- 长度阀 :第一段有声段必须 ≤
0.35s(长的就不是哼声,是正文); - 后文阀:静音之后必须还有正文(整条音频只有一声短音节时,那可能本身就是正文,不裁);
- 保底阀 :裁完留
0.05s起手静音,不贴着脸切,避免削到字头。
python
# sse/utils/cosyvoice_tts_util.py
_LEADING_BLIP_MAX = 0.35 # 第一段有声段的上限
_LEADING_BLIP_GAP = 0.08 # 判定为「伪影后停顿」的最小静音
_BLIP_HEAD_KEEP = 0.05 # 裁完保留的起手静音
def _trim_leading_blip(path: str) -> None:
"""去掉输出开头的哼声伪影,就地把 wav 改掉。"""
if not path or not os.path.exists(path):
return
try:
import numpy as np
import soundfile as sf
wav, sr = sf.read(path)
mono = wav.mean(axis=1).astype("float32") if wav.ndim > 1 else wav.astype("float32")
silences = _silence_intervals_from_array(mono, sr, min_dur=_LEADING_BLIP_GAP)
except Exception as e:
logger.warning(f"[CosyVoice3] 开头伪影检测失败,保留原音频:{e}")
return
if not silences or silences[0][0] > _LEADING_BLIP_MAX:
return # 阀 1:开头不是短音节
end_of_first_gap = silences[0][1]
if end_of_first_gap >= len(mono) / sr - 0.15:
return # 阀 2:静音之后没正文
cut = int(max(end_of_first_gap - _BLIP_HEAD_KEEP, 0) * sr)
if cut <= 0 or cut >= len(wav):
return
logger.info(f"[CosyVoice3] trimmed leading blip: {cut / sr:.2f}s from {os.path.basename(path)}")
sf.write(path, wav[cut:], sr) # 阀 3:留 0.05s 起手静音
5.3 静音判据为什么不用 silencedetect
裁剪依赖「静音在哪」,而这个判据本身就是一次踩坑的产物:
CosyVoice 的停顿里混着气声与房间底噪(实测 -30dB 上下,只比语音低 6~8dB)。
- 用 ffmpeg
silencedetect固定 -40dB :同一段 12.5s 的旁白只认得出 0.28s 静音,等于白裁; - 定高了(-20dB):开始啃字头。
所以改用帧级 RMS 的相对门限(10ms 帧 / 25ms 窗):
语音电平 loud = p90(db)
门限 thr = max( p5(db) + 6dB, loud - 30dB, -34dB )
再夹一次:thr = min(thr, loud - 8dB) # 电平平坦的素材(一声长音)别整条被判成静音
这段判据现在是 _silence_intervals_from_array,被三处共用:伪影裁切、_normalize_pacing 的首尾裁剪、句内长停顿压缩。
5.4 挂在哪个位置
裁切放在 _infer_single 的落盘之后,而不是放在最外层 generate_speech:
_infer_single是唯一的落盘口------单段合成、拆尾合成的 head/tail 两段都要经过它,一处拦截全覆盖;- 拆尾那条路(
_infer_with_tail_guard)拼接前会重新整理静音,若把裁切放在更外层,head 段的伪影会先被拼进成片再处理,判据就被污染了。
5.5 缓存必须跟着失效
这一步容易漏,漏了等于没修:
TTS 产物有内容寻址缓存(key = 文本 + 参考音频 sha256 + 参数 + 模型/算法版本 )。后处理代码的改动不会改变 key ,而 store 又是「已存在就不覆盖」------旧缓存里的伪影成品会被继续命中,重跑也写不进去。
所以这次同时把 COSYVOICE_CACHE_VERSION 从 1 升到 2。
6. 验证:单测 → 真实合成 → 端到端
| 层级 | 做法 | 结果 |
|---|---|---|
| 单元 | 构造「0.2s 短音 + 0.3s 静音 + 1.5s 正文」 | 2.00s → 1.57s,伪影段被裁走 |
| 单元 | 构造「开头直接是正文」的 2.00s 音频 | 不变(不该误伤) |
| 真实合成 | 用「温柔姐9s」的参考音频跑 generate_speech |
日志 trimmed leading blip: 0.33s,正文从 0.1s 开始 |
| 端到端 | 后端 /voice/tts 提交 → worker 合成 → 回调(record 125736) |
产物 0.05s 直接正文,ASR 转写内容完整无缺字 |
端到端这一环用的是假身份(md5=...001),需要给它在 interactive_user.credits 和 credit_service(credit_limit / expire_date)两张表都补额度才能过 402;验完把两表记录和业务记录一起清掉。
7. 已知局限
诚实记录边界,比宣称「已完全解决」有用:
- 短应答词开头会被误裁 。像
OK,我们开始。这种「短音节 + 停顿」的开场,签名与伪影完全相同(OK约 0.3s)。目前的缓解是「静音后必须有正文」------它保证了不会裁空,但保不住语义。真正的解法需要文本对齐(判断开头音节是否落在文本首字上),属于下一步。 - 只裁一次,不做连环裁切。伪影目前只观察到一次,按未知情况不做过度设计。
- 依赖能量判据。极端素材(全程高噪、动态范围不足 12dB)会走「检测不出 → 保留原音频」的保守分支,不会误伤,但也不会修好。
- 老音色不会自动变干净。伪影是音色属性,已克隆音色靠输出侧兜底;新克隆走的是同一套输出侧逻辑,行为一致。
8. 可复用的四条方法论
- 先把最像的那个假设做成可证伪实验。「音文不一致」花两分钟证伪,省掉了后面所有基于错误假设的分析。
- 听感 → 能量 → 语义,三级观测递进。ASR 干净不代表音频干净:短音节会被语音活动检测吞掉。切出来放大、重复、再识别,是让「疑似语气词」变成「确定是嗯」的关键一步。
- 找到稳定复现的最小变量集,再决定改哪一侧。跨文本(历史记录)、跨种子、跨参考侧三种处理 ------ 三个变量都不影响,才敢下「输入端无解」的结论。少做一个对照,都可能是「改了输入端但其实没修好」。
- 修复位置要跟着因果走,缓存要跟着修复走。因果在输出侧就别在输入端堆补丁;而只要动了后处理,就必须升缓存版本------因为缓存 key 只对输入敏感,对代码不敏感,且写入是「不覆盖」的。