ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
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

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
@@ -0,0 +1,276 @@
const axios = require('axios');
const FormData = require('form-data');
const fs = require('fs');
const path = require('path');
/**
* Base ASR Provider class
*/
class BaseASRProvider {
constructor(config, apiKey) {
this.config = config;
this.apiKey = apiKey;
}
/**
* Convert raw audio buffer to WAV format
* @param {Buffer} audioBuffer - Raw PCM audio data
* @param {Object} options - Audio format options
* @returns {Buffer} WAV formatted audio data
*/
createWavBuffer(audioBuffer, options = {}) {
const sampleRate = options.sampleRate || 16000;
const channels = options.channels || 1;
const bitsPerSample = options.bitsPerSample || 16;
const byteRate = sampleRate * channels * bitsPerSample / 8;
const blockAlign = channels * bitsPerSample / 8;
const dataSize = audioBuffer.length;
const fileSize = 36 + dataSize;
const header = Buffer.alloc(44);
// RIFF header
header.write('RIFF', 0);
header.writeUInt32LE(fileSize, 4);
header.write('WAVE', 8);
// fmt chunk
header.write('fmt ', 12);
header.writeUInt32LE(16, 16); // PCM format chunk size
header.writeUInt16LE(1, 20); // PCM format
header.writeUInt16LE(channels, 22);
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(byteRate, 28);
header.writeUInt16LE(blockAlign, 32);
header.writeUInt16LE(bitsPerSample, 34);
// data chunk
header.write('data', 36);
header.writeUInt32LE(dataSize, 40);
return Buffer.concat([header, audioBuffer]);
}
async transcribe(audioBuffer, options = {}) {
throw new Error('transcribe method must be implemented by subclass');
}
}
/**
* OpenAI Whisper ASR Provider
*/
class OpenAIASRProvider extends BaseASRProvider {
async transcribe(audioBuffer, tempDir, options = {}) {
try {
// Create WAV buffer
const wavBuffer = this.createWavBuffer(audioBuffer, {
sampleRate: 16000,
channels: 1,
bitsPerSample: 16
});
// Create temporary file
const tempFileName = `audio_${Date.now()}_${Math.random().toString(36).substring(2)}.wav`;
const tempFilePath = path.join(tempDir, tempFileName);
// Write audio to temporary file
fs.writeFileSync(tempFilePath, wavBuffer);
try {
// Create form data
const formData = new FormData();
formData.append('file', fs.createReadStream(tempFilePath));
formData.append('model', this.config.model);
formData.append('response_format', 'json');
if (options.language) {
formData.append('language', options.language);
}
if (options.prompt) {
formData.append('prompt', options.prompt);
}
// Make API request
const response = await axios({
method: 'post',
url: this.config.apiUrl,
data: formData,
headers: {
'Authorization': `Bearer ${this.apiKey}`,
...formData.getHeaders()
},
timeout: 30000 // 30 second timeout
});
const result = {
success: true,
text: response.data.text || '',
language: response.data.language || 'unknown',
duration: response.data.duration || 0,
confidence: response.data.confidence || 1.0,
requestId: response.headers?.['x-request-id'] || response.headers?.['request-id'] || null,
responseModel: response.data.model || this.config.model,
timestamp: Date.now(),
provider: 'openai'
};
console.log('OpenAI ASR Result:', {
text: result.text,
language: result.language,
duration: result.duration
});
return result;
} finally {
// Clean up temporary file
try {
fs.unlinkSync(tempFilePath);
} catch (cleanupError) {
console.warn('Failed to cleanup temp file:', cleanupError.message);
}
}
} catch (error) {
console.error('OpenAI ASR error:', error.response?.data || error.message);
return {
success: false,
text: '',
error: error.response?.data?.error?.message || error.message,
timestamp: Date.now(),
provider: 'openai'
};
}
}
}
/**
* SenseVoice via Siliconflow ASR Provider
*/
class SiliconflowASRProvider extends BaseASRProvider {
async transcribe(audioBuffer, tempDir, options = {}) {
try {
// Create WAV buffer
const wavBuffer = this.createWavBuffer(audioBuffer, {
sampleRate: 16000,
channels: 1,
bitsPerSample: 16
});
// Create temporary file
const tempFileName = `audio_${Date.now()}_${Math.random().toString(36).substring(2)}.wav`;
const tempFilePath = path.join(tempDir, tempFileName);
// Write audio to temporary file
fs.writeFileSync(tempFilePath, wavBuffer);
try {
// Create form data
const formData = new FormData();
formData.append('file', fs.createReadStream(tempFilePath));
formData.append('model', this.config.model);
formData.append('response_format', 'json');
// SenseVoice specific parameters
if (options.language) {
formData.append('language', options.language);
} else {
// SenseVoice supports auto language detection
formData.append('language', 'auto');
}
if (options.prompt) {
formData.append('prompt', options.prompt);
}
// Make API request
const response = await axios({
method: 'post',
url: this.config.apiUrl,
data: formData,
headers: {
'Authorization': `Bearer ${this.apiKey}`,
...formData.getHeaders()
},
timeout: 30000 // 30 second timeout
});
const result = {
success: true,
text: response.data.text || '',
language: response.data.language || 'unknown',
duration: response.data.duration || 0,
confidence: response.data.confidence || 1.0,
requestId: response.headers?.['x-request-id'] || response.headers?.['request-id'] || null,
responseModel: response.data.model || this.config.model,
timestamp: Date.now(),
provider: 'siliconflow'
};
console.log('SenseVoice ASR Result:', {
text: result.text,
language: result.language,
duration: result.duration
});
return result;
} finally {
// Clean up temporary file
try {
fs.unlinkSync(tempFilePath);
} catch (cleanupError) {
console.warn('Failed to cleanup temp file:', cleanupError.message);
}
}
} catch (error) {
console.error('SenseVoice ASR error:', error.response?.data || error.message);
return {
success: false,
text: '',
error: error.response?.data?.error?.message || error.message,
timestamp: Date.now(),
provider: 'siliconflow'
};
}
}
}
/**
* ASR Provider Factory
*/
class ASRProviderFactory {
static createProvider(providerName, config, globalConfig) {
const providerConfig = config.ASR_PROVIDERS[providerName];
if (!providerConfig) {
throw new Error(`ASR provider ${providerName} not found in configuration`);
}
// Get API key from global config
const apiKey = globalConfig[providerConfig.apiKey];
if (!apiKey) {
throw new Error(`API key ${providerConfig.apiKey} not found in configuration`);
}
switch (providerName) {
case 'openai':
return new OpenAIASRProvider(providerConfig, apiKey);
case 'siliconflow':
return new SiliconflowASRProvider(providerConfig, apiKey);
default:
throw new Error(`Unsupported ASR provider: ${providerName}`);
}
}
}
module.exports = {
BaseASRProvider,
OpenAIASRProvider,
SiliconflowASRProvider,
ASRProviderFactory
};
@@ -0,0 +1,248 @@
const axios = require('axios');
function providerErrorMessage(error) {
const data = error.response?.data;
if (data && typeof data === 'object' && !data.readable) {
return data.error?.message || data.message || error.message;
}
return error.response?.status
? `HTTP ${error.response.status}: ${error.response.statusText || error.message}`
: error.message;
}
/**
* Base LLM Provider class
*/
class BaseLLMProvider {
constructor(config, apiKey) {
this.config = config;
this.apiKey = apiKey;
}
async createChatCompletion(messages, options = {}) {
throw new Error('createChatCompletion method must be implemented by subclass');
}
/**
* Prepare headers for API request
* @returns {Object} Headers object
*/
getHeaders() {
return {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
};
}
/**
* Prepare request payload
* @param {Array} messages - Chat messages
* @param {Object} options - Additional options
* @returns {Object} Request payload
*/
getRequestPayload(messages, options = {}) {
return {
model: this.config.model,
messages: messages,
stream: true,
max_tokens: options.max_tokens || 4096,
...options
};
}
}
/**
* OpenAI LLM Provider
*/
class OpenAILLMProvider extends BaseLLMProvider {
async createChatCompletion(messages, options = {}) {
try {
const payload = this.getRequestPayload(messages, options);
const headers = this.getHeaders();
console.log('OpenAI LLM Request:', {
model: payload.model,
messagesCount: messages.length,
stream: payload.stream
});
const response = await axios.post(
this.config.apiUrl,
payload,
{
headers: headers,
cancelToken: options.cancelToken,
responseType: 'stream'
}
);
return {
success: true,
response: response,
provider: 'openai'
};
} catch (error) {
const message = providerErrorMessage(error);
console.error('OpenAI LLM error:', message);
return {
success: false,
error: message,
provider: 'openai'
};
}
}
}
/**
* OpenRouter LLM Provider (supports both GPT-4o and Gemini)
*/
class OpenRouterLLMProvider extends BaseLLMProvider {
getHeaders() {
return {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://live-audio-chat.local',
'X-Title': 'Live Audio Chat'
};
}
async createChatCompletion(messages, options = {}) {
try {
const payload = this.getRequestPayload(messages, options);
const headers = this.getHeaders();
console.log('OpenRouter LLM Request:', {
model: payload.model,
messagesCount: messages.length,
stream: payload.stream
});
const response = await axios.post(
this.config.apiUrl,
payload,
{
headers: headers,
cancelToken: options.cancelToken,
responseType: 'stream'
}
);
return {
success: true,
response: response,
provider: 'openrouter'
};
} catch (error) {
const message = providerErrorMessage(error);
console.error('OpenRouter LLM error:', message);
return {
success: false,
error: message,
provider: 'openrouter'
};
}
}
}
/**
* ARK (Doubao) LLM Provider
*/
class ARKLLMProvider extends BaseLLMProvider {
getHeaders() {
return {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
};
}
getRequestPayload(messages, options = {}) {
// ARK API format is similar to OpenAI but may have slight differences
return {
model: this.config.model,
messages: messages,
stream: true,
max_tokens: options.max_tokens || 4096,
temperature: options.temperature || 0.7,
...options
};
}
async createChatCompletion(messages, options = {}) {
try {
const payload = this.getRequestPayload(messages, options);
const headers = this.getHeaders();
console.log('ARK (Doubao) LLM Request:', {
model: payload.model,
messagesCount: messages.length,
stream: payload.stream
});
const response = await axios.post(
this.config.apiUrl,
payload,
{
headers: headers,
cancelToken: options.cancelToken,
responseType: 'stream'
}
);
return {
success: true,
response: response,
provider: 'ark'
};
} catch (error) {
const message = providerErrorMessage(error);
console.error('ARK (Doubao) LLM error:', message);
return {
success: false,
error: message,
provider: 'ark'
};
}
}
}
/**
* LLM Provider Factory
*/
class LLMProviderFactory {
static createProvider(providerName, config, globalConfig) {
const providerConfig = config.LLM_PROVIDERS[providerName];
if (!providerConfig) {
throw new Error(`LLM provider ${providerName} not found in configuration`);
}
// Get API key from global config
const apiKey = globalConfig[providerConfig.apiKey];
if (!apiKey) {
throw new Error(`API key ${providerConfig.apiKey} not found in configuration`);
}
switch (providerName) {
case 'openai':
return new OpenAILLMProvider(providerConfig, apiKey);
case 'openrouter':
case 'openrouter-gpt':
case 'openrouter-gemini':
return new OpenRouterLLMProvider(providerConfig, apiKey);
case 'ark':
return new ARKLLMProvider(providerConfig, apiKey);
default:
throw new Error(`Unsupported LLM provider: ${providerName}`);
}
}
}
module.exports = {
BaseLLMProvider,
OpenAILLMProvider,
OpenRouterLLMProvider,
ARKLLMProvider,
LLMProviderFactory
};
@@ -0,0 +1,147 @@
const fs = require('fs');
const path = require('path');
const config = require('../config');
const { ASRProviderFactory } = require('./providers/asrProviders');
class SpeechToTextService {
constructor() {
this.tempDir = path.join(__dirname, '../temp');
this.ensureTempDirectory();
// Initialize ASR provider based on configuration
this.initializeProvider();
}
/**
* Initialize ASR provider based on configuration
*/
initializeProvider() {
try {
const providerName = config.ASR_PROVIDER || 'openai';
this.asrProvider = ASRProviderFactory.createProvider(providerName, config, config);
console.log(`ASR Provider initialized: ${providerName}`);
} catch (error) {
console.error('Failed to initialize ASR provider:', error);
throw error;
}
}
/**
* Switch ASR provider dynamically
* @param {string} providerName - Provider name to switch to
*/
switchProvider(providerName) {
try {
this.asrProvider = ASRProviderFactory.createProvider(providerName, config, config);
console.log(`ASR Provider switched to: ${providerName}`);
} catch (error) {
console.error('Failed to switch ASR provider:', error);
throw error;
}
}
/**
* Ensure temp directory exists
*/
ensureTempDirectory() {
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true });
}
}
/**
* Transcribe audio using the configured ASR provider
* @param {Buffer} audioBuffer - Raw audio data
* @param {Object} options - Transcription options
* @returns {Promise<Object>} Transcription result
*/
async transcribeAudio(audioBuffer, options = {}) {
if (!this.asrProvider) {
throw new Error('ASR provider not initialized');
}
try {
const result = await this.asrProvider.transcribe(audioBuffer, this.tempDir, options);
return result;
} catch (error) {
console.error('Transcription error:', error);
return {
success: false,
text: '',
error: error.message,
timestamp: Date.now(),
provider: this.asrProvider.config ? 'unknown' : 'uninitialized'
};
}
}
/**
* Clean up old temporary files
*/
cleanupTempFiles() {
try {
const files = fs.readdirSync(this.tempDir);
const now = Date.now();
const maxAge = 10 * 60 * 1000; // 10 minutes
files.forEach(file => {
const filePath = path.join(this.tempDir, file);
const stats = fs.statSync(filePath);
if (now - stats.mtime.getTime() > maxAge) {
try {
fs.unlinkSync(filePath);
console.log('Cleaned up old temp file:', file);
} catch (error) {
console.warn('Failed to cleanup old temp file:', file, error.message);
}
}
});
} catch (error) {
console.warn('Failed to cleanup temp directory:', error.message);
}
}
/**
* Check if audio buffer has sufficient content for transcription
* @param {Buffer} audioBuffer - Audio buffer to check
* @returns {boolean} True if buffer has sufficient content
*/
hasSufficientAudio(audioBuffer) {
if (!audioBuffer || audioBuffer.length === 0) {
return false;
}
// Check minimum duration (at least 0.1 seconds of audio)
const minSamples = config.AUDIO_SAMPLE_RATE * 0.1; // 0.1 seconds
const minBytes = minSamples * 2; // 16-bit = 2 bytes per sample
if (audioBuffer.length < minBytes) {
return false;
}
// Since we're using Silero VAD for speech detection, we trust its decision
// and only check for minimum duration. The energy check is redundant and
// can reject valid speech that Silero VAD correctly identified.
return true;
}
/**
* Get current provider information
* @returns {Object} Current provider information
*/
getProviderInfo() {
if (!this.asrProvider) {
return { provider: 'none', status: 'not initialized' };
}
return {
provider: this.asrProvider.constructor.name,
model: this.asrProvider.config.model,
apiUrl: this.asrProvider.config.apiUrl,
status: 'ready'
};
}
}
module.exports = SpeechToTextService;
@@ -0,0 +1,12 @@
const assert = require('assert');
const Module = require('module');
const orig = Module.prototype.require;
Module.prototype.require = function (id) {
if (id === 'emoji-regex') return () => /(?!)/g;
return orig.apply(this, arguments);
};
const { markdownToText } = require('./textProcessor.js');
const out = markdownToText('Call get_user_id then save_to_db');
assert.ok(out.includes('get_user_id'));
assert.ok(out.includes('save_to_db'));
console.log('ok');
@@ -0,0 +1,13 @@
const assert = require('assert');
const Module = require('module');
const orig = Module.prototype.require;
Module.prototype.require = function (id) {
if (id === 'emoji-regex') return () => /(?!)/g;
return orig.apply(this, arguments);
};
const { numberToWords } = require('./textProcessor.js');
assert.strictEqual(numberToWords(0), 'zero');
assert.ok(!numberToWords(1e12).includes('undefined'));
assert.ok(numberToWords(1e12).includes('trillion'));
assert.strictEqual(numberToWords(Infinity), 'Infinity');
console.log('ok');
@@ -0,0 +1,195 @@
const emojiRegex = require('emoji-regex');
// Remove emoji from the sentence
function removeEmoji(sentence) {
const regex = emojiRegex();
return sentence.replace(regex, ' ').trim();
}
// Convert markdown to plain text
function markdownToText(markdown) {
let text = markdown;
// Remove links, keeping only the link text
text = text.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1');
// Remove headers
text = text.replace(/^#+\s*/gm, '');
// Remove bold and italic markers
// Keep snake_case identifiers; only strip markdown __bold__ markers.
text = text.replace(/\*\*/g, '').replace(/\*/g, '')
.replace(/__/g, '');
// Remove blockquotes
text = text.replace(/^>\s*/gm, '');
// Remove horizontal rules
text = text.replace(/[-*_]{3,}/g, '');
// Remove list markers
text = text.replace(/^[-*+]\s*/gm, '');
// Remove code block markers
text = text.replace(/```/g, '');
return text.trim();
}
// Convert numbers to words
function numberToWords(num) {
const ones = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'];
const tens = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
const teens = ['ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'];
const scales = ['', 'thousand', 'million', 'billion', 'trillion', 'quadrillion'];
if (num === 0) return 'zero';
if (!Number.isFinite(num)) return String(num);
function convertGroup(n) {
let result = '';
if (n >= 100) {
result += ones[Math.floor(n / 100)] + ' hundred ';
n %= 100;
}
if (n >= 20) {
result += tens[Math.floor(n / 10)] + ' ';
n %= 10;
if (n > 0) {
result += ones[n] + ' ';
}
} else if (n >= 10) {
result += teens[n - 10] + ' ';
} else if (n > 0) {
result += ones[n] + ' ';
}
return result;
}
let result = '';
let groupIndex = 0;
while (num > 0) {
const group = num % 1000;
if (group !== 0) {
const scale = scales[groupIndex] || '';
result = convertGroup(group) + scale + ' ' + result;
}
num = Math.floor(num / 1000);
groupIndex++;
}
return result.trim();
}
// Pronounce special characters
function pronounceSpecialCharacters(text, isCodeBlock = false) {
const specialCharMap = {
'@': 'at',
'#': 'hash',
'$': 'dollar',
'%': 'percent',
'^': 'caret',
'&': 'ampersand',
'*': 'asterisk',
'_': 'underscore',
'=': 'equals',
'+': 'plus',
'[': 'left square bracket',
']': 'right square bracket',
'{': 'left curly brace',
'}': 'right curly brace',
'|': 'vertical bar',
'\\': 'backslash',
'<': 'less than',
'>': 'greater than',
'/': 'slash',
'`': 'backtick',
'~': 'tilde',
};
const punctuationMap = {
'!': 'exclamation',
'.': 'dot',
',': 'comma',
'?': 'question mark',
';': 'semicolon',
':': 'colon',
'"': 'double quote',
"'": 'single quote',
'-': 'minus',
'(': 'left parenthesis',
')': 'right parenthesis',
};
let processedText = text;
// Replace special characters
Object.entries(specialCharMap).forEach(([char, pronunciation]) => {
processedText = processedText.replace(new RegExp(char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), ` ${pronunciation} `);
});
// Replace punctuation if in code block
if (isCodeBlock) {
Object.entries(punctuationMap).forEach(([char, pronunciation]) => {
processedText = processedText.replace(new RegExp(char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), ` ${pronunciation} `);
});
}
return processedText;
}
// Pronounce numbers in text
function pronounceNumbers(text, language) {
if (language.startsWith('zh')) return text;
// Only consume the '.' when it is a real decimal point (digits follow).
// The old /(\d+\.?\d*)/ also matched "42." at the end of a sentence, which
// ate the period and emitted a dangling "point".
return text.replace(/\d+(?:\.\d+)?/g, match => {
const num = parseFloat(match);
if (isNaN(num)) return match;
if (match.includes('.')) {
const [integer, decimal] = match.split('.');
return `${numberToWords(parseInt(integer))} point ${decimal.split('').map(d => numberToWords(parseInt(d))).join(' ')}`;
}
return numberToWords(parseInt(match));
});
}
// Remove emotional indicators
function removeEmotions(text) {
return text.replace(/\*[a-zA-Z0-9 -]*\*/g, '').trim();
}
// Process code blocks
function pronounceCodeBlock(text) {
return text.replace(/`([^`\n]+)`|```(?:[\s\S]*?)```/g, (match) => {
const content = match.startsWith('```')
? match.slice(3, -3)
: match.slice(1, -1);
return pronounceSpecialCharacters(content, true);
});
}
// Main preprocessing function
function preprocessSentence(sentence, language = 'en') {
let processed = sentence;
processed = pronounceCodeBlock(processed);
processed = markdownToText(processed);
processed = pronounceNumbers(processed, language);
processed = removeEmotions(processed);
processed = pronounceSpecialCharacters(processed);
processed = removeEmoji(processed);
return processed.trim();
}
module.exports = {
preprocessSentence,
numberToWords,
markdownToText,
};
+306
View File
@@ -0,0 +1,306 @@
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;