做语音实验的人大概都经历过这个场景:手上一批录音,需要逐条算基频曲线、前三个共振峰、音强包络,还要按 TextGrid 里的标注切成元音段分别统计。早年间的做法是在 Praat 界面里一条条点,点完导出 txt 再回 Excel 里拼;再后来学会写 Praat 脚本,但脚本语法自成一套,变量、循环、字符串处理都别扭,一旦要和机器学习流程对接就非常难受。Parselmouth就是在这个缝隙里长出来的东西——把 Praat 那套经过几十年验证的声学算法,直接封装成 Python 模块,pip装完就能在 NumPy 数组和 DataFrame 之间来回倒腾。这篇内容面向三类人:做语音学、心理语言学实验、需要批量出声学指标的研究者;做语音识别、情感识别、声纹相关工程、需要可靠 F0 和共振峰基线的工程师;以及已经会用 librosa 但发现它在基频和共振峰上不够准、想找一个"学术标准答案"的开发者。下面从选型逻辑讲到代码落地,把我踩过的坑一并交代清楚。
1. 先搞清楚 Parselmouth 到底绑定了什么
1.1 Praat 的四条绕行路线与各自的代价
在 Python 里想用上 Praat 的算法,历史上大致有四条路,每条都有明确的代价。第一条是命令行调用:装好 Praat 可执行文件,写一个.praat脚本,用subprocess把音频路径传进去,让它吐出一个文本文件再读回来。这条路最通用,Praat 能做的事它都能做,但进程启动开销大,几百个文件跑下来光启动就耗掉可观时间,中间数据只能靠文件传递,出错时错误信息藏在 Praat 的 stdout 里,调试体验很差。
第二条是纯 Python 重写,比如自己实现自相关求 F0、用 LPC 求共振峰,或者用现成的 TextGrid 读写库处理标注。好处是零外部依赖,坏处是算法细节很难和 Praat 完全对齐——Praat 在清浊音判决、倍频误判修正上有一套很细的启发式规则,自己写的版本往往在噪声段和句首句尾出问题,最后发论文时审稿人一问"你用的是什么基频提取算法",很难交代。
第三条是JVM 桥接,因为 Praat 有一部分实现跑在 Java 侧,理论上可以桥接,但配置复杂、内存模型割裂,实际采用的人不多。第四条就是Parselmouth 这条路:它把 Praat 的 C++ 源码编译成 Python 扩展模块,函数调用直接在进程内完成,没有子进程,没有文件中转,返回的是 NumPy 数组。这就是它真正的价值所在——不是"又一个音频库",而是"Praat 的算法内核,换了一身 Python 的皮"。
1.2 底层机制决定了它能做什么、不能做什么
理解了绑定方式,很多行为就顺理成章了。Parselmouth 编译时把 Praat 的源码静态链接进去,所以你不需要预先安装 Praat 软件,也不需要配 Java 环境,pip install praat-parselmouth装完就是自包含的。这也意味着它内部的算法版本跟你本机装的 Praat 版本可能不一致——它打包的是某个特定版本的 Praat 源码,版本信息可以通过parselmouth.PRAAT_VERSION查出来。做实验写方法学部分时,这一点必须写清楚,否则复现性会出问题。
反过来,它的边界也很清晰。Praat 界面里那些交互式的东西——编辑器、波形上手动打点、实时播放——Parselmouth 一概没有,它只暴露计算层。另外,Praat 的对象模型(Sound、Pitch、Formant、Intensity、TextGrid、Manipulation、PointProcess)被一对一映射成了 Python 类,对象的创建和转换要遵循 Praat 的规则,比如to_pitch只能从 Sound 调,get_jitter只能从 PointProcess 调,跨类型硬来会直接抛异常。刚上手时最容易犯的错就是把它当成 librosa 用,指望load完直接拿一个特征矩阵,实际上你得先想清楚要哪个 Praat 对象,再一步步转过去。
1.3 和 librosa、torchaudio 这些库怎么分工
经常有人问"有了 librosa 还要 Parselmouth 干嘛"。我的看法是它们根本不是竞争关系。librosa 强在通用音频信号处理:梅尔谱、MFCC、色度特征、节拍跟踪、数据增强,这些是深度学习流水线里的标准件,速度快、接口现代。Parselmouth 强在声学语音学的测量:基频的选点策略、共振峰的 LPC 阶数选择、强度的时间步长、抖动闪烁这类嗓音质量指标,这些是几十年语音学研究中反复校准过的东西。
实际项目里我通常这么分工:前端读音频、做重采样、切帧、算对数梅尔谱交给 librosa 或 torchaudio;需要输出"这段元音的第一共振峰是多少赫兹""这位说话人的基频均值""浊音段的谐噪比"这类可解释的声学量时,交给 Parselmouth。两边共用同一个 NumPy 数组,parselmouth.Sound(values, sampling_frequency=sr)直接从一个 float 数组构造 Sound 对象,不需要先落盘成 wav,这一点在流式处理里很好用。
提示:先用 librosa 读文件、统一采样率,再用
parselmouth.Sound(array, sampling_frequency=16000)构造对象,能避免掉一大半采样率相关的坑。反过来用 Parselmouth 读文件再转 NumPy 也可以,但它的重采样和声道处理选项不如 librosa 灵活。
2. 环境准备与第一个能跑通的声学分析脚本
2.1 装包时最容易栽的那一下
包名和导入名不一致,这是新手第一个坎。正确的安装命令是pip install praat-parselmouth,导入时写import parselmouth。有人直接pip install parselmouth,运气好会装上一个同名的空壳包或者根本找不到,然后卡在ImportError上半天。装上之后建议先跑三行验证:
import parselmouth print(parselmouth.__version__) # Parselmouth 自己的版本,通常是 0.4.x 系列 print(parselmouth.PRAAT_VERSION) # 打包进去的 Praat 源码版本版本这块有个实际约束:0.4.x 系列对 Python 版本和 NumPy 大版本有要求,如果环境里 NumPy 是刚发布的新大版本,可能遇到 ABI 不兼容的报错,表现是import阶段直接崩。稳妥做法是给它一个干净的虚拟环境,先装 Parselmouth 让它自己决定 NumPy 版本,再装其他东西。Windows 上如果pip装的是 wheel 包,一般不需要编译器;如果所在平台没有预编译 wheel,会退化成源码编译,那时候需要 C++ 工具链,这一步耗时很长,建议优先升级 Python 或换到有 wheel 的版本组合上。
2.2 读音频时的三个隐性坑
第一个坑是采样率与共振峰上限的关系。Praat 做共振峰分析时,maximum_formant必须明显低于奈奎斯特频率,也就是采样率的一半。如果你手上是 16 kHz 的音频,奈奎斯特是 8 kHz,maximum_formant设成 5500 勉强能用,设成 8000 就会出问题——要么 Praat 直接拒绝,要么给出毫无意义的结果。我的习惯是:16 kHz 音频用maximum_formant=5000到5500,44.1 kHz 或 48 kHz 的原生录音可以用5500甚至更高(女声、儿童语音需要更高)。
第二个坑是声道数。Sound对象内部是[通道数, 采样点数]的二维数组,立体声文件读进来两个通道都在里面,而绝大多数 Praat 分析命令只对单通道有意义。读之前先确认,必要时取一个通道。第三个坑是时间起点。Praat 的 Sound 有一个start_time属性,绝大多数音频文件是 0,但从更长的录音里截出来的片段,或者某些工具导出的文件,起点可能不是 0。如果你的代码默认"第 i 个采样点对应 i/sr 秒",在起点非零的文件上会整体错位。稳妥写法是始终用snd.get_start_time()和snd.get_total_duration()来算绝对时间。
import parselmouth import numpy as np snd = parselmouth.Sound("sample.wav") print("采样率:", snd.sampling_frequency) print("起点:", snd.get_start_time()) print("总时长:", snd.get_total_duration()) print("数组形状:", snd.values.shape) # (通道数, 采样点数)2.3 二十行拿到 F0、共振峰和强度
先给一段能直接跑、跑完就能看到结果的骨架代码。这段代码的定位是"确认环境和数据没问题",后面所有复杂分析都是在这个基础上加东西。
import parselmouth import numpy as np from parselmouth.praat import call snd = parselmouth.Sound("sample.wav") # 基频:自相关法,时间步长 10 ms,搜索范围 75-600 Hz pitch = snd.to_pitch(time_step=0.01, pitch_floor=75, pitch_ceiling=600) f0 = pitch.selected_array["frequency"].astype(float) f0[f0 == 0] = np.nan # Praat 用 0 表示清音帧,转成 NaN 方便统计 t_f0 = pitch.xs() # 共振峰:Burg 法,最多 5 个,上限 5500 Hz,窗长 25 ms formant = snd.to_formant_burg( time_step=0.01, max_number_of_formants=5, maximum_formant=5500, window_length=0.025, ) t_f = formant.xs() f1 = np.array([formant.get_value_at_time(1, t) for t in t_f]) f2 = np.array([formant.get_value_at_time(2, t) for t in t_f]) # 强度:最低基频 100 Hz 决定时间步长 intensity = snd.to_intensity(minimum_pitch=100) t_i = intensity.xs() db = intensity.values[0] print(f"有效浊音帧占比: {np.isfinite(f0).mean():.2%}") print(f"F0 均值: {np.nanmean(f0):.1f} Hz") print(f"F1 均值: {np.nanmean(f1):.1f} Hz, F2 均值: {np.nanmean(f2):.1f} Hz")跑通这段,你手上就有了三条不同栅格的时间序列:F0 用的是 10 ms 步长,共振峰也是 10 ms,强度的时间步长则由minimum_pitch决定(Praat 的规则是0.8 / minimum_pitch秒)。三条序列的采样点对不齐,后面要做联合分析必须先重采样到同一时间轴,这个话题在第 3 章的 3.4 节展开。
3. 四大声学量的提取细节与参数取舍
3.1 基频:ac 还是 cc,参数怎么定
snd.to_pitch()默认走自相关法(autocorrelation),对应的显式方法是to_pitch_ac()。Praat 里还有一条互相关法to_pitch_cc(),走的是波形互相关,对带噪语音和低基频更稳一些,代价是慢。实际取舍我的经验是:安静环境下的正常录音,ac 完全够用,速度快一倍以上;如果是远场录音、有背景噪声、或者分析的是老人和病理嗓音这类基频很不稳的样本,换 cc 更有把握。
三个参数里,pitch_floor是决定性的。它不仅是搜索下界,还决定了时间步长的自动值和分析窗长——Praat 的规则大致是窗长取3 / pitch_floor秒。设成 75 Hz 时窗长约 40 ms,能覆盖一个低沉的男低音周期;如果对象是女声或儿童,把 floor 提到 100 甚至 150,窗长变短,对快速音高变化的时间分辨率提高,但太短会丢失低基频成分。pitch_ceiling一般设到floor的 3 到 4 倍,设得太窄容易在语调上扬处削顶,设得太宽则倍频误判风险上升。
time_step设成 0 表示让 Praat 自动决定,通常是0.75 / pitch_floor。做韵律研究我一般显式写0.01,理由很简单:要和共振峰、强度的时间栅格对齐时,固定 10 ms 省掉一次插值。清音帧的处理是新手最容易忽略的细节——Praat 用频率值 0 标记"这一帧判为清音",如果你直接np.mean(f0),句首句尾和停顿段那一堆 0 会把均值拉得极低。必须先把 0 转成np.nan再统计,或者用pitch.selected_array配合pitch.get_value_at_time()逐点判断。
3.2 共振峰:窗长和上限比算法选择更关键
snd.to_formant_burg()是主力,Burg 算法在短窗上比自相关法稳定,Praat 界面里默认也是它。参数里真正影响结果的是maximum_formant和window_length,max_number_of_formants设为 5 通常足够,它只是上限,实际返回的第 N 个共振峰可能因为超出上限而无效。
window_length的经验约束是至少覆盖两到三个基音周期。25 ms 的窗在 100 Hz 基频下覆盖 2.5 个周期,刚好够用;但在 60 Hz 的男低音上只有 1.5 个周期,F1 会明显抖动。这时候要么把窗长设到 30 到 40 ms,要么接受 F1 的噪声。反过来,窗长太长会把相邻音素糊在一起,塞音爆破和元音过渡段尤其明显,做连续语音分析时要警惕。
maximum_formant的取值直接决定分析器"预期看到几个共振峰"。设得偏高(比如 6500),算法会在高频区强行找峰,把 F1 的估计带偏;设得偏低(比如 4000),女声的 F3、F4 直接被截断。我的习惯是按说话人分组设:成年男性 5000,成年女性 5500,儿童 7000 到 8000(前提是采样率够)。做批量处理时这个参数应该跟着说话人信息走,而不是全局写死一个值。
还有一个容易忽略的点:get_value_at_time()在未被分析的时段会返回 NaN 或者一个特殊值。如果你的音频有长静音段,逐点取值时要留意返回类型,做除法或取对数前先做有效性过滤。
3.3 强度与谐噪比:两个"看着简单但很容易设错"的量
强度这一项,snd.to_intensity(minimum_pitch=100)里的minimum_pitch名字很有迷惑性——它不是在算基频,而是用来决定分析窗长和步长的。Praat 的规则是窗长约3.2 / minimum_pitch,步长约0.8 / minimum_pitch。设 100 Hz 意味着窗长 32 ms、步长 8 ms,适合大多数语音;分析快速变化的塞音爆破时可以把值调大(窗变短),分析长元音的稳定段时可以调小(窗变长、曲线更平滑)。
另外 Praat 算的是声压级相对值,不是绝对 dB SPL。输出的分贝值内部参考是某个固定声压,跨录音直接比较绝对值没有物理意义,但同一录音内部的相对起伏、动态范围、以及不同说话人在同一录音条件下的相对大小是可以用的。要做绝对声压级标定,得自己走一遍校准流程,这已经超出 Parselmouth 的范围了。
谐噪比走to_harmonicity_cc,从 Sound 直接出 Harmonicity 对象,再逐点取值。它的物理含义是"谐波成分能量与噪声成分能量之比",对嗓音嘶哑度非常敏感,做病理嗓音筛查、疲劳检测这类任务时是个低成本高信息量的特征。参数里silence_threshold决定多安静算静音(低于阈值直接判为无谐波),periods_per_window控制平滑程度,默认 1.0,想曲线平滑一点给 1.5 到 2.0。
3.4 时间轴对齐:三条序列怎么合成一张表
前面说过,F0、共振峰、强度三条序列的栅格不一样。最省事的做法是定一个公共时间轴(比如 10 ms 等间隔),然后用np.interp对每条序列做线性插值。但插值前有个必须处理的细节:NaN 不能直接参与插值,np.interp遇到 NaN 会把整段污染。正确做法是先把 NaN 位置记下来,用有效点插值,再把原本无效的位置重新置回 NaN。
| 声学量 | 默认时间步长 | 决定因素 | 对齐建议 |
|---|---|---|---|
| 基频 F0 | 10 ms(显式指定) | time_step参数 | 有效帧直接插值,清音帧标记保留 |
| 共振峰 F1-F3 | 10 ms(显式指定) | time_step参数 | 同 F0,无效值单独标记 |
| 强度 | 约 8 ms | minimum_pitch推算 | 平滑后插值,避免尖峰被抹平 |
| 谐噪比 HNR | 10 ms | time_step参数 | 同上 |
对齐之后把结果塞进一个 pandas DataFrame,列名用time, f0, f1, f2, f3, intensity_db, hnr,一行一帧。这种长表结构最好用——后面做分组统计、画轨迹图、喂给分类器都是一句话的事。我习惯额外加一列voiced布尔标记,把清音和浊音分开,很多统计只应该跑在浊音帧上。
4. TextGrid 标注驱动的批量分析流水线
4.1 读标注、取区间、算分段统计
真正有价值的分析几乎都是按标注分段的,而不是整段音频一起算。Praat 的 TextGrid 是这套工作流的事实标准,Parselmouth 能直接读。核心 API 是拿到 TextGrid 对象后按层名取 tier,再按时间取区间。
import parselmouth tg = parselmouth.read("sample.TextGrid") print(tg.tier_names) # 所有层的名字 tier = tg.get_tier_by_name("vowel") for iv in tier.intervals: if iv.text.strip() == "": continue # 跳过空白区间 seg = snd.extract_part( from_time=iv.start_time, to_time=iv.end_time, preserve_times=False, ) seg_pitch = seg.to_pitch(time_step=0.01, pitch_floor=75, pitch_ceiling=600) f0 = seg_pitch.selected_array["frequency"] f0 = f0[f0 > 0] print(iv.text, iv.start_time, iv.end_time, f0.mean() if len(f0) else None)extract_part的preserve_times参数值得多说一句:设成True时保留原始时间偏移,片段的start_time还是原来的绝对值;设成False时片段从 0 开始。做"每段单独算指标"时用False更省心,因为后续所有 Praat 命令的时间参数都从 0 起算;但如果你要把片段的分析结果再映射回全局时间轴(比如画一条覆盖整段录音的 F0 曲线),就必须保留原时间,否则整体错位。
另一个实用技巧:TextGrid 的层级可以是区间层(interval tier)也可以是点层(point tier)。点层常用于标音节核、声门塞音位置,取法是tier.points,每个点有time和mark。做嗓音起始时间(VOT)分析时,我一般让标注者在点层标爆破除阻点和浊音起始点,脚本直接取两点时间差,比在区间层里手动配对可靠得多。
4.2 批量脚本的骨架与并发策略
单文件分析跑通之后就是批量。批量脚本的骨架我一般是这个结构:先扫描目录生成任务列表,每个任务包含音频路径和对应的标注路径;然后逐任务处理,把每个任务的中间结果写成一行或若干行记录;最后统一汇总。
有一个细节必须提前想清楚:当标注和音频对不上时怎么办。常见情况是标注文件缺失、标注时长为零、标注区间超出音频范围。我的处理原则是"记录但不中断"——异常任务写进一个errors.csv,带上文件路径和错误类型,主流程继续跑。批量跑几百个文件时,中途因为一个坏文件整体崩掉是最浪费时间的事。
并发方面,Parselmouth 的底层是 C++ 计算,Python 层面主要是数据搬运。用concurrent.futures.ProcessPoolExecutor做多进程通常能有接近线性的加速,四核机器上提速 3 倍左右很常见。用线程池效果不明显,因为重计算部分会占着 GIL(虽然部分绑定的代码会释放 GIL,但释放时机不由你控制)。进程数别开太多,一般设为 CPU 核数减一,留一个核给系统和 IO。
from concurrent.futures import ProcessPoolExecutor, as_completed import traceback def analyze_one(args): wav_path, tg_path = args try: # ... 完整分析流程 ... return {"file": wav_path, "status": "ok", "rows": rows} except Exception as e: return {"file": wav_path, "status": "error", "msg": f"{type(e).__name__}: {e}", "tb": traceback.format_exc()} with ProcessPoolExecutor(max_workers=4) as ex: futures = {ex.submit(analyze_one, t): t for t in tasks} for fut in as_completed(futures): res = fut.result() # 分门别类写入 ok / error 两个缓冲4.3 结果落地与一致性校验
结果落地我建议两层:一层是片段级明细表,一行一个标注区间,字段包括文件名、说话人、区间标签、起止时间、F0 均值/中位数/标准差、F1-F3 均值、强度均值、HNR 均值;另一层是文件级汇总表,一行一个录音。两层分开的好处是,做统计时想看哪种粒度都行,不用反复重算。
一致性校验这一步很多人会跳过,但它救过我不少次。最有效的三招:一是数量校验,预期每段录音有 20 个元音区间,实际统计跑出 18 个就要查是不是漏标;二是时长校验,所有区间的总时长不应该超过音频总时长,超了说明标注文件配错了;三是数值范围校验,给 F0 设一个合理区间(比如 50 到 500 Hz),给 F1 设 200 到 1200 Hz,落在区间外的比例超过 5% 就人工抽查几个。这三招能在跑完统计之前就把数据错误拦下来,省掉大量返工。
注意:不同批次的录音如果采样率不同,共振峰结果的系统性偏差可能达到几十赫兹。做跨批次汇总前,先把采样率统一,或者至少在结果表里记下原始采样率,别让它变成分析里说不清的变量。
5. 常见问题与排查实录
5.1 安装与环境类问题
ImportError: DLL load failed这类报错基本都指向依赖版本冲突,尤其在 Windows 上装了多个 Python 环境、或者 NumPy 刚从 1.x 升到 2.x 的时候。处理顺序是:先确认当前python -c "import sys; print(sys.executable)"指向的是你预期的那个解释器;再确认 NumPy 版本和 Parselmouth 的 wheel 匹配;实在搞不定就新建一个干净虚拟环境,只装这一个包验证。不要在一个已经装了无数科学计算包的老环境里硬修,时间成本远高于重建。
另一个高频问题是"装了但代码补全提示不出来"。这多半是 IDE 索引没更新,重启一下语言服务器通常就好了,跟包本身没关系。
5.2 数值异常的排查路径
跑出来的数不对,比跑不出来更难查。我整理了一张速查表,覆盖实际遇到过的绝大多数情况。
| 现象 | 最可能的原因 | 排查与修正 |
|---|---|---|
| F0 全是 0 或大量 NaN | 音频太安静,或pitch_floor高于实际基频 | 先看波形幅度,再把 floor 降到 60 试一次 |
| F0 出现大量翻倍值 | 倍频误判,pitch_ceiling设得太宽 | 收窄上限,或改用 cc 方法 |
| F1 剧烈抖动 | window_length短于两个基音周期 | 窗长提到 0.03 到 0.04,或先判说话人性别分组建模 |
| 共振峰在高频段乱跳 | maximum_formant接近奈奎斯特频率 | 降到采样率的 30% 以下,或先重采样到 16 kHz |
| 强度值明显偏高或偏低 | 音频幅度未归一化,或minimum_pitch设得离谱 | 统一峰值归一化,minimum_pitch回到 60 到 100 区间 |
| 分段统计出现负的时长 | 标注区间起止反了,或文件配错 | 加一条断言end > start |
| 多进程跑一会儿就卡死 | 内存吃满,或被分析对象跨进程共享 | 降低 worker 数,确保每个进程内部自建对象 |
举一个具体例子。之前有同学反馈"同一段音频,Praat 界面里算出的 F0 均值是 210 Hz,脚本跑出来 165 Hz"。查下来原因是界面里看到的 Pitch 对象是手动调过 ceiling 的,而脚本用的是默认参数,pitch_ceiling只有 500 但pitch_floor用了默认的 75,窗长 40 ms,把快速变化段的峰抹平了。把脚本参数改成和界面一致之后数值就对上了。这件事的教训是:脚本里的每一个参数都应该是显式写出来的,不要依赖默认值,因为默认值会随版本变化,而且不同人理解不同。
5.3 性能与内存的实际表现
处理长时间录音时,内存是个真问题。一小时的 44.1 kHz 单声道录音,float64 存储约 1.2 GB,如果再加上中间产生的 Pitch、Formant、Intensity 对象,峰值很容易翻倍。我的做法是先降采样:语音分析真正用到的频率上限很少超过 8 kHz,重采样到 16 kHz 后数据量降到不到原来的 40%,共振峰分析反而更稳。降采样用snd.resample(16000)就行,或者干脆在读取阶段用 librosa 做完再构造 Sound。
速度方面,单个 5 秒文件的完整分析(F0 + 共振峰 + 强度 + HNR)在我的机器上大约 0.1 到 0.3 秒,主要耗时在共振峰,因为它要逐帧做 LPC 求解。有一类优化很有效:只在需要的区间上做分析。如果只关心标注出来的元音段,就别对整段音频算共振峰,先用extract_part切出来,再逐段算,总耗时能降一个数量级。这个思路在处理长录音、稀疏标注的场景下收益巨大。
6. 用 praat.call 打通剩下的能力
6.1 通用命令调用:把 Praat 手册变成 Python 接口
Parselmouth 提供了 Python 风格的方法,但覆盖并不完整——Praat 有几百条命令,绑定的只是一部分常用项。剩下的怎么办?答案是parselmouth.praat.call()。它接受一个对象(或对象列表)、一个 Praat 命令字符串、以及若干参数,把命令原封不动转发给底层。这意味着Praat 手册里能查到的命令,你在 Python 里基本都能调。
from parselmouth.praat import call # 例:用 Praat 命令算强度,参数顺序和界面字段一一对应 intensity = call(snd, "To Intensity", 100, 0.0, "yes") # 例:先做预加重滤波,再算基频 filtered = call(snd, "Filter (pre-emphasis)", 50) pitch = call(filtered, "To Pitch (ac)", 0.01, 75, 15, "no", 0.03, 0.45, 0.01, 0.35, 0.14, 600)用call的关键是参数顺序必须和 Praat 手册完全一致,包括那些看着可有可无的布尔参数(用"yes"/"no"字符串传)。这些参数在 Python 里没有类型提示,写错一个顺序就得到完全不同的结果,而且不报错。我的习惯是每写一个新命令,先在 Praat 界面里跑一遍,记下界面上的每个字段值和输出数值,再在 Python 里复现,两个结果对上才算写对。这个过程看着笨,但比事后查错快得多。
6.2 抖动、闪烁与嗓音质量指标
做嗓音分析时绕不开 jitter(基频微扰)和 shimmer(幅度微扰),这两个指标都不能直接从 Sound 算,得先构造周期点过程。流程是先用互相关法找出每个基音周期的位置,得到一个 PointProcess 对象,再从它身上取指标。
pp = call(snd, "To PointProcess (periodic, cc)", 75, 600) jitter_local = call(pp, "Get jitter (local)", 0, 0, 0.0001, 0.02, 1.3) shimmer_local = call([snd, pp], "Get shimmer (local)", 0, 0, 0.0001, 0.02, 1.3, 1.6) print(f"jitter(local): {jitter_local:.4%}") print(f"shimmer(local): {shimmer_local:.4%}")注意 shimmer 需要同时传 Sound 和 PointProcess,call的第一个参数写成列表就行。这几个参数的含义是:起止时间、最短周期、最长周期、最大周期因子。0.0001 和 0.02 秒对应 50 到 10000 Hz 的周期范围,覆盖了人声的绝大部分。周期因子用来排除倍频跳变,1.3 是常用值,噪声特别大的样本可以放宽到 1.6。
有一个实践提醒:jitter 和 shimmer 对录音质量极度敏感,不同麦克风、不同增益、不同降噪处理都会带来系统性差异。做组间比较时,录音条件必须在组间平衡,否则你测到的差异可能全是设备差异。这个坑我在早期项目里吃过一次,样本量做得不小,最后发现两组用的是两套录音设备,数据基本作废。
6.3 操纵合成:改音高、改时长、再合成
Praat 的 Manipulation 对象提供了一套"分析—修改—重合成"的能力,Parselmouth 也暴露了。典型用途是:把一段语音的基频曲线整体提高 10%,或者按比例拉长时长,用于制作实验刺激。
manip = call(snd, "To Manipulation", 0.01, 75, 600) pitch_tier = call(manip, "Extract pitch tier") # 整体移调:所有点乘一个系数 for i in range(1, call(pitch_tier, "Get number of points") + 1): t = call(pitch_tier, "Get time from index", i) f = call(pitch_tier, "Get value at index", i) call(pitch_tier, "Remove point", i) # 注意删除会改变索引上面这段代码里那个"删除会改变索引"的注释是个真实的坑:边遍历边删点会让索引错乱,正确做法是先把所有时间和频率读出来存成列表,再统一重建 PitchTier:call("Create PitchTier", name, tmin, tmax)得到空 tier,然后call(tier, "Add point", t, f)逐点加回去。这类"Praat 对象是可变状态"的特性在批处理里很容易埋雷,写的时候要多留一个心眼。
合成侧的另一个常用操作是改时长:call(manip, "Set duration", new_duration, "Proportional"),然后call(manip, "Get resynthesis (overlap-add)")拿到新的 Sound 对象。做语速感知实验、制作等时长刺激材料时这套流程很省事,比在音频软件里手动拉伸要精确得多。
7. 我个人的几条经验
先说一个最实在的:参数一定要存档。每次跑分析,把pitch_floor、pitch_ceiling、time_step、maximum_formant、window_length、minimum_pitch这些值连同结果一起写进一个params.json。半年后回来看结果,或者别人要复现,靠回忆猜参数是不现实的。这个习惯看起来是小题大做,但它在需要重跑分析的时候能省掉一整天。
第二个是先在单文件上肉眼验证。挑一个有代表性的文件,在 Praat 界面里打开,看着波形和频谱图,把 F0 曲线、共振峰轨迹叠上去看一眼。哪里跳变、哪里断开、哪里明显不对,一眼就能看出来。这一步花五分钟,能帮你省掉后面几千行数据返工的功夫。我见过太多人直接拿脚本跑全量数据,跑完画个均值柱状图就交差了,结果中间某个参数设错导致某个说话人组整体偏移,全靠运气没被发现。
第三个是不要迷信单一指标。F0 均值、F1 均值这些聚合量丢失的信息太多,尤其是跨说话人比较时,个体差异会淹没效应。我现在的习惯是同时输出均值和标准差,画个体轨迹图,必要时看分布而不是看均值。Parselmouth 的好处正在这里——它给的是一帧一帧的曲线,不是打包好的一个数字,你有充分的空间去做更细的分析。至于后续怎么扩展,一条路是把这套流水线接到特征工程里,做说话人层面的建模;另一条路是往实时方向走,把分析窗口做小、只算关键指标,用于交互式的发声反馈。这两条我都试过一部分,前者稳、后者有意思但坑多,具体怎么选还得看你手上的数据规模和目标。