PESQ语音质量评估实战:MOS-LQO、采样率与可复现评测

发布时间:2026/10/27 12:37:26
PESQ语音质量评估实战:MOS-LQO、采样率与可复现评测

简介:mos-pesq.zip 是面向音频编解码与语音质量评估开发者的 PESQ/MOS 测试工具包,适合在项目开发中验证 PCM 编解码前后的音质差异,也可用于算法调试阶段的客观评分对比。压缩包共 12 个文件,以 5 个 C 源码和 3 个头文件为主体,辅以 Makefile、运行脚本、说明文档、测试音频压缩包及外层 zip 结构,整体约 207KB,体积轻便但保留了完整编译与运行链路。代码按 PESQ 主流程、DSP 信号处理、音频读写等模块组织,附带测试音频与评分脚本,编译后可按采样率对干净语音和加噪语音进行对比测试,便于理解 PESQ 评分实现、替换自有 PCM 数据做回归验证,并据此排查编解码导致的音质劣化。已有 963 人学习,适合需要快速搭建音频质量客观评测环境、积累排错与调参思路的音频与语音算法开发者参考。

1. mos-pesq.zip 不是“打分神器”:它把两段波形折成一个 1~4.5 的数

拿到 mos-pesq.zip 的人,第一反应基本一致:解压、找入口、扔两个 wav 进去,看它吐出一个 3.82 还是 2.14。这个包的内核是 PESQ(Perceptual Evaluation of Speech Quality),一种拿“干净参考语音”和“过完链路之后的语音”做逐帧比对、最后折算成 MOS 分的客观评测方法。它解决的是一个很具体的工程问题:你改了降噪、换了编码器、动了增益曲线,上线前总得知道语音“听起来”损失了多少,而组织一轮几十人的主观 MOS 打分要排期、要花钱、还不可复现。

适合上手的人:做语音降噪、回声消除、编解码、TTS 后处理、录音链路人,手上有成对的干净参考和对应处理输出。不适合的人:只有处理后音频、没有参考的;想评含丢包、抖动、回声、时变延时的实时通话的——那些不是 PESQ 的靶心,硬套会得到一个看起来很专业、实际完全误导的小数点。

2. 从波形到 MOS-LQO:PESQ 内部那条四步评分链路

把 mos-pesq.zip 当黑匣子用的人,最常见的困惑是“我明明只改了增益,分数怎么没动”和“我只过载了一点点,分数怎么掉了 0.6”。这两个现象都能从评分链路里找到解释。整条链路大致四步:电平规整、时间对齐、听觉域变换(按 Bark 带划分子带、算响度)、坏区间加权与分数映射。你改动的东西落在哪一步,决定了它会不会体现在最终那个数字上。

2.1 电平规整与削顶:为什么“整体调小 3dB”几乎不掉分,“过载一点点”却掉得厉害

PESQ 在比对前会把参考和被测都规整到一个固定的等效听音电平,也就是说,它对“整体增益”这件事是基本免疫的。我做过一组很朴素的对照:同一段 16k 干净语音,被测侧整体乘 0.7、乘 1.4,MOS-LQO 的抖动在 0.05 以内,肉眼看着像是随机噪声。这也解释了不少人拿它去评“自动增益控制”效果时,为什么分数纹丝不动——AGC 做得再激进,只要没削顶、没把底噪抬上来,PESQ 就不认为你损失了什么。

真正致命的两个东西是削顶和底噪随增益抬升。削顶产生的是宽带谐波失真,落在每个 Bark 带里都是不可忽略的感知误差;底噪被抬升则直接改变了信噪比,而 PESQ 的听觉模型对“语音间隙里的稳态噪声”特别敏感。所以评测前先看一眼峰值和底噪,比反复调参有用得多:

# 同时看均值电平与峰值电平,max_volume 接近 0 dB 就要怀疑削顶 ffmpeg -hide_banner -i deg_16k.wav -af volumedetect -f null - 2>&1 \ | grep -E "mean_volume|max_volume"

