FunASR 实战:一条 SOND 数据预处理流水线,从原始波形到说话人分离特征
【免费下载链接】FunASROpen-source speech recognition toolkit for training, inference, streaming ASR, VAD, punctuation, speaker diarization pipelines, and OpenAI-compatible/MCP serving.项目地址: https://gitcode.com/GitHub_Trending/fun/FunASR
在 FunASR 中,SOND(Speaker Overlapping Noise Detection)模型要在多人抢话、环境嘈杂的音频上输出"谁在什么时候说话"。本文跟随一段原始音频走完它的预处理流水线:16kHz 重采样、80 维梅尔频谱提取、SpecAugment 增强、utterance 级归一化与 VAD 定位,并给出可直接套用的训练/推理配置建议。
三种"难缠"的声音:会议录音里发生了什么
想象一场真实会议:两个人同时开口,背景空调低频轰鸣,录音设备还是三个月前用旧手机拍的,底噪比新设备高出不止一点。这段音频丢进任何 ASR 前端,都会同时撞上三类问题。
先说重叠。两人同时发声时,波形是两条语音的直接叠加,能量、基频互相干扰——模型不能只学"听清一个声音",还得学会"把混在一起的声音拆开、分清归属"。再说噪声。空调嗡鸣、键盘敲击在频谱上是一片连续的暗纹,覆盖几乎所有频带,特征稍有不慎就会被噪声主导。最后是设备差异:采样率、音量、底噪各不相同,同一段内容换台设备录,特征分布就漂了一大截。
SOND 的定位就是专治这三件事:重叠感知 + 噪声容忍 + 跨设备鲁棒。而它的第一块基石,是数据预处理。
全景图:一段音频要闯哪些"关卡"
FunASR 把 SOND 的预处理拆成了五道关卡,训练和推理在第三关分叉——这是最容易配错的地方:
下面按关卡顺序展开,每个环节回答"为什么这么做、参数怎么定"。
关卡 1:怎么把不同设备的音频拉进同一套坐标系?
清单驱动加载。训练数据用 Kaldi 风格的wav.scp管理,每行一条"utterance ID + 音频路径",仓库里就有现成样例可看:data/list/train_wav.scp。
# wav.scp 每行一条:utterance ID 空格 音频路径 # 例如:meeting_0001 /data/audio/meeting_0001.wav with open("wav.scp", "r", encoding="utf-8") as f: for line in f: utt_id, wav_path = line.strip().split(maxsplit=1) # 加载并转成单声道 float 张量,得到 (1, samples) wav, sr = torchaudio.load(wav_path)采样率统一。前端的一切频域计算都按 16kHz 标定,44.1kHz 的音乐录音或 8kHz 的电话音进来后必须先重采样,否则梅尔滤波器组的频率边界整体错位:
from torchaudio.sox import ApplyEffect # 或 torchaudio.transforms.Resample target_sr = 16000 # SOND 前端标定的采样率 resampler = torchaudio.transforms.Resample(orig_freq=sr, new_freq=target_sr) wav = resampler(wav) # 输出与训练集同坐标系响度拉齐。不同设备增益差异能差出好几个数量级,峰值归一化把每条音频拉到同一响度,让模型别把"设备增益"当成说话人特征:
def peak_normalize(wav, eps=1e-6): """把振幅峰值压到 ±1,消除设备/距离造成的响度差异""" peak = wav.abs().max() return wav / peak.clamp(min=eps)关卡 2:波形如何变成模型能"看懂"的图?
一维波形对人直观,对模型是黑箱。第一步是 STFT——把波形切成重叠的短帧、逐帧做傅里叶变换,得到"时间 × 频率"的二维图。三组关键参数:
import torch wav = wav.float() stft = torch.stft( wav, n_fft=512, # FFT 点数:决定频率分辨率 win_length=400, # 窗长 25ms,兼顾瞬态细节与频率稳定 hop_length=160, # 窗移 10ms,时间轴约每秒 100 帧 return_complex=True, ) mag = stft.abs() # 取幅度谱为什么是 25ms/10ms/512?25ms 一帧足够短,能跟上辅音的快变化;10ms 的窗移保证相邻帧高度重叠,不丢细节;512 点 FFT 在 16kHz 下给出 31ms 的等效窗宽,频率轴分辨率落在 31Hz 一档,是语音频段的经典折中。
第二步是梅尔滤波器组。把等间距的频率轴换成梅尔刻度——人耳对低频敏感、对高频迟钝——再用 80 个三角滤波器把对数能量压成 80 维。可以理解为给频谱换了个"人耳视角",模型学到的就是人真正在听的结构。
SOND 这条流水线实际走的是 Kaldi 风格的 fbank 前端(内部已含加窗、STFT、梅尔网格、取对数),由 funasr/frontends/wav_frontend.py 实现;若你的模型更需要原始频谱信息,仓库里另有 STFT 版前端可选:funasr/frontends/default.py。
关卡 3:怎样让模型"不怕变"?
特征层面做数据增强是性价比最高的抗干扰手段。SpecAugment 的思路很直白:在梅尔图上做随机变换,逼模型学会"缺了点什么还能认出来"。SOND 训练时启用的是 funasr/models/specaug/specaug.py 里的SpecAug模块,三种手段各模拟一类真实干扰:
- 时间扭曲:对时间轴做插值弯曲,模拟说话快慢、节奏变化;
- 频率掩码:随机涂抹几条频带,模拟"某些频率被噪声吃掉";
- 时间掩码:随机抹掉几段时间,模拟信号缺失或被打断。
时间掩码的核心实现不到十行:
import numpy as np def time_mask(feat, num_mask=2, width_ratio=0.2): """随机抹掉 num_mask 段时间:模拟信号缺失""" t, d = feat.shape width = max(1, int(t * width_ratio)) # 按帧数比例决定掩码宽度 for _ in range(num_mask): start = np.random.randint(0, max(1, t - width)) feat[start : start + width, :] = 0 return feat频率/时间掩码的默认参数(频带掩码宽度 0~20 帧、各 2 条,时间掩码 2 段)都写在SpecAug的构造参数里,调参时改配置即可,无需碰代码。关键纪律:训练开、推理关——增强是训练手段,不是信号处理,推理时开着只会让结果随机漂移。
关卡 4:不同录制条件的特征分布怎么抹平?
就算重采样、拉齐响度,不同房间的底噪仍会让特征均值方差整体平移。utterance 级 MVN 的做法:对每条样本自己的特征图,减去它的时间均值、除以时间标准差:
def utterance_mvn(feat): """utterance 级归一化:每条样本独立拉成零均值、单位方差""" mean = feat.mean(dim=0, keepdim=True) std = feat.std(dim=0, keepdim=True) return (feat - mean) / (std + 1e-8)好处是模型不用再"适应环境",收敛更快;代价是绝对电平信息被抹掉——对"谁在说话"这类任务恰好无所谓。FunASR 还支持另一条路线:全局 CMVN,即在WavFrontend里传cmvn_file,用训练集统计的全局均值/方差做归一化,两条路线二选一即可,别混用。
关卡 5:噪声底上,怎么先找到"有人说话"的地方?
长音频里大量时间其实没人说话。VAD(Voice Activity Detection,语音活动检测)的作用就是逐帧判断"这里是语音还是静音",把计算集中到有效片段上。FunASR 里的 FSMN-VAD 就是这个角色,它输出语音段的起止时间戳,前端后续只对语音段提特征——长音频场景下这一步能省掉大量无效计算。
VAD 解决"哪里有语音",重叠分割解决"这段里是谁"。SOND 的主模型在 funasr/models/sond/e2e_diar_sond.py 中:编码器在重叠段上输出每帧的说话人激活向量(每个说话人一个通道),相当于把"混合信号拆成几条干净通道";再配合 CI/CD 打分模块,判断各说话人段的先后关系,最后由解码器输出时序标签。VAD 段是它的地基——段切歪了,后面的归属再准也是错的。
一站式调用:WavFrontend 如何串起整条线
上述环节最终收敛成一次forward。WavFrontend内部按 fbank 提取 → LFR 低帧率拼接 → 可选 CMVN 的顺序执行,并自动把 batch 内不等长的特征 pad 对齐:
from funasr.frontends.wav_frontend import WavFrontend frontend = WavFrontend( fs=16000, # 采样率 n_mels=80, # 梅尔维数 frame_length=25, # 帧长(ms) frame_shift=10, # 帧移(ms) cmvn_file=None, # 全局归一化统计文件,None 则跳过 ) feats, feats_lens = frontend(waveform, waveform_lengths) # 一次调用出全部特征训练时它输出的特征会继续进入 SpecAug 分支做增强;推理时直接用返回值。切换模式不改代码,只改配置。流式场景还有WavFrontendOnline同一文件里的在线版本,按帧流式出特征、内部自带缓存。
常见疑问
为什么统一是 16kHz?8kHz 丢掉高频辅音细节,"s、sh、f"这类区分度直接归零;44.1kHz 对 ASR 是纯浪费,帧数翻三倍、频域还得重新标定。16kHz 覆盖 0~8kHz,是电话/会议语音的事实标准。
梅尔维数为什么是 80?0~8kHz 范围里,80 个三角滤波器是分辨率与特征宽度之间的经典平衡点,也是绝大多数公开语音模型的默认配置。想加大时间方向的信息量,FunASR 的惯用手段是 LFR 拼接(lfr_m/lfr_n),而不是盲目堆梅尔维数。
推理时还能开增强吗?不能。增强改变的是"模型见什么",不是"信号本身"。推理一开,同一段音频两次结果就会不一致,线上排查问题时你会恨上这个开关。
为什么先重采样再归一化?重采样是线性过程,顺序无所谓,但峰值归一化依赖采样后波形;反过来先归一化再重采样,插值会引入微小幅度波动,峰值位置可能漂移。
参数速查与实战建议
| 参数 | 默认值 | 说明 |
|---|---|---|
| fs | 16000 | 采样率,全管线统一 |
| frame_length / frame_shift | 25 / 10 ms | 帧长 / 帧移,决定时间轴约 100 帧/秒 |
| n_mels | 80 | 梅尔维数 |
| lfr_m / lfr_n | 1 / 1 | 低帧率拼接,默认关闭 |
| cmvn_file | None | 全局归一化统计,与 utterance 级 MVN 二选一 |
| SpecAugment | 训练开 / 推理关 | 三变换各 2 次,默认宽度见 specaug.py |
💡 清单式建议:
- 训练期全开时间扭曲 + 双掩码,让模型见多识广;推理期关增强,特征必须"所见即所得";
- 长音频务必 VAD 前置,只对语音段提特征;
- 远场麦克风场景,在进入前端前补一道 AEC 回声消除 / 降噪,前端只负责"翻译",不负责"打扫";
- 重采样一定放在峰值归一化之前;
- 换新数据集时,全局 CMVN 统计要重新计算,别拿旧统计文件硬套。
延伸阅读
- 官方中文教程:docs/tutorial/README_zh.md
- 前端源码目录(含 WavFrontend 与 CMVN 加载):funasr/frontends/
- SpecAugment 实现(时间扭曲、频/时掩码):funasr/models/specaug/
- 训练与推理示例:examples/industrial_data_pretraining/paraformer/
- 数据清单格式参考:data/list/train_wav.scp
【免费下载链接】FunASROpen-source speech recognition toolkit for training, inference, streaming ASR, VAD, punctuation, speaker diarization pipelines, and OpenAI-compatible/MCP serving.项目地址: https://gitcode.com/GitHub_Trending/fun/FunASR
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考