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
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:
@@ -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);
|
||||
@@ -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 };
|
||||
+2231
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
+164
@@ -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."
|
||||
}
|
||||
}
|
||||
+26
@@ -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.
|
||||
+102
@@ -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."
|
||||
}
|
||||
}
|
||||
+77
@@ -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."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user