mean_volume是被测语音的平均电平,max_volume是峰值。峰值贴着 0 dB(比如 -0.1 dB)说明大概率已经削过,这种情况下算出来的分数低不代表算法差,代表素材废了。我一般要求峰值留 3 dB 以上余量,均值电平落在 -26 dB 到 -20 dB 之间,参考和被测的差值控制在 1 dB 内——不是因为 PESQ 在乎,而是为了让人耳复核时听感一致,避免自己骗自己。

2.2 时间对齐与坏区间剔除:PESQ 的容错边界在哪

PESQ 会先估计参考和被测之间的整体延时,把两段信号拉到同一时间轴上,再按约 32 ms 一帧、50% 重叠切块,逐帧算扰动。对齐这一步是它最脆弱的地方:它假设延时是缓变的、近似全局一致的。所以纯线性滤波(EQ、编码器)、固定延时(几十毫秒的算法延迟)它都能扛住;而丢包导致的整块缺失、抖动导致的时变延时、变速播放导致的时长不一致,会让对齐直接失效,这时候报出来的分数通常低得离谱,且不可解释。

链路里还有一步叫坏区间识别:某些帧的失真严重到“人耳会说这段完全糊了”,这些区间会被单独标记并在聚合时加重惩罚。这带来一个反直觉的后果——少量极端坏的片段,比均匀的中等失真更能拉低总分。做降噪时如果算法只在少数强噪帧上崩掉,PESQ 会把这几个帧放大成整体评价,跟你“大部分时候都挺好”的直觉不符。想看清楚是哪些帧出了问题,就把长音频按 8~12 秒切段分别打分,看分数分布,而不是只盯着一个全局限平均。

注意:PESQ 的对齐能力有明确边界,不要指望它容忍变速、丢帧、时间轴拼接错误。凡是“两段音频内容对不上”的问题,都是预处理问题,不是打分问题。

2.3 原始分与 MOS-LQO:同一段音频为什么能报出 3.1 和 3.9

mos-pesq.zip 这类包最容易被忽略的一点,是它内部有两套量纲。原始分(raw score)大致落在 -0.5 到 4.5 之间,是算法直接输出的扰动聚合值;MOS-LQO 是经过一条 logistic 曲线压出来的映射分,窄带和宽带各有一条曲线,取值范围大约 1.0 到 4.6。同一段音频,报 3.1 还是 3.9,往往不是算法不同,而是输出的量纲不同、或者窄带/宽带模式不同。

输出量纲大致范围含义用的时候注意
raw score-0.5 ~ 4.5算法直接输出的扰动聚合值只能同模式内部比,别跨版本
MOS-LQO(窄带)约 1.0 ~ 4.68 kHz 模式映射分与主观 MOS 的线性关系稍好
MOS-LQO(宽带)约 1.0 ~ 4.616 kHz 模式映射分同一条链路通常高于窄带
主观 MOS1 ~ 5人打分样本量小时置信区间很宽

我吃过一次亏:拿 A 方案窄带模式的 MOS-LQO 去对比 B 方案宽带模式的 MOS-LQO,得出“B 明显更好”的结论,后来统一到 16k/wb 重跑,差距只剩 0.1。写报告的时候一定要把这四个字带上——量纲、模式、采样率、语料集,缺一个,数字就不可复现。

3. 本地跑通最小评测:解压、编译、一条命令出分

从零到跑出第一个分数,其实只有三件事:把音频规整成 PESQ 能吃的格式、让工具链跑起来、拿一对样本验证输出是否合理。别一上来就写批量脚本,先用一对样本把链路打通,确认数字在合理区间,再谈规模化。

3.1 环境与依赖:三件套加上一条采样率硬约束

需要的就三样:能编译 C 的编译环境(解压出来的参考实现通常是 C 写的,make一下就有可执行文件;如果里面已经有预编译产物,跳过这步)、一个 Python 环境用来写批处理、以及 ffmpeg 或 sox 做格式规整。检查一遍:

