
简介在语音信号处理中端点检测是定位语音起止点的关键技术。一个围绕双门限法实现的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 或 -1diff计算相邻符号的差值非零值表示发生了一次穿越求和后除以 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(end1, :) [speech_start, i - silence_count]; end state 0; end end end end if state 2 n_frames - speech_start min_speech segments(end1, :) [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);两个分布的重叠面积越少说明当前特征对这段录音的可分性越好。如果重叠明显优先查预加重和门限初始化而不是继续调状态机参数。本文还有配套的精品资源点击获取