Files
ai-agent-book/chapter6/live-audio/backend/utils/vad.js
T
liqiang b119135836
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

307 lines
9.2 KiB
JavaScript

const config = require('../config');
const ort = require('onnxruntime-node');
const path = require('path');
const fs = require('fs');
class VoiceActivityDetector {
constructor(options = {}) {
this.threshold = options.threshold || config.VAD_THRESHOLD || 0.5;
this.frameLength = options.frameLength || config.VAD_FRAME_LENGTH || 512;
this.minSpeechDuration = options.minSpeechDuration || config.VAD_MIN_SPEECH_DURATION || 250;
this.maxSilenceDuration = options.maxSilenceDuration || config.VAD_MAX_SILENCE_DURATION || 500;
this.sampleRate = options.sampleRate || config.AUDIO_SAMPLE_RATE || 16000;
// Silero VAD specific parameters
this.sileroFrameLength = 512; // Fixed frame length for Silero VAD
this.sileroSampleRate = 16000; // Fixed sample rate for Silero VAD
// State variables
this.isSpeaking = false;
this.speechStartTime = null;
this.lastSpeechTime = null;
this.audioBuffer = Buffer.alloc(0);
this.speechBuffer = Buffer.alloc(0);
// ONNX Runtime session
this.session = null;
this.isInitialized = false;
this.initializationPromise = null;
// Silero VAD state
this.state = null;
this.sr = null;
// Initialize the Silero VAD
this.initializationPromise = this.initializeSileroVAD();
}
/**
* Initialize the Silero VAD ONNX model
*/
async initializeSileroVAD() {
try {
console.log('Initializing Silero VAD...');
// Load the ONNX model
const modelPath = path.join(__dirname, '../models/silero_vad.onnx');
if (!fs.existsSync(modelPath)) {
throw new Error(`Silero VAD model not found at ${modelPath}`);
}
// Create ONNX Runtime session
this.session = await ort.InferenceSession.create(modelPath, {
executionProviders: ['cpu'],
graphOptimizationLevel: 'all',
enableMemPattern: true
});
// Initialize state tensors for Silero VAD
this.resetSileroState();
this.isInitialized = true;
console.log('Silero VAD initialized successfully');
} catch (error) {
console.error('Failed to initialize Silero VAD:', error);
throw error;
}
}
/**
* Reset Silero VAD state tensors
*/
resetSileroState() {
// Initialize state tensor for Silero VAD
// The state tensor combines h and c LSTM hidden states
this.state = new ort.Tensor('float32', new Float32Array(2 * 1 * 128).fill(0.0), [2, 1, 128]);
this.sr = new ort.Tensor('int64', new BigInt64Array([BigInt(this.sileroSampleRate)]), [1]);
}
/**
* Convert Buffer to Float32Array for Silero VAD
* @param {Buffer} buffer - PCM 16-bit audio buffer
* @returns {Float32Array} Normalized audio samples
*/
bufferToFloat32Array(buffer) {
const samples = new Float32Array(buffer.length / 2);
for (let i = 0; i < samples.length; i++) {
// Convert 16-bit PCM to normalized float (-1 to 1)
const sample = buffer.readInt16LE(i * 2);
samples[i] = sample / 32768.0;
}
return samples;
}
/**
* Run Silero VAD inference on audio chunk
* @param {Float32Array} audioSamples - Normalized audio samples
* @returns {Promise<number>} Speech probability (0-1)
*/
async runSileroVAD(audioSamples) {
if (!this.session || !this.isInitialized) {
throw new Error('Silero VAD not initialized');
}
try {
// Create input tensor
const inputTensor = new ort.Tensor('float32', audioSamples, [1, audioSamples.length]);
// Run inference
const feeds = {
input: inputTensor,
state: this.state,
sr: this.sr
};
const results = await this.session.run(feeds);
// Update state tensor for next inference
this.state = results.stateN;
// Get speech probability
const speechProb = results.output.data[0];
return speechProb;
} catch (error) {
console.error('Error running Silero VAD inference:', error);
return 0.0; // Return silence probability on error
}
}
/**
* Process audio chunk and detect voice activity using Silero VAD
* @param {Buffer} audioChunk - Raw audio data
* @returns {Promise<Array>} VAD result with detection status and audio data
*/
async processAudioChunk(audioChunk) {
// Wait for initialization if not complete
if (!this.isInitialized) {
await this.initializationPromise;
}
const currentTime = Date.now();
const results = [];
// Add to buffer
this.audioBuffer = Buffer.concat([this.audioBuffer, audioChunk]);
// Process audio in chunks suitable for Silero VAD (512 samples)
const chunkSize = this.sileroFrameLength * 2; // 16-bit = 2 bytes per sample
while (this.audioBuffer.length >= chunkSize) {
const chunk = this.audioBuffer.slice(0, chunkSize);
this.audioBuffer = this.audioBuffer.slice(chunkSize);
try {
// Convert to Float32Array for Silero VAD
const audioSamples = this.bufferToFloat32Array(chunk);
// Run Silero VAD inference
const speechProb = await this.runSileroVAD(audioSamples);
// Check if speech is detected
const isVoiceActive = speechProb > this.threshold;
if (isVoiceActive) {
if (!this.isSpeaking) {
// Speech started
this.isSpeaking = true;
this.speechStartTime = currentTime;
this.speechBuffer = Buffer.alloc(0);
console.log('Silero VAD: Speech started (prob:', speechProb.toFixed(3), ')');
// Emit the rising edge so the server can tell the client to barge
// in. Without this the client's 'speech_start' handler is dead and
// the assistant keeps talking over the user.
results.push({ type: 'speech_start', timestamp: currentTime });
}
this.lastSpeechTime = currentTime;
this.speechBuffer = Buffer.concat([this.speechBuffer, chunk]);
} else if (this.isSpeaking) {
// Check if silence duration exceeds threshold
const silenceDuration = currentTime - this.lastSpeechTime;
if (silenceDuration > this.maxSilenceDuration) {
// Speech ended
const speechDuration = currentTime - this.speechStartTime;
if (speechDuration >= this.minSpeechDuration) {
console.log('Silero VAD: Speech ended', {
duration: speechDuration,
bufferSize: this.speechBuffer.length
});
results.push({
type: 'speech_end',
audioData: this.speechBuffer,
duration: speechDuration,
observedSilenceDuration: silenceDuration,
endpointThreshold: this.maxSilenceDuration,
timestamp: currentTime
});
}
this.isSpeaking = false;
this.speechStartTime = null;
this.lastSpeechTime = null;
this.speechBuffer = Buffer.alloc(0);
} else {
// Still in speech, add chunk to buffer
this.speechBuffer = Buffer.concat([this.speechBuffer, chunk]);
}
}
} catch (error) {
console.error('Error processing audio chunk with Silero VAD:', error);
// Continue processing other chunks
}
}
return results;
}
/**
* Force end current speech session
* @returns {Promise<Object|null>} Final speech data if available
*/
async forceEndSpeech() {
if (this.isSpeaking && this.speechBuffer.length > 0) {
const currentTime = Date.now();
const speechDuration = currentTime - this.speechStartTime;
if (speechDuration >= this.minSpeechDuration) {
const result = {
type: 'speech_end',
audioData: this.speechBuffer,
duration: speechDuration,
timestamp: currentTime
};
this.isSpeaking = false;
this.speechStartTime = null;
this.lastSpeechTime = null;
this.speechBuffer = Buffer.alloc(0);
return result;
}
}
this.reset();
return null;
}
/**
* Reset VAD state
*/
reset() {
this.isSpeaking = false;
this.speechStartTime = null;
this.lastSpeechTime = null;
this.audioBuffer = Buffer.alloc(0);
this.speechBuffer = Buffer.alloc(0);
// Reset Silero VAD state
if (this.isInitialized) {
this.resetSileroState();
}
}
/**
* Get current VAD state
* @returns {Object} Current state information
*/
getState() {
return {
isSpeaking: this.isSpeaking,
speechDuration: this.speechStartTime ? Date.now() - this.speechStartTime : 0,
bufferSize: this.speechBuffer.length,
threshold: this.threshold,
isInitialized: this.isInitialized
};
}
/**
* Cleanup resources
*/
async cleanup() {
if (this.session) {
try {
await this.session.release();
} catch (error) {
console.error('Error releasing ONNX session:', error);
}
this.session = null;
}
this.state = null;
this.sr = null;
this.isInitialized = false;
this.reset();
}
}
module.exports = VoiceActivityDetector;