能跑出 wav 文件,只说明推理链路走通了,不代表输出正确。Ming-3.0 这类语音模型把采样率、声道数、时长这些参数分别放在推理配置和写文件两端,任意一端的默认值不同,听感就会变成“发闷”“语速偏快或偏慢”“尾音被吃掉”。判断是否正常,不看有没有音频文件,而是把输出侧参数读出来,与输入逐项对照,再按输入长短分类记录时长比例,最后看首尾静音和截断位置。
输出是否正常的判断口径:先用 ffprobe 或 soxi 读出输出采样率、声道、时长,与输入逐项比对;再按短、中、长三档输入记录输出/输入时长比例,看偏差是否随输入长度变化;首尾静音段异常或尾部出现成片零值,通常指向截断或补零。同一输入固定 seed 跑两次仍不一致,先归因随机性,再回到配置。以上都需要结合具体环境和配置确认,不能按单一文件的听感下结论。
把输出音频的参数读出来与输入逐项对照
这一步的目的很直接:确认推理链路有没有在输出侧偷偷改动采样率或声道。不要在播放器里靠耳朵判断,先读文件头。下面几条命令按你手头工具选一条执行,out.wav 换成实际输出文件,in.wav 换成对应的输入文件。
# 方式一:ffprobe
ffprobe -v error -show_entries stream=codec_name,sample_rate,channels,duration \
-of default=noprint_wrappers=1 out.wav
# 方式二:sox / soxi
soxi -r -c -D out.wav
# 方式三:Python 标准库,不依赖第三方
python -c "import wave,sys; w=wave.open(sys.argv[1]); print(w.getframerate(), w.getnchannels(), w.getsampwidth(), w.getnframes()/w.getframerate())" out.wav
把输入和输出的读数并排填进下表,一行对一行,不要只看输出。
| 项目 | 输入侧读数 | 输出侧读数 | 不一致时的下一处排查方向 |
|---|---|---|---|
| 采样率 | 查推理配置里的输出采样率字段、重采样环节是否被二次重采样、写文件时采样率参数是否与实际数据一致 | ||
| 声道数 | 查输出是单声道被复制成双声道,还是做了下混;确认模型期望的声道与写入声道一致 | ||
| 位深 / 编码 | 查写文件库的默认参数(如 16bit 与 float32 混淆),以及是否中途转码 | ||
| 时长 | 转入下一节的计时记录,不要在这一步下结论 |
采样率不一致时,声音发闷和语速异常往往是同一个原因:数据按 A 采样率生成、文件头却写成 B 采样率。常见情形是配置里写了输出采样率,但写文件的代码没有把它传进去。声道不一致时,先确认模型输出维度,再确认写文件时是否做了扩展。这两项没对齐之前,先不要去调音质相关的参数。
记录输入时长与输出时长的比例关系
单个文件的时长对不上,可能是静音裁剪、也可能是长度限制或分块逻辑。更有效的做法是按输入长短分档各记一条,看比例是否稳定。建议准备三档输入:短档一句话或几个字,中档一到两段,长档接近你实际使用的最长文本。
import subprocess, time, wave, json
def dur(p):
with wave.open(p) as w:
return w.getnframes() / w.getframerate()
cases = [("short", "in_short.wav"), ("mid", "in_mid.wav"), ("long", "in_long.wav")]
for tag, inp in cases:
out = f"out_{tag}.wav"
t0 = time.time()
subprocess.run(["python", "infer.py", "`--input`", inp, "`--output`", out], check=True)
wall = time.time() - t0
di, do = dur(inp), dur(out)
print(json.dumps({
"case": tag, "in_sec": round(di, 3), "out_sec": round(do, 3),
"ratio": round(do / di, 3) if di else None, "wall_sec": round(wall, 2)
}, ensure_ascii=False))
把三行结果记下来,重点看两件事:短档比例明显小于中长档,通常指向首尾静音被裁或短输入触发最小长度限制;长档比例明显下降,通常指向长度上限、分块拼接丢帧或尾部缓冲没写完。比例在三档之间基本一致但整体偏离,则更可能是采样率或时长字段写错,回到第一节核对。记录时同时写下输入文本字数、语言、是否带标点,便于判断偏差是否只出现在某一类输入上。
检查输出首尾的静音段与被截断的位置
时长对不上时,差异大多集中在首尾。这里不需要具体框架的接口,只要能拿到采样点序列,按固定窗口算能量即可。观察点是三个:从第 0 帧到第一个超过阈值的窗口(首部静音长度);从最后一个超过阈值的窗口到文件结束(尾部静音长度);尾部是否出现成片恒定零值。
def frame_energy(samples, sr, win_ms=20, thresh=1e-3):
n = max(1, int(sr * win_ms / 1000))
frames = [samples[i:i + n] for i in range(0, len(samples) - n + 1, n)]
energy = [sum(abs(x) for x in f) / len(f) for f in frames]
voiced = [i for i, e in enumerate(energy) if e > thresh]
if not voiced:
return "全静音或阈值偏高"
head = voiced[0] * win_ms / 1000
tail = (len(frames) - 1 - voiced[-1]) * win_ms / 1000
return {"head_silence_sec": round(head, 3),
"tail_silence_sec": round(tail, 3),
"total_sec": round(len(frames) * win_ms / 1000, 3)}
# 调用示例:读出 samples 后分别对输入、输出各算一次,再并排比较
# print(frame_energy(samples_in, sr_in))
# print(frame_energy(samples_out, sr_out))
首部静音明显长于输入,多半是生成端加了前置填充;尾部静音长且能量恒为零、长度刚好凑到某个整数值,通常是补零补齐,而不是模型真的输出了静音。真正被截断的表现是:最后一个有声窗口之后很快结束,且末尾波形有明显跳变或不自然的衰减。这三种情况处理方向不同,补零不用改模型参数,改的是写文件时的长度处理;截断则要回到长度上限和缓冲写入逻辑。阈值需要结合你的音频电平确认,安静录音把阈值调低,否则会把正常弱音判成静音。
用同一段输入重复跑两次并比对
确认参数和时长之后,还剩一种情况:同一段输入,两次结果听起来不一样。这时要先分清是采样随机性,还是配置在两个进程里读到了不同值。做法是固定输入和 seed,输出到两个文件,再比对。
# 两次运行,除输出文件名外其余参数完全一致
python infer.py `--input` demo.wav `--output` run1.wav `--seed` 1234
python infer.py `--input` demo.wav `--output` run2.wav `--seed` 1234
# 比对方式一:时长与文件特征
ffprobe -v error -show_entries stream=sample_rate,channels,duration \
-of csv=p=0 run1.wav run2.wav
# 比对方式二:逐采样点最大绝对差(同采样率、同长度时可用)
python -c "import wave,struct;\
import sys;\
A=wave.open('run1.wav'); B=wave.open('run2.wav');\
print('len', A.getnframes(), B.getnframes());\
print('rate', A.getframerate(), B.getframerate())"
比对顺序建议是:先比采样率、声道、总帧数,这三项不同说明不是随机性问题,直接回到前两节;三项相同再比首尾静音长度;最后比波形差异幅度。逐点比对时,同格式同长度的文件可以直接算最大绝对差,长度或格式不同就先不要做逐点差,先解释长度差。
出现差异时,记录内容建议固定为六项,便于后续判断:两次运行的完整命令行、seed 与温度等采样参数、输入文件的路径与时长、两次输出的时长与采样率、首尾静音长度差、最大波形差异幅度。如果两次结果在固定 seed 下仍有明显差异,通常说明该链路里存在未受控的随机源或未固定的一致性设置;如果固定 seed 后完全一致、换 seed 才变化,那属正常的采样行为,不需要改配置。记录模板建议每次排查都存一份,和输出文件放在同一目录,避免只凭听感来回判断。