简介:MOS-PESQ是一套面向音频开发者的语音质量客观评测工具,基于ITU-T P.862建议实现,可用于测试PCM数据编解码前后的语音质量差异,帮助定位编码参数不当、采样率不匹配或损伤引入等问题。压缩包共12个文件,以C语言源码为核心(5个.c文件与3个.h文件),搭配Makefile编译脚本、自动化测试shell脚本及README说明文档;另含一个内置测试音频的zip压缩包,整体仅207KB,轻量易用。项目按配套Makefile执行make编译后,运行./pesq +16000 speech.wav speech_bab_0dB.wav即可直接得到MOS评分,免去自行准备样本的麻烦,适合快速验证音频处理链路。已有962人学习下载,可供需要评估语音编解码算法质量、调试VoIP或音频处理链路的工程师参考使用。
1. 什么是 mos-pesq.zip,以及为什么音质调试离不开它
做语音质量评估的时候,总免不了要回答“这次改动会让用户感知变差吗”。主观试听太慢,而且不同人评价飘忽,于是大家会找一个客观指标顶上去。mos-pesq.zip 就是这类任务里常出现的工具包,里面装的是 ITU-T P.862 建议书定义的 PESQ 算法,能拿一对参考/降级语音 WAV 文件,算出一个接近主观平均意见分的 MOS-LQO 分数。它适合 VoIP、音频处理、通信系统验证的工程师,目标是在没有真人人耳的情况下,用可复现的数字度量音质。下文我会沿着这个 zip 包从原理讲到落地,覆盖编译、批量跑分和常见踩坑。
2. PESQ 的评分原理与两个关键指标:为什么不是简单算 SNR
很多第一次接触 PESQ 的人会问:我直接算信噪比不就行了吗?其实不行,因为人耳对失真并不是在所有频带和所有响度下一视同仁。PESQ 之所以能常用不衰,是因为它把心理声学模型塞进了算法里。它先对参考信号和降级信号做电平归一化,再把信号映射到模拟人耳基底膜响应的频带上,然后局部对齐在时间上错位的片段,最后计算扰动密度,映射到主观 MOS 分。整个过程复杂,但基于一个朴素目标:让客观分数尽量贴合真人试听的平均结果。
2.1 PESQ 内部的处理流程:电平归一化、时间对齐与感知扰动
先说一下电平归一化。参考信号和降级信号往往来自不同增益环境,比如参考 WAV 是合成语音,降级 WAV 经过了 AGC 和编解码,响度可能有几 dB 的差异。PESQ 不会直接拿原始波形做比较,而是先用一个 IIR 滤波器模拟听筒的频响,之后计算整体增益差异并做一次电平对齐。我在实际使用中经常看到,增益差 3dB 以内,PESQ 分数基本不受影响,超过这个范围就会明显偏低。
时间对齐是另一个核心步骤。网络传输、缓冲、编解码都会引入固定或变化的延迟。PESQ 会把信号分成 32ms 的帧,用互相关找到每帧的延迟,再做局部的对齐。这里要特别强调:PESQ 能容忍的延迟不是无限的。我遇到过的案例里,超过几百毫秒的延迟很容易让算法把语音片段匹配错位,导致分数比真实听感差很多。所以,在跑分之前先用延迟估计工具确认一下对齐情况,是值得花时间的。
对齐之后,PESQ 会把两个信号都送入一个模拟人耳听觉的滤波器组,分成多个 Bark 频带。然后逐帧计算谱扰动,包括加性噪声的扰动和乘性失真(比如编解码引入的非线性失真)的扰动。这里用的不是简单的幅度差,而是基于人耳响度感知的声压级模型。最后,扰动密度通过两个不对称参数——扰动密度和坏区间比例——映射到 PESQ 分。之所以分两步,是感知模型认为短暂但严重的失真比持续轻微失真更影响主观感受。
2.2 MOS-LQO 与 MOS-LQS:输出里那么多数字到底用哪个
PESQ 命令行跑完,输出往往有两三行。最常见的是:
- PESQ_MOS(有时标为 Raw MOS 或 PESQ Score):算法原始输出,范围 -0.5 到 4.5,是映射前的结果。
- MOS-LQO(Mean Opinion Score – Listening Quality Objective):按 ITU-T P.862.1 映射到 1~5 的客观分数,用来近似主观意见分。
- MOS-LQS(Listening Quality Subjective)我很少在工具输出里见到,它是 P.862 文档引入的主观参考概念,主要用于校准测试,不是每个包都显示。
我在给团队写评估报告时,一般会用 MOS-LQO,因为它的量纲和主观 MOS 一致,好解释。例如 “3.4 MOS-LQO” 听起来比 “2.8 PESQ_MOS” 更直观。但要注意,PESQ_MOS 和 MOS-LQO 的映射关系是固定的,在一个方向的下降趋势上,两者几乎等价。实际使用中,我建议你选一个指标并在整个测试周期内固定,不要在同比时混用两个指标。
因为 PESQ 原始分数到 MOS-LQO 不是线性映射,在分数较低的区域,很小的原始分变化会被放大。也就是说,当两个候选方案原始分都在 1.5 附近时,MOS-LQO 的差异可能比原始分差异更明显。这会让报告显得更敏感,但也意味着你要小心比较基准:所有横向对比必须用同一种计分方式,否则得不出公平的排序。
2.3 参考信号与降级信号的选择原则:格式、时长、内容
PESQ 是一个有参考评估(intrusive)算法,所以它需要一对文件:干净的参考信号和待测的降级信号。选择参考信号时,有几个要点。第一,内容最好是语音,而不是纯音乐或全静音,因为 PESQ 的感知模型主要针对语音频段,对音乐的评估是无效的。第二,时长不能太短。我常用的经验是至少 8 到 12 秒,因为太短的语音片段无法让算法完成时间对齐和扰动统计。第三,采样率要和参数对应:窄带用 8000 Hz,宽带用 16000 Hz。
降级信号则必须和参考信号来自同一个“起点”。也就是说,你不能拿一个和参考内容完全不同的文件来做降级信号,也不能把参考信号截掉一半后当作降级信号,除非你想测的是与丢失相关的损伤。通常的做法是把参考 WAV 送入被测系统(编码器、网络模拟器、降噪算法),把输出录下来作为降级信号。录制的时候要特别注意采集链路本身就引入的额外损伤,比如声卡重采样、麦克风频响,这些都会被 PESQ 当成系统损伤算进去。
2.4 PESQ 的局限:什么时候别拿它下结论
PESQ 并不是所有音质问题的万能钥匙。它面向的是对话语音,对音乐类内容完全无效;对 48 kHz 采样的高清音频或超宽带语音,PESQ 也力不从心,这时应该去找 POLQA 这类更新标准。另外,PESQ 对极端丢包下连续丢失 200ms 以上的场景,分数会崩得很厉害,但真人试听可能还能通过上下文猜出内容,所以主观分不一定那么低。明白这些边界,才能解释为什么某个分数和你的耳朵不一致。
3. 在 Linux 上跑通 mos-pesq:解压、编译与命令行参数
mos-pesq.zip 不是那种解压即用的现代 Python 包,它通常是一份包含 C 源码、维护脚本和 README 的传统发布包。我第一次拿到时也天真地以为里面有现成的可执行文件,结果发现得自己编译。好在编译步骤不算复杂,主要依赖 gcc 和 make,下面按步骤走一遍。
3.1 解压后的目录结构:怎么最快找到入口
unzip mos-pesq.zip -d mos-pesq cd mos-pesq ls -la解压后你会看到类似 top-level 目录,里面通常有源码文件、一个 README 或 Readme.txt,以及 Makefile。我一般第一件事不是直接把 make 跑起来,而是先读 README 里的 Command line 段落,因为不同发布版对命令参数的处理可能不一样(比如是不是要加+号、输出格式什么顺序)。有的版本直接编译出pesq可执行文件,有的则把窄带和宽带拆成pesq和pesq_wideband两个二进制。这一点读 README 能省很多试错时间。
如果 ls 看到有Makefile,说明可以直接 make。没有 Makefile 也可以手动编译:源码文件通常是pesqmain.c、pesqdsp.c和pesqio.c,把它们一起编译即可。我试用过的大多数 mos-pesq 包都自带 Makefile,所以下面先从 make 讲起。
3.2 编译源码与老代码兼容性问题
make如果一切顺利,当前目录下会生成pesq可执行文件。但这条命令在这类老代码上有一定概率失败,尤其是新版本 gcc 对默认编译标准更严格的时候。我遇到的报错大多是 “conflicting types for function” 或 “called object is not a function or pointer”,这通常是因为老代码遵循的是 C89 的隐式声明规则。这时候不要慌,改用兼容模式编译:
make clean make CFLAGS="-std=gnu89 -w"-w是让警告不打断编译,-std=gnu89是用 GNU 方言的 C89 标准,老代码大多能顺利通过。如果还报错,可以把-w换成-Wno-implicit-function-declaration,只封掉那一类警告,保留其他有用信息。若项目里没有 Makefile,也可以直接手动编译:
gcc -O2 -o pesq pesqmain.c pesqdsp.c pesqio.c -lm这里-lm用来链接数学库,PESQ 的扰动计算和映射函数里大量用到了powf、sqrtf等数学函数。编译出来如果出现pesq和pesq_wideband两个文件,后面根据测试场景选一个用。
3.3 运行命令行的最小示例:从两个 WAV 文件拿到 MOS 分
./pesq +16000 ref.wav deg.wav这个命令里+16000表示输入采样率是 16 kHz。如果两个文件都是 8 kHz 采样率,就要换成+8000。注意参数顺序是“先参考,后降级”,我刚开始就反了,结果分数来回波动,后来才发现是顺序问题。
运行成功后,终端会打印参考文件名、降级文件名,以及一行PESQ_MOS = x.xxx MOS_LQO = y.yyy。不同版本格式略有差异,有的会先打印PESQ_MOS,有的先打印MOS_LQO。在脚本里解析时,我一般直接用正则去匹配[\d.]+,而不是硬编码第几列。
如果你不确定编译出来的支持哪种采样率,直接不带参数运行./pesq,绝大多数版本会把 usage 打出来。usage 里会列出+8000和+16000两种模式,以及示例命令。这比到处找 README 更直观。
3.4 窄带与宽带模式:为什么要用 +16000 / +8000
PESQ 的标准版本最初是面向 8 kHz 采样的窄带电话语音,后来才有了宽带的扩展版本,扩展到 16 kHz 采样。窄带模式模拟的是 300–3400 Hz 的传统电话频段,宽带模式覆盖到 50–7000 Hz,对现代 VoIP 和高清语音更适用。你手里的 mos-pesq 包是否支持宽带模式,在 README 里会说清楚;如果编译后有pesq_wideband,运行方式可能是./pesq_wideband +16000 ref.wav deg.wav,也有版本用同一个二进制通过参数区分。所以,如果你的音频是在电脑上用麦克风录的高清语音,请务必使用 16 kHz 模式,否则 8 kHz 模式会把高于 4 kHz 的内容全都滤掉,导致分数偏向保守。
一个容易踩的点是:即使你把输入文件重采样到了 16 kHz,原始信号的实际带宽也未必达到宽带水平。比如某段语音是直接从 8 kHz 电话里录出来的,强行按宽带跑,分数反而会降低,因为算法会在宽带模式下寻找参考信号中根本不存在的 4~7 kHz 成分。因此,不要迷信“用宽带模式一定更准”,先确认被测系统的真实带宽再决定。
3.5 编译产物验证:别急着跑数据,先做冒烟测试
file ./pesq ./pesqfile命令能告诉你编译产物是不是 ELF 可执行文件,以及是 32 位还是 64 位。如果显示 32 位,而你系统缺少 32 位运行库,运行时可能会报No such file or directory。这种情况在老服务器上很常见,解决方法是安装对应的libc6-i386,或者重新用-m64编译。不带参数运行./pesq则是最快的冒烟测试:它应该输出一行使用说明并退出,而不是崩溃或段错误。
4. 把 mos-pesq 打包进批量评分脚本:从一两个文件到整个语料库
命令行跑单个文件只是热身,实际工程场景里通常要一次性评估几十个文件。如果每次手动敲命令,既慢又容易拼错路径。我一般用 Python 写一个小包装器,把 PESQ 二进制包在一个函数里,再用循环遍历目录。下面给出一个可用版本。
4.1 Python 封装 PESQ 命令行的最小实现
import subprocess import re import sys def pesq_score(binary, ref_wav, deg_wav, sample_rate=16000): cmd = [binary, f"+{sample_rate}", ref_wav, deg_wav] proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if proc.returncode != 0: raise RuntimeError(f"PESQ failed on {ref_wav} -> {deg_wav}: {proc.stderr}") match = re.search(r"MOS_LQO\s*=\s*([\d.]+)", proc.stdout) if not match: match = re.search(r"PESQ_MOS\s*=\s*([\d.]+)", proc.stdout) return float(match.group(1))这段代码的关键点是:用subprocess.run而不是os.system,这样可以捕获输出并设置超时;解析结果用正则匹配,而不是按行、按列硬编码,因为不同版本打印格式会变;timeout=60是为了防止 PESQ 在某个坏文件上卡死。值得注意的是,returncode非零说明进程异常,最好把 STDERR 一起抛出来,方便排查。
如果你手里的 PESQ 输出只有原始 PESQ_MOS,没有 MOS_LQO,我建议不要在脚本里自行换算。直接返回 PESQ_MOS 就行,因为你需要的是一个可复现的数值,而不是凑出一个看起来更主观的量纲。换算规则在 ITU-T P.862.1 里,不同实现可能浮点误差不同,自己换算反而增加变量。
4.2 用 ffmpeg 统一格式:采样率、声道数与 PCM 编码
PESQ 对输入格式要求比较苛刻:它期望读取的是标准 WAV 文件,内部是 PCM 编码,一般是 16-bit 有符号整型。如果你喂给它一个 24-bit 或浮点型 WAV,轻则报警告,重则读取到一段噪声。更隐蔽的问题是立体声。PESQ 内部以单声道信号建立模型,如果你传入双声道 WAV,不同版本会采取不同的处理:有的只取左声道,有的直接读错位,结果就变成不可重复的分数。所以,进入 PESQ 之前,最好强制统一格式:
ffmpeg -y -i input.wav -ar 16000 -ac 1 -sample_fmt s16 mono.wav在 Python 脚本里,我通常在调用pesq_score之前先对每个 WAV 执行这个 ffmpeg 命令。-ar 16000是目标采样率,-ac 1是单声道,-sample_fmt s16是 16-bit PCM。这样虽然多花一点时间,但至少能保证所有文件在同一个格式基准上比较。
需要注意,重采样本身也算一种失真。如果你的参考 WAV 本身是 48 kHz 的高清语音,而测试场景就是评估这个高清链路,你把它全部降到 16 kHz 再去测,那 PESQ 测到的就不是原始系统的质量,而是重采样链路的残留。所以我通常会把统一的格式处理放在系统录制之前:直接让被测系统输出 16 kHz 单声道 WAV,而不是事后做暴力转换。
4.3 批量遍历目录并输出结果表
import os import csv from concurrent.futures import ProcessPoolExecutor def evaluate_pair(pair): ref, deg, binary = pair try: score = pesq_score(binary, ref, deg) return ref, deg, score, None except Exception as exc: return ref, deg, None, str(exc) ref_files = [f for f in os.listdir("refs") if f.endswith(".wav")] deg_files = [f.replace(".wav", "_deg.wav") for f in ref_files] pairs = [("refs/" + r, "degs/" + d, "./pesq_wideband") for r, d in zip(ref_files, deg_files)] with ProcessPoolExecutor(max_workers=4) as ex: results = list(ex.map(evaluate_pair, pairs)) with open("results.csv", "w", newline="") as f: writer = csv.writer(f) writer.writerow(["ref", "deg", "mos_lqo", "error"]) writer.writerows(results)这里用ProcessPoolExecutor做并行,因为 PESQ 是 C 程序,多进程互不干扰,可以吃满 CPU。max_workers=4可以根据服务器核数调大。结果先写 CSV,再交给 pandas 做二次分析。注意:我在deg_files用的是按文件名匹配的方式,这要求你的降级文件命名规则和参考文件一一对应。如果你的命名不规律,最好维护一个显式的 pair 列表,否则会张冠李戴。
4.4 结果汇总中的一点统计经验
批量跑完拿到 CSV 还不是终点。我一般会顺手算三样东西:平均 MOS-LQO、95% 置信区间、以及“质量低于 3.5 分”的样本占比。平均分容易被少数极端样本拉高或拉低,尤其当某个文件在传输中丢包严重,分数跌到 1.x,会把整体均值拉低将近一个点。所以我还会把每对文件的分数变化率画出来,看是否存在明显边缘样本。如果某一个文件的分数和同类差得特别远,不要急着删,先检查这个样本的文件格式和时长。
5. mos-pesq 常见避坑:跑出异常分数时的排查路径
机器跑出来的分并不总是可信的。我在多次测试中总结出几条高频问题,每一条都用“现象 → 原因 → 解决”的方式记录。如果你发现某个音频的分数明显不合理,按照这里面的顺序排查,能省下不少时间。
5.1 采样率不匹配导致的崩溃与幻觉分
现象:PESQ 在某些文件上直接报错退出,或者跑出来的分数很低,而主观试听并没有那么差。
原因:最常见的是参考信号是 16 kHz,降级信号是 8 kHz,但命令行里统一用了+16000。PESQ 会按 16 kHz 的帧结构去解读 8 kHz 文件,内部时间对齐和扰动计算全都错位。
解决:先把两个文件都转换成命令行指定的采样率。我一般用第 4 章里的 ffmpeg 命令批量统一,转换后再跑。转换时注意-ar参数必须与+参数一致。如果是宽带测试,确认两个文件原始采样率都高于 16 kHz,再用 Sinc 重采样降下来。
5.2 参考与降级音频时长差异过大
现象:运行成功,但分数波动极大,同一个参考配不同长度的降级文件,MOS-LQO 从 2.8 跳到 4.0。
原因:PESQ 的评分是基于整段语音时长归一化的扰动密度,如果降级文件比参考文件短一截,算法会把缺失部分理解为持续丢弃,导致分数下降;如果降级文件更长,多余部分又会被算作噪声或无关内容。
解决:保证两个文件时长一致,最好由同一个系统录制,不要手动剪辑。如果必须处理传输造成的延迟,建议先做时间戳标记,或者用脚本来回错位对齐后再截取同一段有效语音。我自己的经验是:一致性比绝对时长更重要。
5.3 WAV 文件是立体声或非 16-bit,PESQ 读取到噪声
现象:分数异常低,打开输出波形发现全是高频噪声。或者同一个文件在不同机器上分数不同。
原因:PESQ 是按 16-bit PCM 数据块读文件的,立体声 WAV 的采样交织会让左、右声道混在一起,变成一串乱码;24-bit 和浮点 WAV 的字节宽度不对,读取之后信号幅度被严重压缩或溢出。
解决:统一转成单声道 16-bit PCM,再用ffprobe验证格式。我在脚本里会对输入文件先跑ffprobe,检查sample_fmt和channels,不满足就直接跳到转换逻辑。这条能拦截掉至少一半的“现象异常”报告。
5.4 语音前后静音过长导致分数虚高
现象:给一段 30 秒的句子,前后各 5 秒静音,MOS 分明显比其他只有 0.2 秒静音的文件高。
原因:PESQ 会在时间对齐后统计所有帧的扰动,静音区因为没有语音,扰动几乎为零,这相当于在平均值里加了大量零,把总体平均分拉高了。这反映了整体统计在感知模型上的局限:短暂的严重失真和长时间的静音共同作用下,分数并不能直观反映主观感受。
解决:在送入 PESQ 前,用 VAD 检测去除首尾静音。常见做法是先用ffmpeg silencedetect标出静音段,再截取从语音开始到语音结束的区间。这个处理要同时作用在参考和降级文件上,否则会带来额外时长失配。
5.5 不同编译版本之间的分数不一致
现象:同一个文件,用 A 机器上自己编译的 pesq 和 B 机器上从网上下载的二进制各跑一遍,MOS-LQO 差出 0.2 分以上。
原因:PESQ 的老源码经过多次修正,不同发布包可能带进了不同的编译器优化或补丁。尤其浮点运算顺序、平台相关的数学库函数,可能导致细微差异,并在映射到 MOS 时放大。
解决:固定使用同一份源码、同一个编译器、同一种编译参数。在项目文档里记录编译命令和二进制 SHA-256,发布时把二进制一起归档。做横向对比时,不要拿两份不同来源的 PESQ 结果放在同一张表里。这类差异看起来像是玄学,其实背后是版本管理。
6. 进阶用法:把 mos-pesq 变成回归测试的一部分,尽早拦住音质下降
当你的算法或系统进入迭代期,手动跑分反而成了瓶颈。我现在的习惯是,把 mos-pesq 和 CI 工具结合起来,做成一个最小化的回归测试。做法并不复杂:准备一组固定参考音频(我通常放 8 条普通话和 4 条英文,覆盖男声、女声、安静和噪声场景),把每组参考文件经被测系统处理后得到的降级文件,和旧版本的结果一起放进对比脚本,算出差值。
import json def evaluate_version(binary, refs, degs): scores = {ref: pesq_score(binary, ref, deg) for ref, deg in zip(refs, degs)} return scores base_scores = json.load(open("baseline.json")) new_scores = evaluate_version("./pesq_wideband", refs, degs) for ref in base_scores: delta = new_scores[ref] - base_scores[ref] print(f"{ref}: {delta:+.3f}")回归策略上,我给自己定了一个规定:任何改动不能让整体平均分比基线低 0.2 分以上。这个阈值不是数学公式推出来的,而是来自一次血泪经验:某次我把噪声抑制调得太激进,平均分只掉了 0.1,但实际听感已经“闷”了很多。后来我把阈值设得更严,并且额外看单条样本最大下降分,防止个别场景被掩盖。如果你想更精细,可以按场景分组算平均差,比如只统计“男性语音远场”组的 delta,而不是把全部样本混在一起。
还有一个小技巧:PESQ 跑完除了分数,还会生成*.txt日志文件,里面有逐帧扰动的详细数据。虽然主流观点是只看最终分,但我在排查某个自定义模块时,会对照日志里扰动特别大的时间点,去回放对应的音频片段。这种做法能精准定位是哪一个 32ms 帧出了问题,比纯靠耳朵反复试听高效得多。
长期维护这类脚本,一定要把参考音频和基线分数纳入版本控制。不要相信“这次分数低一点可能是偶然”,如果你没有固定的参考集和阈值,每一次“再试试”都是在和随机性赌博。希望我的这些经验和教训能帮到你,在下次调音质时少走几步弯路。
本文还有配套的精品资源,点击获取