首页 / 资讯中心 / 文章详情

Parselmouth实战:Python调用Praat做声学特征提取与批处理

Parselmouth实战:Python调用Praat做声学特征提取与批处理 ★ FEATURED ARTICLE
做语音学量化分析、嗓音评估或者声学特征工程的人大概率都经历过这样一个循环先在 Praat 的图形界面里点开一段录音看频谱、拉音高曲线、手动打标注觉得这套算法靠谱然后想把它接进 Python 的批量流水线里结果发现要么自己用 librosa 重写一套得到的基频数值和 Praat 对不上要么只能靠命令行调 Praat 脚本参数传来传去中间结果全靠临时文件周转。Parselmouth 就是从这个缝里长出来的东西——它把 Praat 的 C 内核直接编译成 Python 扩展让你在 Python 里创建 Sound 对象、算 Pitch、提 Formant、读写 TextGrid数值和 Praat 界面里看到的一模一样。这篇内容面向的是已经会写一点 Python、手上有一批音频要处理、并且对声学参数有基本概念的人也适合那些被Python 算出来的基频和 Praat 差了十几赫兹折磨过的同行。1. 为什么我还要在 Python 里绕一圈去够 Praat1.1 声学分析的标准答案为什么在 Praat 手里先把这个前提说清楚在语音学、临床嗓音医学、第二语言语音研究这些方向上Praat 不是其中一个工具它基本就是事实标准。一篇论文里报告基频均值、jitter、shimmer、HNR、F1/F2审稿人默认你用 Praat 的算法和默认参数。这意味着两件事——第一你的数值要和别人可比第二你的参数设置要能被复现。问题在于Python 生态里的通用音频库并不是为这个场景设计的。librosa 的 pyin 是一套很好的基频跟踪算法但它和 Praat 的 autocorrelation 方法在清浊音判决、八度错误处理、帧移策略上都不一样。同一段录音Praat 给出 118 Hzpyin 给出 236 Hz典型的倍频错误你拿去和文献对照直接对不上。torchaudio 更偏向深度学习前端它连共振峰这个概念都没有。所以真正务实的做法不是用 Python 重写 Praat而是用 Python 调用 Praat。Parselmouth 干的就是这件事。它底层是把 Praat 的源码用 pybind11 包了一层你调snd.to_pitch()的时候跑的是 Praat 里那段To Pitch的原始 C 代码不是 Python 的近似实现。这一点很关键它决定了你算出来的数能不能直接写进论文表格。1.2 Parselmouth 到底封装了什么没封装什么很多人第一次用会有个误解以为 Parselmouth 是Praat 的 Python 版界面。不是。它是对象层的映射。Praat 内部有一套对象系统Sound、Pitch、Intensity、Formant、TextGrid、PointProcess、Harmonicity、Spectrogram、Manipulation……Parselmouth 把这些对象一一对应成 Python 类把 Praat 的菜单命令对应成方法比如Sound.to_pitch()对应To Pitch...菜单项再留一个parselmouth.praat.call()兜底任何没有高层封装的方法都能通过对象 命令名 参数的方式调起来。它没封装的是Praat 的图形界面逻辑、编辑器的交互状态、以及一部分依赖 GUI 上下文的功能。另外Praat 脚本语言里的变量作用域、条件分支、循环这些Parselmouth 不负责翻译你要么写成 Python 循环要么用praat.run()直接跑整段脚本。我个人的判断标准很简单如果你的流程需要对每个文件做同样的几步分析然后把结果汇总成表用 Parselmouth如果你的流程需要人在回路里看波形、改边界、听回放老老实实开 Praat 界面或者做半自动——脚本先跑一遍生成初版 TextGrid人工再修。2. 装不上、导不进环境配置阶段的三个真实绊脚石2.1 包名和导入名不是一回事这是新人栽的第一个跟头而且栽得莫名其妙。你在终端敲pip install parselmouth可能装上了也可能装了个完全不相干的东西正确的包名是praat-parselmouth。装完之后导入的时候又是另一个名字pip install praat-parselmouthimport parselmouth from parselmouth import praat print(parselmouth.__version__)包名带前缀、导入名不带这个不对称设计有它的历史原因避免和另一个同名的旧项目冲突但对使用者来说就是纯粹的认知负担。我给团队写环境文档的时候会把这一条单独拎出来放在最上面因为报错信息ModuleNotFoundError: No module named parselmouth在我明明 pip install 过了的语境下极具误导性。顺带说一句版本。praat-parselmouth的版本号和它内置的 Praat 内核版本是两套数字0.4.x 系列大致对应 Praat 6.1 到 6.2 的内核。如果你的分析要和三五年前发表的文献对齐注意一下对方用的 Praat 版本某些算法尤其是 Pitch 的路径寻找和 Formant 的窗口策略在小版本之间有过调整。2.2 平台与 Python 版本的兼容矩阵Parselmouth 是编译型扩展不是纯 Python 包所以它依赖预编译的 wheel。这意味着你的操作系统、CPU 架构、Python 版本三者必须落在它有 wheel 的范围内否则 pip 会退回源码编译而源码编译需要完整的 C 工具链在 Windows 上尤其折腾。我整理过一份实际踩过的对照供参考环境常见状况处理思路Windows 官方 Python 3.9~3.12一般能直接装到 wheel确保有对应版本的 VC 运行库Windows 精简版/嵌入式 Python装上了但导入时报 DLL 缺失换官方安装包别用绿色版macOS Intelwheel 齐全直接装macOS Apple Silicon 较新 Python旧版本可能没有 arm64 wheel升级到较新的 parselmouth 版本或走 Rosetta 环境Linux 服务器 condaconda 源里有时版本滞后优先用 pip 装别混用各种环境 Python 3.13 刚发布时生态还没跟上项目里锁 3.11 或 3.12这张表的核心结论是别追最新的 Python 版本。语音处理这类依赖科学计算栈的项目Python 版本领先半步就够领先一步往往要花半天解决编译问题而这半天对项目进度毫无贡献。2.3 虚拟环境里验收集成是否完整装完之后不要只import一下就完事那只能证明模块加载成功证明不了内核功能正常。我习惯用一个三十秒的冒烟测试脚本把关键路径都过一遍import numpy as np import parselmouth from parselmouth import praat # 造一段 1 秒的 120Hz 正弦波采样率 16k sr 16000 t np.linspace(0, 1.0, sr, endpointFalse) wave 0.5 * np.sin(2 * np.pi * 120 * t) snd parselmouth.Sound(wave, sampling_frequencysr) print(duration:, snd.duration, channels:, snd.n_channels) pitch snd.to_pitch(pitch_floor75, pitch_ceiling500) freq pitch.selected_array[frequency] voiced freq[freq 0] print(median f0:, np.median(voiced)) intensity snd.to_intensity() print(mean intensity:, np.mean(intensity.values)) tg praat.call(snd, To TextGrid, word, point) print(tiers:, praat.call(tg, Get number of tiers))这段脚本的价值在于它同时验证了四件事numpy 数组能不能直接构造 Sound、Pitch 提取路径通不通、Intensity 对象能不能取 values、praat.call的对象传参和字符串返回是否正常。四项全过后面写业务代码才不会在奇怪的地方翻车。提示如果你在容器里跑注意基础镜像是否带了libgomp之类的 OpenMP 运行库。某些 slim 镜像会缺表现是导入时报共享库找不到而不是 Python 层的错误。3. Sound 对象所有分析的起点和你必须搞懂的采样细节3.1 从文件到 numpy 数组中间发生了什么parselmouth.Sound(xxx.wav)这一行背后做了不少事读文件头、解码 PCM、把整型样本归一化成 double、按声道组织成二维数组。你可以直接拿到这个数组snd parselmouth.Sound(speech.wav) arr snd.values # numpy.ndarray, dtypefloat64 print(arr.shape) # (n_channels, n_samples) print(arr.dtype, arr.min(), arr.max())这里有几个必须记住的事实。第一values永远是二维的单声道也是(1, n_samples)不要写arr[0]之外的花活。第二dtype 是 float64取值范围理论上是 [-1, 1]但实际录音几乎不会打满通常在 ±0.3 到 ±0.8 之间。第三这是副本还是视图取决于版本和调用方式保险起见把它当只读数据用要改就先np.array(arr, copyTrue)。如果你手上是 numpy 数组而不是文件构造 Sound 时最容易犯的错是把一维数组直接塞进去# 错误一维数组会被当成多声道解释结果完全不对 snd parselmouth.Sound(wave, sampling_frequency16000) # 正确显式 reshape 成 (1, n) snd parselmouth.Sound(wave.reshape(1, -1), sampling_frequency16000)我第一次遇到这个坑是在做批量重采样的时候一维数组传进去没报错后面提出来的基频全是乱的排查了快两个小时才定位到 shape 上。这个错误之所以危险是因为它不抛异常只是结果悄悄错掉。3.2 声道、位深与幅度的三个转换陷阱做实际项目音频来源五花八门这里有三类转换必须自己把关。第一个是位深。用scipy.io.wavfile.read()读出来的 int16 数组如果不除 32768 就直接构造 Sound幅度会变成原来的三万倍虽然比例关系还在但任何涉及绝对强度的分析都会失真——尤其是to_intensity()它算的是声压级对幅度绝对敏感。正确的写法from scipy.io import wavfile sr, data wavfile.read(speech.wav) if data.dtype np.int16: data data.astype(np.float64) / 32768.0 snd parselmouth.Sound(data.reshape(1, -1), sampling_frequencysr)第二个是声道。双声道录音直接丢进to_pitch()Praat 内部会怎么处理不同版本、不同分析类型的行为不完全一致有的取平均有的只处理第一声道。这种不确定性在批处理里是灾难。我的做法是永远显式降成单声道if snd.n_channels 1: snd snd.extract_channel(1) # 1 是左声道索引从 1 开始注意这里的索引从 1 开始这是 Praat 的老传统和 Python 的 0 基索引冲突。Parselmouth 在对象方法上保留了这个约定你在extract_channel、get_value_at_time的共振峰序号这些地方都会碰到。第三个是采样率。语音分析常用 16 kHz但录音笔给你 44.1 kHz 或 48 kHz 是常态。降采样本身不复杂关键是滤波器选择snd16 snd.resample(16000, sinc70)Praat 提供sinc70、sinc700、autocorrelation几种重采样方式。sinc70是默认值精度和速度平衡得不错sinc700精度更高但慢很多除非你在做极精细的频谱分析一般不用。反过来说如果你的目标只是算基频和强度根本不需要重采样Praat 的算法对采样率不敏感多这一步纯属浪费算力。3.3 时间轴的两种取法及其差别从 Sound 里取某一帧的时间坐标有两种方式用错了会差半个帧。xs snd.xs() # 每个采样点的中心时刻长度 n_samples grid snd.x_grid() # 按帧网格取长度 n_framesxs()返回的是逐个采样点的时间适合你把波形和某个时间点对齐x_grid()是按time_step划出来的分析帧时间适合和 Pitch、Intensity 这类帧级结果对齐。两者长度通常不一样。更常见的场景是从 Pitch 对象取时间轴pitch snd.to_pitch(time_step0.01) times pitch.xs() freqs pitch.selected_array[frequency]这里pitch.xs()的长度和freqs的长度是一致的可以直接 zip 成一个时间序列。我见过有人在pitch.selected_array上直接用snd.xs()去索引长度对不上就报错或者更糟——长度碰巧对上了但时间轴是错的结果整个曲线平移了。4. 音高、强度、共振峰、谐噪比四类参数的实际提取路径4.1 Pitch为什么 time_step 设 0 往往比设 0.01 更靠谱Praat 的To Pitch有三个核心参数time_step、pitch_floor、pitch_ceiling。大部分教程会告诉你time_step0.01也就是 10 ms 一帧。但 Praat 官方文档其实建议设成 0让算法根据pitch_floor自动决定帧移——具体是time_step 0.75 / pitch_floor当pitch_floor75时得到 10 ms当pitch_floor60时得到 12.5 ms。为什么这个细节重要因为帧移和目标基频之间存在耦合。如果帧移相对周期太短相邻帧高度相关平滑处理的效果会打折如果太长又会漏掉音高的快速变化。让算法自己算比拍脑袋定一个 0.01 更稳。pitch snd.to_pitch(time_step0.0, pitch_floor60, pitch_ceiling500) freq pitch.selected_array[frequency] voiced_ratio np.mean(freq 0) f0_median np.median(freq[freq 0])pitch_floor和pitch_ceiling的设定比time_step更影响结果。经验值成年男声 60~350成年女声 75~500儿童 100~800。范围设宽了会引入倍频和半频错误设窄了会把真实的音高变化裁掉。我一般会先用宽范围跑一遍看分布再收窄重跑。还有一个坑清音帧的频率值是 0不是 NaN。所以判空要写freq 0不要写~np.isnan(freq)。这个设计选择很 Praat但对 numpy 用户来说反直觉。想要更细粒度的控制可以走底层命令pitch praat.call(snd, To Pitch (ac), 0.0, 60, 15, no, 0.03, 0.45, 0.01, 0.35, 0.14, 500)这一串参数依次是时间步长、基频下限、最多候选数、是否非常准确、静音阈值、清浊判决阈值、倍频代价、倍频跳跃代价、发声阈值、基频上限。这些是 Praat 论文里明确给出的默认值调它们之前建议先读一遍原始方法说明否则很容易调出一个看起来能用但没法解释的结果。4.2 Formantmaximum_formant 与说话人性别的那点事共振峰提取的默认参数在 Praat 里是max_number_of_formants5.0、maximum_formant5500女声/5000男声。这个 5500 的上限设定直接影响 F1 到 F5 的追踪因为算法需要在给定范围内找出局部极大值。fmt snd.to_formant_burg( time_step0.01, max_number_of_formants5.0, maximum_formant5500.0, window_length0.025, pre_emphasis_from50.0, ) f1 fmt.get_value_at_time(1, 0.5) # 第 1 个共振峰在 0.5 秒处window_length的默认 25 ms 对应大概 40 Hz 的频率分辨率这对区分 F1 和 F2 够用但对更精细的测量可能不够。加长窗口能提高频率分辨率代价是时间分辨率下降——这是一个绕不过去的取舍本质上是时频不确定原理。get_value_at_time()在取不到值时返回 0这个和 Pitch 的处理方式一致。但要注意共振峰取不到值和值为零是两回事判断时要结合上下文比如 F1 在 500 Hz 附近如果返回 0 那一定是无效帧。还有一个容易被忽略的点maximum_formant要根据说话人设定。做男女混合的语料用一个固定值必然有一方的结果不理想。我通常的做法是先用基频中位数做一个粗略的性别判定男声 165 Hz女声 165 Hz然后分两组用不同参数。下表是我常用的一套配置说话人类别pitch_floorpitch_ceilingmaximum_formantmax_formants成年男声6035050005.0成年女声7550055005.0儿童10080080005.0未知/混合6060055005.04.3 Intensity 与 Harmonicity最容易被低估的两个量Intensity在 Praat 里不是简单的 RMS它做了加窗、平滑、转 dB 一套流程目的是让结果和人耳的响度感知更接近。所以它和20*log10(rms)算出来的值不一样不要拿两者互相校验会疑神疑鬼。intensity snd.to_intensity(minimum_pitch100, time_step0.01) db intensity.values[0] # 一维数组 t intensity.xs()minimum_pitch这个参数名字容易让人困惑——强度分析为什么要设基频下限因为 Praat 的强度计算需要知道分析的周期长度minimum_pitch决定了窗口大小。100 Hz 对应 10 ms 窗口是语音分析的常用值。做很短的音节或者语速很快的语料时可以适当提高这个值。Harmonicity也就是 HNR谐噪比反映的是周期性成分和噪声成分的比例临床上常用来评估嗓音嘶哑程度hnr snd.to_harmonicity(minimum_pitch75, time_step0.01) values hnr.values[0] valid values[values -200] # 无效帧是 -200注意这里的无效标记是-200又一次和 Pitch 的 0、Formant 的 0 不一样。这三套不同的无效值约定是我觉得这个库最不友好的设计之一写通用清洗函数的时候必须分别处理。我现在的习惯是每提一个量就在封装层里立刻统一成np.nan后面所有逻辑只认 NaN省心很多。5. praat.call把 Praat 命令手册变成 Python 函数的那把钥匙5.1 参数顺序、类型与返回值的隐式约定praat.call的签名是call(object, command, *args)。第一个参数是目标对象可以是单个对象也可以是对象列表第二个参数是 Praat 命令名必须和菜单里的字符串完全一致包括空格和括号的位置后面是参数顺序必须和 Praat 手册里列的顺序一致。# 等价于在 Praat 里选中 Sound执行 Modify Scale intensity... praat.call(snd, Scale intensity, 70.0) # 返回布尔或数值 n praat.call(snd, Get number of channels) # 返回字符串 name praat.call(snd, Get name)这几个约定听起来简单实际用起来有几个高频错误。第一命令名拼错不会报命令不存在而是报一个含糊的参数不匹配错误因为 Praat 内部会先把参数个数对一遍。第二参数类型必须严格Praat 区分整数和浮点praat.call(snd, To Pitch, 0, 75, 500)里那个0如果写成整数而不是0.0在某些版本上会报类型错误。我的习惯是所有数值参数一律带小数点。第三个更容易忽略布尔参数用字符串 yes/no不是 Python 的 True/False。比如前面提到的To Pitch (ac)里那个非常准确参数必须传no或者yes。5.2 用 call 补足高层 API 没覆盖的能力jitter 与 shimmer 实例高层封装覆盖的是最常用的路径但像 jitter、shimmer 这些嗓音分析指标高层 API 是不提供的只能走praat.call。以局部 jitter 为例完整链路是三步# 第一步生成周期点过程 pp praat.call(snd, To PointProcess (periodic, cc), 75, 600) # 第二步在该点过程上算局部 jitter jitter_local praat.call(pp, Get jitter (local), 0.0, 0.0, # 起止时间0 表示全段 0.0001, 0.02, # 最短/最长周期 1.3) # 最大周期因子 # 第三步算 shimmer 需要先有强度对象 intensity snd.to_intensity(minimum_pitch75) shimmer_local praat.call([snd, pp], Get shimmer (local), 0.0, 0.0, 0.0001, 0.02, 1.3, 1.6)注意第三步里praat.call的第一个参数是个列表[snd, pp]。这是 Praat 命令需要多个对象选中时的表达方式顺序必须和命令要求的一致。这个语法在很多教程里都没提是看源码和报错才摸出来的。参数0.0001, 0.02是周期上下限对应 10 kHz 和 50 Hz基本覆盖所有人声1.3和1.6是周期倍数因子用于排除异常长的周期。这些值来自 Praat 的默认设定改动前最好想清楚理由——因为在论文里你得解释这个 1.3 是怎么来的。5.3 run 与 run_file让旧脚本资产直接复活如果你手上有历史积累的 Praat 脚本.praat文件完全不用重写直接调from parselmouth import praat result praat.run_file(analyze.praat, args[input.wav, 70]) print(result)praat.run()则接受脚本文本script form Analyze sentence Sound_file real Intensity_target 70 endform Read from file: sound_file$ Scale intensity: intensity_target To Pitch: 0, 75, 600 f0 Get mean: 0, 0, Hertz writeInfoLine: f0 out praat.run(script, args[speech.wav, 70])这里有个必须知道的细节脚本里writeInfoLine输出的内容才是返回值。如果你在脚本里用了appendInfoLine多次它们会用换行拼起来作为一个字符串返回。而脚本里如果只是操作对象没有输出返回值就是空字符串——很多人第一次跑完发现什么都没返回其实是脚本没写输出语句。另一个坑是脚本里的Read from file:用的是相对路径相对的是当前工作目录不是脚本文件所在目录。批处理跑在别的目录下会静默失败Praat 报错的行为在不同版本间不一致我的做法是在脚本里用绝对路径或者调用前显式os.chdir()。6. TextGrid 标注的读写与批量流水线的组织方式6.1 三种生成 TextGrid 的路径与各自适用场景TextGrid 是 Praat 的标注容器分成区间层interval tier和点层point tier。生成方式有三种适用场景完全不同。第一种从零构建空标注。适合你自己有一套切分逻辑只需要一个容器往里填tg praat.call(snd, To TextGrid, phoneme, boundary point) praat.call(tg, Insert boundary, 1, 0.0) praat.call(tg, Insert boundary, 1, 0.5) praat.call(tg, Set interval text, 1, 1, sil) praat.call(tg, Set interval text, 1, 2, a)phoneme是区间层名boundary point是点层名多个层名之间用空格分隔。这个字符串格式很容易写错层名中间不能有空格多个层名之间也不能有多余空格。第二种基于静音检测自动切分。适合做初始标注人工再修正tg praat.call(snd, To TextGrid (silences), 100, 0.0, -25.0, 0.1, 0.1, silent, sounding)参数依次是最小基频、时间步长、静音阈值dB、最短静音时长、最短发声时长、静音标签、发声标签。-25这个阈值对你的录音质量很敏感安静环境可以设 -30嘈杂环境得设 -20最好的办法是打开 Praat 看一眼强度曲线找到噪声底和语音的中间带。第三种从文件读入。适合已经有人工标注的情况tg praat.call(Read from file, speech.TextGrid)注意这里第一个参数直接是命令名没有目标对象因为你是在读文件而不是操作对象。6.2 读取区间标签的正确姿势与浮点边界处理从 TextGrid 里取数据有一套固定的调用序列写起来略显啰嗦但每一步都是必需的n_tiers praat.call(tg, Get number of tiers) n_intervals praat.call(tg, Get number of intervals, 1) for i in range(1, n_intervals 1): label praat.call(tg, Get label of interval, 1, i) start praat.call(tg, Get start time of interval, 1, i) end praat.call(tg, Get end time of interval, 1, i) if label.strip(): seg snd.extract_part(start, end, preserve times)几个关键点。索引从 1 开始包括层号和区间号。空区间的标签是空字符串不是 None判空要用label.strip()而不是label is not None。extract_part的第三个参数要传preserve times否则截出来的片段时间从 0 开始如果你后面要回填原时间轴就会错位。时间边界的浮点误差是另一个隐形杀手。Praat 存边界用的是 double但写 TextGrid 文件时是文本格式来回读写可能引入 1e-9 量级的误差。做精确匹配时比如判断两个 TextGrid 的边界是否完全一致必须用容差比较def same_boundary(a, b, tol1e-6): return abs(a - b) tol我吃过一次亏批量把标注按 0.5 秒切段用start in boundary_set做判断结果有 3% 的片段被漏掉。查了半天才发现是文本格式精度问题。6.3 一个可复用的批处理骨架把前面的东西串起来这是我实际项目里用的一套结构逻辑上分四层发现文件、加载分析、抽取特征、写出结果。import os import numpy as np import pandas as pd import parselmouth from parselmouth import praat def analyze_one(path): try: snd parselmouth.Sound(path) except Exception as e: return {file: os.path.basename(path), error: str(e)} if snd.n_channels 1: snd snd.extract_channel(1) pitch snd.to_pitch(time_step0.0, pitch_floor60, pitch_ceiling500) f0 pitch.selected_array[frequency] voiced f0[f0 0] intensity snd.to_intensity(minimum_pitch75) hnr snd.to_harmonicity(minimum_pitch75) return { file: os.path.basename(path), duration: snd.duration, f0_median: float(np.median(voiced)) if voiced.size else np.nan, f0_sd: float(np.std(voiced)) if voiced.size else np.nan, voiced_ratio: float(np.mean(f0 0)), intensity_mean: float(np.mean(intensity.values)), hnr_mean: float(np.mean(hnr.values[hnr.values -200])), error: , } rows [] for root, _, files in os.walk(corpus): for f in files: if f.lower().endswith(.wav): rows.append(analyze_one(os.path.join(root, f))) df pd.DataFrame(rows) df.to_csv(features.csv, indexFalse, encodingutf-8-sig)这个骨架里try/except包住的是加载而不是分析因为分析出错通常意味着参数和音频不匹配那是需要人工看的不该被静默吞掉。encodingutf-8-sig是为了让 Excel 打开中文路径不乱码这种小细节在交付给非技术同事时特别重要。7. 长音频、多进程和版本漂移工程化阶段绕不开的三件事7.1 内存占用到底怎么算什么时候该切段Praat 内部用 double 存样本这个成本要提前算清楚。以 10 分钟、44.1 kHz、双声道的录音为例总采样点数是 44100 × 600 26,460,000双声道翻倍是 52,920,000每个 double 8 字节光样本数组就是约 423 MB。而这个数字在分析过程中会被放大——Pitch、Formant、Spectrogram 各自都要建中间对象同时存活的话峰值可能到 1 GB 以上。处理多声道立体声时我一般先把单声道提取出来再分析内存直接腰斩。更长的录音比如访谈录音、课堂录音则需要切段def chunked_pitch(snd, chunk_sec30.0): results [] t 0.0 while t snd.duration: end min(t chunk_sec, snd.duration) part snd.extract_part(t, end, preserve times) p part.to_pitch(pitch_floor60, pitch_ceiling500) results.append((p.xs(), p.selected_array[frequency])) t end times np.concatenate([r[0] for r in results]) freqs np.concatenate([r[1] for r in results]) return times, freqs切段有个副作用段边界处的基频估计会失真因为分析窗口跨越了边界。所以我一般让相邻片段重叠一个窗口长度约 25~50 ms拼接时丢掉重叠部分。另外切片之后time_step0.0的自动计算会基于片段重新算理论上结果和整段一致因为只跟pitch_floor有关但为了稳我在切段场景下一律显式指定time_step。7.2 多线程跑不动的时候换多进程批处理几千个文件第一反应是多线程。但 Praat 内核里有一部分全局状态默认设置、工作目录相关的行为多线程并发调用praat.call在某些场景下会出现难以复现的串结果。我试过用ThreadPoolExecutor跑一开始没问题跑到七八百个文件之后开始零星出现结果异常——同一段音频单独跑和批量跑基频中位数差了 3 Hz。换成多进程就干净了from concurrent.futures import ProcessPoolExecutor with ProcessPoolExecutor(max_workers4) as ex: rows list(ex.map(analyze_one, wav_paths))进程之间内存隔离全局状态互不干扰。max_workers建议设成物理核心数的 0.7 倍左右因为每个进程里 numpy 还可能开自己的线程池开满反而互相抢。我一般还会在入口处加一句os.environ[OMP_NUM_THREADS] 1避免每个子进程再各自开一堆 OpenMP 线程。多进程的代价是启动开销和内存翻倍。如果单文件只有几百毫秒的音频进程池的调度成本可能比计算本身还高这时候用chunksize参数让每个进程一次领一批任务会好很多。7.3 版本锁定与结果可复现最后说一件容易被忽视但后果很严重的事Praat 的算法在不同版本间会变。改动通常很小但对我上个月跑出来的数和这个月复现的数之间的差异足以让人怀疑人生。我的做法是在项目根目录固定依赖并且把版本号写进输出文件praat-parselmouth0.4.3 numpy1.26.4同时在特征表里加一列engine_version存parselmouth.__version__。这样半年后有人质疑某批数据的来源你可以精确定位到当时用的引擎版本。这个习惯是从一次返工里学来的——当时论文投稿后审稿人要求补充数据重新跑了三个月前的脚本结果 F1 均值差了 15 Hz排查了一整天才意识到是中间升级过库版本。注意升级 Parselmouth 之后务必挑十几个有代表性的样本做一次新旧对照跑把关键指标列成表比对。变化在 1% 以内通常可以接受超过 5% 就得查是不是算法路径变了。最后分享一个我在实际项目中总结的小技巧。做嗓音分析时jitter 和 shimmer 这类指标对录音质量和切段边界极其敏感同一段持续元音头尾各截掉 50 ms 和不截jitter 能差出一倍。所以我现在的流程是先用强度曲线自动找到发声段的稳定区间一般取峰值强度下降 10 dB 以内的部分再从这段里提参数并且把这个稳定区间的时间戳一起存进结果表。后来复查数据时靠这一列定位到了好几条异常记录那些异常全是当时录音环境有背景噪声导致的——如果没有这个时间戳我只会看到一堆莫名其妙的离群值。
阅读完成 · 觉得有帮助?
咨询建站