简介:在语音信号处理中,端点检测是定位语音起止点的关键技术。一个围绕双门限法实现的MATLAB脚本可作为入门参考,面向语音处理初学者与算法研究人员,重点演示通过高、低两个门限区分语音段与静音段的完整流程。压缩包仅含1个m文件,整体大小925B,代码精简,适合作为课程设计或算法验证的起点,也便于在此基础上对比不同参数的影响;目前已有1029人学习下载。脚本覆盖语音读取、分帧、能量计算、门限比较等核心步骤,并结合阈值设置与窗口大小对检测效果的影响展开说明;读者可借此直观理解双门限法的参数调整与判断逻辑,也能结合原理进一步扩展自适应门限、噪声估计等改进策略,用于语音识别、语音压缩等实际场景的训练与实验,并作为二次开发的基础。
1. 双门限法:语音端点检测最实用的工程起点
一段语音录音里,哪一部分是人声、哪一部分是静音、哪一段又是背景噪声?语音端点检测就是回答这个问题的算法。双门限法通过短时能量和短时过零率两个特征,设置高低两级门限,把语音段从连续信号里干净地切出来。它不需要训练数据,几十行 MATLAB 代码就能跑通,也容易移植到 C 语言做嵌入式部署。网上以 matlab.rar 命名的压缩包里经常能看到这类源码,但参数往往写死,直接套用容易出问题。这篇文章从原理讲到实现,再讲参数调优和 C 移植,适合做语音前端处理、给模型准备训练语料、或者刚开始接触语音信号处理的工程师。
2. 双门限法原理:短时能量、短时过零率与两级判决状态机
2.1 短时能量:先分帧,再算浊音与静音的区分度
双门限法处理的对象不是整段录音,而是分帧后的信号。常见做法是取 25ms 一帧、10ms 帧移,16kHz 采样率下正好是 400 点一帧、相邻帧重叠 160 点。分帧之后,每一帧都能算出两个特征:短时能量和短时过零率,它们构成后续所有判决的基础。
短时能量的定义是:
energy = sum(frame.^2) / length(frame);也就是一帧内所有采样点的平方和再除以帧长。浊音由声带振动产生,幅度大,能量明显高于静音;静音段的能量接近麦克风底噪。这个差距通常在 10dB 以上,所以能量是第一个被用来判决的特征。注意这里用的是相对值而不是绝对值,门限需要根据当前录音的平均能量来定,不能直接抄一个固定数。
2.2 短时过零率:清音与噪声的区分要靠它
清音段是气流摩擦产生的,频谱集中在高频,波形振荡快,单位时间内穿越零轴的次数显著高于能量相近的静音。所以过零率解决了“能量低但确实是语音”的分类问题。
zcr = sum(abs(diff(sign(frame)))) / (2 * length(frame));sign把每个采样点变成 +1 或 -1,diff计算相邻符号的差值,非零值表示发生了一次穿越,求和后除以 2 得到穿越次数,再除以帧长做归一化。16kHz 采样率下,静音帧的过零率通常在 10 到 30 之间,清音帧可以达到 60 到 100。这个值受直流偏置影响很大,如果信号里有直流分量,过零率会被严重拉低,所以预加重或高通滤波是必要的前置处理。
2.3 两级门限状态机:先“怀疑”,再“确认”
单门限的问题很直接:语音中间有停顿,能量一低就被切成两段;空调声、键盘声一响,又被误判成语音。双门限法用低门限做“怀疑”,用高门限做“确认”,再配合最短语音长度和最大静音长度两个约束,把边界稳下来。
| 状态 | 触发条件 | 动作 |
|---|---|---|
| 静音 | 能量或过零率超过低门限 | 记录候选起点,进入可能语音 |
| 可能语音 | 能量超过高门限 | 确认为语音,记录起点 |
| 可能语音 | 能量和过零率回落到低门限以下 | 取消候选,回到静音 |
| 语音中 | 连续多帧低于低门限 | 结束当前语音段,回到静音 |
| 语音中 | 能量或过零率维持在门限以上 | 延长当前语音段 |
这里的关键是“或”还是“且”。实际工程里,我用能量超过低门限 || 过零率超过门限进入候选,用能量超过高门限确认。因为在清音段,能量可能只比静音高一点点,但过零率很高;在浊音段,能量很高但过零率未必突出。两个特征互补,缺一个都会漏掉一部分语音。
状态机还需要两个约束:最小语音长度防止把噪声毛刺当语音,常见做法是连续 5 帧以上才算数;最大静音长度防止把词间停顿误切成两段,常见做法是允许语音内部出现最多 8 帧的低能量帧,超过才判定结束。这两个值就是后面 MATLAB 代码里的params.min_speech_len和params.max_silence_len。
还有一个经常被忽略的细节:双门限法判断的是“语音是否存在”,而不是“暂停是否发生”。如果一句话中间停顿超过max_silence_len帧,算法会把这句话切成两段。对识别任务来说这不是问题,识别引擎本来就按短句建模;对数据集切分任务,如果你希望把一句话保持为一段,max_silence_len需要按语句的实际节奏来设,而不是按算法默认值。
3. 用 MATLAB 实现双门限端点检测:可直接运行的最小代码
3.1 读取音频并分帧:预加重与帧参数选择
先把语音读进来。无论你用的 MATLAB 版本是 r2023b 还是新版本,audioread的接口都一致,返回列向量x和采样率fs。下面是读文件、转单声道、归一化、预加重的完整片段:
[x, fs] = audioread('speech.wav'); if size(x, 2) > 1 x = mean(x, 2); % 双声道合成单声道 end x = x / max(abs(x)); % 归一化,避免不同录音音量影响门限 x = filter([1, -0.97], 1, x); % 预加重,补偿语音高频能量衰减预加重系数 0.97 是语音处理里的常用值,作用是让清音的高频成分在能量特征里不至于太弱。实际使用时,如果录音本身已经做过均衡,这个系数可以调到 0.9 甚至省略,否则清音的过零率优势会被能量特征盖过去。
分帧参数需要根据采样率换算。帧长取 25ms,帧移取 10ms,是最常见的配置;帧长了时间分辨率差,边界会模糊,帧短了单帧统计不稳定。
frame_len = round(fs * 0.025); % 25ms 帧长 frame_shift = round(fs * 0.010); % 10ms 帧移 n_frames = floor((length(x) - frame_len) / frame_shift) + 1; frames = zeros(frame_len, n_frames); for i = 1:n_frames start_idx = (i - 1) * frame_shift + 1; frames(:, i) = x(start_idx : start_idx + frame_len - 1); end这里用循环分帧而不是buffer函数,是因为buffer的输出矩阵列数受信号长度影响,边界情况容易算错。循环分帧把索引关系写在明面上,后面定位端点时,帧号转时间采样点只需要(frame_idx - 1) * frame_shift + 1,不容易出现索引偏移错误。
3.2 计算短时能量、过零率并初始化门限
对每一帧计算能量和过零率,然后初始化三个门限:
energy = sum(frames.^2, 1) / frame_len; zcr = sum(abs(diff(sign(frames), 1, 1)), 1) / (2 * frame_len); noise_frames = 1:min(10, n_frames); % 假设前 10 帧是静音 energy_low = mean(energy(noise_frames)) * 3; % 低门限 = 3 倍噪声能量 energy_high = max(energy) * 0.15; % 高门限 = 峰值能量的 15% zcr_thresh = mean(zcr(noise_frames)) * 2; % 过零率门限 = 2 倍噪声过零率低门限取 3 倍噪声能量,依据是静音段能量近似服从高斯分布,3 倍均值能把绝大多数噪声帧挡在候选之外。高门限取峰值能量的 15%,这个比例对平稳噪声录音基本够用,但如果录音里有突发噪声,15% 可能把噪声也确认为语音。门限的比例系数需要依据实际数据分布调整,直方图是调整的依据。前 10 帧假设是静音,这个假设在录音开头立刻说话时会失效,解决办法是取整段能量最小的 10 帧作为噪声估计,而不是固定取前 10 帧。
下面这张参数表是调优时的核心参照:
| 参数 | 推荐范围 | 作用 | 调大后的影响 | 调小后的影响 |
|---|---|---|---|---|
| 帧长 | 20-30ms | 特征计算粒度 | 边界变粗糙 | 单帧统计不稳定 |
| 帧移 | 5-15ms | 端点定位精度 | 端点误差变大 | 计算量增大 |
| 低门限系数 | 2-5 倍噪声能量 | 候选段触发灵敏度 | 漏检弱语音 | 噪声误触发变多 |
| 高门限 | 10%-20% 峰值能量 | 语音确认阈值 | 语音段变短 | 噪声被确认为语音 |
| 最小语音长度 | 3-10 帧 | 滤除噪声毛刺 | 短词被丢弃 | 噪声毛刺残留 |
| 最大静音长度 | 6-15 帧 | 合并词间停顿 | 词被合并成段 | 词被切断 |
3.3 双门限状态机主循环与边界处理
把上面的特征和门限放进状态机。下面是完整的vad_dual_threshold函数,输入是语音信号和采样率,输出是segments,每一行是[起点帧, 终点帧]:
function segments = vad_dual_threshold(x, fs) frame_len = round(fs * 0.025); frame_shift = round(fs * 0.010); n_frames = floor((length(x) - frame_len) / frame_shift) + 1; frames = zeros(frame_len, n_frames); for i = 1:n_frames si = (i - 1) * frame_shift + 1; frames(:, i) = x(si : si + frame_len - 1); end energy = sum(frames.^2, 1) / frame_len; zcr = sum(abs(diff(sign(frames), 1, 1)), 1) / (2 * frame_len); noise_frames = 1:min(10, n_frames); energy_low = mean(energy(noise_frames)) * 3; energy_high = max(energy) * 0.15; zcr_thresh = mean(zcr(noise_frames)) * 2; min_speech = 5; max_silence = 8; segments = []; state = 0; % 0 静音, 1 可能语音, 2 语音 cand_start = 0; speech_start = 0; silence_count = 0; for i = 1:n_frames above_low = energy(i) > energy_low || zcr(i) > zcr_thresh; above_high = energy(i) > energy_high; switch state case 0 if above_low cand_start = i; state = 1; end case 1 if above_high speech_start = cand_start; state = 2; silence_count = 0; elseif ~above_low state = 0; end case 2 if above_low silence_count = 0; else silence_count = silence_count + 1; if silence_count >= max_silence if i - silence_count - speech_start >= min_speech segments(end+1, :) = [speech_start, i - silence_count]; end state = 0; end end end end if state == 2 && n_frames - speech_start >= min_speech segments(end+1, :) = [speech_start, n_frames]; end end循环里几个细节容易写错。case 2中结束位置是i - silence_count,因为最后max_silence帧都是静音,要把它们从语音段尾部去掉。结尾处如果语音一直持续到最后一帧,需要单独补一次输出,否则最后一段语音会丢失。case 1里~above_low时回到静音,这是为了消除低门限误触发的短噪声,如果候选段在达到高门限之前就消失了,说明它很可能不是语音。
调用并画图标注的代码:
[x, fs] = audioread('speech.wav'); x = x(:, 1); x = x / max(abs(x)); x = filter([1, -0.97], 1, x); segments = vad_dual_threshold(x, fs); frame_shift = round(fs * 0.010); t = (0:length(x)-1) / fs; plot(t, x); hold on; for k = 1:size(segments, 1) xline((segments(k,1)-1) * frame_shift / fs, 'r'); xline((segments(k,2)-1) * frame_shift / fs, 'b'); end输出结果以帧号为单位,转成时间戳时注意(segment_start - 1) * frame_shift / fs才是这段语音的起始时间。如果需要保存成标签文件,常见格式是 HTK 的.lab或简单的 CSV 两列,前者时间单位是 100ns,需要乘 10^7 换算。保存时我一般会把边界前后各扩展 2 帧,给后续识别或标注留一点余量。
4. 双门限法参数调优与五个常见坑:阈值为什么不能照抄
4.1 用直方图确定门限,而不是抄代码里的数字
网上能搜到的双门限法 MATLAB 代码,门限基本都是写死的,比如energy_low = 0.01、energy_high = 0.1。这些数字来自作者当时的录音设备、说话距离和信噪比,换一个麦克风就不成立。正确做法是先看特征分布:
histogram(energy, 50); xline(energy_low, 'r'); xline(energy_high, 'g');如果直方图出现明显的双峰,左侧峰是静音,右侧峰是语音,低门限应该取两个峰之间的谷底,高门限取右侧峰的半山腰。如果只有一个峰,说明录音的信噪比太低,或者整段录音根本就是纯静音、纯语音,此时参数怎么调都没有意义,先解决数据质量问题。
另一种不依赖直方图的初始化方法是取全段能量最小的 10 帧作为噪声估计:
[~, idx] = mink(energy, 10); noise_energy = mean(energy(idx)); energy_low = noise_energy * 3;这个方法比固定取前 10 帧更可靠,因为录音开头不一定安静,可能出现开关声、衣物摩擦声。用mink取最小能量帧,相当于假设录音中至少存在 10 帧相对安静的背景段,这个假设在实际语音录音里通常成立。
4.2 五个典型问题与排查手段
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 语音后半段被截断 | 低门限偏高,词尾音量低于门限 | 打印段尾 10 帧的能量,观察是否贴着门限 |
| 清音全部丢失 | 过零率门限偏高,或能量门限把清音帧排除 | 单独画清音帧的过零率,和噪声过零率对比 |
| 噪声被当成语音 | 高门限偏低,或最小语音长度太小 | 看被误检段的能量,是否超过峰值能量的 15% |
| 端点每次标注都不一样 | 帧移过大,边界落在不同帧 | 改用 5ms 帧移,或对边界做二次搜索 |
| 语音段被切成多段 | 最大静音长度太小,词间停顿被切断 | 观察段内最大静音帧数,上调max_silence |
第一个坑在调试中遇到最多。语音词尾是浊音衰减过程,能量不断下降,如果低门限偏高,状态机会提前判定语音结束,尾音被截断。我一般会把段尾前后 20 帧的能量打出来,确认是不是贴着门限下降。如果是,就把低门限从 3 倍噪声能量降到 2 倍。注意这不是无脑降低,降太多会把噪声帧带进来,所以同时要看噪声帧的能量分布,低门限不能低于噪声能量的最大值。
清音丢失的典型特征是切出来的语音段长度偏短,听感上词头辅音没了。这时先看zcr_thresh的初始化,它取的是噪声过零率的 2 倍。如果录音里本身有电脑风扇这类低频噪声,过零率很低,2 倍依然很小,问题往往出在能量门限而不是过零率门限。把 case 1 进入 case 2 的条件从energy > high改成energy > high || zcr > zcr_thresh * 3,清音就能保住。
4.3 双门限法与深度学习方法的分工
双门限法在平稳噪声、信噪比高于 10dB 的场景下,端点准确率足够用于大部分数据集切分。但在低信噪比、非平稳噪声(键盘声、关门声、街道噪声)下,能量和过零率的分布会重叠,门限无法分开两类。这时常见做法是改用深度学习方法,比如在 MATLAB 里用 BiLSTM 做帧级语音活动检测,特征用能量、过零率加上 20 维梅尔频谱。搜“bilstm 代码 matlab”能找到这类参考实现。
我一般不会让两者二选一,而是让双门限法当标注工具:先用它切出候选语音段,再把段边界前后各扩展 50ms,人工复核后作为训练集标签。这样既省去逐帧标注的体力活,又比直接拿双门限输出当标签可靠,因为深度模型的训练标签需要精确到帧的噪声段,而不是粗边界。如果需要自动搜索最优门限组合,可以借助 matlab 优化工具箱,把端点误差作为目标函数做网格搜索,但前提是有一份人工标注的验证集。
5. 从 MATLAB 到 C:端点检测落地嵌入式环境的三个技巧
5.1 用 MATLAB Coder 把检测函数转成 C
把入口函数改成只接收数组和采样率、参数通过结构体传入,然后用codegen生成 C 源码。
codegen -config c -o vad_dual_threshold vad_dual_threshold.m生成后在 vscode 配置 c/c++ 环境里编译。注意把动态分配内存关掉,用coder.Constant声明固定帧长,输出数组预分配最大段数,避免运行时 malloc。
5.2 定点化实现:能量累加与过零率判断
嵌入式环境没有浮点单元时,能量可以缩放成整数累加,过零率用符号位比较:
int32_t energy = 0; for (int i = 0; i < frame_len; i++) { energy += ((int32_t)x[i] * x[i]) >> 4; } int zcr = 0; for (int i = 1; i < frame_len; i++) { if (((x[i] >> 15) ^ (x[i-1] >> 15)) & 1) zcr++; }>> 4是为了防止 16 位采样值平方后累加溢出,具体右移位数取决于帧长和 ADC 位深。注意门限也要按同样缩放换算,不要一边浮点一边定点。
5.3 用能量直方图验证边界是否可信
移植完成后,在 MATLAB 里把切出来的语音帧和非语音帧的能量分布画出来对比:
speech_energy = energy(segments(1):segments(2)); noise_energy = energy([1:segments(1)-1, segments(2)+1:end]); histogram(speech_energy, 30); hold on; histogram(noise_energy, 30);两个分布的重叠面积越少,说明当前特征对这段录音的可分性越好。如果重叠明显,优先查预加重和门限初始化,而不是继续调状态机参数。
本文还有配套的精品资源,点击获取