gcc --version | head -n 1 # 有输出就说明编译器在 python3 -c "import numpy, soundfile; print('ok')" ffmpeg -version | head -n 1

硬约束只有一条,但踩的人最多:PESQ 只在 8 kHz 和 16 kHz 两个采样率下工作,8k 对应窄带模式,16k 对应宽带模式。喂 44.1k 直接报错,喂 16k 却选了窄带模式也会报错。常见做法是统一走 16k/单声道/16 bit PCM,除非你评的就是电话带宽链路。位深和声道其实不是算法硬要求,但混着来会让你在排查时多花两小时,所以我在流水线入口就一律强制统一。

3.2 单文件跑分:先从 8k/16k 规整开始

先规整格式。注意重采样只做一次,别在链路里来回 16k→48k→16k 折腾,每次重采样都会引入一点点滤波失真,攒起来就体现在分数上:

# 统一成 16 kHz / 单声道 / 16 bit PCM;-y 覆盖旧文件避免残留 ffmpeg -y -i ref_raw.wav -ac 1 -ar 16000 -sample_fmt s16 ref_16k.wav ffmpeg -y -i deg_raw.wav -ac 1 -ar 16000 -sample_fmt s16 deg_16k.wav # 顺手确认两段时长一致,差超过几十毫秒基本就是内容没对齐 ffprobe -v error -show_entries format=duration -of csv=p=0 ref_16k.wav ffprobe -v error -show_entries format=duration -of csv=p=0 deg_16k.wav

参考实现的命令行入口一般是“采样率 + 参考 + 被测”三个位置参数,16k 用+16000,8k 用+8000:

# 若解压目录里是 C 源码,先编译;已有可执行文件则跳过 make make # 输出里通常会同时打印原始分和 MOS-LQO,注意看清是哪一列 ./pesq +16000 ref_16k.wav deg_16k.wav

Python 侧更省事,一个函数调用就够,适合后面写批处理:

import soundfile as sf from pesq import pesq # dtype="float32" 是为了匹配底层 C 实现的入参类型,省一次隐式转换 ref, sr = sf.read("ref_16k.wav", dtype="float32") deg, _ = sf.read("deg_16k.wav", dtype="float32") print(sr, len(ref), len(deg)) # 先确认采样率与长度 # 16k 配 "wb",8k 配 "nb",喂错组合会直接抛异常而不是给你个错分 print("MOS-LQO:", pesq(sr, ref, deg, "wb"))

第一对样本一定要自己造:把参考复制一份当被测,分数应该在 4.5 附近(宽带模式);把被测加一点白噪声到 10 dB 信噪比,分数应该明显掉下来。如果复制品都跑不到 4.4 以上,说明格式规整那一步有问题,别急着往下走。

3.3 批量评测脚本:两个目录配对成一张 CSV

批量脚本的核心不是“循环调用”,而是“把异常显式记下来”。真实的评测集里总有几对文件采样率不一致、长度不一致、内容压根不是同一句,这些必须落盘成错误行,而不是悄悄跳过——跳过就意味着你的均值只统计了“能跑通的那部分”,系统性偏高。

import os import csv from concurrent.futures import ProcessPoolExecutor import soundfile as sf from pesq import pesq, PesqError REF_DIR, DEG_DIR = "data/ref", "data/deg" # 两侧文件名必须一一对应 OUT_CSV, SR, MODE = "scores.csv", 16000, "wb" def score_one(name): ref, sr1 = sf.read(os.path.join(REF_DIR, name), dtype="float32") deg, sr2 = sf.read(os.path.join(DEG_DIR, name), dtype="float32") # 采样率不一致直接判失败:先重采样再进来,不在打分环节做隐式转换 if sr1 != SR or sr2 != SR: return name, None, f"sr {sr1}/{sr2}" # 长度不一致通常是内容错位或裁剪不一致,这种分数没有意义 if abs(len(ref) - len(deg)) > 160: # 160 点约 10 ms 容差 return name, None, f"len {len(ref)}/{len(deg)}" try: s = pesq(SR, ref, deg, MODE) except PesqError as e: # 太短、全静音等都会走到这里 return name, None, str(e) return name, round(float(s), 3), "" def main(): names = sorted(set(os.listdir(REF_DIR)) & set(os.listdir(DEG_DIR))) with ProcessPoolExecutor(max_workers=os.cpu_count()) as ex, \ open(OUT_CSV, "w", newline="", encoding="utf-8") as f: w = csv.writer(f) w.writerow(["file", "mos_lqo", "error"]) for name, s, err in ex.map(score_one, names): w.writerow([name, s, err]) if __name__ == "__main__": main()

