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
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env node
/**
* check-setup.js — Headless environment verifier for the Live Voice Chat demo (实验 6-3).
*
* Runs WITHOUT any API keys, microphone, or browser. It only checks that the
* local prerequisites for the cascaded VAD -> ASR -> LLM -> TTS pipeline are in
* place, and reports which providers you have credentials for, so a reader can
* confirm their setup before opening the browser UI.
*
* Usage:
* node check-setup.js
*
* Exit code 0 means the backend can start; non-zero means a hard prerequisite
* (Node version, VAD model, or a loadable config) is missing.
*/
const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');
let hardFailures = 0;
let warnings = 0;
const ok = (msg) => console.log(`${msg}`);
const warn = (msg) => { console.log(` ⚠️ ${msg}`); warnings++; };
const fail = (msg) => { console.log(`${msg}`); hardFailures++; };
console.log('🔍 Live Voice Chat — setup check (实验 6-3)');
console.log('='.repeat(60));
// 1. Node version (README requires v16+)
console.log('\nNode.js runtime:');
const major = parseInt(process.versions.node.split('.')[0], 10);
if (major >= 16) {
ok(`Node ${process.version} (>= v16 required)`);
} else {
fail(`Node ${process.version} is too old; v16 or higher is required`);
}
// 2. FFmpeg (used by server.js to convert incoming audio for ASR)
console.log('\nFFmpeg (audio format conversion):');
try {
const out = execFileSync('ffmpeg', ['-version'], { encoding: 'utf8' });
ok(`ffmpeg found: ${out.split('\n')[0]}`);
} catch (e) {
fail('ffmpeg not found on PATH. Install it (e.g. `brew install ffmpeg`) — see README.');
}
// 3. Silero VAD model file
console.log('\nSilero VAD model:');
const modelPath = path.join(__dirname, 'models', 'silero_vad.onnx');
if (fs.existsSync(modelPath)) {
const kb = (fs.statSync(modelPath).size / 1024).toFixed(0);
ok(`silero_vad.onnx present (${kb} KB)`);
} else {
fail(`Missing ${modelPath}. Download it — see README "Download the Silero VAD model".`);
}
// 4. Config loads, and report selected providers + key availability
console.log('\nConfiguration & providers:');
let config;
try {
config = require('./config');
ok('config.js loaded');
} catch (e) {
fail(`config.js failed to load: ${e.message}`);
}
if (config) {
// Map each selected provider to the env var its credentials come from.
const keyFor = {
asr: (config.ASR_PROVIDERS[config.ASR_PROVIDER] || {}).apiKey,
llm: (config.LLM_PROVIDERS[config.LLM_PROVIDER] || {}).apiKey,
tts: (config.TTS_PROVIDERS[config.TTS_PROVIDER] || {}).apiKey,
};
const stageLine = (stage, provider, envVar) => {
if (!provider || !envVar) {
fail(`${stage.toUpperCase()}: provider "${provider}" is not defined in config.js`);
return;
}
const val = process.env[envVar];
const placeholder = !val || /your-.*-api-key-here/.test(val);
if (placeholder) {
warn(`${stage.toUpperCase()}: provider "${provider}" selected, but ${envVar} is not set`);
} else {
ok(`${stage.toUpperCase()}: provider "${provider}" ready (${envVar} set)`);
}
};
stageLine('asr', config.ASR_PROVIDER, keyFor.asr);
stageLine('llm', config.LLM_PROVIDER, keyFor.llm);
stageLine('tts', config.TTS_PROVIDER, keyFor.tts);
console.log('\nServer will listen on:');
ok(`ws://${config.LISTEN_HOST}:${config.LISTEN_PORT} (WebSocket) — frontend must use WEBSOCKET_PORT=${config.LISTEN_PORT}`);
}
// Summary
console.log('\n' + '='.repeat(60));
if (hardFailures === 0 && warnings === 0) {
console.log('✅ Setup looks good. Start the backend with: npm start');
} else if (hardFailures === 0) {
console.log(`⚠️ Prerequisites OK, but ${warnings} provider key(s) missing.`);
console.log(' The backend will start; set the missing API key(s) before recording.');
} else {
console.log(`${hardFailures} hard prerequisite(s) missing — fix these before running the backend.`);
}
process.exit(hardFailures === 0 ? 0 : 1);
+99
View File
@@ -0,0 +1,99 @@
const config = {
// API Keys
OPENAI_API_KEY: process.env.OPENAI_API_KEY || 'your-openai-api-key-here',
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY || 'your-openrouter-api-key-here',
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || 'your-anthropic-api-key-here',
ARK_API_KEY: process.env.ARK_API_KEY || 'your-ark-api-key-here',
SILICONFLOW_API_KEY: process.env.SILICONFLOW_API_KEY || 'your-siliconflow-api-key-here',
FISH_API_KEY: process.env.FISH_API_KEY || 'your-fish-api-key-here',
// Provider Selection
ASR_PROVIDER: 'siliconflow', // 'openai' (whisper-1) or 'siliconflow' (SenseVoice)
LLM_PROVIDER: 'openrouter', // 'openrouter' (gpt-5.6-luna, default), 'openai', 'openrouter-gemini', 'ark'
TTS_PROVIDER: 'siliconflow', // 'siliconflow' (CosyVoice2, keep current)
// ASR Configuration
ASR_PROVIDERS: {
openai: {
apiUrl: 'https://api.openai.com/v1/audio/transcriptions',
model: 'whisper-1',
apiKey: 'OPENAI_API_KEY'
},
siliconflow: {
apiUrl: 'https://api.siliconflow.cn/v1/audio/transcriptions',
model: 'FunAudioLLM/SenseVoiceSmall',
apiKey: 'SILICONFLOW_API_KEY'
}
},
// LLM Configuration
LLM_PROVIDERS: {
// OpenRouter with a current cheap flagship chat model (default / recommended:
// gpt-5.6* on OpenAI direct needs org verification, OpenRouter avoids that step).
openrouter: {
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
model: 'openai/gpt-5.6-luna',
apiKey: 'OPENROUTER_API_KEY'
},
openai: {
apiUrl: 'https://api.openai.com/v1/chat/completions',
model: 'gpt-5.6-luna',
apiKey: 'OPENAI_API_KEY'
},
'openrouter-gpt': {
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
model: 'openai/gpt-5.6-luna',
apiKey: 'OPENROUTER_API_KEY'
},
'openrouter-gemini': {
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
model: 'google/gemini-3.5-flash',
apiKey: 'OPENROUTER_API_KEY'
},
ark: {
apiUrl: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
model: 'doubao-seed-1-6-flash-250615',
apiKey: 'ARK_API_KEY'
}
},
// TTS Configuration (keep current)
TTS_PROVIDERS: {
siliconflow: {
apiUrl: 'https://api.siliconflow.cn/v1/audio/speech',
model: 'FunAudioLLM/CosyVoice2-0.5B',
voice: 'FunAudioLLM/CosyVoice2-0.5B:diana',
apiKey: 'SILICONFLOW_API_KEY'
},
fish: {
model: 's1',
apiKey: 'FISH_API_KEY'
}
},
// Legacy support (will be deprecated)
LLM_MODEL: 'gpt-5.6-luna',
LLM_API_URL: 'https://api.openai.com/v1/chat/completions',
STT_API_URL: 'https://api.openai.com/v1/audio/transcriptions',
STT_MODEL: 'whisper-1',
TTS_API_URL: 'https://api.siliconflow.cn/v1/audio/speech',
// Common Configuration
VISION_MAX_TOKENS: 4096,
// Silero VAD Configuration
VAD_THRESHOLD: 0.5, // Speech probability threshold for Silero VAD (0.0 to 1.0)
VAD_FRAME_LENGTH: 512, // Frame length for VAD analysis (samples)
VAD_MIN_SPEECH_DURATION: 250, // Minimum speech duration in ms
VAD_MAX_SILENCE_DURATION: 500, // Maximum silence duration before ending speech in ms
AUDIO_SAMPLE_RATE: 16000, // Sample rate for audio processing (required for Silero VAD)
AUDIO_CHUNK_SIZE: 4096, // Audio chunk size for processing
// Server Configuration
LISTEN_PORT: 8848,
LISTEN_HOST: '0.0.0.0',
SYSTEM_PROMPT: 'You are a helpful AI assistant.',
CANCEL_PLAYBACK_TIME_THRESHOLD: 3000,
};
module.exports = config;
@@ -0,0 +1,94 @@
const config = {
// API Keys
OPENAI_API_KEY: process.env.OPENAI_API_KEY || 'your-openai-api-key-here',
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY || 'your-openrouter-api-key-here',
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || 'your-anthropic-api-key-here',
ARK_API_KEY: process.env.ARK_API_KEY || 'your-ark-api-key-here',
SILICONFLOW_API_KEY: process.env.SILICONFLOW_API_KEY || 'your-siliconflow-api-key-here',
// Provider Selection
ASR_PROVIDER: 'siliconflow', // 'openai' (whisper-1) or 'siliconflow' (SenseVoice)
LLM_PROVIDER: 'openrouter', // 'openrouter' (gpt-5.6-luna, default), 'openai', 'openrouter-gemini', 'ark'
TTS_PROVIDER: 'siliconflow', // 'siliconflow' (CosyVoice2, keep current)
// ASR Configuration
ASR_PROVIDERS: {
openai: {
apiUrl: 'https://api.openai.com/v1/audio/transcriptions',
model: 'whisper-1',
apiKey: 'OPENAI_API_KEY'
},
siliconflow: {
apiUrl: 'https://api.siliconflow.cn/v1/audio/transcriptions',
model: 'FunAudioLLM/SenseVoiceSmall',
apiKey: 'SILICONFLOW_API_KEY'
}
},
// LLM Configuration
LLM_PROVIDERS: {
// OpenRouter with a current cheap flagship chat model (default / recommended:
// gpt-5.6* on OpenAI direct needs org verification, OpenRouter avoids that step).
openrouter: {
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
model: 'openai/gpt-5.6-luna',
apiKey: 'OPENROUTER_API_KEY'
},
openai: {
apiUrl: 'https://api.openai.com/v1/chat/completions',
model: 'gpt-5.6-luna',
apiKey: 'OPENAI_API_KEY'
},
'openrouter-gpt4o': {
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
model: 'openai/gpt-4o',
apiKey: 'OPENROUTER_API_KEY'
},
'openrouter-gemini': {
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
model: 'google/gemini-2.5-flash',
apiKey: 'OPENROUTER_API_KEY'
},
ark: {
apiUrl: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
model: 'doubao-seed-1-6-flash-250615',
apiKey: 'ARK_API_KEY'
}
},
// TTS Configuration (keep current)
TTS_PROVIDERS: {
siliconflow: {
apiUrl: 'https://api.siliconflow.cn/v1/audio/speech',
model: 'FunAudioLLM/CosyVoice2-0.5B',
voice: 'FunAudioLLM/CosyVoice2-0.5B:diana',
apiKey: 'SILICONFLOW_API_KEY'
}
},
// Legacy support (will be deprecated)
LLM_MODEL: 'gpt-5.6-luna',
LLM_API_URL: 'https://api.openai.com/v1/chat/completions',
STT_API_URL: 'https://api.openai.com/v1/audio/transcriptions',
STT_MODEL: 'whisper-1',
TTS_API_URL: 'https://api.siliconflow.cn/v1/audio/speech',
// Common Configuration
VISION_MAX_TOKENS: 4096,
// Silero VAD Configuration
VAD_THRESHOLD: 0.5, // Speech probability threshold for Silero VAD (0.0 to 1.0)
VAD_FRAME_LENGTH: 512, // Frame length for VAD analysis (samples)
VAD_MIN_SPEECH_DURATION: 250, // Minimum speech duration in ms
VAD_MAX_SILENCE_DURATION: 500, // Maximum silence duration before ending speech in ms
AUDIO_SAMPLE_RATE: 16000, // Sample rate for audio processing (required for Silero VAD)
AUDIO_CHUNK_SIZE: 4096, // Audio chunk size for processing
// Server Configuration
LISTEN_PORT: 8848,
LISTEN_HOST: '0.0.0.0',
SYSTEM_PROMPT: 'You are a helpful AI assistant.',
CANCEL_PLAYBACK_TIME_THRESHOLD: 3000,
};
module.exports = config;
@@ -0,0 +1,90 @@
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
function sha256(filePath) {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
function isRealStage(stage) {
return Boolean(
stage &&
stage.execution === 'real' &&
stage.mock !== true &&
stage.probe_only !== true &&
stage.fallback_used !== true
);
}
function validateExperimentEvidence(evidence, evidenceDir) {
const source = evidence?.source_media || {};
const vad = evidence?.stages?.vad || {};
const asr = evidence?.stages?.asr || {};
const llm = evidence?.stages?.llm || {};
const tts = evidence?.stages?.tts || {};
const sourcePath = source.path ? path.resolve(evidenceDir, source.path) : '';
const segmentPath = vad.segment_path ? path.resolve(evidenceDir, vad.segment_path) : '';
const outputPath = tts.output_path ? path.resolve(evidenceDir, tts.output_path) : '';
const sourceAuthentic = Boolean(
source.capture_method === 'browser_microphone_over_websocket' &&
sourcePath && fs.existsSync(sourcePath) &&
source.sha256 === sha256(sourcePath) &&
source.sample_rate_hz === 16000 &&
source.channels === 1 &&
source.bits_per_sample === 16
);
const segmentAuthentic = Boolean(
segmentPath && fs.existsSync(segmentPath) && vad.segment_sha256 === sha256(segmentPath)
);
const outputAuthentic = Boolean(
outputPath && fs.existsSync(outputPath) && tts.output_sha256 === sha256(outputPath) &&
Number(tts.output_bytes) > 1000 && Number(tts.output_duration_seconds) > 0
);
const gates = {
schema_and_scope: evidence?.schema_version === 1 && evidence?.experiment === '6-3',
real_websocket_microphone_media: sourceAuthentic,
real_silero_vad_endpoint: Boolean(
isRealStage(vad) &&
vad.implementation === 'Silero VAD ONNX' &&
vad.model_sha256 &&
vad.endpoint_detected === true &&
vad.forced_endpoint === false &&
Number(vad.max_silence_ms) === 500 &&
Number(vad.observed_trailing_silence_ms) >= 500 &&
segmentAuthentic
),
real_asr: Boolean(
isRealStage(asr) && asr.inference_completed === true &&
asr.provider && asr.model && String(asr.transcript || '').trim()
),
real_streaming_llm: Boolean(
isRealStage(llm) && llm.api_request_completed === true && llm.streamed === true &&
llm.provider && llm.model && Number(llm.first_token_seconds) > 0 &&
String(llm.response || '').trim()
),
real_tts_media: Boolean(
isRealStage(tts) && tts.api_request_completed === true &&
tts.provider && tts.model && Number(tts.first_audio_byte_seconds) > 0 && outputAuthentic
),
measured_stage_latencies: ['vad', 'asr', 'llm', 'tts'].every(
name => Number(evidence?.stages?.[name]?.latency_seconds) > 0
),
provenance_complete: Boolean(
evidence?.provenance?.host?.platform &&
evidence?.provenance?.host?.architecture &&
evidence?.provenance?.runtime?.node &&
evidence?.provenance?.runtime?.onnxruntime_node &&
source.original_sha256 === source.sha256
),
no_mock_probe_or_fallback: [vad, asr, llm, tts].every(isRealStage),
};
return {
gates,
passed: Object.values(gates).every(Boolean),
statement: 'Passing proves one saved real microphone turn completed Silero VAD -> real ASR -> real LLM -> real TTS. It does not benchmark concurrency or production load.',
};
}
module.exports = { sha256, validateExperimentEvidence };
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "livechat-backend",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"test": "mocha tests/*.js --timeout 120000",
"test:providers": "mocha tests/provider-tests.js --timeout 120000",
"test:asr": "mocha tests/test-asr-providers.js --timeout 120000",
"test:llm": "mocha tests/test-llm-providers.js --timeout 120000",
"test:tts": "mocha tests/test-tts-providers.js --timeout 120000",
"check": "node check-setup.js",
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"axios": "^1.7.5",
"emoji-regex": "^10.4.0",
"express": "^4.21.1",
"fluent-ffmpeg": "^2.1.3",
"form-data": "^4.0.0",
"franc": "^6.2.0",
"node-wav": "^0.0.2",
"onnxruntime-node": "^1.22.0-rev",
"wav": "^1.0.2",
"ws": "^8.18.0"
},
"devDependencies": {
"mocha": "^10.2.0"
}
}
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* Test runner for provider tests
* This script sets up the environment and runs comprehensive tests for all provider combinations
*
* Usage:
* node run-tests.js
*
* Environment variables required:
* - OPENROUTER_API_KEY: OpenRouter API key
* - ANTHROPIC_API_KEY: Anthropic API key
* - ARK_API_KEY: ARK (Doubao) API key
* - SILICONFLOW_API_KEY: Siliconflow API key
* - OPENAI_API_KEY: OpenAI API key (optional if using others)
*/
const { spawn } = require('child_process');
const path = require('path');
console.log('🧪 Starting Provider Tests for Live Audio Backend');
console.log('=' .repeat(60));
// Check environment variables
const requiredEnvVars = {
'OPENROUTER_API_KEY': 'OpenRouter API key',
'ANTHROPIC_API_KEY': 'Anthropic API key',
'ARK_API_KEY': 'ARK (Doubao) API key',
'SILICONFLOW_API_KEY': 'Siliconflow API key'
};
const missingKeys = [];
const availableKeys = [];
Object.entries(requiredEnvVars).forEach(([key, description]) => {
if (process.env[key]) {
availableKeys.push(`${description}`);
} else {
missingKeys.push(`${description} (${key})`);
}
});
console.log('\n🔑 API Key Status:');
availableKeys.forEach(key => console.log(` ${key}`));
missingKeys.forEach(key => console.log(` ${key}`));
if (process.env.OPENAI_API_KEY) {
console.log(` ✅ OpenAI API key (optional)`);
}
console.log('\n📋 Test Plan:');
console.log(' 1. ASR Provider Tests (OpenAI Whisper, SenseVoice)');
console.log(' 2. LLM Provider Tests (OpenAI, OpenRouter GPT-4o, OpenRouter Gemini, ARK Doubao)');
console.log(' 3. Integration Tests (All ASR+LLM combinations)');
console.log(' 4. Provider Switching Tests');
console.log('\n🚀 Running Tests...\n');
// Run the tests
const testProcess = spawn('npm', ['run', 'test:providers'], {
stdio: 'inherit',
cwd: __dirname,
env: process.env
});
testProcess.on('close', (code) => {
console.log('\n' + '='.repeat(60));
if (code === 0) {
console.log('✅ All tests completed successfully!');
console.log('\n📊 Test Summary:');
console.log(' - Provider creation and configuration ✓');
console.log(' - ASR transcription functionality ✓');
console.log(' - LLM chat completion functionality ✓');
console.log(' - Provider integration ✓');
console.log(' - Dynamic provider switching ✓');
} else {
console.log(`❌ Tests failed with exit code ${code}`);
console.log('\n🔧 Troubleshooting:');
console.log(' 1. Ensure all required API keys are set as environment variables');
console.log(' 2. Check network connectivity to API endpoints');
console.log(' 3. Verify API key permissions and quotas');
console.log(' 4. Check the test output above for specific error details');
}
process.exit(code);
});
testProcess.on('error', (error) => {
console.error('❌ Failed to start test process:', error);
process.exit(1);
});
@@ -0,0 +1,441 @@
#!/usr/bin/env node
const axios = require('axios');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const config = require('./config');
const VoiceActivityDetector = require('./utils/vad');
const { ASRProviderFactory } = require('./utils/providers/asrProviders');
const { LLMProviderFactory } = require('./utils/providers/llmProviders');
const { sha256, validateExperimentEvidence } = require('./experiment_validation');
function arg(name, fallback) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : fallback;
}
function requireCredential(name) {
const value = process.env[name];
if (!value || value.startsWith('your-')) throw new Error(`Required real credential is not set: ${name}`);
return value;
}
function redact(value) {
let text = String(value || '');
for (const [name, secret] of Object.entries(process.env)) {
if ((name.includes('KEY') || name.includes('TOKEN')) && secret) text = text.split(secret).join('[REDACTED]');
}
return text.replace(/\b(?:sk|ak)-[A-Za-z0-9_-]{12,}\b/g, '[REDACTED]').slice(0, 3000);
}
function parsePcmWav(filePath) {
const raw = fs.readFileSync(filePath);
if (raw.length < 44 || raw.subarray(0, 4).toString() !== 'RIFF' || raw.subarray(8, 12).toString() !== 'WAVE') {
throw new Error('Input must be a PCM WAV file');
}
const format = raw.readUInt16LE(20);
const channels = raw.readUInt16LE(22);
const sampleRate = raw.readUInt32LE(24);
const bits = raw.readUInt16LE(34);
if (format !== 1 || channels !== 1 || sampleRate !== 16000 || bits !== 16) {
throw new Error(`Expected PCM 16kHz mono 16-bit WAV; got format=${format}, channels=${channels}, sampleRate=${sampleRate}, bits=${bits}`);
}
return { raw, pcm: raw.subarray(44), channels, sampleRate, bits };
}
function wavBuffer(pcm, sampleRate = 16000) {
const header = Buffer.alloc(44);
header.write('RIFF', 0); header.writeUInt32LE(36 + pcm.length, 4); header.write('WAVE', 8);
header.write('fmt ', 12); header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20);
header.writeUInt16LE(1, 22); header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(sampleRate * 2, 28); header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34);
header.write('data', 36); header.writeUInt32LE(pcm.length, 40);
return Buffer.concat([header, pcm]);
}
function mediaProbe(filePath) {
const output = execFileSync('ffprobe', [
'-v', 'error', '-show_entries', 'format=duration,size,format_name', '-of', 'json', filePath,
], { encoding: 'utf8' });
const format = JSON.parse(output).format || {};
return {
duration_seconds: Number(format.duration),
size_bytes: Number(format.size),
format_name: format.format_name,
};
}
function commandVersion(command, args) {
try {
return execFileSync(command, args, { encoding: 'utf8' }).split('\n')[0].trim();
} catch (_) {
return null;
}
}
function resolveWhisperPython(requested) {
if (requested) return requested;
const launcher = execFileSync('which', ['whisper'], { encoding: 'utf8' }).trim();
const firstLine = fs.readFileSync(launcher, 'utf8').split('\n')[0];
if (!firstLine.startsWith('#!')) throw new Error(`Cannot resolve Python from Whisper launcher: ${launcher}`);
return firstLine.slice(2).trim();
}
function buildProvenance() {
const dependencies = require('./package.json').dependencies || {};
return {
host: {
platform: os.platform(),
release: os.release(),
architecture: os.arch(),
cpu_model: os.cpus()[0]?.model || 'unknown',
logical_cpu_count: os.cpus().length,
total_memory_bytes: os.totalmem(),
},
runtime: {
node: process.version,
onnxruntime_node: dependencies['onnxruntime-node'] || null,
axios: dependencies.axios || null,
ffprobe: commandVersion('ffprobe', ['-version']),
},
timing_clock: 'process.hrtime.bigint monotonic clock',
};
}
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
async function runVad(pcm, outputDir) {
const detector = new VoiceActivityDetector({ maxSilenceDuration: 500 });
await detector.initializationPromise;
const started = process.hrtime.bigint();
const events = [];
const frameBytes = 512 * 2;
try {
for (let offset = 0; offset + frameBytes <= pcm.length; offset += frameBytes) {
const frame = pcm.subarray(offset, offset + frameBytes);
events.push(...await detector.processAudioChunk(frame));
await sleep(32);
}
} finally {
await detector.cleanup();
}
const endpoints = events.filter(event => event.type === 'speech_end');
const ended = endpoints.sort((left, right) => right.audioData.length - left.audioData.length)[0];
if (!ended) throw new Error('Silero did not observe a non-forced endpoint after 500 ms silence');
const segmentPath = path.join(outputDir, 'vad_segment.wav');
fs.writeFileSync(segmentPath, wavBuffer(ended.audioData));
return {
execution: 'real', mock: false, probe_only: false, fallback_used: false,
implementation: 'Silero VAD ONNX',
model: 'models/silero_vad.onnx',
model_sha256: sha256(path.join(__dirname, 'models', 'silero_vad.onnx')),
threshold: 0.5,
max_silence_ms: 500,
endpoint_detected: true,
detected_endpoint_count: endpoints.length,
selected_endpoint: 'longest detected speech segment',
forced_endpoint: false,
observed_trailing_silence_ms: ended.observedSilenceDuration,
speech_duration_ms: ended.duration,
latency_seconds: Number(process.hrtime.bigint() - started) / 1e9,
segment_path: 'vad_segment.wav',
segment_sha256: sha256(segmentPath),
segment_bytes: fs.statSync(segmentPath).size,
};
}
function runLocalWhisper(segmentPath, requestedPython, modelName) {
const python = resolveWhisperPython(requestedPython);
const script = [
'import hashlib, json, pathlib, sys, time',
'import torch, whisper',
'audio, model_name = sys.argv[1], sys.argv[2]',
'cache = pathlib.Path.home() / ".cache" / "whisper" / (model_name + ".pt")',
'started = time.perf_counter()',
'model = whisper.load_model(model_name)',
'loaded = time.perf_counter()',
'result = model.transcribe(audio, fp16=False, verbose=False)',
'finished = time.perf_counter()',
'payload = {"text": str(result.get("text") or "").strip(), "language": result.get("language") or "unknown",',
' "model_load_seconds": loaded-started, "inference_seconds": finished-loaded,',
' "python": sys.version.split()[0], "torch": torch.__version__, "whisper": getattr(whisper, "__version__", "unknown"),',
' "model_path": str(cache), "model_sha256": hashlib.sha256(cache.read_bytes()).hexdigest() if cache.exists() else None}',
'print("EXPERIMENT_JSON=" + json.dumps(payload, ensure_ascii=False))',
].join('\n');
const started = process.hrtime.bigint();
const output = execFileSync(python, ['-c', script, segmentPath, modelName], {
encoding: 'utf8', maxBuffer: 10 * 1024 * 1024,
});
const marker = output.split('\n').find(line => line.startsWith('EXPERIMENT_JSON='));
if (!marker) throw new Error('Local Whisper returned no structured result');
const result = JSON.parse(marker.slice('EXPERIMENT_JSON='.length));
if (!result.text) throw new Error('Local Whisper returned an empty transcript');
return {
execution: 'real', mock: false, probe_only: false, fallback_used: false,
provider: 'local-openai-whisper', model: `whisper-${modelName}`,
inference_completed: true, api_request_completed: false, external_request: false,
latency_seconds: Number(process.hrtime.bigint() - started) / 1e9,
transcript: result.text, language: result.language,
runtime: { python: result.python, torch: result.torch, openai_whisper: result.whisper },
model_path: result.model_path, model_sha256: result.model_sha256,
model_load_seconds: result.model_load_seconds,
model_inference_seconds: result.inference_seconds,
provider_reported_cost_usd: 0,
cost_note: 'Local open-source Whisper inference; no external ASR charge.',
};
}
async function runAsr(segmentPath, providerName, evidenceDir, options = {}) {
if (providerName === 'local-whisper') {
return runLocalWhisper(segmentPath, options.whisperPython, options.whisperModel || 'tiny');
}
const providerConfig = config.ASR_PROVIDERS[providerName];
if (!providerConfig) throw new Error(`Unknown ASR provider: ${providerName}`);
requireCredential(providerConfig.apiKey);
const provider = ASRProviderFactory.createProvider(providerName, config, config);
const pcm = parsePcmWav(segmentPath).pcm;
const tempDir = path.join(evidenceDir, '.asr-temp');
fs.mkdirSync(tempDir, { recursive: true });
const started = process.hrtime.bigint();
const result = await provider.transcribe(pcm, tempDir, {});
fs.rmSync(tempDir, { recursive: true, force: true });
if (!result.success || !String(result.text || '').trim()) throw new Error(`Real ASR failed: ${result.error || 'empty transcript'}`);
return {
execution: 'real', mock: false, probe_only: false, fallback_used: false,
provider: providerName,
model: providerConfig.model,
inference_completed: true, api_request_completed: true, external_request: true,
latency_seconds: Number(process.hrtime.bigint() - started) / 1e9,
transcript: String(result.text).trim(),
language: result.language || 'unknown',
provider_request_id: result.requestId || null,
provider_response_model: result.responseModel || providerConfig.model,
billed_input_audio_seconds: mediaProbe(segmentPath).duration_seconds,
provider_reported_cost_usd: null,
cost_note: 'The transcription response did not expose a monetary charge; consult the provider billing ledger.',
};
}
async function runLlm(transcript, providerName, requestedModel) {
if (!config.LLM_PROVIDERS[providerName]) throw new Error(`Unknown LLM provider: ${providerName}`);
const providerConfig = { ...config.LLM_PROVIDERS[providerName] };
requireCredential(providerConfig.apiKey);
if (requestedModel) providerConfig.model = requestedModel;
const localConfig = { ...config, LLM_PROVIDERS: { ...config.LLM_PROVIDERS, [providerName]: providerConfig } };
const provider = LLMProviderFactory.createProvider(providerName, localConfig, localConfig);
const started = process.hrtime.bigint();
const result = await provider.createChatCompletion([
{ role: 'system', content: 'Reply to the user in the same language using one short, natural sentence suitable for speech.' },
{ role: 'user', content: transcript },
], { max_tokens: 80, temperature: 0, stream_options: { include_usage: true } });
if (!result.success) throw new Error(`Real LLM failed: ${result.error}`);
let buffer = '', response = '', firstToken = null, usage = null, finishReason = null;
for await (const chunk of result.response.data) {
buffer += chunk.toString();
const lines = buffer.split('\n'); buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ') || line.trim() === 'data: [DONE]') continue;
const data = JSON.parse(line.slice(6));
const piece = data.choices?.[0]?.delta?.content || '';
if (piece && firstToken === null) firstToken = Number(process.hrtime.bigint() - started) / 1e9;
response += piece;
if (data.usage) usage = data.usage;
if (data.choices?.[0]?.finish_reason) finishReason = data.choices[0].finish_reason;
}
}
if (!response.trim() || firstToken === null) throw new Error('Real LLM stream returned no text');
return {
execution: 'real', mock: false, probe_only: false, fallback_used: false,
provider: providerName, model: providerConfig.model, streamed: true,
api_request_completed: true, external_request: true, first_token_seconds: firstToken,
latency_seconds: Number(process.hrtime.bigint() - started) / 1e9,
response: response.trim(),
provider_request_id: result.response.headers?.['x-request-id'] || result.response.headers?.['request-id'] || null,
finish_reason: finishReason,
usage,
provider_reported_cost_usd: usage?.cost != null && Number.isFinite(Number(usage.cost)) ? Number(usage.cost) : null,
cost_note: usage?.cost == null ? 'The streamed response did not expose a monetary charge; consult the provider billing ledger.' : null,
};
}
function resolveFishReferenceId(requested) {
if (requested) return { id: requested, source: 'command line or FISH_TTS_REFERENCE_ID' };
const manifestPath = path.resolve(__dirname, '..', '..', 'controllable-tts', 'reference_audio', 'manifest.json');
if (!fs.existsSync(manifestPath)) {
throw new Error('Fish TTS requires FISH_TTS_REFERENCE_ID or the authorized Experiment 6-6 reference manifest');
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
if (!manifest.source_reference_id) throw new Error('Fish reference manifest has no source_reference_id');
return { id: manifest.source_reference_id, source: path.relative(__dirname, manifestPath) };
}
function runFishTts(text, outputDir, options = {}) {
requireCredential('FISH_API_KEY');
const python = options.fishPython || resolveWhisperPython();
const reference = resolveFishReferenceId(options.fishReferenceId);
const outputPath = path.join(outputDir, 'assistant_response.mp3');
const script = [
'import json, os, pathlib, sys, time',
'from fish_audio_sdk import Session, TTSRequest',
'text, reference_id, output = sys.argv[1], sys.argv[2], pathlib.Path(sys.argv[3])',
'started = time.perf_counter(); first = None; chunks = []',
'for chunk in Session(os.environ["FISH_API_KEY"]).tts(TTSRequest(text=text, reference_id=reference_id, format="mp3"), backend="s1"):',
' if first is None: first = time.perf_counter() - started',
' chunks.append(chunk)',
'output.write_bytes(b"".join(chunks)); finished = time.perf_counter() - started',
'print("EXPERIMENT_JSON=" + json.dumps({"first_byte_seconds": first, "latency_seconds": finished, "bytes": output.stat().st_size}))',
].join('\n');
const output = execFileSync(python, ['-c', script, text, reference.id, outputPath], {
encoding: 'utf8', maxBuffer: 10 * 1024 * 1024,
});
const marker = output.split('\n').find(line => line.startsWith('EXPERIMENT_JSON='));
if (!marker) throw new Error('Fish Audio returned no structured result');
const result = JSON.parse(marker.slice('EXPERIMENT_JSON='.length));
const probe = mediaProbe(outputPath);
return {
execution: 'real', mock: false, probe_only: false, fallback_used: false,
provider: 'fish', model: 's1', voice: 'authorized zero-shot reference',
api_request_completed: true, external_request: true,
first_audio_byte_seconds: result.first_byte_seconds,
latency_seconds: result.latency_seconds,
output_path: 'assistant_response.mp3', output_sha256: sha256(outputPath),
output_bytes: result.bytes, output_duration_seconds: probe.duration_seconds,
output_format: probe.format_name,
reference_id_sha256: crypto.createHash('sha256').update(reference.id).digest('hex'),
reference_id_source: reference.source,
billed_input_characters: [...text].length,
provider_reported_cost_usd: null,
cost_note: 'The Fish Audio SDK response did not expose a monetary charge; consult the provider billing ledger.',
};
}
async function runTts(text, providerName, outputDir, options = {}) {
if (providerName === 'fish') return runFishTts(text, outputDir, options);
if (providerName !== 'siliconflow') throw new Error(`Unknown TTS provider: ${providerName}`);
const providerConfig = config.TTS_PROVIDERS[providerName];
const apiKey = requireCredential(providerConfig.apiKey);
const started = process.hrtime.bigint();
const response = await axios.post(providerConfig.apiUrl, {
model: providerConfig.model, input: text, voice: providerConfig.voice,
response_format: 'mp3', sample_rate: 32000, stream: true, speed: 1, gain: 0,
}, {
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
responseType: 'stream', timeout: 120000,
});
let firstByte = null;
const chunks = [];
for await (const chunk of response.data) {
if (firstByte === null) firstByte = Number(process.hrtime.bigint() - started) / 1e9;
chunks.push(chunk);
}
const audio = Buffer.concat(chunks);
const outputPath = path.join(outputDir, 'assistant_response.mp3');
fs.writeFileSync(outputPath, audio);
const probe = mediaProbe(outputPath);
return {
execution: 'real', mock: false, probe_only: false, fallback_used: false,
provider: providerName, model: providerConfig.model, voice: providerConfig.voice,
api_request_completed: response.status >= 200 && response.status < 300, external_request: true,
first_audio_byte_seconds: firstByte,
latency_seconds: Number(process.hrtime.bigint() - started) / 1e9,
output_path: 'assistant_response.mp3', output_sha256: sha256(outputPath),
output_bytes: audio.length, output_duration_seconds: probe.duration_seconds,
output_format: probe.format_name,
provider_request_id: response.headers?.['x-request-id'] || response.headers?.['request-id'] || null,
billed_input_characters: [...text].length,
provider_reported_cost_usd: null,
cost_note: 'The speech response did not expose a monetary charge; consult the provider billing ledger.',
};
}
function renderReport(e) {
const a = e.acceptance;
return `# Experiment 6-3 real traditional-voice validation\n\n` +
`- Run ID: \`${e.run_id}\`\n- Complete: **${e.experiment_complete}**\n` +
`- Source: \`${e.source_media.path}\` (${e.source_media.duration_seconds.toFixed(3)} s, saved browser microphone/WebSocket capture)\n` +
`- VAD: Silero ONNX, 500 ms silence, non-forced endpoint = ${e.stages.vad.endpoint_detected}\n` +
`- ASR: ${e.stages.asr.provider} / ${e.stages.asr.model}, ${e.stages.asr.latency_seconds.toFixed(3)} s\n` +
`- Transcript: ${e.stages.asr.transcript}\n` +
`- LLM: ${e.stages.llm.provider} / ${e.stages.llm.model}, TTFT ${e.stages.llm.first_token_seconds.toFixed(3)} s, total ${e.stages.llm.latency_seconds.toFixed(3)} s\n` +
`- Response: ${e.stages.llm.response}\n` +
`- TTS: ${e.stages.tts.provider} / ${e.stages.tts.model}, first byte ${e.stages.tts.first_audio_byte_seconds.toFixed(3)} s, total ${e.stages.tts.latency_seconds.toFixed(3)} s\n` +
`- Post-endpoint time to first audio byte: ${e.latency.post_endpoint_to_first_audio_byte_seconds.toFixed(3)} s\n\n` +
`## Strict gates\n\n${Object.entries(a.gates).map(([k,v]) => `- ${k}: **${v}**`).join('\n')}\n\n` +
`${a.statement}\n`;
}
async function main() {
const input = path.resolve(arg('--input', path.join(__dirname, 'recordings', 'recording_2025-02-06T14-51-38-205Z.wav')));
const outputDir = path.resolve(arg('--output-dir', path.join(__dirname, 'validation', `real_pipeline_${new Date().toISOString().slice(0,10).replaceAll('-', '')}`)));
const asrProvider = arg('--asr-provider', 'local-whisper');
const whisperPython = arg('--whisper-python', process.env.WHISPER_PYTHON);
const whisperModel = arg('--whisper-model', 'tiny');
const llmProvider = arg('--llm-provider', 'openrouter-gemini');
const llmModel = arg('--llm-model', config.LLM_PROVIDERS[llmProvider]?.model);
const ttsProvider = arg('--tts-provider', 'fish');
const fishPython = arg('--fish-python', process.env.FISH_PYTHON);
const fishReferenceId = arg('--fish-reference-id', process.env.FISH_TTS_REFERENCE_ID);
if (fs.existsSync(outputDir)) throw new Error(`Output directory already exists: ${outputDir}`);
fs.mkdirSync(outputDir, { recursive: true });
const wav = parsePcmWav(input);
const sourceCopy = path.join(outputDir, 'microphone_input.wav');
fs.copyFileSync(input, sourceCopy);
const probe = mediaProbe(sourceCopy);
const evidence = {
schema_version: 1, experiment: '6-3', run_id: `exp6-3-${new Date().toISOString().replace(/[-:.]/g, '')}`,
generated_at_utc: new Date().toISOString(), credentials_persisted: false,
provenance: buildProvenance(),
source_media: {
path: 'microphone_input.wav', capture_method: 'browser_microphone_over_websocket',
original_repository_path: path.relative(path.join(__dirname, '..'), input),
sha256: sha256(sourceCopy), size_bytes: fs.statSync(sourceCopy).size,
original_sha256: sha256(input),
duration_seconds: probe.duration_seconds, sample_rate_hz: wav.sampleRate,
channels: wav.channels, bits_per_sample: wav.bits,
provenance_note: 'Existing real microphone capture saved by live-audio/backend/server.js; replayed through the production Silero class for reproducible validation.',
}, stages: {}, experiment_complete: false,
};
try {
evidence.stages.vad = await runVad(wav.pcm, outputDir);
evidence.stages.asr = await runAsr(
path.join(outputDir, evidence.stages.vad.segment_path), asrProvider, outputDir,
{ whisperPython, whisperModel },
);
evidence.stages.llm = await runLlm(evidence.stages.asr.transcript, llmProvider, llmModel);
evidence.stages.tts = await runTts(
evidence.stages.llm.response, ttsProvider, outputDir,
{ fishPython, fishReferenceId },
);
evidence.latency = {
post_endpoint_to_first_audio_byte_seconds:
evidence.stages.asr.latency_seconds + evidence.stages.llm.first_token_seconds + evidence.stages.tts.first_audio_byte_seconds,
complete_serial_pipeline_seconds: ['vad','asr','llm','tts'].reduce((n,k) => n + evidence.stages[k].latency_seconds, 0),
measurement_clock: 'process.hrtime.bigint monotonic clock',
};
evidence.cost = {
paid_external_requests: [evidence.stages.asr, evidence.stages.llm, evidence.stages.tts]
.filter(stage => stage.external_request === true).length,
provider_reported_total_usd: [evidence.stages.asr, evidence.stages.llm, evidence.stages.tts]
.map(stage => stage.provider_reported_cost_usd)
.filter(value => Number.isFinite(value))
.reduce((sum, value) => sum + value, 0),
complete: [evidence.stages.asr, evidence.stages.llm, evidence.stages.tts]
.every(stage => Number.isFinite(stage.provider_reported_cost_usd)),
note: 'A zero total is not a zero-cost claim when complete=false; some providers omit per-request charges.',
};
evidence.acceptance = validateExperimentEvidence(evidence, outputDir);
evidence.experiment_complete = evidence.acceptance.passed;
} catch (error) {
evidence.error = redact(error?.stack || error);
evidence.acceptance = validateExperimentEvidence(evidence, outputDir);
}
fs.writeFileSync(path.join(outputDir, 'evidence.json'), JSON.stringify(evidence, null, 2) + '\n');
if (evidence.experiment_complete) fs.writeFileSync(path.join(outputDir, 'report.md'), renderReport(evidence));
console.log(`Evidence: ${path.join(outputDir, 'evidence.json')}`);
if (!evidence.experiment_complete) throw new Error(evidence.error || 'Strict acceptance gates failed');
}
main().catch(error => { console.error(redact(error.message)); process.exit(1); });
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
const axios = require('axios');
const config = require('./config');
async function testLLMEndpoint() {
console.log('Testing LLM endpoint...');
console.log(`URL: ${config.LLM_API_URL}`);
console.log(`Model: ${config.LLM_MODEL}`);
const testPrompt = "Tell me a short joke.";
try {
const response = await axios.post(
config.LLM_API_URL,
{
model: config.LLM_MODEL,
messages: [
{ role: "user", content: testPrompt }
],
stream: true,
max_tokens: 100
},
{
headers: {
'Authorization': `Bearer ${config.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
responseType: 'stream'
}
);
console.log('Connection established successfully');
let fullResponse = '';
response.data.on('data', chunk => {
const lines = chunk.toString().split('\n');
for (const line of lines) {
if (line.trim() === '') continue;
if (line.trim() === 'data: [DONE]') continue;
if (!line.startsWith('data: ')) continue;
try {
const jsonData = JSON.parse(line.replace('data: ', ''));
const content = jsonData.choices[0]?.delta?.content || '';
fullResponse += content;
process.stdout.write(content); // Stream the response
} catch (e) {
console.error('Error parsing chunk:', e);
console.error('Raw chunk:', line);
}
}
});
response.data.on('end', () => {
console.log('\n\nFull response received:', fullResponse);
console.log('\nTest completed successfully');
});
response.data.on('error', (error) => {
console.error('Stream error:', error);
});
} catch (error) {
console.error('\nError testing LLM endpoint:', error.message);
if (error.response) {
console.error('Response status:', error.response.status);
console.error('Response headers:', JSON.stringify(error.response.headers, null, 2));
}
// Log safe error properties
const safeError = {
message: error.message,
name: error.name,
stack: error.stack,
code: error.code,
status: error.status
};
console.error('\nError details:', JSON.stringify(safeError, null, 2));
}
}
// Run the test
testLLMEndpoint();
@@ -0,0 +1,365 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
// Import provider factories
const { ASRProviderFactory } = require('../utils/providers/asrProviders');
const { LLMProviderFactory } = require('../utils/providers/llmProviders');
// Import services
const SpeechToTextService = require('../utils/speechToText');
// Mock configuration for testing
const testConfig = {
// API Keys from environment variables
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
ARK_API_KEY: process.env.ARK_API_KEY,
SILICONFLOW_API_KEY: process.env.SILICONFLOW_API_KEY,
// Provider configurations
ASR_PROVIDERS: {
openai: {
apiUrl: 'https://api.openai.com/v1/audio/transcriptions',
model: 'whisper-1',
apiKey: 'OPENAI_API_KEY'
},
siliconflow: {
apiUrl: 'https://api.siliconflow.cn/v1/audio/transcriptions',
model: 'FunAudioLLM/SenseVoiceSmall',
apiKey: 'SILICONFLOW_API_KEY'
}
},
LLM_PROVIDERS: {
openai: {
apiUrl: 'https://api.openai.com/v1/chat/completions',
model: 'gpt-5.6-luna',
apiKey: 'OPENAI_API_KEY'
},
'openrouter-gpt': {
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
model: 'openai/gpt-5.6-luna',
apiKey: 'OPENROUTER_API_KEY'
},
'openrouter-gemini': {
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
model: 'google/gemini-3.5-flash',
apiKey: 'OPENROUTER_API_KEY'
},
ark: {
apiUrl: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
model: 'doubao-seed-1-6-flash-250615',
apiKey: 'ARK_API_KEY'
}
},
// Audio configuration
AUDIO_SAMPLE_RATE: 16000,
VISION_MAX_TOKENS: 4096
};
/**
* Create a sample audio buffer for testing
* This creates a simple sine wave audio buffer
*/
function createTestAudioBuffer(durationSeconds = 2, sampleRate = 16000) {
const numSamples = durationSeconds * sampleRate;
const buffer = Buffer.alloc(numSamples * 2); // 16-bit = 2 bytes per sample
// Generate a simple sine wave at 440Hz
const frequency = 440;
for (let i = 0; i < numSamples; i++) {
const sample = Math.sin(2 * Math.PI * frequency * i / sampleRate) * 0.5;
const intSample = Math.round(sample * 32767);
buffer.writeInt16LE(intSample, i * 2);
}
return buffer;
}
/**
* Test suite for ASR providers
*/
describe('ASR Provider Tests', function() {
this.timeout(60000); // 60 second timeout for API calls
const asrProviders = ['openai', 'siliconflow'];
asrProviders.forEach(providerName => {
describe(`${providerName} ASR Provider`, function() {
let provider;
before(function() {
// Skip test if API key is not available
const apiKeyName = testConfig.ASR_PROVIDERS[providerName].apiKey;
if (!testConfig[apiKeyName]) {
this.skip();
}
try {
provider = ASRProviderFactory.createProvider(providerName, testConfig, testConfig);
} catch (error) {
console.error(`Failed to create ${providerName} provider:`, error);
this.skip();
}
});
it('should be created successfully', function() {
assert(provider, 'Provider should be created');
assert(provider.config, 'Provider should have config');
assert(provider.apiKey, 'Provider should have API key');
});
it('should transcribe test audio', async function() {
const audioBuffer = createTestAudioBuffer(2); // 2 seconds of audio
const tempDir = path.join(__dirname, '../temp');
// Ensure temp directory exists
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const result = await provider.transcribe(audioBuffer, tempDir);
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
assert(typeof result.text === 'string', 'Should have text field');
assert(result.provider === providerName, 'Should have correct provider name');
console.log(`${providerName} ASR Result:`, {
success: result.success,
text: result.text.substring(0, 100) + (result.text.length > 100 ? '...' : ''),
language: result.language,
provider: result.provider
});
});
});
});
describe('SpeechToTextService Integration', function() {
asrProviders.forEach(providerName => {
it(`should work with ${providerName} provider`, async function() {
// Skip test if API key is not available
const apiKeyName = testConfig.ASR_PROVIDERS[providerName].apiKey;
if (!testConfig[apiKeyName]) {
this.skip();
}
// Override config for this test
const originalConfig = require('../config');
Object.assign(originalConfig, testConfig);
originalConfig.ASR_PROVIDER = providerName;
const sttService = new SpeechToTextService();
const audioBuffer = createTestAudioBuffer(2);
const result = await sttService.transcribeAudio(audioBuffer);
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
assert(typeof result.text === 'string', 'Should have text field');
console.log(`STT Service with ${providerName}:`, {
success: result.success,
text: result.text.substring(0, 100) + (result.text.length > 100 ? '...' : ''),
provider: result.provider
});
});
});
});
});
/**
* Test suite for LLM providers
*/
describe('LLM Provider Tests', function() {
this.timeout(60000); // 60 second timeout for API calls
const llmProviders = ['openai', 'openrouter-gpt', 'openrouter-gemini', 'ark'];
const testMessages = [
{ role: 'system', content: 'You are a helpful AI assistant.' },
{ role: 'user', content: 'Hello! Please respond with a short greeting.' }
];
llmProviders.forEach(providerName => {
describe(`${providerName} LLM Provider`, function() {
let provider;
before(function() {
// Skip test if API key is not available
const apiKeyName = testConfig.LLM_PROVIDERS[providerName].apiKey;
if (!testConfig[apiKeyName]) {
this.skip();
}
try {
provider = LLMProviderFactory.createProvider(providerName, testConfig, testConfig);
} catch (error) {
console.error(`Failed to create ${providerName} provider:`, error);
this.skip();
}
});
it('should be created successfully', function() {
assert(provider, 'Provider should be created');
assert(provider.config, 'Provider should have config');
assert(provider.apiKey, 'Provider should have API key');
});
it('should generate chat completion', async function() {
const result = await provider.createChatCompletion(testMessages, {
max_tokens: 100
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
assert(result.provider === providerName.split('-')[0], 'Should have correct provider name');
if (result.success) {
assert(result.response, 'Should have response object');
console.log(`${providerName} LLM Result: Success`);
} else {
console.log(`${providerName} LLM Result:`, result.error);
}
});
it('should stream chat completion', async function() {
const result = await provider.createChatCompletion(testMessages, {
max_tokens: 50,
stream: true
});
assert(result, 'Should return a result');
if (result.success) {
assert(result.response, 'Should have response object');
assert(result.response.data, 'Should have data stream');
// Test streaming by collecting some data
let receivedData = false;
const timeout = setTimeout(() => {
if (!receivedData) {
console.log(`${providerName}: No data received within timeout`);
}
}, 10000);
result.response.data.on('data', (chunk) => {
receivedData = true;
clearTimeout(timeout);
console.log(`${providerName} LLM Streaming: Received data chunk`);
});
result.response.data.on('error', (error) => {
clearTimeout(timeout);
console.error(`${providerName} LLM Streaming Error:`, error.message);
});
} else {
console.log(`${providerName} LLM Streaming Result:`, result.error);
}
});
});
});
});
/**
* Integration tests for all provider combinations
*/
describe('Provider Integration Tests', function() {
this.timeout(120000); // 2 minute timeout for integration tests
const testCombinations = [
{ asr: 'openai', llm: 'openai', description: 'OpenAI ASR + OpenAI LLM' },
{ asr: 'openai', llm: 'openrouter-gpt', description: 'OpenAI ASR + OpenRouter GPT' },
{ asr: 'openai', llm: 'openrouter-gemini', description: 'OpenAI ASR + OpenRouter Gemini' },
{ asr: 'openai', llm: 'ark', description: 'OpenAI ASR + ARK Doubao' },
{ asr: 'siliconflow', llm: 'openai', description: 'SenseVoice ASR + OpenAI LLM' },
{ asr: 'siliconflow', llm: 'openrouter-gpt', description: 'SenseVoice ASR + OpenRouter GPT' },
{ asr: 'siliconflow', llm: 'openrouter-gemini', description: 'SenseVoice ASR + OpenRouter Gemini' },
{ asr: 'siliconflow', llm: 'ark', description: 'SenseVoice ASR + ARK Doubao' }
];
testCombinations.forEach(combination => {
it(`should work with ${combination.description}`, async function() {
// Check if API keys are available
const asrApiKey = testConfig.ASR_PROVIDERS[combination.asr].apiKey;
const llmApiKey = testConfig.LLM_PROVIDERS[combination.llm].apiKey;
if (!testConfig[asrApiKey] || !testConfig[llmApiKey]) {
this.skip();
}
try {
// Create providers
const asrProvider = ASRProviderFactory.createProvider(combination.asr, testConfig, testConfig);
const llmProvider = LLMProviderFactory.createProvider(combination.llm, testConfig, testConfig);
// Test ASR
const audioBuffer = createTestAudioBuffer(2);
const tempDir = path.join(__dirname, '../temp');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const asrResult = await asrProvider.transcribe(audioBuffer, tempDir);
assert(asrResult.success !== undefined, 'ASR should return result');
// Test LLM with a simple message
const messages = [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'Say hello in one word.' }
];
const llmResult = await llmProvider.createChatCompletion(messages, {
max_tokens: 10
});
assert(llmResult.success !== undefined, 'LLM should return result');
console.log(`Integration Test - ${combination.description}:`, {
asr: { success: asrResult.success, provider: asrResult.provider },
llm: { success: llmResult.success, provider: llmResult.provider }
});
} catch (error) {
console.error(`Integration test failed for ${combination.description}:`, error.message);
throw error;
}
});
});
});
/**
* Provider switching tests
*/
describe('Provider Switching Tests', function() {
it('should switch ASR providers dynamically', function() {
const originalConfig = require('../config');
Object.assign(originalConfig, testConfig);
const sttService = new SpeechToTextService();
const originalProvider = sttService.getProviderInfo();
// Try switching to different provider
const availableProviders = ['openai', 'siliconflow'];
const newProvider = availableProviders.find(p => p !== originalProvider.provider);
if (newProvider && testConfig[testConfig.ASR_PROVIDERS[newProvider].apiKey]) {
sttService.switchProvider(newProvider);
const newProviderInfo = sttService.getProviderInfo();
assert(newProviderInfo.provider !== originalProvider.provider, 'Provider should change');
console.log('ASR Provider switched from', originalProvider.provider, 'to', newProviderInfo.provider);
} else {
console.log('Skipping ASR provider switching test - insufficient API keys');
}
});
});
// Export test configuration for use in other test files
module.exports = {
testConfig,
createTestAudioBuffer
};
@@ -0,0 +1,307 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
// Import ASR provider factory
const { ASRProviderFactory } = require('../utils/providers/asrProviders');
// Test configuration
const testConfig = {
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
SILICONFLOW_API_KEY: process.env.SILICONFLOW_API_KEY,
ASR_PROVIDERS: {
openai: {
apiUrl: 'https://api.openai.com/v1/audio/transcriptions',
model: 'whisper-1',
apiKey: 'OPENAI_API_KEY'
},
siliconflow: {
apiUrl: 'https://api.siliconflow.cn/v1/audio/transcriptions',
model: 'FunAudioLLM/SenseVoiceSmall',
apiKey: 'SILICONFLOW_API_KEY'
}
},
AUDIO_SAMPLE_RATE: 16000
};
/**
* Create a test audio buffer with spoken content
* This creates a simple sine wave that simulates audio content
*/
function createTestAudioBuffer(durationSeconds = 3, sampleRate = 16000) {
const numSamples = durationSeconds * sampleRate;
const buffer = Buffer.alloc(numSamples * 2); // 16-bit = 2 bytes per sample
// Generate a more complex wave pattern to simulate speech
for (let i = 0; i < numSamples; i++) {
// Mix multiple frequencies to simulate speech-like content
const t = i / sampleRate;
const sample =
0.3 * Math.sin(2 * Math.PI * 300 * t) + // Base frequency
0.2 * Math.sin(2 * Math.PI * 600 * t) + // Harmonic
0.1 * Math.sin(2 * Math.PI * 1200 * t) + // Higher harmonic
0.05 * (Math.random() - 0.5); // Noise for realism
const intSample = Math.round(sample * 16000); // Scale to 16-bit range
buffer.writeInt16LE(intSample, i * 2);
}
return buffer;
}
/**
* ASR Provider Individual Tests
*/
describe('ASR Providers - Individual Testing', function() {
this.timeout(120000); // 2 minute timeout for API calls
const tempDir = path.join(__dirname, '../temp');
before(function() {
// Ensure temp directory exists
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
});
describe('OpenAI Whisper ASR Provider', function() {
let provider;
before(function() {
if (!testConfig.OPENAI_API_KEY) {
console.log('⚠️ Skipping OpenAI ASR tests - OPENAI_API_KEY not found');
this.skip();
}
try {
provider = ASRProviderFactory.createProvider('openai', testConfig, testConfig);
console.log('✅ OpenAI ASR Provider created successfully');
} catch (error) {
console.error('❌ Failed to create OpenAI ASR provider:', error);
this.skip();
}
});
it('should initialize with correct configuration', function() {
assert(provider, 'Provider should be created');
assert.strictEqual(provider.config.model, 'whisper-1', 'Should use whisper-1 model');
assert.strictEqual(provider.config.apiUrl, 'https://api.openai.com/v1/audio/transcriptions', 'Should use correct API URL');
assert(provider.apiKey, 'Should have API key');
console.log('📋 OpenAI ASR Config:', {
model: provider.config.model,
apiUrl: provider.config.apiUrl
});
});
it('should transcribe test audio successfully', async function() {
const audioBuffer = createTestAudioBuffer(3); // 3 seconds of test audio
console.log('🎵 Testing OpenAI ASR with', audioBuffer.length, 'bytes of audio data');
const startTime = Date.now();
const result = await provider.transcribe(audioBuffer, tempDir);
const duration = Date.now() - startTime;
console.log('📊 OpenAI ASR Results:', {
success: result.success,
transcriptionTime: `${duration}ms`,
textLength: result.text ? result.text.length : 0,
language: result.language,
confidence: result.confidence,
provider: result.provider
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
assert(typeof result.text === 'string', 'Should have text field');
assert.strictEqual(result.provider, 'openai', 'Should identify as openai provider');
if (result.success) {
console.log('✅ OpenAI ASR transcription successful');
if (result.text.length > 0) {
console.log('📝 Transcribed text preview:', result.text.substring(0, 100) + (result.text.length > 100 ? '...' : ''));
}
} else {
console.log('❌ OpenAI ASR transcription failed:', result.error);
}
});
it('should handle empty audio gracefully', async function() {
const emptyBuffer = Buffer.alloc(0);
const result = await provider.transcribe(emptyBuffer, tempDir);
console.log('🔍 OpenAI ASR Empty Audio Test:', {
success: result.success,
error: result.error,
provider: result.provider
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
// Empty audio should typically fail or return empty text
if (!result.success) {
assert(result.error, 'Should have error message for empty audio');
}
});
});
describe('SenseVoice (Siliconflow) ASR Provider', function() {
let provider;
before(function() {
if (!testConfig.SILICONFLOW_API_KEY) {
console.log('⚠️ Skipping Siliconflow ASR tests - SILICONFLOW_API_KEY not found');
this.skip();
}
try {
provider = ASRProviderFactory.createProvider('siliconflow', testConfig, testConfig);
console.log('✅ Siliconflow ASR Provider created successfully');
} catch (error) {
console.error('❌ Failed to create Siliconflow ASR provider:', error);
this.skip();
}
});
it('should initialize with correct configuration', function() {
assert(provider, 'Provider should be created');
assert.strictEqual(provider.config.model, 'FunAudioLLM/SenseVoiceSmall', 'Should use SenseVoiceSmall model');
assert.strictEqual(provider.config.apiUrl, 'https://api.siliconflow.cn/v1/audio/transcriptions', 'Should use correct API URL');
assert(provider.apiKey, 'Should have API key');
console.log('📋 Siliconflow ASR Config:', {
model: provider.config.model,
apiUrl: provider.config.apiUrl
});
});
it('should transcribe test audio successfully', async function() {
const audioBuffer = createTestAudioBuffer(3); // 3 seconds of test audio
console.log('🎵 Testing Siliconflow ASR with', audioBuffer.length, 'bytes of audio data');
const startTime = Date.now();
const result = await provider.transcribe(audioBuffer, tempDir);
const duration = Date.now() - startTime;
console.log('📊 Siliconflow ASR Results:', {
success: result.success,
transcriptionTime: `${duration}ms`,
textLength: result.text ? result.text.length : 0,
language: result.language,
confidence: result.confidence,
provider: result.provider
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
assert(typeof result.text === 'string', 'Should have text field');
assert.strictEqual(result.provider, 'siliconflow', 'Should identify as siliconflow provider');
if (result.success) {
console.log('✅ Siliconflow ASR transcription successful');
if (result.text.length > 0) {
console.log('📝 Transcribed text preview:', result.text.substring(0, 100) + (result.text.length > 100 ? '...' : ''));
}
} else {
console.log('❌ Siliconflow ASR transcription failed:', result.error);
}
});
it('should handle different audio lengths', async function() {
const shortAudio = createTestAudioBuffer(1); // 1 second
const longAudio = createTestAudioBuffer(5); // 5 seconds
console.log('🎵 Testing Siliconflow ASR with different audio lengths');
const shortResult = await provider.transcribe(shortAudio, tempDir);
const longResult = await provider.transcribe(longAudio, tempDir);
console.log('📊 Audio Length Tests:', {
short: { success: shortResult.success, textLength: shortResult.text.length },
long: { success: longResult.success, textLength: longResult.text.length }
});
assert(shortResult, 'Should handle short audio');
assert(longResult, 'Should handle long audio');
assert(typeof shortResult.success === 'boolean', 'Short audio should have success field');
assert(typeof longResult.success === 'boolean', 'Long audio should have success field');
});
});
describe('ASR Provider Comparison', function() {
it('should compare provider performance', async function() {
const availableProviders = [];
if (testConfig.OPENAI_API_KEY) {
availableProviders.push('openai');
}
if (testConfig.SILICONFLOW_API_KEY) {
availableProviders.push('siliconflow');
}
if (availableProviders.length < 2) {
console.log('⚠️ Skipping provider comparison - need at least 2 providers');
this.skip();
}
const audioBuffer = createTestAudioBuffer(3);
const results = {};
console.log('🏁 Comparing ASR provider performance...');
for (const providerName of availableProviders) {
const provider = ASRProviderFactory.createProvider(providerName, testConfig, testConfig);
const startTime = Date.now();
const result = await provider.transcribe(audioBuffer, tempDir);
const duration = Date.now() - startTime;
results[providerName] = {
success: result.success,
duration: duration,
textLength: result.text ? result.text.length : 0,
error: result.error
};
}
console.log('📊 ASR Provider Performance Comparison:');
Object.entries(results).forEach(([provider, result]) => {
console.log(` ${provider}:`, {
success: result.success ? '✅' : '❌',
time: `${result.duration}ms`,
textLength: result.textLength,
error: result.error || 'none'
});
});
// Find fastest successful provider
const successfulProviders = Object.entries(results).filter(([_, result]) => result.success);
if (successfulProviders.length > 0) {
const fastest = successfulProviders.reduce((prev, curr) =>
prev[1].duration < curr[1].duration ? prev : curr
);
console.log(`🏆 Fastest provider: ${fastest[0]} (${fastest[1].duration}ms)`);
}
});
});
after(function() {
// Cleanup temp files
try {
const files = fs.readdirSync(tempDir);
files.forEach(file => {
if (file.startsWith('audio_')) {
fs.unlinkSync(path.join(tempDir, file));
}
});
console.log('🧹 Cleaned up temporary audio files');
} catch (error) {
console.warn('⚠️ Failed to cleanup temp files:', error.message);
}
});
});
module.exports = {
testConfig,
createTestAudioBuffer
};
@@ -0,0 +1,58 @@
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { sha256, validateExperimentEvidence } = require('../experiment_validation');
function fixture(dir) {
fs.writeFileSync(path.join(dir, 'input.wav'), Buffer.from('real-input'));
fs.writeFileSync(path.join(dir, 'segment.wav'), Buffer.from('real-segment'));
fs.writeFileSync(path.join(dir, 'output.mp3'), Buffer.alloc(2000, 1));
const real = { execution: 'real', mock: false, probe_only: false, fallback_used: false, latency_seconds: 0.1 };
return {
schema_version: 1, experiment: '6-3',
provenance: { host: { platform: 'test', architecture: 'test' }, runtime: { node: 'test', onnxruntime_node: 'test' } },
source_media: { path: 'input.wav', capture_method: 'browser_microphone_over_websocket', sha256: sha256(path.join(dir, 'input.wav')), original_sha256: sha256(path.join(dir, 'input.wav')), sample_rate_hz: 16000, channels: 1, bits_per_sample: 16 },
stages: {
vad: { ...real, implementation: 'Silero VAD ONNX', model_sha256: 'abc', endpoint_detected: true, forced_endpoint: false, max_silence_ms: 500, observed_trailing_silence_ms: 512, segment_path: 'segment.wav', segment_sha256: sha256(path.join(dir, 'segment.wav')) },
asr: { ...real, provider: 'openai', model: 'whisper-1', inference_completed: true, api_request_completed: true, transcript: 'hello' },
llm: { ...real, provider: 'openai', model: 'gpt-real', api_request_completed: true, streamed: true, first_token_seconds: 0.1, response: 'Hi.' },
tts: { ...real, provider: 'siliconflow', model: 'CosyVoice', api_request_completed: true, first_audio_byte_seconds: 0.1, output_path: 'output.mp3', output_sha256: sha256(path.join(dir, 'output.mp3')), output_bytes: 2000, output_duration_seconds: 1 },
},
};
}
describe('Experiment 6-3 strict evidence gates', () => {
let dir;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'exp6-3-')); });
afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
it('accepts direct real media and all four real stages', () => {
assert.strictEqual(validateExperimentEvidence(fixture(dir), dir).passed, true);
});
for (const field of ['mock', 'probe_only', 'fallback_used']) {
it(`rejects a stage marked ${field}`, () => {
const evidence = fixture(dir); evidence.stages.asr[field] = true;
assert.strictEqual(validateExperimentEvidence(evidence, dir).passed, false);
});
}
it('rejects a forced VAD flush and missing 500ms silence', () => {
const evidence = fixture(dir);
evidence.stages.vad.forced_endpoint = true;
evidence.stages.vad.observed_trailing_silence_ms = 100;
assert.strictEqual(validateExperimentEvidence(evidence, dir).passed, false);
});
it('rejects README claims without hashed output media', () => {
const evidence = fixture(dir); evidence.stages.tts.output_sha256 = 'claimed-only';
assert.strictEqual(validateExperimentEvidence(evidence, dir).passed, false);
});
it('rejects evidence without reproducibility provenance', () => {
const evidence = fixture(dir); delete evidence.provenance.runtime.onnxruntime_node;
assert.strictEqual(validateExperimentEvidence(evidence, dir).passed, false);
});
});
@@ -0,0 +1,560 @@
const assert = require('assert');
// Import LLM provider factory
const { LLMProviderFactory } = require('../utils/providers/llmProviders');
// Test configuration
const testConfig = {
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
ARK_API_KEY: process.env.ARK_API_KEY,
SILICONFLOW_API_KEY: process.env.SILICONFLOW_API_KEY,
LLM_PROVIDERS: {
openai: {
apiUrl: 'https://api.openai.com/v1/chat/completions',
model: 'gpt-5.6-luna',
apiKey: 'OPENAI_API_KEY'
},
'openrouter-gpt': {
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
model: 'openai/gpt-5.6-luna',
apiKey: 'OPENROUTER_API_KEY'
},
'openrouter-gemini': {
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
model: 'google/gemini-3.5-flash',
apiKey: 'OPENROUTER_API_KEY'
},
ark: {
apiUrl: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
model: 'doubao-seed-1-6-flash-250615',
apiKey: 'ARK_API_KEY'
}
},
VISION_MAX_TOKENS: 4096
};
// Test messages for different scenarios
const testMessages = {
simple: [
{ role: 'system', content: 'You are a helpful AI assistant.' },
{ role: 'user', content: 'Hello! Please respond with exactly the word "SUCCESS" to confirm you are working.' }
],
conversation: [
{ role: 'system', content: 'You are a conversational AI assistant.' },
{ role: 'user', content: 'What is the capital of France?' },
{ role: 'assistant', content: 'The capital of France is Paris.' },
{ role: 'user', content: 'What is its population?' }
],
creative: [
{ role: 'system', content: 'You are a creative writing assistant.' },
{ role: 'user', content: 'Write a very short story about a robot learning to paint. Keep it under 50 words.' }
]
};
/**
* Collect streaming response data
*/
async function collectStreamingResponse(response, timeout = 30000) {
return new Promise((resolve, reject) => {
let buffer = '';
let accumulatedContent = '';
let firstTokenTime = null;
let tokenCount = 0;
const timeoutHandle = setTimeout(() => {
reject(new Error('Streaming response timeout'));
}, timeout);
response.data.on('data', (chunk) => {
try {
if (!firstTokenTime) {
firstTokenTime = Date.now();
}
buffer += chunk.toString();
const lines = buffer.split('\n');
// Keep the last line if it's incomplete
buffer = lines.pop() || '';
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine === '[DONE]') continue;
if (!trimmedLine.startsWith('data: ')) continue;
try {
const jsonData = JSON.parse(trimmedLine.replace('data: ', ''));
const content = jsonData.choices[0]?.delta?.content || '';
if (content) {
accumulatedContent += content;
tokenCount++;
}
} catch (parseError) {
// Skip malformed JSON
continue;
}
}
} catch (error) {
reject(error);
}
});
response.data.on('end', () => {
clearTimeout(timeoutHandle);
resolve({
content: accumulatedContent,
tokenCount: tokenCount,
firstTokenTime: firstTokenTime,
success: true
});
});
response.data.on('error', (error) => {
clearTimeout(timeoutHandle);
reject(error);
});
});
}
/**
* LLM Provider Individual Tests
*/
describe('LLM Providers - Individual Testing', function() {
this.timeout(120000); // 2 minute timeout for API calls
describe('OpenAI GPT-4o LLM Provider', function() {
let provider;
before(function() {
if (!testConfig.OPENAI_API_KEY) {
console.log('⚠️ Skipping OpenAI LLM tests - OPENAI_API_KEY not found');
this.skip();
}
try {
provider = LLMProviderFactory.createProvider('openai', testConfig, testConfig);
console.log('✅ OpenAI LLM Provider created successfully');
} catch (error) {
console.error('❌ Failed to create OpenAI LLM provider:', error);
this.skip();
}
});
it('should initialize with correct configuration', function() {
assert(provider, 'Provider should be created');
assert.strictEqual(provider.config.model, 'gpt-5.6-luna', 'Should use gpt-5.6-luna model');
assert.strictEqual(provider.config.apiUrl, 'https://api.openai.com/v1/chat/completions', 'Should use correct API URL');
assert(provider.apiKey, 'Should have API key');
console.log('📋 OpenAI LLM Config:', {
model: provider.config.model,
apiUrl: provider.config.apiUrl
});
});
it('should generate simple chat completion', async function() {
console.log('🤖 Testing OpenAI LLM with simple message');
const startTime = Date.now();
const result = await provider.createChatCompletion(testMessages.simple, {
max_tokens: 100
});
const responseTime = Date.now() - startTime;
console.log('📊 OpenAI LLM Simple Test:', {
success: result.success,
responseTime: `${responseTime}ms`,
provider: result.provider
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
assert.strictEqual(result.provider, 'openai', 'Should identify as openai provider');
if (result.success) {
assert(result.response, 'Should have response object');
console.log('✅ OpenAI LLM simple completion successful');
} else {
console.log('❌ OpenAI LLM simple completion failed:', result.error);
}
});
it('should handle streaming chat completion', async function() {
console.log('🌊 Testing OpenAI LLM streaming');
const startTime = Date.now();
const result = await provider.createChatCompletion(testMessages.simple, {
max_tokens: 50,
stream: true
});
if (!result.success) {
console.log('❌ OpenAI LLM streaming setup failed:', result.error);
assert(false, 'Streaming setup should succeed');
return;
}
try {
const streamResult = await collectStreamingResponse(result.response);
const totalTime = Date.now() - startTime;
const timeToFirstToken = streamResult.firstTokenTime ? streamResult.firstTokenTime - startTime : 0;
console.log('📊 OpenAI LLM Streaming Results:', {
success: streamResult.success,
totalTime: `${totalTime}ms`,
timeToFirstToken: `${timeToFirstToken}ms`,
tokenCount: streamResult.tokenCount,
contentLength: streamResult.content.length
});
assert(streamResult.success, 'Streaming should be successful');
assert(streamResult.content.length > 0, 'Should receive content');
assert(streamResult.tokenCount > 0, 'Should receive tokens');
if (streamResult.content.length > 0) {
console.log('📝 Generated content preview:', streamResult.content.substring(0, 100) + (streamResult.content.length > 100 ? '...' : ''));
}
console.log('✅ OpenAI LLM streaming successful');
} catch (streamError) {
console.error('❌ OpenAI LLM streaming failed:', streamError.message);
assert(false, 'Streaming should not fail: ' + streamError.message);
}
});
it('should handle conversation context', async function() {
console.log('💬 Testing OpenAI LLM with conversation context');
const result = await provider.createChatCompletion(testMessages.conversation, {
max_tokens: 100
});
console.log('📊 OpenAI LLM Conversation Test:', {
success: result.success,
provider: result.provider
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
if (result.success) {
console.log('✅ OpenAI LLM conversation handling successful');
} else {
console.log('❌ OpenAI LLM conversation handling failed:', result.error);
}
});
});
describe('OpenRouter GPT LLM Provider', function() {
let provider;
before(function() {
if (!testConfig.OPENROUTER_API_KEY) {
console.log('⚠️ Skipping OpenRouter GPT tests - OPENROUTER_API_KEY not found');
this.skip();
}
try {
provider = LLMProviderFactory.createProvider('openrouter-gpt', testConfig, testConfig);
console.log('✅ OpenRouter GPT Provider created successfully');
} catch (error) {
console.error('❌ Failed to create OpenRouter GPT provider:', error);
this.skip();
}
});
it('should initialize with correct configuration', function() {
assert(provider, 'Provider should be created');
assert.strictEqual(provider.config.model, 'openai/gpt-5.6-luna', 'Should use openai/gpt-5.6-luna model');
assert.strictEqual(provider.config.apiUrl, 'https://openrouter.ai/api/v1/chat/completions', 'Should use OpenRouter API URL');
assert(provider.apiKey, 'Should have API key');
console.log('📋 OpenRouter GPT Config:', {
model: provider.config.model,
apiUrl: provider.config.apiUrl
});
});
it('should generate chat completion via OpenRouter', async function() {
console.log('🤖 Testing OpenRouter GPT');
const startTime = Date.now();
const result = await provider.createChatCompletion(testMessages.simple, {
max_tokens: 100
});
const responseTime = Date.now() - startTime;
console.log('📊 OpenRouter GPT Test:', {
success: result.success,
responseTime: `${responseTime}ms`,
provider: result.provider
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
assert.strictEqual(result.provider, 'openrouter', 'Should identify as openrouter provider');
if (result.success) {
console.log('✅ OpenRouter GPT completion successful');
} else {
console.log('❌ OpenRouter GPT completion failed:', result.error);
}
});
it('should handle streaming via OpenRouter', async function() {
console.log('🌊 Testing OpenRouter GPT streaming');
const result = await provider.createChatCompletion(testMessages.simple, {
max_tokens: 50,
stream: true
});
if (!result.success) {
console.log('❌ OpenRouter GPT streaming setup failed:', result.error);
return; // Don't fail the test, just log
}
try {
const streamResult = await collectStreamingResponse(result.response);
console.log('📊 OpenRouter GPT Streaming:', {
success: streamResult.success,
tokenCount: streamResult.tokenCount,
contentLength: streamResult.content.length
});
if (streamResult.success) {
console.log('✅ OpenRouter GPT streaming successful');
}
} catch (streamError) {
console.log('❌ OpenRouter GPT streaming error:', streamError.message);
}
});
});
describe('OpenRouter Gemini LLM Provider', function() {
let provider;
before(function() {
if (!testConfig.OPENROUTER_API_KEY) {
console.log('⚠️ Skipping OpenRouter Gemini tests - OPENROUTER_API_KEY not found');
this.skip();
}
try {
provider = LLMProviderFactory.createProvider('openrouter-gemini', testConfig, testConfig);
console.log('✅ OpenRouter Gemini Provider created successfully');
} catch (error) {
console.error('❌ Failed to create OpenRouter Gemini provider:', error);
this.skip();
}
});
it('should initialize with correct configuration', function() {
assert(provider, 'Provider should be created');
assert.strictEqual(provider.config.model, 'google/gemini-3.5-flash', 'Should use gemini-3.5-flash model');
assert.strictEqual(provider.config.apiUrl, 'https://openrouter.ai/api/v1/chat/completions', 'Should use OpenRouter API URL');
assert(provider.apiKey, 'Should have API key');
console.log('📋 OpenRouter Gemini Config:', {
model: provider.config.model,
apiUrl: provider.config.apiUrl
});
});
it('should generate chat completion with Gemini', async function() {
console.log('🤖 Testing OpenRouter Gemini');
const startTime = Date.now();
const result = await provider.createChatCompletion(testMessages.simple, {
max_tokens: 100
});
const responseTime = Date.now() - startTime;
console.log('📊 OpenRouter Gemini Test:', {
success: result.success,
responseTime: `${responseTime}ms`,
provider: result.provider
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
assert.strictEqual(result.provider, 'openrouter', 'Should identify as openrouter provider');
if (result.success) {
console.log('✅ OpenRouter Gemini completion successful');
} else {
console.log('❌ OpenRouter Gemini completion failed:', result.error);
}
});
it('should handle creative tasks with Gemini', async function() {
console.log('🎨 Testing OpenRouter Gemini creative task');
const result = await provider.createChatCompletion(testMessages.creative, {
max_tokens: 150
});
console.log('📊 OpenRouter Gemini Creative Test:', {
success: result.success,
provider: result.provider
});
if (result.success) {
console.log('✅ OpenRouter Gemini creative task successful');
} else {
console.log('❌ OpenRouter Gemini creative task failed:', result.error);
}
});
});
describe('ARK Doubao LLM Provider', function() {
let provider;
before(function() {
if (!testConfig.ARK_API_KEY) {
console.log('⚠️ Skipping ARK Doubao tests - ARK_API_KEY not found');
this.skip();
}
try {
provider = LLMProviderFactory.createProvider('ark', testConfig, testConfig);
console.log('✅ ARK Doubao Provider created successfully');
} catch (error) {
console.error('❌ Failed to create ARK Doubao provider:', error);
this.skip();
}
});
it('should initialize with correct configuration', function() {
assert(provider, 'Provider should be created');
assert.strictEqual(provider.config.model, 'doubao-seed-1-6-flash-250615', 'Should use doubao model');
assert.strictEqual(provider.config.apiUrl, 'https://ark.cn-beijing.volces.com/api/v3/chat/completions', 'Should use ARK API URL');
assert(provider.apiKey, 'Should have API key');
console.log('📋 ARK Doubao Config:', {
model: provider.config.model,
apiUrl: provider.config.apiUrl
});
});
it('should generate chat completion with Doubao', async function() {
console.log('🤖 Testing ARK Doubao');
const startTime = Date.now();
const result = await provider.createChatCompletion(testMessages.simple, {
max_tokens: 100
});
const responseTime = Date.now() - startTime;
console.log('📊 ARK Doubao Test:', {
success: result.success,
responseTime: `${responseTime}ms`,
provider: result.provider
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
assert.strictEqual(result.provider, 'ark', 'Should identify as ark provider');
if (result.success) {
console.log('✅ ARK Doubao completion successful');
} else {
console.log('❌ ARK Doubao completion failed:', result.error);
}
});
it('should handle Chinese language tasks', async function() {
console.log('🇨🇳 Testing ARK Doubao with Chinese');
const chineseMessages = [
{ role: 'system', content: '你是一个有用的AI助手。' },
{ role: 'user', content: '请用一句话介绍北京。' }
];
const result = await provider.createChatCompletion(chineseMessages, {
max_tokens: 100
});
console.log('📊 ARK Doubao Chinese Test:', {
success: result.success,
provider: result.provider
});
if (result.success) {
console.log('✅ ARK Doubao Chinese handling successful');
} else {
console.log('❌ ARK Doubao Chinese handling failed:', result.error);
}
});
});
describe('LLM Provider Performance Comparison', function() {
it('should compare provider response times', async function() {
const availableProviders = [];
if (testConfig.OPENAI_API_KEY) availableProviders.push('openai');
if (testConfig.OPENROUTER_API_KEY) {
availableProviders.push('openrouter-gpt', 'openrouter-gemini');
}
if (testConfig.ARK_API_KEY) availableProviders.push('ark');
if (availableProviders.length < 2) {
console.log('⚠️ Skipping provider comparison - need at least 2 providers');
this.skip();
}
const results = {};
console.log('🏁 Comparing LLM provider performance...');
for (const providerName of availableProviders) {
try {
const provider = LLMProviderFactory.createProvider(providerName, testConfig, testConfig);
const startTime = Date.now();
const result = await provider.createChatCompletion(testMessages.simple, {
max_tokens: 50
});
const duration = Date.now() - startTime;
results[providerName] = {
success: result.success,
duration: duration,
error: result.error
};
} catch (error) {
results[providerName] = {
success: false,
duration: 0,
error: error.message
};
}
}
console.log('📊 LLM Provider Performance Comparison:');
Object.entries(results).forEach(([provider, result]) => {
console.log(` ${provider}:`, {
success: result.success ? '✅' : '❌',
time: `${result.duration}ms`,
error: result.error || 'none'
});
});
// Find fastest successful provider
const successfulProviders = Object.entries(results).filter(([_, result]) => result.success);
if (successfulProviders.length > 0) {
const fastest = successfulProviders.reduce((prev, curr) =>
prev[1].duration < curr[1].duration ? prev : curr
);
console.log(`🏆 Fastest LLM provider: ${fastest[0]} (${fastest[1].duration}ms)`);
}
// At least one provider should work
assert(successfulProviders.length > 0, 'At least one LLM provider should be successful');
});
});
});
module.exports = {
testConfig,
testMessages,
collectStreamingResponse
};
@@ -0,0 +1,424 @@
const assert = require('assert');
const axios = require('axios');
const fs = require('fs');
const path = require('path');
// Test configuration
const testConfig = {
SILICONFLOW_API_KEY: process.env.SILICONFLOW_API_KEY,
TTS_PROVIDERS: {
siliconflow: {
apiUrl: 'https://api.siliconflow.cn/v1/audio/speech',
model: 'FunAudioLLM/CosyVoice2-0.5B',
voice: 'FunAudioLLM/CosyVoice2-0.5B:diana',
apiKey: 'SILICONFLOW_API_KEY'
}
}
};
// Test texts for different scenarios
const testTexts = {
simple: 'Hello, this is a simple text-to-speech test.',
multilingual: 'Hello world. 你好世界. こんにちは世界.',
punctuation: 'Testing punctuation: question? exclamation! comma, period.',
numbers: 'The year is 2025, and the time is 12:34 PM.',
long: 'This is a longer text to test the text-to-speech synthesis capability. It contains multiple sentences and should demonstrate the natural flow of speech generation. The quality and naturalness of the audio output will be evaluated.'
};
/**
* TTS Provider for CosyVoice2 via Siliconflow
*/
class SiliconflowTTSProvider {
constructor(config, apiKey) {
this.config = config;
this.apiKey = apiKey;
}
async synthesize(text, options = {}) {
try {
const response = await axios({
method: 'post',
url: this.config.apiUrl,
data: {
model: this.config.model,
input: text,
voice: options.voice || this.config.voice,
response_format: options.format || 'mp3',
sample_rate: options.sampleRate || 32000,
stream: options.stream || false,
speed: options.speed || 1.0,
gain: options.gain || 0
},
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
responseType: 'arraybuffer',
timeout: 60000 // 60 second timeout
});
const result = {
success: true,
audioData: Buffer.from(response.data),
format: options.format || 'mp3',
sampleRate: options.sampleRate || 32000,
provider: 'siliconflow',
timestamp: Date.now()
};
return result;
} catch (error) {
console.error('Siliconflow TTS error:', error.response?.data || error.message);
return {
success: false,
audioData: null,
error: error.response?.data?.error?.message || error.message,
provider: 'siliconflow',
timestamp: Date.now()
};
}
}
}
/**
* TTS Provider Individual Tests
*/
describe('TTS Providers - Individual Testing', function() {
this.timeout(120000); // 2 minute timeout for API calls
const tempDir = path.join(__dirname, '../temp');
before(function() {
// Ensure temp directory exists
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
});
describe('CosyVoice2 (Siliconflow) TTS Provider', function() {
let provider;
before(function() {
if (!testConfig.SILICONFLOW_API_KEY) {
console.log('⚠️ Skipping Siliconflow TTS tests - SILICONFLOW_API_KEY not found');
this.skip();
}
try {
const providerConfig = testConfig.TTS_PROVIDERS.siliconflow;
provider = new SiliconflowTTSProvider(providerConfig, testConfig.SILICONFLOW_API_KEY);
console.log('✅ Siliconflow TTS Provider created successfully');
} catch (error) {
console.error('❌ Failed to create Siliconflow TTS provider:', error);
this.skip();
}
});
it('should initialize with correct configuration', function() {
assert(provider, 'Provider should be created');
assert.strictEqual(provider.config.model, 'FunAudioLLM/CosyVoice2-0.5B', 'Should use CosyVoice2 model');
assert.strictEqual(provider.config.voice, 'FunAudioLLM/CosyVoice2-0.5B:diana', 'Should use diana voice');
assert.strictEqual(provider.config.apiUrl, 'https://api.siliconflow.cn/v1/audio/speech', 'Should use correct API URL');
assert(provider.apiKey, 'Should have API key');
console.log('📋 Siliconflow TTS Config:', {
model: provider.config.model,
voice: provider.config.voice,
apiUrl: provider.config.apiUrl
});
});
it('should synthesize simple text to speech', async function() {
console.log('🎵 Testing Siliconflow TTS with simple text');
const startTime = Date.now();
const result = await provider.synthesize(testTexts.simple);
const synthesisTime = Date.now() - startTime;
console.log('📊 Siliconflow TTS Simple Test:', {
success: result.success,
synthesisTime: `${synthesisTime}ms`,
audioSize: result.audioData ? `${result.audioData.length} bytes` : 'none',
format: result.format,
sampleRate: result.sampleRate,
provider: result.provider
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
assert.strictEqual(result.provider, 'siliconflow', 'Should identify as siliconflow provider');
if (result.success) {
assert(result.audioData, 'Should have audio data');
assert(result.audioData.length > 0, 'Audio data should not be empty');
assert.strictEqual(result.format, 'mp3', 'Should return MP3 format');
// Save test audio file
const testAudioPath = path.join(tempDir, `tts_simple_${Date.now()}.mp3`);
fs.writeFileSync(testAudioPath, result.audioData);
console.log('💾 Saved test audio to:', testAudioPath);
console.log('✅ Siliconflow TTS simple synthesis successful');
} else {
console.log('❌ Siliconflow TTS simple synthesis failed:', result.error);
}
});
it('should handle multilingual text', async function() {
console.log('🌍 Testing Siliconflow TTS with multilingual text');
const result = await provider.synthesize(testTexts.multilingual);
console.log('📊 Siliconflow TTS Multilingual Test:', {
success: result.success,
audioSize: result.audioData ? `${result.audioData.length} bytes` : 'none',
provider: result.provider
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
if (result.success) {
assert(result.audioData, 'Should have audio data for multilingual text');
assert(result.audioData.length > 0, 'Multilingual audio data should not be empty');
// Save multilingual test audio
const testAudioPath = path.join(tempDir, `tts_multilingual_${Date.now()}.mp3`);
fs.writeFileSync(testAudioPath, result.audioData);
console.log('💾 Saved multilingual audio to:', testAudioPath);
console.log('✅ Siliconflow TTS multilingual synthesis successful');
} else {
console.log('❌ Siliconflow TTS multilingual synthesis failed:', result.error);
}
});
it('should handle different audio formats and settings', async function() {
console.log('⚙️ Testing Siliconflow TTS with different settings');
const settings = [
{ format: 'mp3', sampleRate: 24000, speed: 1.0 },
{ format: 'mp3', sampleRate: 32000, speed: 1.2 },
{ format: 'mp3', sampleRate: 16000, speed: 0.8 }
];
for (const setting of settings) {
const result = await provider.synthesize(testTexts.simple, setting);
console.log(`📊 TTS Settings Test (${setting.format}, ${setting.sampleRate}Hz, ${setting.speed}x):`, {
success: result.success,
audioSize: result.audioData ? `${result.audioData.length} bytes` : 'none',
error: result.error || 'none'
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
if (result.success) {
assert(result.audioData, 'Should have audio data');
assert(result.audioData.length > 0, 'Audio data should not be empty');
// Save test audio with settings info
const filename = `tts_settings_${setting.sampleRate}hz_${setting.speed}x_${Date.now()}.mp3`;
const testAudioPath = path.join(tempDir, filename);
fs.writeFileSync(testAudioPath, result.audioData);
console.log(`💾 Saved settings test audio to: ${testAudioPath}`);
}
}
console.log('✅ Siliconflow TTS settings variation tests completed');
});
it('should handle punctuation and numbers correctly', async function() {
console.log('🔢 Testing Siliconflow TTS with punctuation and numbers');
const punctuationResult = await provider.synthesize(testTexts.punctuation);
const numbersResult = await provider.synthesize(testTexts.numbers);
console.log('📊 Siliconflow TTS Punctuation Test:', {
success: punctuationResult.success,
audioSize: punctuationResult.audioData ? `${punctuationResult.audioData.length} bytes` : 'none'
});
console.log('📊 Siliconflow TTS Numbers Test:', {
success: numbersResult.success,
audioSize: numbersResult.audioData ? `${numbersResult.audioData.length} bytes` : 'none'
});
assert(punctuationResult, 'Should handle punctuation');
assert(numbersResult, 'Should handle numbers');
if (punctuationResult.success && numbersResult.success) {
console.log('✅ Siliconflow TTS punctuation and numbers handling successful');
}
});
it('should handle longer text synthesis', async function() {
console.log('📝 Testing Siliconflow TTS with longer text');
const startTime = Date.now();
const result = await provider.synthesize(testTexts.long);
const synthesisTime = Date.now() - startTime;
console.log('📊 Siliconflow TTS Long Text Test:', {
success: result.success,
synthesisTime: `${synthesisTime}ms`,
audioSize: result.audioData ? `${result.audioData.length} bytes` : 'none',
textLength: testTexts.long.length
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
if (result.success) {
assert(result.audioData, 'Should have audio data for long text');
assert(result.audioData.length > 0, 'Long text audio data should not be empty');
// Long text should produce more audio data
const expectedMinSize = 50000; // Roughly 50KB minimum for longer text
if (result.audioData.length > expectedMinSize) {
console.log('✅ Long text produced appropriate amount of audio data');
}
// Save long text audio
const testAudioPath = path.join(tempDir, `tts_long_${Date.now()}.mp3`);
fs.writeFileSync(testAudioPath, result.audioData);
console.log('💾 Saved long text audio to:', testAudioPath);
console.log('✅ Siliconflow TTS long text synthesis successful');
} else {
console.log('❌ Siliconflow TTS long text synthesis failed:', result.error);
}
});
it('should handle empty or invalid text gracefully', async function() {
console.log('🔍 Testing Siliconflow TTS with edge cases');
const emptyResult = await provider.synthesize('');
const spaceResult = await provider.synthesize(' ');
const specialResult = await provider.synthesize('!@#$%^&*()');
console.log('📊 Siliconflow TTS Edge Cases:', {
empty: { success: emptyResult.success, error: emptyResult.error || 'none' },
spaces: { success: spaceResult.success, error: spaceResult.error || 'none' },
special: { success: specialResult.success, error: specialResult.error || 'none' }
});
// These should either work or fail gracefully
assert(emptyResult, 'Should handle empty string');
assert(spaceResult, 'Should handle whitespace');
assert(specialResult, 'Should handle special characters');
console.log('✅ Siliconflow TTS edge cases handled');
});
it('should measure synthesis performance metrics', async function() {
console.log('🏁 Testing Siliconflow TTS performance metrics');
const testText = testTexts.simple;
const iterations = 3;
const results = [];
for (let i = 0; i < iterations; i++) {
const startTime = Date.now();
const result = await provider.synthesize(testText);
const duration = Date.now() - startTime;
if (result.success) {
results.push({
duration: duration,
audioSize: result.audioData.length,
success: true
});
} else {
results.push({
duration: duration,
audioSize: 0,
success: false,
error: result.error
});
}
}
const successfulResults = results.filter(r => r.success);
if (successfulResults.length > 0) {
const avgDuration = successfulResults.reduce((sum, r) => sum + r.duration, 0) / successfulResults.length;
const avgAudioSize = successfulResults.reduce((sum, r) => sum + r.audioSize, 0) / successfulResults.length;
const minDuration = Math.min(...successfulResults.map(r => r.duration));
const maxDuration = Math.max(...successfulResults.map(r => r.duration));
console.log('📊 Siliconflow TTS Performance Metrics:', {
successRate: `${successfulResults.length}/${iterations}`,
avgSynthesisTime: `${Math.round(avgDuration)}ms`,
minSynthesisTime: `${minDuration}ms`,
maxSynthesisTime: `${maxDuration}ms`,
avgAudioSize: `${Math.round(avgAudioSize)} bytes`,
textLength: testText.length
});
// Performance expectations
assert(avgDuration < 10000, 'Average synthesis time should be under 10 seconds');
assert(avgAudioSize > 1000, 'Should produce reasonable amount of audio data');
console.log('✅ Siliconflow TTS performance metrics acceptable');
} else {
console.log('❌ No successful TTS synthesis results for performance testing');
}
assert(successfulResults.length > 0, 'At least one synthesis attempt should succeed');
});
});
describe('TTS Provider Integration', function() {
it('should integrate with live audio system', function() {
if (!testConfig.SILICONFLOW_API_KEY) {
console.log('⚠️ Skipping TTS integration test - SILICONFLOW_API_KEY not found');
this.skip();
}
// Simulate the TTS configuration that would be used in the live system
const liveConfig = {
TTS_API_URL: 'https://api.siliconflow.cn/v1/audio/speech',
TTS_PROVIDERS: testConfig.TTS_PROVIDERS,
TTS_PROVIDER: 'siliconflow',
SILICONFLOW_API_KEY: testConfig.SILICONFLOW_API_KEY
};
assert(liveConfig.TTS_API_URL, 'Should have TTS API URL');
assert(liveConfig.TTS_PROVIDERS.siliconflow, 'Should have Siliconflow TTS provider config');
assert(liveConfig.SILICONFLOW_API_KEY, 'Should have Siliconflow API key');
console.log('📋 Live System TTS Integration Config:', {
provider: liveConfig.TTS_PROVIDER,
model: liveConfig.TTS_PROVIDERS.siliconflow.model,
voice: liveConfig.TTS_PROVIDERS.siliconflow.voice,
hasApiKey: !!liveConfig.SILICONFLOW_API_KEY
});
console.log('✅ TTS provider integration configuration valid');
});
});
after(function() {
// Cleanup temp audio files
try {
const files = fs.readdirSync(tempDir);
let cleanedCount = 0;
files.forEach(file => {
if (file.startsWith('tts_') && file.endsWith('.mp3')) {
fs.unlinkSync(path.join(tempDir, file));
cleanedCount++;
}
});
console.log(`🧹 Cleaned up ${cleanedCount} temporary TTS audio files`);
} catch (error) {
console.warn('⚠️ Failed to cleanup temp TTS files:', error.message);
}
});
});
module.exports = {
testConfig,
testTexts,
SiliconflowTTSProvider
};
@@ -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;
@@ -0,0 +1,56 @@
{
"schema_version": 1,
"experiment": "6-3",
"run_id": "exp6-3-20260729T150939685Z",
"generated_at_utc": "2026-07-29T15:09:39.685Z",
"credentials_persisted": false,
"source_media": {
"path": "microphone_input.wav",
"capture_method": "browser_microphone_over_websocket",
"original_repository_path": "backend/recordings/recording_2025-02-06T14-51-38-205Z.wav",
"sha256": "e69d77415ffa39e1a2e00b99e6dbedce675faa9b4c2b703a256585861c6d2085",
"size_bytes": 161836,
"duration_seconds": 5.056,
"sample_rate_hz": 16000,
"channels": 1,
"bits_per_sample": 16,
"provenance_note": "Existing real microphone capture saved by live-audio/backend/server.js; replayed through the production Silero class for reproducible validation."
},
"stages": {
"vad": {
"execution": "real",
"mock": false,
"probe_only": false,
"fallback_used": false,
"implementation": "Silero VAD ONNX",
"model": "models/silero_vad.onnx",
"model_sha256": "2623a2953f6ff3d2c1e61740c6cdb7168133479b267dfef114a4a3cc5bdd788f",
"threshold": 0.5,
"max_silence_ms": 500,
"endpoint_detected": true,
"forced_endpoint": false,
"observed_trailing_silence_ms": 526,
"speech_duration_ms": 692,
"latency_seconds": 5.234941667,
"segment_path": "vad_segment.wav",
"segment_sha256": "068bab529128c19aa1b9d042c947434cca71c62043c55bdf6eb235e973991bfc",
"segment_bytes": 21548
}
},
"experiment_complete": false,
"error": "Error: Real ASR failed: You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.\n at runAsr (/Users/boj/book/ai-agent-book/chapter6/live-audio/backend/run_real_validation.js:120:67)\n at process.processTicksAndRejections (node:internal/process/task_queues:104:5)\n at async main (/Users/boj/book/ai-agent-book/chapter6/live-audio/backend/run_real_validation.js:244:27)",
"acceptance": {
"gates": {
"schema_and_scope": true,
"real_websocket_microphone_media": true,
"real_silero_vad_endpoint": true,
"real_asr": false,
"real_streaming_llm": false,
"real_tts_media": false,
"measured_stage_latencies": false,
"no_mock_probe_or_fallback": false
},
"passed": false,
"statement": "Passing proves one saved real microphone turn completed Silero VAD -> real ASR -> real LLM -> real TTS. It does not benchmark concurrency or production load."
}
}
@@ -0,0 +1,164 @@
{
"schema_version": 1,
"experiment": "6-3",
"run_id": "exp6-3-20260729T153334474Z",
"generated_at_utc": "2026-07-29T15:33:34.475Z",
"credentials_persisted": false,
"provenance": {
"host": {
"platform": "darwin",
"release": "25.3.0",
"architecture": "arm64",
"cpu_model": "Apple M2 Max",
"logical_cpu_count": 12,
"total_memory_bytes": 103079215104
},
"runtime": {
"node": "v25.6.0",
"onnxruntime_node": "^1.22.0-rev",
"axios": "^1.7.5",
"ffprobe": "ffprobe version 8.0.1 Copyright (c) 2007-2025 the FFmpeg developers"
},
"timing_clock": "process.hrtime.bigint monotonic clock"
},
"source_media": {
"path": "microphone_input.wav",
"capture_method": "browser_microphone_over_websocket",
"original_repository_path": "backend/recordings/recording_2025-02-06T14-51-38-205Z.wav",
"sha256": "e69d77415ffa39e1a2e00b99e6dbedce675faa9b4c2b703a256585861c6d2085",
"size_bytes": 161836,
"original_sha256": "e69d77415ffa39e1a2e00b99e6dbedce675faa9b4c2b703a256585861c6d2085",
"duration_seconds": 5.056,
"sample_rate_hz": 16000,
"channels": 1,
"bits_per_sample": 16,
"provenance_note": "Existing real microphone capture saved by live-audio/backend/server.js; replayed through the production Silero class for reproducible validation."
},
"stages": {
"vad": {
"execution": "real",
"mock": false,
"probe_only": false,
"fallback_used": false,
"implementation": "Silero VAD ONNX",
"model": "models/silero_vad.onnx",
"model_sha256": "2623a2953f6ff3d2c1e61740c6cdb7168133479b267dfef114a4a3cc5bdd788f",
"threshold": 0.5,
"max_silence_ms": 500,
"endpoint_detected": true,
"detected_endpoint_count": 2,
"selected_endpoint": "longest detected speech segment",
"forced_endpoint": false,
"observed_trailing_silence_ms": 509,
"speech_duration_ms": 1534,
"latency_seconds": 5.393088166,
"segment_path": "vad_segment.wav",
"segment_sha256": "61d43291d32fdbce3a32a1809c7368e12a5bf98f799f28a31e08848b8e7dda06",
"segment_bytes": 46124
},
"asr": {
"execution": "real",
"mock": false,
"probe_only": false,
"fallback_used": false,
"provider": "local-openai-whisper",
"model": "whisper-tiny",
"inference_completed": true,
"api_request_completed": false,
"external_request": false,
"latency_seconds": 2.710318042,
"transcript": "是男朋友叫这样的",
"language": "zh",
"runtime": {
"python": "3.11.4",
"torch": "2.7.0",
"openai_whisper": "20231106"
},
"model_path": "/Users/boj/.cache/whisper/tiny.pt",
"model_sha256": "65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9",
"model_load_seconds": 0.34045145800337195,
"model_inference_seconds": 1.3047717502340674,
"provider_reported_cost_usd": 0,
"cost_note": "Local open-source Whisper inference; no external ASR charge."
},
"llm": {
"execution": "real",
"mock": false,
"probe_only": false,
"fallback_used": false,
"provider": "ark",
"model": "doubao-seed-1-6-flash-250615",
"streamed": true,
"api_request_completed": true,
"external_request": true,
"first_token_seconds": 0.749332709,
"latency_seconds": 0.791579917,
"response": "那是男朋友叫这样的呀",
"provider_request_id": "021785339222980840fd37cfe82fdd108ca4a9d2a652575aa05c0",
"finish_reason": "stop",
"usage": {
"completion_tokens": 30,
"prompt_tokens": 83,
"total_tokens": 113,
"prompt_tokens_details": {
"cached_tokens": 0
},
"completion_tokens_details": {
"reasoning_tokens": 23
}
},
"provider_reported_cost_usd": null,
"cost_note": "The streamed response did not expose a monetary charge; consult the provider billing ledger."
},
"tts": {
"execution": "real",
"mock": false,
"probe_only": false,
"fallback_used": false,
"provider": "fish",
"model": "s1",
"voice": "authorized zero-shot reference",
"api_request_completed": true,
"external_request": true,
"first_audio_byte_seconds": 2.8189757498912513,
"latency_seconds": 3.2793434159830213,
"output_path": "assistant_response.mp3",
"output_sha256": "2ac0b766ac8afaafb6c6bd09e66a134eb6cc46188aa62edc403ddde32fdf92c8",
"output_bytes": 30928,
"output_duration_seconds": 1.933,
"output_format": "mp3",
"reference_id_sha256": "bb2ec197d6276bfad81bbdbcec4473040861e6bc20caf53cf4d1f8184a7416be",
"reference_id_source": "../../controllable-tts/reference_audio/manifest.json",
"billed_input_characters": 10,
"provider_reported_cost_usd": null,
"cost_note": "The Fish Audio SDK response did not expose a monetary charge; consult the provider billing ledger."
}
},
"experiment_complete": true,
"latency": {
"post_endpoint_to_first_audio_byte_seconds": 6.278626500891251,
"complete_serial_pipeline_seconds": 12.17432954098302,
"measurement_clock": "process.hrtime.bigint monotonic clock"
},
"cost": {
"paid_external_requests": 2,
"provider_reported_total_usd": 0,
"complete": false,
"note": "A zero total is not a zero-cost claim when complete=false; some providers omit per-request charges."
},
"acceptance": {
"gates": {
"schema_and_scope": true,
"real_websocket_microphone_media": true,
"real_silero_vad_endpoint": true,
"real_asr": true,
"real_streaming_llm": true,
"real_tts_media": true,
"measured_stage_latencies": true,
"provenance_complete": true,
"no_mock_probe_or_fallback": true
},
"passed": true,
"statement": "Passing proves one saved real microphone turn completed Silero VAD -> real ASR -> real LLM -> real TTS. It does not benchmark concurrency or production load."
}
}
@@ -0,0 +1,26 @@
# Experiment 6-3 real traditional-voice validation
- Run ID: `exp6-3-20260729T153334474Z`
- Complete: **true**
- Source: `microphone_input.wav` (5.056 s, saved browser microphone/WebSocket capture)
- VAD: Silero ONNX, 500 ms silence, non-forced endpoint = true
- ASR: local-openai-whisper / whisper-tiny, 2.710 s
- Transcript: 是男朋友叫这样的
- LLM: ark / doubao-seed-1-6-flash-250615, TTFT 0.749 s, total 0.792 s
- Response: 那是男朋友叫这样的呀
- TTS: fish / s1, first byte 2.819 s, total 3.279 s
- Post-endpoint time to first audio byte: 6.279 s
## Strict gates
- schema_and_scope: **true**
- real_websocket_microphone_media: **true**
- real_silero_vad_endpoint: **true**
- real_asr: **true**
- real_streaming_llm: **true**
- real_tts_media: **true**
- measured_stage_latencies: **true**
- provenance_complete: **true**
- no_mock_probe_or_fallback: **true**
Passing proves one saved real microphone turn completed Silero VAD -> real ASR -> real LLM -> real TTS. It does not benchmark concurrency or production load.
@@ -0,0 +1,102 @@
{
"schema_version": 1,
"experiment": "6-3",
"run_id": "exp6-3-20260729T153226031Z",
"generated_at_utc": "2026-07-29T15:32:26.031Z",
"credentials_persisted": false,
"provenance": {
"host": {
"platform": "darwin",
"release": "25.3.0",
"architecture": "arm64",
"cpu_model": "Apple M2 Max",
"logical_cpu_count": 12,
"total_memory_bytes": 103079215104
},
"runtime": {
"node": "v25.6.0",
"onnxruntime_node": "^1.22.0-rev",
"axios": "^1.7.5",
"ffprobe": "ffprobe version 8.0.1 Copyright (c) 2007-2025 the FFmpeg developers"
},
"timing_clock": "process.hrtime.bigint monotonic clock"
},
"source_media": {
"path": "microphone_input.wav",
"capture_method": "browser_microphone_over_websocket",
"original_repository_path": "backend/recordings/recording_2025-02-06T14-51-38-205Z.wav",
"sha256": "e69d77415ffa39e1a2e00b99e6dbedce675faa9b4c2b703a256585861c6d2085",
"size_bytes": 161836,
"original_sha256": "e69d77415ffa39e1a2e00b99e6dbedce675faa9b4c2b703a256585861c6d2085",
"duration_seconds": 5.056,
"sample_rate_hz": 16000,
"channels": 1,
"bits_per_sample": 16,
"provenance_note": "Existing real microphone capture saved by live-audio/backend/server.js; replayed through the production Silero class for reproducible validation."
},
"stages": {
"vad": {
"execution": "real",
"mock": false,
"probe_only": false,
"fallback_used": false,
"implementation": "Silero VAD ONNX",
"model": "models/silero_vad.onnx",
"model_sha256": "2623a2953f6ff3d2c1e61740c6cdb7168133479b267dfef114a4a3cc5bdd788f",
"threshold": 0.5,
"max_silence_ms": 500,
"endpoint_detected": true,
"detected_endpoint_count": 2,
"selected_endpoint": "longest detected speech segment",
"forced_endpoint": false,
"observed_trailing_silence_ms": 511,
"speech_duration_ms": 1531,
"latency_seconds": 5.362510583,
"segment_path": "vad_segment.wav",
"segment_sha256": "61d43291d32fdbce3a32a1809c7368e12a5bf98f799f28a31e08848b8e7dda06",
"segment_bytes": 46124
},
"asr": {
"execution": "real",
"mock": false,
"probe_only": false,
"fallback_used": false,
"provider": "local-openai-whisper",
"model": "whisper-tiny",
"inference_completed": true,
"api_request_completed": false,
"external_request": false,
"latency_seconds": 3.981504083,
"transcript": "倒是男生就要战争了",
"language": "zh",
"runtime": {
"python": "3.11.4",
"torch": "2.7.0",
"openai_whisper": "20231106"
},
"model_path": "/Users/boj/.cache/whisper/tiny.pt",
"model_sha256": "65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9",
"model_load_seconds": 0.34700404200702906,
"model_inference_seconds": 2.5749531253241003,
"provider_reported_cost_usd": 0,
"cost_note": "Local open-source Whisper inference; no external ASR charge."
}
},
"experiment_complete": false,
"error": "Error: Real LLM failed: Request failed with status code 401\n at runLlm (/Users/boj/book/ai-agent-book/chapter6/live-audio/backend/run_real_validation.js:234:30)\n at process.processTicksAndRejections (node:internal/process/task_queues:104:5)\n at async main (/Users/boj/book/ai-agent-book/chapter6/live-audio/backend/run_real_validation.js:407:27)",
"acceptance": {
"gates": {
"schema_and_scope": true,
"real_websocket_microphone_media": true,
"real_silero_vad_endpoint": true,
"real_asr": true,
"real_streaming_llm": false,
"real_tts_media": false,
"measured_stage_latencies": false,
"provenance_complete": true,
"no_mock_probe_or_fallback": false
},
"passed": false,
"statement": "Passing proves one saved real microphone turn completed Silero VAD -> real ASR -> real LLM -> real TTS. It does not benchmark concurrency or production load."
}
}
@@ -0,0 +1,77 @@
{
"schema_version": 1,
"experiment": "6-3",
"run_id": "exp6-3-20260729T152935363Z",
"generated_at_utc": "2026-07-29T15:29:35.363Z",
"credentials_persisted": false,
"provenance": {
"host": {
"platform": "darwin",
"release": "25.3.0",
"architecture": "arm64",
"cpu_model": "Apple M2 Max",
"logical_cpu_count": 12,
"total_memory_bytes": 103079215104
},
"runtime": {
"node": "v25.6.0",
"onnxruntime_node": "^1.22.0-rev",
"axios": "^1.7.5",
"ffprobe": "ffprobe version 8.0.1 Copyright (c) 2007-2025 the FFmpeg developers"
},
"timing_clock": "process.hrtime.bigint monotonic clock"
},
"source_media": {
"path": "microphone_input.wav",
"capture_method": "browser_microphone_over_websocket",
"original_repository_path": "backend/recordings/recording_2025-02-06T14-51-38-205Z.wav",
"sha256": "e69d77415ffa39e1a2e00b99e6dbedce675faa9b4c2b703a256585861c6d2085",
"size_bytes": 161836,
"original_sha256": "e69d77415ffa39e1a2e00b99e6dbedce675faa9b4c2b703a256585861c6d2085",
"duration_seconds": 5.056,
"sample_rate_hz": 16000,
"channels": 1,
"bits_per_sample": 16,
"provenance_note": "Existing real microphone capture saved by live-audio/backend/server.js; replayed through the production Silero class for reproducible validation."
},
"stages": {
"vad": {
"execution": "real",
"mock": false,
"probe_only": false,
"fallback_used": false,
"implementation": "Silero VAD ONNX",
"model": "models/silero_vad.onnx",
"model_sha256": "2623a2953f6ff3d2c1e61740c6cdb7168133479b267dfef114a4a3cc5bdd788f",
"threshold": 0.5,
"max_silence_ms": 500,
"endpoint_detected": true,
"detected_endpoint_count": 2,
"selected_endpoint": "longest detected speech segment",
"forced_endpoint": false,
"observed_trailing_silence_ms": 509,
"speech_duration_ms": 1532,
"latency_seconds": 5.396249041,
"segment_path": "vad_segment.wav",
"segment_sha256": "61d43291d32fdbce3a32a1809c7368e12a5bf98f799f28a31e08848b8e7dda06",
"segment_bytes": 46124
}
},
"experiment_complete": false,
"error": "Error: Real ASR failed: Request failed with status code 401\n at runAsr (/Users/boj/book/ai-agent-book/chapter6/live-audio/backend/run_real_validation.js:153:67)\n at process.processTicksAndRejections (node:internal/process/task_queues:104:5)\n at async main (/Users/boj/book/ai-agent-book/chapter6/live-audio/backend/run_real_validation.js:295:27)",
"acceptance": {
"gates": {
"schema_and_scope": true,
"real_websocket_microphone_media": true,
"real_silero_vad_endpoint": true,
"real_asr": false,
"real_streaming_llm": false,
"real_tts_media": false,
"measured_stage_latencies": false,
"provenance_complete": true,
"no_mock_probe_or_fallback": false
},
"passed": false,
"statement": "Passing proves one saved real microphone turn completed Silero VAD -> real ASR -> real LLM -> real TTS. It does not benchmark concurrency or production load."
}
}