本文主要讲诉如何对音视频中的有效音频进行检测
VAD(语音活动检测)的核心作用是区分语音与非语音信号,核心原理是基于信号特征分析,主要应用于需要精准处理语音的场景
一、为何要进行 VAD 检测
- 减少无效数据处理,降低系统计算量和存储成本,提升效率。
- 避免非语音噪声干扰,提高语音相关任务的准确性(如识别、编码)。
- 实现语音交互的触发与中断控制,优化用户体验。
二、VAD 检测的主要应用场景
- 语音通信:通话降噪、回声消除、半双工通信切换(如对讲机模式)。
- 语音识别 / 助手:唤醒词触发、语音指令截取、转文字时过滤静音段。
- 音频处理:录音自动启停、音频编辑时分割语音片段、视频字幕生成预处理。
- 安防监控:通过语音活动触发录像或报警,减少无效监控数据。
三、VAD 检测的核心原理
- 提取信号特征:分析音频的能量、过零率、频谱特征等关键指标。
- 设定判断阈值:基于特征建立语音与非语音的区分标准(如能量高于阈值且过零率在特定范围)。
- 动态决策:结合时间连续性等规则,避免单帧误判,最终输出 “语音” 或 “非语音” 的判定结果
功能概述
本文业务主要是用于语音识别,音视频文件中有效语音时长大于5分钟为合法文件。detectAudioEffective 方法是 NotRealTimeValidate 类中的核心方法,用于对音视频文件进行非实时语音检测,验证音频的有效时长是否满足业务要求。该方法能够检测音频中包含有效语音的片段时长,并判断是否达到最低标准(默认100秒)。
一、准备步骤
1.安装依赖
npm install @ricky0123/vad-web onnxruntime-web
- ffmpeg.wasm的初始化,参考之前的一篇文章 juejin.cn/post/750487…
二、实现步骤解析
- 初始化阶段
const actionId = file?.name || uuidv4();
const startTime = performance.now();
this.emit(VALIDATE_MP4_EVENT.VALIDATE_START);
const sampleRate = 16000;
- 创建唯一标识符用于日志追踪
- 记录开始时间用于性能统计
- 发送验证开始事件通知
- 设置音频采样率16000Hz
- 元数据提取与进度控制
const metadataProgress = this.mockProgress(0, this.actionProportion.firstExtract, (progress) => {
this.emit(VALIDATE_MP4_EVENT.VALIDATE_PROGRESS, progress);
});
metadataProgress.start();
const startTimeStr = this.secondsToTimeFormat(extractStart);
const durationStr = this.secondsToTimeFormat(extractDuration);
const tryBuffer = await this.ffmpegUtil.extractWav(file, {
startTimeStr,
durationStr,
metaCallback: getMetadata,
});
- 使用 mockProgress 创建进度控制器
- 调用 ffmpegUtil.extractWav 提取前5分钟音频数据
- 通过 metaCallback 回调获取文件元数据
- 总时长验证
const validDurationInfo = this.checkTotalDuration(currentMetaData);
if (!validDurationInfo.valid) {
return validDurationInfo;
}
- 调用 checkTotalDuration 验证文件总时长是否满足要求(≥5分钟)
- 不满足则直接返回错误信息
- 首次语音有效性检测
const tryAudio = new Float32Array(tryBuffer);
const validEffectiveInfo = await this.detectSpeechSegments(tryAudio, sampleRate);
effectiveDuration += validEffectiveInfo.fragmentDuration;
- 将音频数据转换为 Float32Array 格式
- 调用 detectSpeechSegments 检测有效语音片段
- 累计计算有效时长
- 循环检测后续音频片段
while (extractStart < duration) {
const actualDuration = Math.min(extractDuration, duration - extractStart);
const nextStartTimeStr = this.secondsToTimeFormat(extractStart);
const nextDurationStr = this.secondsToTimeFormat(actualDuration);
const fragmentBuffer = await this.ffmpegUtil.extractWav(file, {
startTimeStr: nextStartTimeStr,
durationStr: nextDurationStr,
});
const fragmentAudio = new Float32Array(fragmentBuffer);
const fragmentEffectiveInfo = await this.detectSpeechSegments(fragmentAudio, sampleRate, effectiveDuration);
effectiveDuration = fragmentEffectiveInfo.fragmentDuration;
if (fragmentEffectiveInfo.valid) {
break;
}
extractStart += actualDuration;
}
- 循环提取后续5分钟音频片段
- 每段都进行语音有效性检测
- 累计有效时长,满足100秒要求时提前退出
- 最终结果判定
if (effectiveDuration <= MEDIA_AUDIO_EFFECTIVE_DURATION * 1000) {
return {
valid: false,
errorInfo: [
{
errorMessage: `音频有效时长不足,当前时长(${formatDurationTime({ time: effectiveDuration, isChinese: true })})`,
type: 'error',
},
],
};
}
return { valid: true, errorInfo: [] };
- 判断累计有效时长是否≥100秒
- 满足要求返回验证通过,否则返回具体错误信息
三、以上需要使用到的部分方法
- 提取音视频中的语音
/**
* 提取适合silero-vad检测的音频数据 (16kHz 单声道)
* @param {string | File | Blob} file 输入文件
* @param {object} options 提取选项
* @returns {Promise<ArrayBuffer>} 返回音频数据的ArrayBuffer
*/
async extractWav(file: string | File | Blob, options: ExtractAudioOptions): Promise<ArrayBuffer> {
// 1. 加载检查
if (!this.isLoaded) await this.load();
// 2. 文件处理
const { startTimeStr, durationStr } = options;
const { inputName, outputName } = this.generateUniqueFileName(file, '.raw');
await this.ffmpeg.writeFile(inputName, await fetchFile(file));
// 3. 音频转换
await this.ffmpeg.exec([
'-i', inputName,
'-ss', startTimeStr, // 开始时间
'-t', durationStr, // 持续时间
'-vn', // 移除视频流
'-acodec', 'pcm_f32le', // 指定音频编解码器为32位浮点格式
'-ar', '16000', // 设置采样率为16kHz
'-ac', '1', // 设置为单声道
'-f', 'f32le', // 输出格式为32位浮点小端格式
outputName
]);
// 4. 结果返回
const data = await this.ffmpeg.readFile(outputName);
return data.buffer;
}
- vad检测
/**
* 检测音频中的有效语音片段(简化版)
* @param float32Audio 32位浮点格式的音频数据
* @param sampleRate 采样率
* @returns 检测结果
*/
async function detectSpeechSegmentsDemo(float32Audio: Float32Array, sampleRate: number) {
// 1. 初始化VAD模型
const myvad = await NonRealTimeVAD.new({
modelURL: '/path/to/model.onnx'
});
let effectiveDuration = 0;
// 2. 运行VAD检测
for await (const segment of myvad.run(float32Audio, sampleRate)) {
// 3. 累计有效语音时长
effectiveDuration += segment.end - segment.start;
// 4. 达到要求时长则停止检测
if (effectiveDuration > 100000) { // 100秒
break;
}
}
// 5. 返回结果
return {
valid: effectiveDuration > 100000,
fragmentDuration: effectiveDuration
};
}
- 非实时的检测类(改造为支持V5模型)
import * as ortInstance from 'onnxruntime-web';
import { log } from '@/utils/vad/v5/logging';
import { defaultModelFetcher } from './default-model-fetcher';
import {
defaultFrameProcessorOptions,
FrameProcessor,
FrameProcessorEvent,
FrameProcessorInterface,
FrameProcessorOptions,
validateOptions,
} from './frame-processor';
import { Message } from './messages';
import { ModelFetcher, OrtModule, OrtOptions, SileroV5 } from './models';
import { Resampler } from './resampler';
interface NonRealTimeVADSpeechData {
audio: Float32Array;
start: number;
end: number;
}
export interface NonRealTimeVADOptions extends FrameProcessorOptions, OrtOptions {
modelURL: string;
modelFetcher: (path: string) => Promise<ArrayBuffer>;
}
export const defaultNonRealTimeVADOptions: NonRealTimeVADOptions = {
...defaultFrameProcessorOptions,
modelURL: '',
modelFetcher: defaultModelFetcher,
};
export class NonRealTimeVAD {
frameSamples: number = 512;
static async new(options: Partial<NonRealTimeVADOptions> = {}) {
const fullOptions = {
...defaultNonRealTimeVADOptions,
...options,
};
validateOptions(fullOptions);
if (fullOptions.ortConfig !== undefined) {
fullOptions.ortConfig(ortInstance);
}
const modelFetcher = () => fullOptions.modelFetcher(fullOptions.modelURL);
const model = await SileroV5.new(ortInstance, modelFetcher);
const frameProcessor = new FrameProcessor(
model.process,
model.reset_state,
{
positiveSpeechThreshold: fullOptions.positiveSpeechThreshold,
negativeSpeechThreshold: fullOptions.negativeSpeechThreshold,
redemptionMs: fullOptions.redemptionMs,
preSpeechPadMs: fullOptions.preSpeechPadMs,
minSpeechMs: fullOptions.minSpeechMs,
submitUserSpeechOnPause: fullOptions.submitUserSpeechOnPause,
},
1536 / 16,
);
frameProcessor.resume();
const vad = new this(modelFetcher, ortInstance, fullOptions, frameProcessor);
return vad;
}
constructor(
public modelFetcher: ModelFetcher,
public ort: OrtModule,
public options: NonRealTimeVADOptions,
public frameProcessor: FrameProcessorInterface,
) {
log.debug('non-real-time-vad constructor');
}
async *run(inputAudio: Float32Array, sampleRate: number): AsyncGenerator<NonRealTimeVADSpeechData> {
const resamplerOptions = {
nativeSampleRate: sampleRate,
targetSampleRate: 16000,
targetFrameSize: this.frameSamples,
};
const resampler = new Resampler(resamplerOptions);
let start = 0;
let end = 0;
let frameIndex = 0;
for await (const frame of resampler.stream(inputAudio)) {
const messageContainer: FrameProcessorEvent[] = [];
await this.frameProcessor.process(frame, (event) => {
messageContainer.push(event);
});
for (const event of messageContainer) {
switch (event.msg) {
case Message.SpeechStart:
start = (frameIndex * this.frameSamples) / 16;
break;
case Message.SpeechEnd:
end = ((frameIndex + 1) * this.frameSamples) / 16;
yield { audio: event.audio, start, end };
break;
default:
break;
}
}
frameIndex++;
}
const messageContainer: FrameProcessorEvent[] = [];
this.frameProcessor.endSegment((event) => {
messageContainer.push(event);
});
for (const event of messageContainer) {
switch (event.msg) {
case Message.SpeechEnd:
yield {
audio: event.audio,
start,
end: (frameIndex * this.frameSamples) / 16,
};
break;
default:
break;
}
}
}
}
原创不易,动动小手,一键三连噢!
完整代码见如下git仓库:
document-helper/juejin/vad-validate at master · yc-lm/document-helper