几个参数值得单独说。SR=16000和MODE="wb"必须成对出现,改一个就要改另一个;max_workers用 CPU 核数,PESQ 是纯计算密集型,进程池比线程池合适;abs(len(ref)-len(deg)) > 160这个容差不是标准里给的,是我按经验定的——超过 10 ms 的长度差,多半意味着两条链路里有一条做了额外裁剪,这种样本的分数不可信。跑完之后先看error列有多少非空行,再对mos_lqo求均值和中位数,两者差得多说明分布有长尾,得去看最差的那几条。

4. 让分数可复现的六个设置:采样率、电平、句长与参考对齐

同一个算法,两个人跑出 0.4 的差距,九成不是工具问题,是语料准备不一致。这一章列的六件事,做到位了,你的分数才具备“别人照着重跑能得到同样结论”的资格。

4.1 采样率、位深、声道:16k/mono/s16 是硬通货

采样率决定模式和上限,这件事没有折中空间。评电话链路(8k 源)就用 8k + 窄带,别升采样到 16k 再走宽带模式,那等于给原始信号补了一段本不存在的带宽,分数会虚高。位深统一 16 bit,虽然内部计算是浮点,但输入如果是 8 bit 或者做了 μ 律压缩没解开,失真会直接算进你的分数里。声道必须单声道,双声道文件读进来会变成二维数组,长度校验这关就过不去。

重采样工具链也要统一。我见过同一个项目里,参考侧用 A 工具重采样、被测侧用 B 工具,两者群延时差了半个采样点,累积起来在部分句子上就体现为分数偏低。定一条规矩:全流程只用一个重采样器、只重采样一次、参数写死在配置里。

4.2 语料切分与静音裁剪:句长 8~12 秒,别让静音拉高分数

PESQ 是全段平均的,静音段在没有噪声时几乎不贡献惩罚,所以一段 60 秒里如果 40 秒是静音,分数会被“稀释”得偏高。我一般把语料切成 8~12 秒的句子,逐句打分再取中位数,这样既能看出方差,也不至于让静音主导结果。

裁剪时留 0.2~0.3 秒前后静音是必要的,因为算法需要一点上下文来做对齐;但别留 2 秒。另外,如果被测信号在静音段有底噪(降噪算法常见的残留噪声、编码器的舒适噪声),这些段恰恰是最能暴露问题的,全裁掉就等于把缺陷删了。所以裁剪策略要在参考和被测上完全一致:用参考的 VAD 结果去裁两段,而不是各裁各的。

4.3 参考与被测必须“同句同轴”:内容错位比噪声更致命

最隐蔽的一类错误,是文件名配对上了、内容其实错位了。比如参考是 001.wav 对应“第三句话”,被测的 001.wav 因为前面多了一段提示音,实际是“第二句话”。这时候 PESQ 会给出一个 1.2 之类的极低分,你可能会以为是算法彻底崩了,回头查两天代码。

防御手段很土但有效:先算两段信号的互相关,看峰值位置对应的延时是否稳定;再抽 10 对做时长比对,正常链路里两侧时长差应该在几十毫秒内。还有一个更省事的办法——把参考复制一份当被测跑一遍全量,如果某一对跑出来不是 4.4 以上,那一对必然有问题,这步自检只要几分钟,能省掉后面几小时的怀疑人生。

4.4 一张可以直接抄的参数表

参数推荐值为什么这么定写错的后果
采样率16 kHz(宽带)/ 8 kHz(窄带)只有两档被支持直接报错或模式错配
模式与采样率严格配对映射曲线不同分数不可跨方案比较
位深/声道16 bit PCM / 单声道避免隐式转换失真长度校验失败、分数偏低
句长8 ~ 12 秒兼顾对齐上下文与静音稀释长句掩盖局部缺陷
峰值余量≥ 3 dB防削顶谐波失真导致假低分
参考被测电平差≤ 1 dB便于人耳复核对齐不影响分数但影响判断
每句打分聚合中位数 + 分位数抗长尾均值被极端句带偏

提示:这张表建议直接写进项目的评测 README,而不是留在某个人的脚本注释里。参数一旦口口相传,第三次复现就一定会跑偏。

5. 分数不对劲时的排查清单:五条踩坑记录与定位方法

分数异常分两类:虚高和虚低。虚高更危险,因为它不会触发任何报警,你会带着一个错误的结论去做决策。下面五条是我这几年反复遇到的,每条都按“现象—原因—解决”写,遇到了可以直接对号入座。

5.1 采样率与重采样链路的三次翻车

现象一:某批样本稳定报错,提示模式与采样率不匹配,但文件头显示是 16k。原因是文件头写了 16k,实际内容是被某个工具“声明式”改了头、没有真正重采样,送给算法时的样点数与采样率对不上。解决:不要信文件头,用ffprobe读出实际采样率,再用解码后的样点数除以时长交叉验证;批量入口处加一道断言,采样率不等于目标值就拒绝进入打分环节。

现象二:同一批音频,换台机器跑,均分差了 0.3。原因是重采样器版本不同,滤波器群延时和通带特性有差异,在宽带模式下这段差异会落在高频子带里被计入。解决:把重采样这一步固化成流水线里的一个独立阶段,产物落盘、带哈希,打分阶段只读不转。这样任何人重跑,输入都是同一批样点。

现象三:8k 语料升采样到 16k 后走宽带模式,分数比预期高一大截。现象看着像“算法变好了”,实际是升采样补齐了带宽,PESQ 认为高频没有损失。原因是模式选错。解决:模式由源语料采样率决定,不由“我想评宽带”决定;在配置里把语料采样率和模式绑成一个枚举,不给手工改的机会。

5.2 内容与电平不一致造成的假低分

现象四:个别句子分数只有 1.5 左右,其余句子都在 3.8 以上,分布呈明显双峰。原因是这些句子的参考和被测内容错位,或者被测侧多了一段前导提示音导致时间轴偏移。双峰分布是内容错位的典型指纹——如果是算法在某些噪声类型上失效,分布通常是单峰长尾而不是干净的双峰。解决:对低分句子做互相关峰值检查,确认延时是否恒定;确认后在预处理阶段用参考的 VAD 统一裁剪两侧。

现象五:整批分数普遍偏低,但人耳听着都还行。原因是电平或削顶问题,最常见的是被测侧经过 AGC 后峰值贴到 0 dB,削顶引入的宽带失真被听觉模型捕捉到。解决:先跑一遍峰值与均值统计,把峰值余量小于 3 dB 的样本单独拎出来,用限幅器压回去再重跑一遍,对比两次分数——如果差距超过 0.3,说明这批数据本身就不该直接拿来评。

5.3 把 PESQ 用在不该用的场景

现象六:评一条带丢包模拟的实时通话链路,分数波动巨大,且和主观听感完全不相关。原因是丢包造成的时间轴跳变破坏了 PESQ 的对齐假设,它给出的低分反映的是“对齐失败”,不是“听感差”。这类场景 PESQ 本来就不适用。解决:换成对流式损伤更鲁棒的评测方式,或者先把丢包位置对齐、按“丢包前/丢包后”分段评测;同时必须补主观抽检,否则你无法知道那个数字到底在说什么。

现象七:评 TTS 合成语音,用真人录音当参考,分数低得离谱。原因是文本相同但发音人、语速、韵律完全不同,逐帧比对的意义被削弱。这种“跨说话人”的评测,PESQ 只能作为弱参考。解决:要么用同一个说话人的重录版本做参考,要么改用不依赖帧对齐的指标做交叉验证,别拿一个数字下结论。

6. 单指标不可信:用 STOI 与 SI-SDR 交叉验证,再抽 30 条做人耳复核

6.1 三个指标各自看得见什么

PESQ 高不代表可懂度高——这是最容易被忽略的一条。降噪算法把噪声压得很干净、但吃掉了辅音,PESQ 可能只掉 0.3,而人耳已经听不清“s”和“f”的区别。反过来,SI-SDR 很高也不代表好听,因为它是波形级的相似度,对相位和轻微延时极其敏感,人耳却未必在意。所以我的习惯是三个指标一起看,任何两个方向相反,就必须人工介入。

指标主要反映对什么敏感对什么不敏感
PESQ / MOS-LQO整体感知质量噪声、编码失真、削顶时变延时、丢包、语义可懂度
STOI语音可懂度时频掩蔽类失真整体音色、稳态噪声
SI-SDR波形保真度相位误差、残留噪声人耳感知加权
import numpy as np import soundfile as sf from pystoi import stoi from pesq import pesq def si_sdr(ref, deg): # 先去掉直流,再最小二乘求最优缩放,避免整体增益干扰相似度 ref, deg = ref - ref.mean(), deg - deg.mean() alpha = np.dot(deg, ref) / np.dot(ref, ref) target = alpha * ref noise = deg - target return 10 * np.log10(np.sum(target ** 2) / np.sum(noise ** 2)) ref, sr = sf.read("ref_16k.wav", dtype="float32") deg, _ = sf.read("deg_16k.wav", dtype="float32") print("PESQ :", round(pesq(sr, ref, deg, "wb"), 3)) print("STOI :", round(stoi(ref, deg, sr, extended=False), 3)) print("SI-SDR:", round(si_sdr(ref, deg), 2), "dB")

si_sdr里那个alpha是关键:不做最优缩放的话,被测侧只要整体音量差一点,SI-SDR 就会断崖式下跌,你会误判成算法有问题。stoi的extended=False给的是标准可懂度分(0~1),打开扩展版会同时返回更多子指标,日常排查用标准版够用。三个数一起打印,看方向一致性:PESQ 涨、STOI 平、SI-SDR 涨,是典型的“去噪去了但没伤到语音”;PESQ 涨、STOI 跌,那就要警惕了,多半是过度压制把弱辅音一起抹了。

6.2 一个 30 条的抽检流程

批量跑完几百条之后,我一定会抽 30 条做人工复核,抽样规则固定下来:分数最高的 10 条、最低的 10 条、中间随机 10 条。听的时候只问三个问题——有没有残留的噪声起伏、字与字之间有没有被“切断”的感觉、整体音量是否明显小于参考。这三个问题分别对应稳态噪声、过度压制、增益失配,正好覆盖了指标最容易骗人的三类情况。

抽检结果我习惯记成三列:机器分、主观印象(好/中/差)、是否愿意上线。如果出现“机器分最高但主观印象差”的样本,那一条就是最有价值的样本,通常能挖出评测集本身的缺陷——比如参考录音本身带底噪,算法把底噪压掉了,PESQ 反而认为你和参考不像了。这种发现只靠跑脚本永远得不到。

我自己的教训是:早期太相信一个数字,把一次 MOS-LQO 从 3.1 提到 3.9 当作重大改进写进汇报,结果上线后用户反馈“声音发闷”。回头查,算法确实提升了信噪比,但同时把 4 kHz 以上压掉了 6 dB,PESQ 的宽带加权对这段损失的惩罚有限,人耳却很明显。从那以后我的规矩是:任何指标提升超过 0.3,必须有对应的主观抽检记录,没有就不算数。评测这件事,慢一点比返工便宜。希望帮到你。

本文还有配套的精品资源,点击获取