ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
+682
View File
@@ -0,0 +1,682 @@
## English
# Live Voice Chat Demo
A real-time voice chat demo featuring speech-to-text, AI conversation, and text-to-speech capabilities. The application supports multiple AI service providers and provides a seamless conversational experience with minimal latency.
> This is the companion code for **实验 6-3「构建传统语音 Agent」** in 《深入理解 AI Agent》第 6 章. It implements the **cascaded** voice pipeline (VAD → ASR → LLM → TTS) discussed there: the frontend captures the microphone and streams audio over a WebSocket; the backend runs Silero VAD to detect end-of-speech (~500 ms of silence), then routes the utterance through pluggable ASR, LLM, and TTS providers and streams synthesized audio back for playback.
## Code map
- **Run first:** `node backend/check-setup.js`, then the browser demo with a single utterance.
- **Start here:** backend/server.js owns the WebSocket session and media loop.
- **Core behavior:** backend/utils/vad.js → speechToText.js → provider LLM → TTS; frontend audioWorklet.js supplies chunks.
- **State / protocol:** WebSocket message/audio events and the per-utterance provider result.
- **Verifier:** backend tests plus the validation evidence; record actual media/model hashes and latency.
- **Experiment variable:** VAD endpointing, provider combination and streaming versus buffered response.
- **Skip on first pass:** Next.js styling and provider-specific credential plumbing.
## Features
- 🎤 Real-time voice input with Voice Activity Detection (VAD)
- 🤖 AI-powered conversations with **multiple provider support**
- 🔊 Text-to-speech synthesis
- ⚡ Low-latency audio streaming
- 📊 Real-time latency monitoring and logging
- 🎯 WebSocket-based communication
- 🔧 **Flexible provider selection** for ASR, LLM, and TTS services
## Supported AI Providers
### ASR (Automatic Speech Recognition)
- **OpenAI Whisper**: High accuracy, excellent language support
- **SenseVoice** (via Siliconflow): Low latency, cost-effective, auto language detection
### LLM (Large Language Model)
- **OpenAI GPT-4o**: Excellent reasoning, balanced performance
- **OpenRouter GPT-4o**: No geographic restrictions, unified interface
- **OpenRouter Gemini**: Fast response, optimized for real-time chat
- **ARK Doubao**: Low latency in China, optimized for Chinese language
### TTS (Text-to-Speech)
- **CosyVoice2** (via Siliconflow): Natural voice synthesis, multiple system voices
## Architecture Overview
The system consists of a frontend-backend architecture with real-time audio processing and **pluggable provider architecture**:
### Frontend (Next.js)
- **Audio Capture**: Uses Web Audio API to capture microphone input
- **Audio Processing**: Client-side audio processing and streaming to backend
- **WebSocket Communication**: Sends audio stream to backend and receives responses
- **Audio Playback**: Plays back TTS audio responses from the backend
### Backend (Node.js)
- **WebSocket Server**: Handles real-time audio streaming and client connections
- **Voice Activity Detection**: Server-side Silero VAD processing to detect speech boundaries with high accuracy
- **Multi-Provider Support**: Flexible ASR, LLM, and TTS provider integration
- **Provider Factories**: Dynamic provider creation and switching capabilities
### Data Flow
```
User Speech → WebSocket → Backend VAD → Multi-Provider STT → Multi-Provider LLM → TTS → Audio Response
```
### Ports
| Component | Port | Notes |
|-----------|------|-------|
| Backend (WebSocket server) | **8848** | Set by `LISTEN_PORT` in `backend/config.js`. The frontend connects to `ws://localhost:8848`. |
| Frontend (Next.js dev server) | **3000** | Open http://localhost:3000 in the browser. |
The frontend learns the backend port from the `WEBSOCKET_PORT` environment variable (see `frontend/.env.example`). It must match the backend's `LISTEN_PORT`.
## Prerequisites
- Node.js (v16 or higher)
- npm or yarn
- **FFmpeg** - Required for audio processing and format conversion
- **Google Chrome** (recommended) - Best performance and compatibility for real-time audio
- Not recommended: Safari, Edge, or other browsers due to WebAudio API limitations
- **API keys** from the supported providers (see Configuration section)
### Installing FFmpeg
#### macOS (using Homebrew)
```bash
brew install ffmpeg
```
#### Ubuntu/Debian
```bash
sudo apt update
sudo apt install ffmpeg
```
#### Windows
- Download from https://ffmpeg.org/download.html
- Or use Chocolatey: `choco install ffmpeg`
- Make sure `ffmpeg` is in your PATH
## Project Structure
```
/backend
- server.js: Main WebSocket server with provider integration
- config.js: Multi-provider configuration settings
- utils/
- providers/
- asrProviders.js: ASR provider implementations (OpenAI, Siliconflow)
- llmProviders.js: LLM provider implementations (OpenAI, OpenRouter, ARK)
- vad.js: Voice Activity Detection implementation
- speechToText.js: Provider-aware STT service
- textProcessor.js: Text preprocessing utilities
- tests/
- provider-tests.js: Comprehensive provider testing
- run-tests.js: Test runner with environment validation
- utils/providers/: Provider configuration (ASR / LLM / TTS)
- package.json: Backend dependencies and scripts
```
```
/frontend
- pages/: Next.js pages
- index.tsx: Main application interface
- components/: Reusable UI components
- public/: Static assets
- audioWorklet.js: Audio processing and VAD implementation
- next.config.js: Next.js configuration
- tailwind.config.js: Tailwind CSS settings
- package.json: Frontend dependencies and scripts
```
## Installation
1. Clone the repository
2. Install backend dependencies:
```bash
cd backend && npm install
```
3. Install frontend dependencies:
```bash
cd frontend && npm install
```
4. Download the Silero VAD model (already included in this repo at `backend/models/silero_vad.onnx`; only needed if missing):
```bash
cd backend/models
wget https://huggingface.co/deepghs/silero-vad-onnx/resolve/main/silero_vad.onnx
```
5. Configure the frontend's WebSocket port (defaults to 8848 if omitted):
```bash
cd frontend && cp .env.example .env # sets WEBSOCKET_PORT=8848 to match the backend
```
After installing, verify your environment (Node version, FFmpeg, VAD model, provider keys) without needing a microphone or browser:
```bash
cd backend && npm run check # or: node check-setup.js
```
This prints which prerequisites are satisfied and which selected providers have their API keys set. It exits non-zero only if a hard prerequisite (Node < 16, missing FFmpeg, or missing VAD model) is absent.
## Configuration
### Provider-Based Configuration
The system now supports **multiple AI service providers** for maximum flexibility. You can mix and match different providers for ASR, LLM, and TTS services.
### 1. Environment Variables Setup
Set up your API keys as environment variables:
```bash
# Required for OpenAI services
export OPENAI_API_KEY="your-openai-api-key"
# Required for OpenRouter services
export OPENROUTER_API_KEY="your-openrouter-api-key"
# Required for ARK (Doubao) services
export ARK_API_KEY="your-ark-api-key"
# Required for Siliconflow services (ASR and TTS)
export SILICONFLOW_API_KEY="your-siliconflow-api-key"
# For future use
export ANTHROPIC_API_KEY="your-anthropic-api-key"
```
### 2. Provider Selection
1. This repo already ships a ready-to-edit `backend/config.js`. If it is missing (e.g. a fresh checkout that ignores it), copy the example first:
```bash
cp backend/config.js.example backend/config.js
```
2. Edit `backend/config.js` to select your preferred providers:
```javascript
const config = {
// Provider Selection - Choose your preferred providers
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)
// API Keys (loaded from environment variables)
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,
// ... other configuration options
};
```
### 3. Recommended Provider Combinations
#### Default / Recommended (works anywhere with an OpenRouter key)
```javascript
ASR_PROVIDER: 'siliconflow', // SenseVoice
LLM_PROVIDER: 'openrouter', // openai/gpt-5.6-luna via OpenRouter (avoids gpt-5.6* org verification)
TTS_PROVIDER: 'siliconflow', // CosyVoice2
```
#### For Real-time Performance (Low Latency in China)
```javascript
ASR_PROVIDER: 'siliconflow', // SenseVoice
LLM_PROVIDER: 'ark', // Doubao (fast in China); or 'openrouter' for gpt-5.6-luna
TTS_PROVIDER: 'siliconflow', // CosyVoice2
```
#### For Best Accuracy
```javascript
ASR_PROVIDER: 'openai', // Accurate Whisper
LLM_PROVIDER: 'openrouter', // openai/gpt-5.6-luna via OpenRouter
TTS_PROVIDER: 'siliconflow' // CosyVoice2
```
### 4. API Key Requirements
You only need the API keys for the providers you plan to use:
| Provider | ASR | LLM | TTS | Required API Key |
|----------|-----|-----|-----|------------------|
| OpenAI | ✅ Whisper | ✅ gpt-5.6-luna | ❌ | `OPENAI_API_KEY` |
| OpenRouter | ❌ | ✅ gpt-5.6-luna, Gemini | ❌ | `OPENROUTER_API_KEY` |
| ARK (Doubao) | ❌ | ✅ Doubao | ❌ | `ARK_API_KEY` |
| Siliconflow | ✅ SenseVoice | ❌ | ✅ CosyVoice2 | `SILICONFLOW_API_KEY` |
### 5. Configuration Validation
The system includes comprehensive validation and testing tools:
```bash
# Test all configured providers
npm run test:providers
# Run the full test suite with environment validation
node run-tests.js
```
### Legacy Configuration Support
The system maintains backward compatibility with the previous hardcoded configuration format, but using the new provider selection is strongly recommended for better flexibility.
## Usage
1. **Set up your API keys** (see Configuration section)
2. **Configure your preferred providers** in `backend/config.js`
3. (Optional) **Verify your setup**: `cd backend && npm run check`
4. Start the backend server (WebSocket server on port **8848**):
```bash
cd backend && npm start
```
You should see `Server is running on 0.0.0.0:8848`.
5. Start the frontend development server (on port **3000**):
```bash
cd frontend && npm run dev
```
6. Open http://localhost:3000 in your browser (Chrome recommended)
7. Click "Start Recording" and grant microphone permission to begin a conversation
**Expected behavior**: after you finish speaking, the backend detects ~500 ms of silence (VAD), transcribes your speech (ASR), streams an LLM reply, and synthesizes it back as audio (TTS) that plays automatically. The on-screen log panel shows per-stage latency (WebSocket RTT, transcription, LLM, TTS). If you start speaking again while the assistant is talking, playback is interrupted.
## Testing
### Provider Testing
Test individual providers and all combinations:
```bash
cd backend
# Test all providers with your API keys
node run-tests.js
# Test specific providers only
npm run test:providers
# Install test dependencies if needed
npm install
```
The test suite will automatically skip providers for which you don't have API keys configured.
### Test Coverage
- ✅ ASR provider functionality (OpenAI Whisper, SenseVoice)
- ✅ LLM provider functionality (OpenAI, OpenRouter GPT-4o, OpenRouter Gemini, ARK Doubao)
- ✅ TTS provider functionality (CosyVoice2 via Siliconflow)
- ✅ All provider combinations (8 ASR+LLM combinations)
- ✅ Dynamic provider switching
- ✅ Error handling and fallback mechanisms
## Troubleshooting
### Common Issues
1. **Missing API Keys**: Ensure required environment variables are set
2. **FFmpeg Not Found**: Ensure FFmpeg is installed and available in your system PATH
- Test with: `ffmpeg -version`
- If not found, refer to the FFmpeg installation instructions above
3. **Network Issues**: Check connectivity to API endpoints
4. **Rate Limiting**: Consider switching providers or implementing retry logic
5. **Geographic Restrictions**: Use OpenRouter for global access
6. **ONNX Runtime Issues**: The backend uses ONNX Runtime for voice activity detection
- Usually resolved by the `onnxruntime-node` package automatically
- On some systems, you may need additional system libraries
### Performance Optimization
- **Low Latency**: Use Siliconflow ASR + OpenRouter Gemini
- **High Accuracy**: Use OpenAI ASR + OpenAI LLM
- **China Deployment**: Use Siliconflow ASR + ARK LLM
For provider configuration, see [`backend/config.js.example`](backend/config.js.example) and the provider implementations under [`backend/utils/providers/`](backend/utils/providers).
## License
MIT
---
## 中文
# 实时语音聊天演示
一个具备语音转文本、AI 对话和文本转语音能力的实时语音聊天演示。该应用支持多家 AI 服务提供商,以极低延迟提供流畅的对话体验。
> 这是《深入理解 AI Agent》第 6 章 **实验 6-3「构建传统语音 Agent」**的配套代码。它实现了书中讨论的**级联式**语音流水线(VAD → ASR → LLM → TTS):前端采集麦克风音频并通过 WebSocket 以流的形式传输;后端运行 Silero VAD,通过约 500 ms 的静音检测语音结束,随后将话语依次交给可插拔的 ASR、LLM 和 TTS 提供商,并将合成音频流式返回播放。
## 功能特性
- 🎤 采用语音活动检测(VAD)的实时语音输入
- 🤖 支持**多家提供商**的 AI 对话
- 🔊 文本转语音合成
- ⚡ 低延迟音频流
- 📊 实时延迟监控与日志记录
- 🎯 基于 WebSocket 的通信
- 🔧 可灵活选择 ASR、LLM 和 TTS 服务提供商
## 支持的 AI 提供商
### ASR(自动语音识别)
- **OpenAI Whisper**:准确率高,语言支持出色
- **SenseVoice**(通过 Siliconflow):低延迟、经济实惠、自动检测语言
### LLM(大语言模型)
- **OpenAI GPT-4o**:推理能力出色、性能均衡
- **OpenRouter GPT-4o**:无地域限制、统一接口
- **OpenRouter Gemini**:响应迅速,针对实时聊天优化
- **ARK Doubao**:在中国低延迟,针对中文优化
### TTS(文本转语音)
- **CosyVoice2**(通过 Siliconflow):自然语音合成,提供多种系统音色
## 架构概览
系统采用前后端架构,具备实时音频处理和**可插拔的提供商架构**:
### 前端(Next.js
- **音频采集**:使用 Web Audio API 采集麦克风输入
- **音频处理**:在客户端处理音频并将其流式传输至后端
- **WebSocket 通信**:向后端发送音频流并接收响应
- **音频播放**:播放后端返回的 TTS 音频响应
### 后端(Node.js
- **WebSocket 服务器**:处理实时音频流和客户端连接
- **语音活动检测**:在服务端运行 Silero VAD,以高准确率检测语音边界
- **多提供商支持**:灵活集成 ASR、LLM 和 TTS 提供商
- **提供商工厂**:支持动态创建和切换提供商
### 数据流
```text
用户语音 → WebSocket → 后端 VAD → 多提供商 STT → 多提供商 LLM → TTS → 音频响应
```
### 端口
| 组件 | 端口 | 说明 |
|-----------|------|-------|
| 后端(WebSocket 服务器) | **8848** | 由 `backend/config.js` 中的 `LISTEN_PORT` 设置。前端连接到 `ws://localhost:8848`。 |
| 前端(Next.js 开发服务器) | **3000** | 在浏览器中打开 http://localhost:3000。 |
前端从 `WEBSOCKET_PORT` 环境变量获取后端端口(参见 `frontend/.env.example`)。它必须与后端的 `LISTEN_PORT` 一致。
## 前置条件
- Node.jsv16 或更高版本)
- npm 或 yarn
- **FFmpeg**——音频处理和格式转换所必需
- **Google Chrome**(推荐)——实时音频的性能和兼容性最佳
- 不推荐:Safari、Edge 或其他浏览器,因为 WebAudio API 存在限制
- 支持的提供商所需的 **API key**(参见“配置”一节)
### 安装 FFmpeg
#### macOS(使用 Homebrew
```bash
brew install ffmpeg
```
#### Ubuntu/Debian
```bash
sudo apt update
sudo apt install ffmpeg
```
#### Windows
- 从 https://ffmpeg.org/download.html 下载
- 或使用 Chocolatey`choco install ffmpeg`
- 确保 `ffmpeg` 位于 PATH 中
## 项目结构
```text
/backend
- server.js: 集成提供商的主 WebSocket 服务器
- config.js: 多提供商配置设置
- utils/
- providers/
- asrProviders.js: ASR 提供商实现(OpenAI、Siliconflow
- llmProviders.js: LLM 提供商实现(OpenAI、OpenRouter、ARK
- vad.js: 语音活动检测实现
- speechToText.js: 感知提供商的 STT 服务
- textProcessor.js: 文本预处理工具
- tests/
- provider-tests.js: 完整的提供商测试
- run-tests.js: 带环境校验的测试运行器
- utils/providers/: 提供商配置(ASR / LLM / TTS
- package.json: 后端依赖和脚本
```
```text
/frontend
- pages/: Next.js 页面
- index.tsx: 主应用界面
- components/: 可复用 UI 组件
- public/: 静态资源
- audioWorklet.js: 音频处理与 VAD 实现
- next.config.js: Next.js 配置
- tailwind.config.js: Tailwind CSS 设置
- package.json: 前端依赖和脚本
```
## 安装
1. 克隆仓库
2. 安装后端依赖:
```bash
cd backend && npm install
```
3. 安装前端依赖:
```bash
cd frontend && npm install
```
4. 下载 Silero VAD 模型(本仓库已在 `backend/models/silero_vad.onnx` 包含该文件;仅在文件缺失时需要):
```bash
cd backend/models
wget https://huggingface.co/deepghs/silero-vad-onnx/resolve/main/silero_vad.onnx
```
5. 配置前端的 WebSocket 端口(省略时默认为 8848):
```bash
cd frontend && cp .env.example .env # 将 WEBSOCKET_PORT=8848 设为与后端一致
```
安装完成后,无需麦克风或浏览器即可检查环境(Node 版本、FFmpeg、VAD 模型和提供商 key):
```bash
cd backend && npm run check # 或:node check-setup.js
```
该命令会打印哪些前置条件已经满足,以及所选提供商是否已设置 API key。只有在缺少硬性前置条件(Node < 16、缺少 FFmpeg 或缺少 VAD 模型)时才会以非零状态退出。
## 配置
### 基于提供商的配置
系统现在支持**多家 AI 服务提供商**,以获得最大的灵活性。ASR、LLM 和 TTS 服务可以自由混合搭配不同提供商。
### 1. 设置环境变量
将 API key 设置为环境变量:
```bash
# OpenAI 服务所必需
export OPENAI_API_KEY="your-openai-api-key"
# OpenRouter 服务所必需
export OPENROUTER_API_KEY="your-openrouter-api-key"
# ARKDoubao)服务所必需
export ARK_API_KEY="your-ark-api-key"
# Siliconflow 服务(ASR 和 TTS)所必需
export SILICONFLOW_API_KEY="your-siliconflow-api-key"
# 留作将来使用
export ANTHROPIC_API_KEY="your-anthropic-api-key"
```
### 2. 选择提供商
1. 本仓库已经提供可直接编辑的 `backend/config.js`。如果该文件缺失(例如全新检出时被忽略),请先复制示例:
```bash
cp backend/config.js.example backend/config.js
```
2. 编辑 `backend/config.js`,选择偏好的提供商:
```javascript
const config = {
// 提供商选择——选择偏好的提供商
ASR_PROVIDER: 'siliconflow', // 'openai'whisper-1)或 'siliconflow'SenseVoice
LLM_PROVIDER: 'openrouter', // 'openrouter'gpt-5.6-luna,默认)、'openai'、'openrouter-gemini'、'ark'
TTS_PROVIDER: 'siliconflow', // 'siliconflow'CosyVoice2
// API Key(从环境变量加载)
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,
// ……其他配置选项
};
```
### 3. 推荐的提供商组合
#### 默认 / 推荐(只要有 OpenRouter key 即可在任何地方使用)
```javascript
ASR_PROVIDER: 'siliconflow', // SenseVoice
LLM_PROVIDER: 'openrouter', // 通过 OpenRouter 使用 openai/gpt-5.6-luna(避免 gpt-5.6* 组织验证)
TTS_PROVIDER: 'siliconflow', // CosyVoice2
```
#### 实时性能优先(在中国低延迟)
```javascript
ASR_PROVIDER: 'siliconflow', // SenseVoice
LLM_PROVIDER: 'ark', // Doubao(在中国速度快);也可用 'openrouter' 运行 gpt-5.6-luna
TTS_PROVIDER: 'siliconflow', // CosyVoice2
```
#### 准确率优先
```javascript
ASR_PROVIDER: 'openai', // 高准确率的 Whisper
LLM_PROVIDER: 'openrouter', // 通过 OpenRouter 使用 openai/gpt-5.6-luna
TTS_PROVIDER: 'siliconflow' // CosyVoice2
```
### 4. API Key 要求
只需配置计划使用的提供商所需的 API key:
| 提供商 | ASR | LLM | TTS | 所需 API Key |
|----------|-----|-----|-----|------------------|
| OpenAI | ✅ Whisper | ✅ gpt-5.6-luna | ❌ | `OPENAI_API_KEY` |
| OpenRouter | ❌ | ✅ gpt-5.6-luna、Gemini | ❌ | `OPENROUTER_API_KEY` |
| ARKDoubao | ❌ | ✅ Doubao | ❌ | `ARK_API_KEY` |
| Siliconflow | ✅ SenseVoice | ❌ | ✅ CosyVoice2 | `SILICONFLOW_API_KEY` |
### 5. 配置校验
系统包含完整的校验与测试工具:
```bash
# 测试所有已配置的提供商
npm run test:providers
# 运行带环境校验的完整测试套件
node run-tests.js
```
### 旧版配置支持
系统继续向后兼容先前的硬编码配置格式,但强烈建议使用新的提供商选择机制,以获得更好的灵活性。
## 使用方法
1. **设置 API key**(参见“配置”一节)
2. 在 `backend/config.js` 中**配置偏好的提供商**
3. (可选)**验证配置**`cd backend && npm run check`
4. 启动后端服务器(WebSocket 服务器使用端口 **8848**):
```bash
cd backend && npm start
```
此时应看到 `Server is running on 0.0.0.0:8848`。
5. 启动前端开发服务器(使用端口 **3000**):
```bash
cd frontend && npm run dev
```
此时应看到 `Server is running on 0.0.0.0:3000`。
6. 在浏览器中打开 http://localhost:3000(推荐 Chrome
7. 点击“Start Recording”并授予麦克风权限,开始对话
**预期行为**:说话结束后,后端检测约 500 ms 的静音(VAD),转录语音(ASR),以流的形式生成 LLM 回复,再将其合成为自动播放的音频(TTS)。屏幕日志面板会显示各阶段延迟(WebSocket RTT、转录、LLM、TTS)。如果在助手说话时再次开口,播放会被打断。
## 测试
### 提供商测试
测试各个提供商和所有组合:
```bash
cd backend
# 使用 API key 测试所有提供商
node run-tests.js
# 仅测试指定提供商
npm run test:providers
# 如有需要,安装测试依赖
npm install
```
测试套件会自动跳过未配置 API key 的提供商。
### 测试覆盖范围
- ✅ ASR 提供商功能(OpenAI Whisper、SenseVoice
- ✅ LLM 提供商功能(OpenAI、OpenRouter GPT-4o、OpenRouter Gemini、ARK Doubao
- ✅ TTS 提供商功能(通过 Siliconflow 使用 CosyVoice2
- ✅ 所有提供商组合(8 种 ASR+LLM 组合)
- ✅ 动态切换提供商
- ✅ 错误处理和回退机制
## 故障排查
### 常见问题
1. **缺少 API Key**:确保已经设置所需的环境变量
2. **找不到 FFmpeg**:确保 FFmpeg 已安装且位于系统 PATH 中
- 使用 `ffmpeg -version` 测试
- 如果找不到,请参阅上面的 FFmpeg 安装说明
3. **网络问题**:检查与 API 端点的连通性
4. **速率限制**:考虑切换提供商或实现重试逻辑
5. **地域限制**:使用 OpenRouter 获得全球访问能力
6. **ONNX Runtime 问题**:后端使用 ONNX Runtime 进行语音活动检测
- 通常会由 `onnxruntime-node` 包自动解决
- 在某些系统上,可能需要额外的系统库
### 性能优化
- **低延迟**:使用 Siliconflow ASR + OpenRouter Gemini
- **高准确率**:使用 OpenAI ASR + OpenAI LLM
- **中国部署**:使用 Siliconflow ASR + ARK LLM
提供商配置请参见 [`backend/config.js.example`](backend/config.js.example),实现代码位于 [`backend/utils/providers/`](backend/utils/providers)。
## 许可证
MIT
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env node
/**
* check-setup.js — Headless environment verifier for the Live Voice Chat demo (实验 6-3).
*
* Runs WITHOUT any API keys, microphone, or browser. It only checks that the
* local prerequisites for the cascaded VAD -> ASR -> LLM -> TTS pipeline are in
* place, and reports which providers you have credentials for, so a reader can
* confirm their setup before opening the browser UI.
*
* Usage:
* node check-setup.js
*
* Exit code 0 means the backend can start; non-zero means a hard prerequisite
* (Node version, VAD model, or a loadable config) is missing.
*/
const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');
let hardFailures = 0;
let warnings = 0;
const ok = (msg) => console.log(`${msg}`);
const warn = (msg) => { console.log(` ⚠️ ${msg}`); warnings++; };
const fail = (msg) => { console.log(`${msg}`); hardFailures++; };
console.log('🔍 Live Voice Chat — setup check (实验 6-3)');
console.log('='.repeat(60));
// 1. Node version (README requires v16+)
console.log('\nNode.js runtime:');
const major = parseInt(process.versions.node.split('.')[0], 10);
if (major >= 16) {
ok(`Node ${process.version} (>= v16 required)`);
} else {
fail(`Node ${process.version} is too old; v16 or higher is required`);
}
// 2. FFmpeg (used by server.js to convert incoming audio for ASR)
console.log('\nFFmpeg (audio format conversion):');
try {
const out = execFileSync('ffmpeg', ['-version'], { encoding: 'utf8' });
ok(`ffmpeg found: ${out.split('\n')[0]}`);
} catch (e) {
fail('ffmpeg not found on PATH. Install it (e.g. `brew install ffmpeg`) — see README.');
}
// 3. Silero VAD model file
console.log('\nSilero VAD model:');
const modelPath = path.join(__dirname, 'models', 'silero_vad.onnx');
if (fs.existsSync(modelPath)) {
const kb = (fs.statSync(modelPath).size / 1024).toFixed(0);
ok(`silero_vad.onnx present (${kb} KB)`);
} else {
fail(`Missing ${modelPath}. Download it — see README "Download the Silero VAD model".`);
}
// 4. Config loads, and report selected providers + key availability
console.log('\nConfiguration & providers:');
let config;
try {
config = require('./config');
ok('config.js loaded');
} catch (e) {
fail(`config.js failed to load: ${e.message}`);
}
if (config) {
// Map each selected provider to the env var its credentials come from.
const keyFor = {
asr: (config.ASR_PROVIDERS[config.ASR_PROVIDER] || {}).apiKey,
llm: (config.LLM_PROVIDERS[config.LLM_PROVIDER] || {}).apiKey,
tts: (config.TTS_PROVIDERS[config.TTS_PROVIDER] || {}).apiKey,
};
const stageLine = (stage, provider, envVar) => {
if (!provider || !envVar) {
fail(`${stage.toUpperCase()}: provider "${provider}" is not defined in config.js`);
return;
}
const val = process.env[envVar];
const placeholder = !val || /your-.*-api-key-here/.test(val);
if (placeholder) {
warn(`${stage.toUpperCase()}: provider "${provider}" selected, but ${envVar} is not set`);
} else {
ok(`${stage.toUpperCase()}: provider "${provider}" ready (${envVar} set)`);
}
};
stageLine('asr', config.ASR_PROVIDER, keyFor.asr);
stageLine('llm', config.LLM_PROVIDER, keyFor.llm);
stageLine('tts', config.TTS_PROVIDER, keyFor.tts);
console.log('\nServer will listen on:');
ok(`ws://${config.LISTEN_HOST}:${config.LISTEN_PORT} (WebSocket) — frontend must use WEBSOCKET_PORT=${config.LISTEN_PORT}`);
}
// Summary
console.log('\n' + '='.repeat(60));
if (hardFailures === 0 && warnings === 0) {
console.log('✅ Setup looks good. Start the backend with: npm start');
} else if (hardFailures === 0) {
console.log(`⚠️ Prerequisites OK, but ${warnings} provider key(s) missing.`);
console.log(' The backend will start; set the missing API key(s) before recording.');
} else {
console.log(`${hardFailures} hard prerequisite(s) missing — fix these before running the backend.`);
}
process.exit(hardFailures === 0 ? 0 : 1);
+99
View File
@@ -0,0 +1,99 @@
const config = {
// API Keys
OPENAI_API_KEY: process.env.OPENAI_API_KEY || 'your-openai-api-key-here',
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY || 'your-openrouter-api-key-here',
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || 'your-anthropic-api-key-here',
ARK_API_KEY: process.env.ARK_API_KEY || 'your-ark-api-key-here',
SILICONFLOW_API_KEY: process.env.SILICONFLOW_API_KEY || 'your-siliconflow-api-key-here',
FISH_API_KEY: process.env.FISH_API_KEY || 'your-fish-api-key-here',
// Provider Selection
ASR_PROVIDER: 'siliconflow', // 'openai' (whisper-1) or 'siliconflow' (SenseVoice)
LLM_PROVIDER: 'openrouter', // 'openrouter' (gpt-5.6-luna, default), 'openai', 'openrouter-gemini', 'ark'
TTS_PROVIDER: 'siliconflow', // 'siliconflow' (CosyVoice2, keep current)
// ASR Configuration
ASR_PROVIDERS: {
openai: {
apiUrl: 'https://api.openai.com/v1/audio/transcriptions',
model: 'whisper-1',
apiKey: 'OPENAI_API_KEY'
},
siliconflow: {
apiUrl: 'https://api.siliconflow.cn/v1/audio/transcriptions',
model: 'FunAudioLLM/SenseVoiceSmall',
apiKey: 'SILICONFLOW_API_KEY'
}
},
// LLM Configuration
LLM_PROVIDERS: {
// OpenRouter with a current cheap flagship chat model (default / recommended:
// gpt-5.6* on OpenAI direct needs org verification, OpenRouter avoids that step).
openrouter: {
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
model: 'openai/gpt-5.6-luna',
apiKey: 'OPENROUTER_API_KEY'
},
openai: {
apiUrl: 'https://api.openai.com/v1/chat/completions',
model: 'gpt-5.6-luna',
apiKey: 'OPENAI_API_KEY'
},
'openrouter-gpt': {
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
model: 'openai/gpt-5.6-luna',
apiKey: 'OPENROUTER_API_KEY'
},
'openrouter-gemini': {
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
model: 'google/gemini-3.5-flash',
apiKey: 'OPENROUTER_API_KEY'
},
ark: {
apiUrl: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
model: 'doubao-seed-1-6-flash-250615',
apiKey: 'ARK_API_KEY'
}
},
// TTS Configuration (keep current)
TTS_PROVIDERS: {
siliconflow: {
apiUrl: 'https://api.siliconflow.cn/v1/audio/speech',
model: 'FunAudioLLM/CosyVoice2-0.5B',
voice: 'FunAudioLLM/CosyVoice2-0.5B:diana',
apiKey: 'SILICONFLOW_API_KEY'
},
fish: {
model: 's1',
apiKey: 'FISH_API_KEY'
}
},
// Legacy support (will be deprecated)
LLM_MODEL: 'gpt-5.6-luna',
LLM_API_URL: 'https://api.openai.com/v1/chat/completions',
STT_API_URL: 'https://api.openai.com/v1/audio/transcriptions',
STT_MODEL: 'whisper-1',
TTS_API_URL: 'https://api.siliconflow.cn/v1/audio/speech',
// Common Configuration
VISION_MAX_TOKENS: 4096,
// Silero VAD Configuration
VAD_THRESHOLD: 0.5, // Speech probability threshold for Silero VAD (0.0 to 1.0)
VAD_FRAME_LENGTH: 512, // Frame length for VAD analysis (samples)
VAD_MIN_SPEECH_DURATION: 250, // Minimum speech duration in ms
VAD_MAX_SILENCE_DURATION: 500, // Maximum silence duration before ending speech in ms
AUDIO_SAMPLE_RATE: 16000, // Sample rate for audio processing (required for Silero VAD)
AUDIO_CHUNK_SIZE: 4096, // Audio chunk size for processing
// Server Configuration
LISTEN_PORT: 8848,
LISTEN_HOST: '0.0.0.0',
SYSTEM_PROMPT: 'You are a helpful AI assistant.',
CANCEL_PLAYBACK_TIME_THRESHOLD: 3000,
};
module.exports = config;
@@ -0,0 +1,94 @@
const config = {
// API Keys
OPENAI_API_KEY: process.env.OPENAI_API_KEY || 'your-openai-api-key-here',
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY || 'your-openrouter-api-key-here',
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || 'your-anthropic-api-key-here',
ARK_API_KEY: process.env.ARK_API_KEY || 'your-ark-api-key-here',
SILICONFLOW_API_KEY: process.env.SILICONFLOW_API_KEY || 'your-siliconflow-api-key-here',
// Provider Selection
ASR_PROVIDER: 'siliconflow', // 'openai' (whisper-1) or 'siliconflow' (SenseVoice)
LLM_PROVIDER: 'openrouter', // 'openrouter' (gpt-5.6-luna, default), 'openai', 'openrouter-gemini', 'ark'
TTS_PROVIDER: 'siliconflow', // 'siliconflow' (CosyVoice2, keep current)
// ASR Configuration
ASR_PROVIDERS: {
openai: {
apiUrl: 'https://api.openai.com/v1/audio/transcriptions',
model: 'whisper-1',
apiKey: 'OPENAI_API_KEY'
},
siliconflow: {
apiUrl: 'https://api.siliconflow.cn/v1/audio/transcriptions',
model: 'FunAudioLLM/SenseVoiceSmall',
apiKey: 'SILICONFLOW_API_KEY'
}
},
// LLM Configuration
LLM_PROVIDERS: {
// OpenRouter with a current cheap flagship chat model (default / recommended:
// gpt-5.6* on OpenAI direct needs org verification, OpenRouter avoids that step).
openrouter: {
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
model: 'openai/gpt-5.6-luna',
apiKey: 'OPENROUTER_API_KEY'
},
openai: {
apiUrl: 'https://api.openai.com/v1/chat/completions',
model: 'gpt-5.6-luna',
apiKey: 'OPENAI_API_KEY'
},
'openrouter-gpt4o': {
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
model: 'openai/gpt-4o',
apiKey: 'OPENROUTER_API_KEY'
},
'openrouter-gemini': {
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
model: 'google/gemini-2.5-flash',
apiKey: 'OPENROUTER_API_KEY'
},
ark: {
apiUrl: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
model: 'doubao-seed-1-6-flash-250615',
apiKey: 'ARK_API_KEY'
}
},
// TTS Configuration (keep current)
TTS_PROVIDERS: {
siliconflow: {
apiUrl: 'https://api.siliconflow.cn/v1/audio/speech',
model: 'FunAudioLLM/CosyVoice2-0.5B',
voice: 'FunAudioLLM/CosyVoice2-0.5B:diana',
apiKey: 'SILICONFLOW_API_KEY'
}
},
// Legacy support (will be deprecated)
LLM_MODEL: 'gpt-5.6-luna',
LLM_API_URL: 'https://api.openai.com/v1/chat/completions',
STT_API_URL: 'https://api.openai.com/v1/audio/transcriptions',
STT_MODEL: 'whisper-1',
TTS_API_URL: 'https://api.siliconflow.cn/v1/audio/speech',
// Common Configuration
VISION_MAX_TOKENS: 4096,
// Silero VAD Configuration
VAD_THRESHOLD: 0.5, // Speech probability threshold for Silero VAD (0.0 to 1.0)
VAD_FRAME_LENGTH: 512, // Frame length for VAD analysis (samples)
VAD_MIN_SPEECH_DURATION: 250, // Minimum speech duration in ms
VAD_MAX_SILENCE_DURATION: 500, // Maximum silence duration before ending speech in ms
AUDIO_SAMPLE_RATE: 16000, // Sample rate for audio processing (required for Silero VAD)
AUDIO_CHUNK_SIZE: 4096, // Audio chunk size for processing
// Server Configuration
LISTEN_PORT: 8848,
LISTEN_HOST: '0.0.0.0',
SYSTEM_PROMPT: 'You are a helpful AI assistant.',
CANCEL_PLAYBACK_TIME_THRESHOLD: 3000,
};
module.exports = config;
@@ -0,0 +1,90 @@
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
function sha256(filePath) {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
function isRealStage(stage) {
return Boolean(
stage &&
stage.execution === 'real' &&
stage.mock !== true &&
stage.probe_only !== true &&
stage.fallback_used !== true
);
}
function validateExperimentEvidence(evidence, evidenceDir) {
const source = evidence?.source_media || {};
const vad = evidence?.stages?.vad || {};
const asr = evidence?.stages?.asr || {};
const llm = evidence?.stages?.llm || {};
const tts = evidence?.stages?.tts || {};
const sourcePath = source.path ? path.resolve(evidenceDir, source.path) : '';
const segmentPath = vad.segment_path ? path.resolve(evidenceDir, vad.segment_path) : '';
const outputPath = tts.output_path ? path.resolve(evidenceDir, tts.output_path) : '';
const sourceAuthentic = Boolean(
source.capture_method === 'browser_microphone_over_websocket' &&
sourcePath && fs.existsSync(sourcePath) &&
source.sha256 === sha256(sourcePath) &&
source.sample_rate_hz === 16000 &&
source.channels === 1 &&
source.bits_per_sample === 16
);
const segmentAuthentic = Boolean(
segmentPath && fs.existsSync(segmentPath) && vad.segment_sha256 === sha256(segmentPath)
);
const outputAuthentic = Boolean(
outputPath && fs.existsSync(outputPath) && tts.output_sha256 === sha256(outputPath) &&
Number(tts.output_bytes) > 1000 && Number(tts.output_duration_seconds) > 0
);
const gates = {
schema_and_scope: evidence?.schema_version === 1 && evidence?.experiment === '6-3',
real_websocket_microphone_media: sourceAuthentic,
real_silero_vad_endpoint: Boolean(
isRealStage(vad) &&
vad.implementation === 'Silero VAD ONNX' &&
vad.model_sha256 &&
vad.endpoint_detected === true &&
vad.forced_endpoint === false &&
Number(vad.max_silence_ms) === 500 &&
Number(vad.observed_trailing_silence_ms) >= 500 &&
segmentAuthentic
),
real_asr: Boolean(
isRealStage(asr) && asr.inference_completed === true &&
asr.provider && asr.model && String(asr.transcript || '').trim()
),
real_streaming_llm: Boolean(
isRealStage(llm) && llm.api_request_completed === true && llm.streamed === true &&
llm.provider && llm.model && Number(llm.first_token_seconds) > 0 &&
String(llm.response || '').trim()
),
real_tts_media: Boolean(
isRealStage(tts) && tts.api_request_completed === true &&
tts.provider && tts.model && Number(tts.first_audio_byte_seconds) > 0 && outputAuthentic
),
measured_stage_latencies: ['vad', 'asr', 'llm', 'tts'].every(
name => Number(evidence?.stages?.[name]?.latency_seconds) > 0
),
provenance_complete: Boolean(
evidence?.provenance?.host?.platform &&
evidence?.provenance?.host?.architecture &&
evidence?.provenance?.runtime?.node &&
evidence?.provenance?.runtime?.onnxruntime_node &&
source.original_sha256 === source.sha256
),
no_mock_probe_or_fallback: [vad, asr, llm, tts].every(isRealStage),
};
return {
gates,
passed: Object.values(gates).every(Boolean),
statement: 'Passing proves one saved real microphone turn completed Silero VAD -> real ASR -> real LLM -> real TTS. It does not benchmark concurrency or production load.',
};
}
module.exports = { sha256, validateExperimentEvidence };
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "livechat-backend",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"test": "mocha tests/*.js --timeout 120000",
"test:providers": "mocha tests/provider-tests.js --timeout 120000",
"test:asr": "mocha tests/test-asr-providers.js --timeout 120000",
"test:llm": "mocha tests/test-llm-providers.js --timeout 120000",
"test:tts": "mocha tests/test-tts-providers.js --timeout 120000",
"check": "node check-setup.js",
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"axios": "^1.7.5",
"emoji-regex": "^10.4.0",
"express": "^4.21.1",
"fluent-ffmpeg": "^2.1.3",
"form-data": "^4.0.0",
"franc": "^6.2.0",
"node-wav": "^0.0.2",
"onnxruntime-node": "^1.22.0-rev",
"wav": "^1.0.2",
"ws": "^8.18.0"
},
"devDependencies": {
"mocha": "^10.2.0"
}
}
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* Test runner for provider tests
* This script sets up the environment and runs comprehensive tests for all provider combinations
*
* Usage:
* node run-tests.js
*
* Environment variables required:
* - OPENROUTER_API_KEY: OpenRouter API key
* - ANTHROPIC_API_KEY: Anthropic API key
* - ARK_API_KEY: ARK (Doubao) API key
* - SILICONFLOW_API_KEY: Siliconflow API key
* - OPENAI_API_KEY: OpenAI API key (optional if using others)
*/
const { spawn } = require('child_process');
const path = require('path');
console.log('🧪 Starting Provider Tests for Live Audio Backend');
console.log('=' .repeat(60));
// Check environment variables
const requiredEnvVars = {
'OPENROUTER_API_KEY': 'OpenRouter API key',
'ANTHROPIC_API_KEY': 'Anthropic API key',
'ARK_API_KEY': 'ARK (Doubao) API key',
'SILICONFLOW_API_KEY': 'Siliconflow API key'
};
const missingKeys = [];
const availableKeys = [];
Object.entries(requiredEnvVars).forEach(([key, description]) => {
if (process.env[key]) {
availableKeys.push(`${description}`);
} else {
missingKeys.push(`${description} (${key})`);
}
});
console.log('\n🔑 API Key Status:');
availableKeys.forEach(key => console.log(` ${key}`));
missingKeys.forEach(key => console.log(` ${key}`));
if (process.env.OPENAI_API_KEY) {
console.log(` ✅ OpenAI API key (optional)`);
}
console.log('\n📋 Test Plan:');
console.log(' 1. ASR Provider Tests (OpenAI Whisper, SenseVoice)');
console.log(' 2. LLM Provider Tests (OpenAI, OpenRouter GPT-4o, OpenRouter Gemini, ARK Doubao)');
console.log(' 3. Integration Tests (All ASR+LLM combinations)');
console.log(' 4. Provider Switching Tests');
console.log('\n🚀 Running Tests...\n');
// Run the tests
const testProcess = spawn('npm', ['run', 'test:providers'], {
stdio: 'inherit',
cwd: __dirname,
env: process.env
});
testProcess.on('close', (code) => {
console.log('\n' + '='.repeat(60));
if (code === 0) {
console.log('✅ All tests completed successfully!');
console.log('\n📊 Test Summary:');
console.log(' - Provider creation and configuration ✓');
console.log(' - ASR transcription functionality ✓');
console.log(' - LLM chat completion functionality ✓');
console.log(' - Provider integration ✓');
console.log(' - Dynamic provider switching ✓');
} else {
console.log(`❌ Tests failed with exit code ${code}`);
console.log('\n🔧 Troubleshooting:');
console.log(' 1. Ensure all required API keys are set as environment variables');
console.log(' 2. Check network connectivity to API endpoints');
console.log(' 3. Verify API key permissions and quotas');
console.log(' 4. Check the test output above for specific error details');
}
process.exit(code);
});
testProcess.on('error', (error) => {
console.error('❌ Failed to start test process:', error);
process.exit(1);
});
@@ -0,0 +1,441 @@
#!/usr/bin/env node
const axios = require('axios');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const config = require('./config');
const VoiceActivityDetector = require('./utils/vad');
const { ASRProviderFactory } = require('./utils/providers/asrProviders');
const { LLMProviderFactory } = require('./utils/providers/llmProviders');
const { sha256, validateExperimentEvidence } = require('./experiment_validation');
function arg(name, fallback) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : fallback;
}
function requireCredential(name) {
const value = process.env[name];
if (!value || value.startsWith('your-')) throw new Error(`Required real credential is not set: ${name}`);
return value;
}
function redact(value) {
let text = String(value || '');
for (const [name, secret] of Object.entries(process.env)) {
if ((name.includes('KEY') || name.includes('TOKEN')) && secret) text = text.split(secret).join('[REDACTED]');
}
return text.replace(/\b(?:sk|ak)-[A-Za-z0-9_-]{12,}\b/g, '[REDACTED]').slice(0, 3000);
}
function parsePcmWav(filePath) {
const raw = fs.readFileSync(filePath);
if (raw.length < 44 || raw.subarray(0, 4).toString() !== 'RIFF' || raw.subarray(8, 12).toString() !== 'WAVE') {
throw new Error('Input must be a PCM WAV file');
}
const format = raw.readUInt16LE(20);
const channels = raw.readUInt16LE(22);
const sampleRate = raw.readUInt32LE(24);
const bits = raw.readUInt16LE(34);
if (format !== 1 || channels !== 1 || sampleRate !== 16000 || bits !== 16) {
throw new Error(`Expected PCM 16kHz mono 16-bit WAV; got format=${format}, channels=${channels}, sampleRate=${sampleRate}, bits=${bits}`);
}
return { raw, pcm: raw.subarray(44), channels, sampleRate, bits };
}
function wavBuffer(pcm, sampleRate = 16000) {
const header = Buffer.alloc(44);
header.write('RIFF', 0); header.writeUInt32LE(36 + pcm.length, 4); header.write('WAVE', 8);
header.write('fmt ', 12); header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20);
header.writeUInt16LE(1, 22); header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(sampleRate * 2, 28); header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34);
header.write('data', 36); header.writeUInt32LE(pcm.length, 40);
return Buffer.concat([header, pcm]);
}
function mediaProbe(filePath) {
const output = execFileSync('ffprobe', [
'-v', 'error', '-show_entries', 'format=duration,size,format_name', '-of', 'json', filePath,
], { encoding: 'utf8' });
const format = JSON.parse(output).format || {};
return {
duration_seconds: Number(format.duration),
size_bytes: Number(format.size),
format_name: format.format_name,
};
}
function commandVersion(command, args) {
try {
return execFileSync(command, args, { encoding: 'utf8' }).split('\n')[0].trim();
} catch (_) {
return null;
}
}
function resolveWhisperPython(requested) {
if (requested) return requested;
const launcher = execFileSync('which', ['whisper'], { encoding: 'utf8' }).trim();
const firstLine = fs.readFileSync(launcher, 'utf8').split('\n')[0];
if (!firstLine.startsWith('#!')) throw new Error(`Cannot resolve Python from Whisper launcher: ${launcher}`);
return firstLine.slice(2).trim();
}
function buildProvenance() {
const dependencies = require('./package.json').dependencies || {};
return {
host: {
platform: os.platform(),
release: os.release(),
architecture: os.arch(),
cpu_model: os.cpus()[0]?.model || 'unknown',
logical_cpu_count: os.cpus().length,
total_memory_bytes: os.totalmem(),
},
runtime: {
node: process.version,
onnxruntime_node: dependencies['onnxruntime-node'] || null,
axios: dependencies.axios || null,
ffprobe: commandVersion('ffprobe', ['-version']),
},
timing_clock: 'process.hrtime.bigint monotonic clock',
};
}
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
async function runVad(pcm, outputDir) {
const detector = new VoiceActivityDetector({ maxSilenceDuration: 500 });
await detector.initializationPromise;
const started = process.hrtime.bigint();
const events = [];
const frameBytes = 512 * 2;
try {
for (let offset = 0; offset + frameBytes <= pcm.length; offset += frameBytes) {
const frame = pcm.subarray(offset, offset + frameBytes);
events.push(...await detector.processAudioChunk(frame));
await sleep(32);
}
} finally {
await detector.cleanup();
}
const endpoints = events.filter(event => event.type === 'speech_end');
const ended = endpoints.sort((left, right) => right.audioData.length - left.audioData.length)[0];
if (!ended) throw new Error('Silero did not observe a non-forced endpoint after 500 ms silence');
const segmentPath = path.join(outputDir, 'vad_segment.wav');
fs.writeFileSync(segmentPath, wavBuffer(ended.audioData));
return {
execution: 'real', mock: false, probe_only: false, fallback_used: false,
implementation: 'Silero VAD ONNX',
model: 'models/silero_vad.onnx',
model_sha256: sha256(path.join(__dirname, 'models', 'silero_vad.onnx')),
threshold: 0.5,
max_silence_ms: 500,
endpoint_detected: true,
detected_endpoint_count: endpoints.length,
selected_endpoint: 'longest detected speech segment',
forced_endpoint: false,
observed_trailing_silence_ms: ended.observedSilenceDuration,
speech_duration_ms: ended.duration,
latency_seconds: Number(process.hrtime.bigint() - started) / 1e9,
segment_path: 'vad_segment.wav',
segment_sha256: sha256(segmentPath),
segment_bytes: fs.statSync(segmentPath).size,
};
}
function runLocalWhisper(segmentPath, requestedPython, modelName) {
const python = resolveWhisperPython(requestedPython);
const script = [
'import hashlib, json, pathlib, sys, time',
'import torch, whisper',
'audio, model_name = sys.argv[1], sys.argv[2]',
'cache = pathlib.Path.home() / ".cache" / "whisper" / (model_name + ".pt")',
'started = time.perf_counter()',
'model = whisper.load_model(model_name)',
'loaded = time.perf_counter()',
'result = model.transcribe(audio, fp16=False, verbose=False)',
'finished = time.perf_counter()',
'payload = {"text": str(result.get("text") or "").strip(), "language": result.get("language") or "unknown",',
' "model_load_seconds": loaded-started, "inference_seconds": finished-loaded,',
' "python": sys.version.split()[0], "torch": torch.__version__, "whisper": getattr(whisper, "__version__", "unknown"),',
' "model_path": str(cache), "model_sha256": hashlib.sha256(cache.read_bytes()).hexdigest() if cache.exists() else None}',
'print("EXPERIMENT_JSON=" + json.dumps(payload, ensure_ascii=False))',
].join('\n');
const started = process.hrtime.bigint();
const output = execFileSync(python, ['-c', script, segmentPath, modelName], {
encoding: 'utf8', maxBuffer: 10 * 1024 * 1024,
});
const marker = output.split('\n').find(line => line.startsWith('EXPERIMENT_JSON='));
if (!marker) throw new Error('Local Whisper returned no structured result');
const result = JSON.parse(marker.slice('EXPERIMENT_JSON='.length));
if (!result.text) throw new Error('Local Whisper returned an empty transcript');
return {
execution: 'real', mock: false, probe_only: false, fallback_used: false,
provider: 'local-openai-whisper', model: `whisper-${modelName}`,
inference_completed: true, api_request_completed: false, external_request: false,
latency_seconds: Number(process.hrtime.bigint() - started) / 1e9,
transcript: result.text, language: result.language,
runtime: { python: result.python, torch: result.torch, openai_whisper: result.whisper },
model_path: result.model_path, model_sha256: result.model_sha256,
model_load_seconds: result.model_load_seconds,
model_inference_seconds: result.inference_seconds,
provider_reported_cost_usd: 0,
cost_note: 'Local open-source Whisper inference; no external ASR charge.',
};
}
async function runAsr(segmentPath, providerName, evidenceDir, options = {}) {
if (providerName === 'local-whisper') {
return runLocalWhisper(segmentPath, options.whisperPython, options.whisperModel || 'tiny');
}
const providerConfig = config.ASR_PROVIDERS[providerName];
if (!providerConfig) throw new Error(`Unknown ASR provider: ${providerName}`);
requireCredential(providerConfig.apiKey);
const provider = ASRProviderFactory.createProvider(providerName, config, config);
const pcm = parsePcmWav(segmentPath).pcm;
const tempDir = path.join(evidenceDir, '.asr-temp');
fs.mkdirSync(tempDir, { recursive: true });
const started = process.hrtime.bigint();
const result = await provider.transcribe(pcm, tempDir, {});
fs.rmSync(tempDir, { recursive: true, force: true });
if (!result.success || !String(result.text || '').trim()) throw new Error(`Real ASR failed: ${result.error || 'empty transcript'}`);
return {
execution: 'real', mock: false, probe_only: false, fallback_used: false,
provider: providerName,
model: providerConfig.model,
inference_completed: true, api_request_completed: true, external_request: true,
latency_seconds: Number(process.hrtime.bigint() - started) / 1e9,
transcript: String(result.text).trim(),
language: result.language || 'unknown',
provider_request_id: result.requestId || null,
provider_response_model: result.responseModel || providerConfig.model,
billed_input_audio_seconds: mediaProbe(segmentPath).duration_seconds,
provider_reported_cost_usd: null,
cost_note: 'The transcription response did not expose a monetary charge; consult the provider billing ledger.',
};
}
async function runLlm(transcript, providerName, requestedModel) {
if (!config.LLM_PROVIDERS[providerName]) throw new Error(`Unknown LLM provider: ${providerName}`);
const providerConfig = { ...config.LLM_PROVIDERS[providerName] };
requireCredential(providerConfig.apiKey);
if (requestedModel) providerConfig.model = requestedModel;
const localConfig = { ...config, LLM_PROVIDERS: { ...config.LLM_PROVIDERS, [providerName]: providerConfig } };
const provider = LLMProviderFactory.createProvider(providerName, localConfig, localConfig);
const started = process.hrtime.bigint();
const result = await provider.createChatCompletion([
{ role: 'system', content: 'Reply to the user in the same language using one short, natural sentence suitable for speech.' },
{ role: 'user', content: transcript },
], { max_tokens: 80, temperature: 0, stream_options: { include_usage: true } });
if (!result.success) throw new Error(`Real LLM failed: ${result.error}`);
let buffer = '', response = '', firstToken = null, usage = null, finishReason = null;
for await (const chunk of result.response.data) {
buffer += chunk.toString();
const lines = buffer.split('\n'); buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ') || line.trim() === 'data: [DONE]') continue;
const data = JSON.parse(line.slice(6));
const piece = data.choices?.[0]?.delta?.content || '';
if (piece && firstToken === null) firstToken = Number(process.hrtime.bigint() - started) / 1e9;
response += piece;
if (data.usage) usage = data.usage;
if (data.choices?.[0]?.finish_reason) finishReason = data.choices[0].finish_reason;
}
}
if (!response.trim() || firstToken === null) throw new Error('Real LLM stream returned no text');
return {
execution: 'real', mock: false, probe_only: false, fallback_used: false,
provider: providerName, model: providerConfig.model, streamed: true,
api_request_completed: true, external_request: true, first_token_seconds: firstToken,
latency_seconds: Number(process.hrtime.bigint() - started) / 1e9,
response: response.trim(),
provider_request_id: result.response.headers?.['x-request-id'] || result.response.headers?.['request-id'] || null,
finish_reason: finishReason,
usage,
provider_reported_cost_usd: usage?.cost != null && Number.isFinite(Number(usage.cost)) ? Number(usage.cost) : null,
cost_note: usage?.cost == null ? 'The streamed response did not expose a monetary charge; consult the provider billing ledger.' : null,
};
}
function resolveFishReferenceId(requested) {
if (requested) return { id: requested, source: 'command line or FISH_TTS_REFERENCE_ID' };
const manifestPath = path.resolve(__dirname, '..', '..', 'controllable-tts', 'reference_audio', 'manifest.json');
if (!fs.existsSync(manifestPath)) {
throw new Error('Fish TTS requires FISH_TTS_REFERENCE_ID or the authorized Experiment 6-6 reference manifest');
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
if (!manifest.source_reference_id) throw new Error('Fish reference manifest has no source_reference_id');
return { id: manifest.source_reference_id, source: path.relative(__dirname, manifestPath) };
}
function runFishTts(text, outputDir, options = {}) {
requireCredential('FISH_API_KEY');
const python = options.fishPython || resolveWhisperPython();
const reference = resolveFishReferenceId(options.fishReferenceId);
const outputPath = path.join(outputDir, 'assistant_response.mp3');
const script = [
'import json, os, pathlib, sys, time',
'from fish_audio_sdk import Session, TTSRequest',
'text, reference_id, output = sys.argv[1], sys.argv[2], pathlib.Path(sys.argv[3])',
'started = time.perf_counter(); first = None; chunks = []',
'for chunk in Session(os.environ["FISH_API_KEY"]).tts(TTSRequest(text=text, reference_id=reference_id, format="mp3"), backend="s1"):',
' if first is None: first = time.perf_counter() - started',
' chunks.append(chunk)',
'output.write_bytes(b"".join(chunks)); finished = time.perf_counter() - started',
'print("EXPERIMENT_JSON=" + json.dumps({"first_byte_seconds": first, "latency_seconds": finished, "bytes": output.stat().st_size}))',
].join('\n');
const output = execFileSync(python, ['-c', script, text, reference.id, outputPath], {
encoding: 'utf8', maxBuffer: 10 * 1024 * 1024,
});
const marker = output.split('\n').find(line => line.startsWith('EXPERIMENT_JSON='));
if (!marker) throw new Error('Fish Audio returned no structured result');
const result = JSON.parse(marker.slice('EXPERIMENT_JSON='.length));
const probe = mediaProbe(outputPath);
return {
execution: 'real', mock: false, probe_only: false, fallback_used: false,
provider: 'fish', model: 's1', voice: 'authorized zero-shot reference',
api_request_completed: true, external_request: true,
first_audio_byte_seconds: result.first_byte_seconds,
latency_seconds: result.latency_seconds,
output_path: 'assistant_response.mp3', output_sha256: sha256(outputPath),
output_bytes: result.bytes, output_duration_seconds: probe.duration_seconds,
output_format: probe.format_name,
reference_id_sha256: crypto.createHash('sha256').update(reference.id).digest('hex'),
reference_id_source: reference.source,
billed_input_characters: [...text].length,
provider_reported_cost_usd: null,
cost_note: 'The Fish Audio SDK response did not expose a monetary charge; consult the provider billing ledger.',
};
}
async function runTts(text, providerName, outputDir, options = {}) {
if (providerName === 'fish') return runFishTts(text, outputDir, options);
if (providerName !== 'siliconflow') throw new Error(`Unknown TTS provider: ${providerName}`);
const providerConfig = config.TTS_PROVIDERS[providerName];
const apiKey = requireCredential(providerConfig.apiKey);
const started = process.hrtime.bigint();
const response = await axios.post(providerConfig.apiUrl, {
model: providerConfig.model, input: text, voice: providerConfig.voice,
response_format: 'mp3', sample_rate: 32000, stream: true, speed: 1, gain: 0,
}, {
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
responseType: 'stream', timeout: 120000,
});
let firstByte = null;
const chunks = [];
for await (const chunk of response.data) {
if (firstByte === null) firstByte = Number(process.hrtime.bigint() - started) / 1e9;
chunks.push(chunk);
}
const audio = Buffer.concat(chunks);
const outputPath = path.join(outputDir, 'assistant_response.mp3');
fs.writeFileSync(outputPath, audio);
const probe = mediaProbe(outputPath);
return {
execution: 'real', mock: false, probe_only: false, fallback_used: false,
provider: providerName, model: providerConfig.model, voice: providerConfig.voice,
api_request_completed: response.status >= 200 && response.status < 300, external_request: true,
first_audio_byte_seconds: firstByte,
latency_seconds: Number(process.hrtime.bigint() - started) / 1e9,
output_path: 'assistant_response.mp3', output_sha256: sha256(outputPath),
output_bytes: audio.length, output_duration_seconds: probe.duration_seconds,
output_format: probe.format_name,
provider_request_id: response.headers?.['x-request-id'] || response.headers?.['request-id'] || null,
billed_input_characters: [...text].length,
provider_reported_cost_usd: null,
cost_note: 'The speech response did not expose a monetary charge; consult the provider billing ledger.',
};
}
function renderReport(e) {
const a = e.acceptance;
return `# Experiment 6-3 real traditional-voice validation\n\n` +
`- Run ID: \`${e.run_id}\`\n- Complete: **${e.experiment_complete}**\n` +
`- Source: \`${e.source_media.path}\` (${e.source_media.duration_seconds.toFixed(3)} s, saved browser microphone/WebSocket capture)\n` +
`- VAD: Silero ONNX, 500 ms silence, non-forced endpoint = ${e.stages.vad.endpoint_detected}\n` +
`- ASR: ${e.stages.asr.provider} / ${e.stages.asr.model}, ${e.stages.asr.latency_seconds.toFixed(3)} s\n` +
`- Transcript: ${e.stages.asr.transcript}\n` +
`- LLM: ${e.stages.llm.provider} / ${e.stages.llm.model}, TTFT ${e.stages.llm.first_token_seconds.toFixed(3)} s, total ${e.stages.llm.latency_seconds.toFixed(3)} s\n` +
`- Response: ${e.stages.llm.response}\n` +
`- TTS: ${e.stages.tts.provider} / ${e.stages.tts.model}, first byte ${e.stages.tts.first_audio_byte_seconds.toFixed(3)} s, total ${e.stages.tts.latency_seconds.toFixed(3)} s\n` +
`- Post-endpoint time to first audio byte: ${e.latency.post_endpoint_to_first_audio_byte_seconds.toFixed(3)} s\n\n` +
`## Strict gates\n\n${Object.entries(a.gates).map(([k,v]) => `- ${k}: **${v}**`).join('\n')}\n\n` +
`${a.statement}\n`;
}
async function main() {
const input = path.resolve(arg('--input', path.join(__dirname, 'recordings', 'recording_2025-02-06T14-51-38-205Z.wav')));
const outputDir = path.resolve(arg('--output-dir', path.join(__dirname, 'validation', `real_pipeline_${new Date().toISOString().slice(0,10).replaceAll('-', '')}`)));
const asrProvider = arg('--asr-provider', 'local-whisper');
const whisperPython = arg('--whisper-python', process.env.WHISPER_PYTHON);
const whisperModel = arg('--whisper-model', 'tiny');
const llmProvider = arg('--llm-provider', 'openrouter-gemini');
const llmModel = arg('--llm-model', config.LLM_PROVIDERS[llmProvider]?.model);
const ttsProvider = arg('--tts-provider', 'fish');
const fishPython = arg('--fish-python', process.env.FISH_PYTHON);
const fishReferenceId = arg('--fish-reference-id', process.env.FISH_TTS_REFERENCE_ID);
if (fs.existsSync(outputDir)) throw new Error(`Output directory already exists: ${outputDir}`);
fs.mkdirSync(outputDir, { recursive: true });
const wav = parsePcmWav(input);
const sourceCopy = path.join(outputDir, 'microphone_input.wav');
fs.copyFileSync(input, sourceCopy);
const probe = mediaProbe(sourceCopy);
const evidence = {
schema_version: 1, experiment: '6-3', run_id: `exp6-3-${new Date().toISOString().replace(/[-:.]/g, '')}`,
generated_at_utc: new Date().toISOString(), credentials_persisted: false,
provenance: buildProvenance(),
source_media: {
path: 'microphone_input.wav', capture_method: 'browser_microphone_over_websocket',
original_repository_path: path.relative(path.join(__dirname, '..'), input),
sha256: sha256(sourceCopy), size_bytes: fs.statSync(sourceCopy).size,
original_sha256: sha256(input),
duration_seconds: probe.duration_seconds, sample_rate_hz: wav.sampleRate,
channels: wav.channels, bits_per_sample: wav.bits,
provenance_note: 'Existing real microphone capture saved by live-audio/backend/server.js; replayed through the production Silero class for reproducible validation.',
}, stages: {}, experiment_complete: false,
};
try {
evidence.stages.vad = await runVad(wav.pcm, outputDir);
evidence.stages.asr = await runAsr(
path.join(outputDir, evidence.stages.vad.segment_path), asrProvider, outputDir,
{ whisperPython, whisperModel },
);
evidence.stages.llm = await runLlm(evidence.stages.asr.transcript, llmProvider, llmModel);
evidence.stages.tts = await runTts(
evidence.stages.llm.response, ttsProvider, outputDir,
{ fishPython, fishReferenceId },
);
evidence.latency = {
post_endpoint_to_first_audio_byte_seconds:
evidence.stages.asr.latency_seconds + evidence.stages.llm.first_token_seconds + evidence.stages.tts.first_audio_byte_seconds,
complete_serial_pipeline_seconds: ['vad','asr','llm','tts'].reduce((n,k) => n + evidence.stages[k].latency_seconds, 0),
measurement_clock: 'process.hrtime.bigint monotonic clock',
};
evidence.cost = {
paid_external_requests: [evidence.stages.asr, evidence.stages.llm, evidence.stages.tts]
.filter(stage => stage.external_request === true).length,
provider_reported_total_usd: [evidence.stages.asr, evidence.stages.llm, evidence.stages.tts]
.map(stage => stage.provider_reported_cost_usd)
.filter(value => Number.isFinite(value))
.reduce((sum, value) => sum + value, 0),
complete: [evidence.stages.asr, evidence.stages.llm, evidence.stages.tts]
.every(stage => Number.isFinite(stage.provider_reported_cost_usd)),
note: 'A zero total is not a zero-cost claim when complete=false; some providers omit per-request charges.',
};
evidence.acceptance = validateExperimentEvidence(evidence, outputDir);
evidence.experiment_complete = evidence.acceptance.passed;
} catch (error) {
evidence.error = redact(error?.stack || error);
evidence.acceptance = validateExperimentEvidence(evidence, outputDir);
}
fs.writeFileSync(path.join(outputDir, 'evidence.json'), JSON.stringify(evidence, null, 2) + '\n');
if (evidence.experiment_complete) fs.writeFileSync(path.join(outputDir, 'report.md'), renderReport(evidence));
console.log(`Evidence: ${path.join(outputDir, 'evidence.json')}`);
if (!evidence.experiment_complete) throw new Error(evidence.error || 'Strict acceptance gates failed');
}
main().catch(error => { console.error(redact(error.message)); process.exit(1); });
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
const axios = require('axios');
const config = require('./config');
async function testLLMEndpoint() {
console.log('Testing LLM endpoint...');
console.log(`URL: ${config.LLM_API_URL}`);
console.log(`Model: ${config.LLM_MODEL}`);
const testPrompt = "Tell me a short joke.";
try {
const response = await axios.post(
config.LLM_API_URL,
{
model: config.LLM_MODEL,
messages: [
{ role: "user", content: testPrompt }
],
stream: true,
max_tokens: 100
},
{
headers: {
'Authorization': `Bearer ${config.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
responseType: 'stream'
}
);
console.log('Connection established successfully');
let fullResponse = '';
response.data.on('data', chunk => {
const lines = chunk.toString().split('\n');
for (const line of lines) {
if (line.trim() === '') continue;
if (line.trim() === 'data: [DONE]') continue;
if (!line.startsWith('data: ')) continue;
try {
const jsonData = JSON.parse(line.replace('data: ', ''));
const content = jsonData.choices[0]?.delta?.content || '';
fullResponse += content;
process.stdout.write(content); // Stream the response
} catch (e) {
console.error('Error parsing chunk:', e);
console.error('Raw chunk:', line);
}
}
});
response.data.on('end', () => {
console.log('\n\nFull response received:', fullResponse);
console.log('\nTest completed successfully');
});
response.data.on('error', (error) => {
console.error('Stream error:', error);
});
} catch (error) {
console.error('\nError testing LLM endpoint:', error.message);
if (error.response) {
console.error('Response status:', error.response.status);
console.error('Response headers:', JSON.stringify(error.response.headers, null, 2));
}
// Log safe error properties
const safeError = {
message: error.message,
name: error.name,
stack: error.stack,
code: error.code,
status: error.status
};
console.error('\nError details:', JSON.stringify(safeError, null, 2));
}
}
// Run the test
testLLMEndpoint();
@@ -0,0 +1,365 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
// Import provider factories
const { ASRProviderFactory } = require('../utils/providers/asrProviders');
const { LLMProviderFactory } = require('../utils/providers/llmProviders');
// Import services
const SpeechToTextService = require('../utils/speechToText');
// Mock configuration for testing
const testConfig = {
// API Keys from environment variables
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
ARK_API_KEY: process.env.ARK_API_KEY,
SILICONFLOW_API_KEY: process.env.SILICONFLOW_API_KEY,
// Provider configurations
ASR_PROVIDERS: {
openai: {
apiUrl: 'https://api.openai.com/v1/audio/transcriptions',
model: 'whisper-1',
apiKey: 'OPENAI_API_KEY'
},
siliconflow: {
apiUrl: 'https://api.siliconflow.cn/v1/audio/transcriptions',
model: 'FunAudioLLM/SenseVoiceSmall',
apiKey: 'SILICONFLOW_API_KEY'
}
},
LLM_PROVIDERS: {
openai: {
apiUrl: 'https://api.openai.com/v1/chat/completions',
model: 'gpt-5.6-luna',
apiKey: 'OPENAI_API_KEY'
},
'openrouter-gpt': {
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
model: 'openai/gpt-5.6-luna',
apiKey: 'OPENROUTER_API_KEY'
},
'openrouter-gemini': {
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
model: 'google/gemini-3.5-flash',
apiKey: 'OPENROUTER_API_KEY'
},
ark: {
apiUrl: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
model: 'doubao-seed-1-6-flash-250615',
apiKey: 'ARK_API_KEY'
}
},
// Audio configuration
AUDIO_SAMPLE_RATE: 16000,
VISION_MAX_TOKENS: 4096
};
/**
* Create a sample audio buffer for testing
* This creates a simple sine wave audio buffer
*/
function createTestAudioBuffer(durationSeconds = 2, sampleRate = 16000) {
const numSamples = durationSeconds * sampleRate;
const buffer = Buffer.alloc(numSamples * 2); // 16-bit = 2 bytes per sample
// Generate a simple sine wave at 440Hz
const frequency = 440;
for (let i = 0; i < numSamples; i++) {
const sample = Math.sin(2 * Math.PI * frequency * i / sampleRate) * 0.5;
const intSample = Math.round(sample * 32767);
buffer.writeInt16LE(intSample, i * 2);
}
return buffer;
}
/**
* Test suite for ASR providers
*/
describe('ASR Provider Tests', function() {
this.timeout(60000); // 60 second timeout for API calls
const asrProviders = ['openai', 'siliconflow'];
asrProviders.forEach(providerName => {
describe(`${providerName} ASR Provider`, function() {
let provider;
before(function() {
// Skip test if API key is not available
const apiKeyName = testConfig.ASR_PROVIDERS[providerName].apiKey;
if (!testConfig[apiKeyName]) {
this.skip();
}
try {
provider = ASRProviderFactory.createProvider(providerName, testConfig, testConfig);
} catch (error) {
console.error(`Failed to create ${providerName} provider:`, error);
this.skip();
}
});
it('should be created successfully', function() {
assert(provider, 'Provider should be created');
assert(provider.config, 'Provider should have config');
assert(provider.apiKey, 'Provider should have API key');
});
it('should transcribe test audio', async function() {
const audioBuffer = createTestAudioBuffer(2); // 2 seconds of audio
const tempDir = path.join(__dirname, '../temp');
// Ensure temp directory exists
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const result = await provider.transcribe(audioBuffer, tempDir);
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
assert(typeof result.text === 'string', 'Should have text field');
assert(result.provider === providerName, 'Should have correct provider name');
console.log(`${providerName} ASR Result:`, {
success: result.success,
text: result.text.substring(0, 100) + (result.text.length > 100 ? '...' : ''),
language: result.language,
provider: result.provider
});
});
});
});
describe('SpeechToTextService Integration', function() {
asrProviders.forEach(providerName => {
it(`should work with ${providerName} provider`, async function() {
// Skip test if API key is not available
const apiKeyName = testConfig.ASR_PROVIDERS[providerName].apiKey;
if (!testConfig[apiKeyName]) {
this.skip();
}
// Override config for this test
const originalConfig = require('../config');
Object.assign(originalConfig, testConfig);
originalConfig.ASR_PROVIDER = providerName;
const sttService = new SpeechToTextService();
const audioBuffer = createTestAudioBuffer(2);
const result = await sttService.transcribeAudio(audioBuffer);
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
assert(typeof result.text === 'string', 'Should have text field');
console.log(`STT Service with ${providerName}:`, {
success: result.success,
text: result.text.substring(0, 100) + (result.text.length > 100 ? '...' : ''),
provider: result.provider
});
});
});
});
});
/**
* Test suite for LLM providers
*/
describe('LLM Provider Tests', function() {
this.timeout(60000); // 60 second timeout for API calls
const llmProviders = ['openai', 'openrouter-gpt', 'openrouter-gemini', 'ark'];
const testMessages = [
{ role: 'system', content: 'You are a helpful AI assistant.' },
{ role: 'user', content: 'Hello! Please respond with a short greeting.' }
];
llmProviders.forEach(providerName => {
describe(`${providerName} LLM Provider`, function() {
let provider;
before(function() {
// Skip test if API key is not available
const apiKeyName = testConfig.LLM_PROVIDERS[providerName].apiKey;
if (!testConfig[apiKeyName]) {
this.skip();
}
try {
provider = LLMProviderFactory.createProvider(providerName, testConfig, testConfig);
} catch (error) {
console.error(`Failed to create ${providerName} provider:`, error);
this.skip();
}
});
it('should be created successfully', function() {
assert(provider, 'Provider should be created');
assert(provider.config, 'Provider should have config');
assert(provider.apiKey, 'Provider should have API key');
});
it('should generate chat completion', async function() {
const result = await provider.createChatCompletion(testMessages, {
max_tokens: 100
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
assert(result.provider === providerName.split('-')[0], 'Should have correct provider name');
if (result.success) {
assert(result.response, 'Should have response object');
console.log(`${providerName} LLM Result: Success`);
} else {
console.log(`${providerName} LLM Result:`, result.error);
}
});
it('should stream chat completion', async function() {
const result = await provider.createChatCompletion(testMessages, {
max_tokens: 50,
stream: true
});
assert(result, 'Should return a result');
if (result.success) {
assert(result.response, 'Should have response object');
assert(result.response.data, 'Should have data stream');
// Test streaming by collecting some data
let receivedData = false;
const timeout = setTimeout(() => {
if (!receivedData) {
console.log(`${providerName}: No data received within timeout`);
}
}, 10000);
result.response.data.on('data', (chunk) => {
receivedData = true;
clearTimeout(timeout);
console.log(`${providerName} LLM Streaming: Received data chunk`);
});
result.response.data.on('error', (error) => {
clearTimeout(timeout);
console.error(`${providerName} LLM Streaming Error:`, error.message);
});
} else {
console.log(`${providerName} LLM Streaming Result:`, result.error);
}
});
});
});
});
/**
* Integration tests for all provider combinations
*/
describe('Provider Integration Tests', function() {
this.timeout(120000); // 2 minute timeout for integration tests
const testCombinations = [
{ asr: 'openai', llm: 'openai', description: 'OpenAI ASR + OpenAI LLM' },
{ asr: 'openai', llm: 'openrouter-gpt', description: 'OpenAI ASR + OpenRouter GPT' },
{ asr: 'openai', llm: 'openrouter-gemini', description: 'OpenAI ASR + OpenRouter Gemini' },
{ asr: 'openai', llm: 'ark', description: 'OpenAI ASR + ARK Doubao' },
{ asr: 'siliconflow', llm: 'openai', description: 'SenseVoice ASR + OpenAI LLM' },
{ asr: 'siliconflow', llm: 'openrouter-gpt', description: 'SenseVoice ASR + OpenRouter GPT' },
{ asr: 'siliconflow', llm: 'openrouter-gemini', description: 'SenseVoice ASR + OpenRouter Gemini' },
{ asr: 'siliconflow', llm: 'ark', description: 'SenseVoice ASR + ARK Doubao' }
];
testCombinations.forEach(combination => {
it(`should work with ${combination.description}`, async function() {
// Check if API keys are available
const asrApiKey = testConfig.ASR_PROVIDERS[combination.asr].apiKey;
const llmApiKey = testConfig.LLM_PROVIDERS[combination.llm].apiKey;
if (!testConfig[asrApiKey] || !testConfig[llmApiKey]) {
this.skip();
}
try {
// Create providers
const asrProvider = ASRProviderFactory.createProvider(combination.asr, testConfig, testConfig);
const llmProvider = LLMProviderFactory.createProvider(combination.llm, testConfig, testConfig);
// Test ASR
const audioBuffer = createTestAudioBuffer(2);
const tempDir = path.join(__dirname, '../temp');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const asrResult = await asrProvider.transcribe(audioBuffer, tempDir);
assert(asrResult.success !== undefined, 'ASR should return result');
// Test LLM with a simple message
const messages = [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'Say hello in one word.' }
];
const llmResult = await llmProvider.createChatCompletion(messages, {
max_tokens: 10
});
assert(llmResult.success !== undefined, 'LLM should return result');
console.log(`Integration Test - ${combination.description}:`, {
asr: { success: asrResult.success, provider: asrResult.provider },
llm: { success: llmResult.success, provider: llmResult.provider }
});
} catch (error) {
console.error(`Integration test failed for ${combination.description}:`, error.message);
throw error;
}
});
});
});
/**
* Provider switching tests
*/
describe('Provider Switching Tests', function() {
it('should switch ASR providers dynamically', function() {
const originalConfig = require('../config');
Object.assign(originalConfig, testConfig);
const sttService = new SpeechToTextService();
const originalProvider = sttService.getProviderInfo();
// Try switching to different provider
const availableProviders = ['openai', 'siliconflow'];
const newProvider = availableProviders.find(p => p !== originalProvider.provider);
if (newProvider && testConfig[testConfig.ASR_PROVIDERS[newProvider].apiKey]) {
sttService.switchProvider(newProvider);
const newProviderInfo = sttService.getProviderInfo();
assert(newProviderInfo.provider !== originalProvider.provider, 'Provider should change');
console.log('ASR Provider switched from', originalProvider.provider, 'to', newProviderInfo.provider);
} else {
console.log('Skipping ASR provider switching test - insufficient API keys');
}
});
});
// Export test configuration for use in other test files
module.exports = {
testConfig,
createTestAudioBuffer
};
@@ -0,0 +1,307 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
// Import ASR provider factory
const { ASRProviderFactory } = require('../utils/providers/asrProviders');
// Test configuration
const testConfig = {
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
SILICONFLOW_API_KEY: process.env.SILICONFLOW_API_KEY,
ASR_PROVIDERS: {
openai: {
apiUrl: 'https://api.openai.com/v1/audio/transcriptions',
model: 'whisper-1',
apiKey: 'OPENAI_API_KEY'
},
siliconflow: {
apiUrl: 'https://api.siliconflow.cn/v1/audio/transcriptions',
model: 'FunAudioLLM/SenseVoiceSmall',
apiKey: 'SILICONFLOW_API_KEY'
}
},
AUDIO_SAMPLE_RATE: 16000
};
/**
* Create a test audio buffer with spoken content
* This creates a simple sine wave that simulates audio content
*/
function createTestAudioBuffer(durationSeconds = 3, sampleRate = 16000) {
const numSamples = durationSeconds * sampleRate;
const buffer = Buffer.alloc(numSamples * 2); // 16-bit = 2 bytes per sample
// Generate a more complex wave pattern to simulate speech
for (let i = 0; i < numSamples; i++) {
// Mix multiple frequencies to simulate speech-like content
const t = i / sampleRate;
const sample =
0.3 * Math.sin(2 * Math.PI * 300 * t) + // Base frequency
0.2 * Math.sin(2 * Math.PI * 600 * t) + // Harmonic
0.1 * Math.sin(2 * Math.PI * 1200 * t) + // Higher harmonic
0.05 * (Math.random() - 0.5); // Noise for realism
const intSample = Math.round(sample * 16000); // Scale to 16-bit range
buffer.writeInt16LE(intSample, i * 2);
}
return buffer;
}
/**
* ASR Provider Individual Tests
*/
describe('ASR Providers - Individual Testing', function() {
this.timeout(120000); // 2 minute timeout for API calls
const tempDir = path.join(__dirname, '../temp');
before(function() {
// Ensure temp directory exists
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
});
describe('OpenAI Whisper ASR Provider', function() {
let provider;
before(function() {
if (!testConfig.OPENAI_API_KEY) {
console.log('⚠️ Skipping OpenAI ASR tests - OPENAI_API_KEY not found');
this.skip();
}
try {
provider = ASRProviderFactory.createProvider('openai', testConfig, testConfig);
console.log('✅ OpenAI ASR Provider created successfully');
} catch (error) {
console.error('❌ Failed to create OpenAI ASR provider:', error);
this.skip();
}
});
it('should initialize with correct configuration', function() {
assert(provider, 'Provider should be created');
assert.strictEqual(provider.config.model, 'whisper-1', 'Should use whisper-1 model');
assert.strictEqual(provider.config.apiUrl, 'https://api.openai.com/v1/audio/transcriptions', 'Should use correct API URL');
assert(provider.apiKey, 'Should have API key');
console.log('📋 OpenAI ASR Config:', {
model: provider.config.model,
apiUrl: provider.config.apiUrl
});
});
it('should transcribe test audio successfully', async function() {
const audioBuffer = createTestAudioBuffer(3); // 3 seconds of test audio
console.log('🎵 Testing OpenAI ASR with', audioBuffer.length, 'bytes of audio data');
const startTime = Date.now();
const result = await provider.transcribe(audioBuffer, tempDir);
const duration = Date.now() - startTime;
console.log('📊 OpenAI ASR Results:', {
success: result.success,
transcriptionTime: `${duration}ms`,
textLength: result.text ? result.text.length : 0,
language: result.language,
confidence: result.confidence,
provider: result.provider
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
assert(typeof result.text === 'string', 'Should have text field');
assert.strictEqual(result.provider, 'openai', 'Should identify as openai provider');
if (result.success) {
console.log('✅ OpenAI ASR transcription successful');
if (result.text.length > 0) {
console.log('📝 Transcribed text preview:', result.text.substring(0, 100) + (result.text.length > 100 ? '...' : ''));
}
} else {
console.log('❌ OpenAI ASR transcription failed:', result.error);
}
});
it('should handle empty audio gracefully', async function() {
const emptyBuffer = Buffer.alloc(0);
const result = await provider.transcribe(emptyBuffer, tempDir);
console.log('🔍 OpenAI ASR Empty Audio Test:', {
success: result.success,
error: result.error,
provider: result.provider
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
// Empty audio should typically fail or return empty text
if (!result.success) {
assert(result.error, 'Should have error message for empty audio');
}
});
});
describe('SenseVoice (Siliconflow) ASR Provider', function() {
let provider;
before(function() {
if (!testConfig.SILICONFLOW_API_KEY) {
console.log('⚠️ Skipping Siliconflow ASR tests - SILICONFLOW_API_KEY not found');
this.skip();
}
try {
provider = ASRProviderFactory.createProvider('siliconflow', testConfig, testConfig);
console.log('✅ Siliconflow ASR Provider created successfully');
} catch (error) {
console.error('❌ Failed to create Siliconflow ASR provider:', error);
this.skip();
}
});
it('should initialize with correct configuration', function() {
assert(provider, 'Provider should be created');
assert.strictEqual(provider.config.model, 'FunAudioLLM/SenseVoiceSmall', 'Should use SenseVoiceSmall model');
assert.strictEqual(provider.config.apiUrl, 'https://api.siliconflow.cn/v1/audio/transcriptions', 'Should use correct API URL');
assert(provider.apiKey, 'Should have API key');
console.log('📋 Siliconflow ASR Config:', {
model: provider.config.model,
apiUrl: provider.config.apiUrl
});
});
it('should transcribe test audio successfully', async function() {
const audioBuffer = createTestAudioBuffer(3); // 3 seconds of test audio
console.log('🎵 Testing Siliconflow ASR with', audioBuffer.length, 'bytes of audio data');
const startTime = Date.now();
const result = await provider.transcribe(audioBuffer, tempDir);
const duration = Date.now() - startTime;
console.log('📊 Siliconflow ASR Results:', {
success: result.success,
transcriptionTime: `${duration}ms`,
textLength: result.text ? result.text.length : 0,
language: result.language,
confidence: result.confidence,
provider: result.provider
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
assert(typeof result.text === 'string', 'Should have text field');
assert.strictEqual(result.provider, 'siliconflow', 'Should identify as siliconflow provider');
if (result.success) {
console.log('✅ Siliconflow ASR transcription successful');
if (result.text.length > 0) {
console.log('📝 Transcribed text preview:', result.text.substring(0, 100) + (result.text.length > 100 ? '...' : ''));
}
} else {
console.log('❌ Siliconflow ASR transcription failed:', result.error);
}
});
it('should handle different audio lengths', async function() {
const shortAudio = createTestAudioBuffer(1); // 1 second
const longAudio = createTestAudioBuffer(5); // 5 seconds
console.log('🎵 Testing Siliconflow ASR with different audio lengths');
const shortResult = await provider.transcribe(shortAudio, tempDir);
const longResult = await provider.transcribe(longAudio, tempDir);
console.log('📊 Audio Length Tests:', {
short: { success: shortResult.success, textLength: shortResult.text.length },
long: { success: longResult.success, textLength: longResult.text.length }
});
assert(shortResult, 'Should handle short audio');
assert(longResult, 'Should handle long audio');
assert(typeof shortResult.success === 'boolean', 'Short audio should have success field');
assert(typeof longResult.success === 'boolean', 'Long audio should have success field');
});
});
describe('ASR Provider Comparison', function() {
it('should compare provider performance', async function() {
const availableProviders = [];
if (testConfig.OPENAI_API_KEY) {
availableProviders.push('openai');
}
if (testConfig.SILICONFLOW_API_KEY) {
availableProviders.push('siliconflow');
}
if (availableProviders.length < 2) {
console.log('⚠️ Skipping provider comparison - need at least 2 providers');
this.skip();
}
const audioBuffer = createTestAudioBuffer(3);
const results = {};
console.log('🏁 Comparing ASR provider performance...');
for (const providerName of availableProviders) {
const provider = ASRProviderFactory.createProvider(providerName, testConfig, testConfig);
const startTime = Date.now();
const result = await provider.transcribe(audioBuffer, tempDir);
const duration = Date.now() - startTime;
results[providerName] = {
success: result.success,
duration: duration,
textLength: result.text ? result.text.length : 0,
error: result.error
};
}
console.log('📊 ASR Provider Performance Comparison:');
Object.entries(results).forEach(([provider, result]) => {
console.log(` ${provider}:`, {
success: result.success ? '✅' : '❌',
time: `${result.duration}ms`,
textLength: result.textLength,
error: result.error || 'none'
});
});
// Find fastest successful provider
const successfulProviders = Object.entries(results).filter(([_, result]) => result.success);
if (successfulProviders.length > 0) {
const fastest = successfulProviders.reduce((prev, curr) =>
prev[1].duration < curr[1].duration ? prev : curr
);
console.log(`🏆 Fastest provider: ${fastest[0]} (${fastest[1].duration}ms)`);
}
});
});
after(function() {
// Cleanup temp files
try {
const files = fs.readdirSync(tempDir);
files.forEach(file => {
if (file.startsWith('audio_')) {
fs.unlinkSync(path.join(tempDir, file));
}
});
console.log('🧹 Cleaned up temporary audio files');
} catch (error) {
console.warn('⚠️ Failed to cleanup temp files:', error.message);
}
});
});
module.exports = {
testConfig,
createTestAudioBuffer
};
@@ -0,0 +1,58 @@
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { sha256, validateExperimentEvidence } = require('../experiment_validation');
function fixture(dir) {
fs.writeFileSync(path.join(dir, 'input.wav'), Buffer.from('real-input'));
fs.writeFileSync(path.join(dir, 'segment.wav'), Buffer.from('real-segment'));
fs.writeFileSync(path.join(dir, 'output.mp3'), Buffer.alloc(2000, 1));
const real = { execution: 'real', mock: false, probe_only: false, fallback_used: false, latency_seconds: 0.1 };
return {
schema_version: 1, experiment: '6-3',
provenance: { host: { platform: 'test', architecture: 'test' }, runtime: { node: 'test', onnxruntime_node: 'test' } },
source_media: { path: 'input.wav', capture_method: 'browser_microphone_over_websocket', sha256: sha256(path.join(dir, 'input.wav')), original_sha256: sha256(path.join(dir, 'input.wav')), sample_rate_hz: 16000, channels: 1, bits_per_sample: 16 },
stages: {
vad: { ...real, implementation: 'Silero VAD ONNX', model_sha256: 'abc', endpoint_detected: true, forced_endpoint: false, max_silence_ms: 500, observed_trailing_silence_ms: 512, segment_path: 'segment.wav', segment_sha256: sha256(path.join(dir, 'segment.wav')) },
asr: { ...real, provider: 'openai', model: 'whisper-1', inference_completed: true, api_request_completed: true, transcript: 'hello' },
llm: { ...real, provider: 'openai', model: 'gpt-real', api_request_completed: true, streamed: true, first_token_seconds: 0.1, response: 'Hi.' },
tts: { ...real, provider: 'siliconflow', model: 'CosyVoice', api_request_completed: true, first_audio_byte_seconds: 0.1, output_path: 'output.mp3', output_sha256: sha256(path.join(dir, 'output.mp3')), output_bytes: 2000, output_duration_seconds: 1 },
},
};
}
describe('Experiment 6-3 strict evidence gates', () => {
let dir;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'exp6-3-')); });
afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
it('accepts direct real media and all four real stages', () => {
assert.strictEqual(validateExperimentEvidence(fixture(dir), dir).passed, true);
});
for (const field of ['mock', 'probe_only', 'fallback_used']) {
it(`rejects a stage marked ${field}`, () => {
const evidence = fixture(dir); evidence.stages.asr[field] = true;
assert.strictEqual(validateExperimentEvidence(evidence, dir).passed, false);
});
}
it('rejects a forced VAD flush and missing 500ms silence', () => {
const evidence = fixture(dir);
evidence.stages.vad.forced_endpoint = true;
evidence.stages.vad.observed_trailing_silence_ms = 100;
assert.strictEqual(validateExperimentEvidence(evidence, dir).passed, false);
});
it('rejects README claims without hashed output media', () => {
const evidence = fixture(dir); evidence.stages.tts.output_sha256 = 'claimed-only';
assert.strictEqual(validateExperimentEvidence(evidence, dir).passed, false);
});
it('rejects evidence without reproducibility provenance', () => {
const evidence = fixture(dir); delete evidence.provenance.runtime.onnxruntime_node;
assert.strictEqual(validateExperimentEvidence(evidence, dir).passed, false);
});
});
@@ -0,0 +1,560 @@
const assert = require('assert');
// Import LLM provider factory
const { LLMProviderFactory } = require('../utils/providers/llmProviders');
// Test configuration
const testConfig = {
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
ARK_API_KEY: process.env.ARK_API_KEY,
SILICONFLOW_API_KEY: process.env.SILICONFLOW_API_KEY,
LLM_PROVIDERS: {
openai: {
apiUrl: 'https://api.openai.com/v1/chat/completions',
model: 'gpt-5.6-luna',
apiKey: 'OPENAI_API_KEY'
},
'openrouter-gpt': {
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
model: 'openai/gpt-5.6-luna',
apiKey: 'OPENROUTER_API_KEY'
},
'openrouter-gemini': {
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
model: 'google/gemini-3.5-flash',
apiKey: 'OPENROUTER_API_KEY'
},
ark: {
apiUrl: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
model: 'doubao-seed-1-6-flash-250615',
apiKey: 'ARK_API_KEY'
}
},
VISION_MAX_TOKENS: 4096
};
// Test messages for different scenarios
const testMessages = {
simple: [
{ role: 'system', content: 'You are a helpful AI assistant.' },
{ role: 'user', content: 'Hello! Please respond with exactly the word "SUCCESS" to confirm you are working.' }
],
conversation: [
{ role: 'system', content: 'You are a conversational AI assistant.' },
{ role: 'user', content: 'What is the capital of France?' },
{ role: 'assistant', content: 'The capital of France is Paris.' },
{ role: 'user', content: 'What is its population?' }
],
creative: [
{ role: 'system', content: 'You are a creative writing assistant.' },
{ role: 'user', content: 'Write a very short story about a robot learning to paint. Keep it under 50 words.' }
]
};
/**
* Collect streaming response data
*/
async function collectStreamingResponse(response, timeout = 30000) {
return new Promise((resolve, reject) => {
let buffer = '';
let accumulatedContent = '';
let firstTokenTime = null;
let tokenCount = 0;
const timeoutHandle = setTimeout(() => {
reject(new Error('Streaming response timeout'));
}, timeout);
response.data.on('data', (chunk) => {
try {
if (!firstTokenTime) {
firstTokenTime = Date.now();
}
buffer += chunk.toString();
const lines = buffer.split('\n');
// Keep the last line if it's incomplete
buffer = lines.pop() || '';
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine === '[DONE]') continue;
if (!trimmedLine.startsWith('data: ')) continue;
try {
const jsonData = JSON.parse(trimmedLine.replace('data: ', ''));
const content = jsonData.choices[0]?.delta?.content || '';
if (content) {
accumulatedContent += content;
tokenCount++;
}
} catch (parseError) {
// Skip malformed JSON
continue;
}
}
} catch (error) {
reject(error);
}
});
response.data.on('end', () => {
clearTimeout(timeoutHandle);
resolve({
content: accumulatedContent,
tokenCount: tokenCount,
firstTokenTime: firstTokenTime,
success: true
});
});
response.data.on('error', (error) => {
clearTimeout(timeoutHandle);
reject(error);
});
});
}
/**
* LLM Provider Individual Tests
*/
describe('LLM Providers - Individual Testing', function() {
this.timeout(120000); // 2 minute timeout for API calls
describe('OpenAI GPT-4o LLM Provider', function() {
let provider;
before(function() {
if (!testConfig.OPENAI_API_KEY) {
console.log('⚠️ Skipping OpenAI LLM tests - OPENAI_API_KEY not found');
this.skip();
}
try {
provider = LLMProviderFactory.createProvider('openai', testConfig, testConfig);
console.log('✅ OpenAI LLM Provider created successfully');
} catch (error) {
console.error('❌ Failed to create OpenAI LLM provider:', error);
this.skip();
}
});
it('should initialize with correct configuration', function() {
assert(provider, 'Provider should be created');
assert.strictEqual(provider.config.model, 'gpt-5.6-luna', 'Should use gpt-5.6-luna model');
assert.strictEqual(provider.config.apiUrl, 'https://api.openai.com/v1/chat/completions', 'Should use correct API URL');
assert(provider.apiKey, 'Should have API key');
console.log('📋 OpenAI LLM Config:', {
model: provider.config.model,
apiUrl: provider.config.apiUrl
});
});
it('should generate simple chat completion', async function() {
console.log('🤖 Testing OpenAI LLM with simple message');
const startTime = Date.now();
const result = await provider.createChatCompletion(testMessages.simple, {
max_tokens: 100
});
const responseTime = Date.now() - startTime;
console.log('📊 OpenAI LLM Simple Test:', {
success: result.success,
responseTime: `${responseTime}ms`,
provider: result.provider
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
assert.strictEqual(result.provider, 'openai', 'Should identify as openai provider');
if (result.success) {
assert(result.response, 'Should have response object');
console.log('✅ OpenAI LLM simple completion successful');
} else {
console.log('❌ OpenAI LLM simple completion failed:', result.error);
}
});
it('should handle streaming chat completion', async function() {
console.log('🌊 Testing OpenAI LLM streaming');
const startTime = Date.now();
const result = await provider.createChatCompletion(testMessages.simple, {
max_tokens: 50,
stream: true
});
if (!result.success) {
console.log('❌ OpenAI LLM streaming setup failed:', result.error);
assert(false, 'Streaming setup should succeed');
return;
}
try {
const streamResult = await collectStreamingResponse(result.response);
const totalTime = Date.now() - startTime;
const timeToFirstToken = streamResult.firstTokenTime ? streamResult.firstTokenTime - startTime : 0;
console.log('📊 OpenAI LLM Streaming Results:', {
success: streamResult.success,
totalTime: `${totalTime}ms`,
timeToFirstToken: `${timeToFirstToken}ms`,
tokenCount: streamResult.tokenCount,
contentLength: streamResult.content.length
});
assert(streamResult.success, 'Streaming should be successful');
assert(streamResult.content.length > 0, 'Should receive content');
assert(streamResult.tokenCount > 0, 'Should receive tokens');
if (streamResult.content.length > 0) {
console.log('📝 Generated content preview:', streamResult.content.substring(0, 100) + (streamResult.content.length > 100 ? '...' : ''));
}
console.log('✅ OpenAI LLM streaming successful');
} catch (streamError) {
console.error('❌ OpenAI LLM streaming failed:', streamError.message);
assert(false, 'Streaming should not fail: ' + streamError.message);
}
});
it('should handle conversation context', async function() {
console.log('💬 Testing OpenAI LLM with conversation context');
const result = await provider.createChatCompletion(testMessages.conversation, {
max_tokens: 100
});
console.log('📊 OpenAI LLM Conversation Test:', {
success: result.success,
provider: result.provider
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
if (result.success) {
console.log('✅ OpenAI LLM conversation handling successful');
} else {
console.log('❌ OpenAI LLM conversation handling failed:', result.error);
}
});
});
describe('OpenRouter GPT LLM Provider', function() {
let provider;
before(function() {
if (!testConfig.OPENROUTER_API_KEY) {
console.log('⚠️ Skipping OpenRouter GPT tests - OPENROUTER_API_KEY not found');
this.skip();
}
try {
provider = LLMProviderFactory.createProvider('openrouter-gpt', testConfig, testConfig);
console.log('✅ OpenRouter GPT Provider created successfully');
} catch (error) {
console.error('❌ Failed to create OpenRouter GPT provider:', error);
this.skip();
}
});
it('should initialize with correct configuration', function() {
assert(provider, 'Provider should be created');
assert.strictEqual(provider.config.model, 'openai/gpt-5.6-luna', 'Should use openai/gpt-5.6-luna model');
assert.strictEqual(provider.config.apiUrl, 'https://openrouter.ai/api/v1/chat/completions', 'Should use OpenRouter API URL');
assert(provider.apiKey, 'Should have API key');
console.log('📋 OpenRouter GPT Config:', {
model: provider.config.model,
apiUrl: provider.config.apiUrl
});
});
it('should generate chat completion via OpenRouter', async function() {
console.log('🤖 Testing OpenRouter GPT');
const startTime = Date.now();
const result = await provider.createChatCompletion(testMessages.simple, {
max_tokens: 100
});
const responseTime = Date.now() - startTime;
console.log('📊 OpenRouter GPT Test:', {
success: result.success,
responseTime: `${responseTime}ms`,
provider: result.provider
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
assert.strictEqual(result.provider, 'openrouter', 'Should identify as openrouter provider');
if (result.success) {
console.log('✅ OpenRouter GPT completion successful');
} else {
console.log('❌ OpenRouter GPT completion failed:', result.error);
}
});
it('should handle streaming via OpenRouter', async function() {
console.log('🌊 Testing OpenRouter GPT streaming');
const result = await provider.createChatCompletion(testMessages.simple, {
max_tokens: 50,
stream: true
});
if (!result.success) {
console.log('❌ OpenRouter GPT streaming setup failed:', result.error);
return; // Don't fail the test, just log
}
try {
const streamResult = await collectStreamingResponse(result.response);
console.log('📊 OpenRouter GPT Streaming:', {
success: streamResult.success,
tokenCount: streamResult.tokenCount,
contentLength: streamResult.content.length
});
if (streamResult.success) {
console.log('✅ OpenRouter GPT streaming successful');
}
} catch (streamError) {
console.log('❌ OpenRouter GPT streaming error:', streamError.message);
}
});
});
describe('OpenRouter Gemini LLM Provider', function() {
let provider;
before(function() {
if (!testConfig.OPENROUTER_API_KEY) {
console.log('⚠️ Skipping OpenRouter Gemini tests - OPENROUTER_API_KEY not found');
this.skip();
}
try {
provider = LLMProviderFactory.createProvider('openrouter-gemini', testConfig, testConfig);
console.log('✅ OpenRouter Gemini Provider created successfully');
} catch (error) {
console.error('❌ Failed to create OpenRouter Gemini provider:', error);
this.skip();
}
});
it('should initialize with correct configuration', function() {
assert(provider, 'Provider should be created');
assert.strictEqual(provider.config.model, 'google/gemini-3.5-flash', 'Should use gemini-3.5-flash model');
assert.strictEqual(provider.config.apiUrl, 'https://openrouter.ai/api/v1/chat/completions', 'Should use OpenRouter API URL');
assert(provider.apiKey, 'Should have API key');
console.log('📋 OpenRouter Gemini Config:', {
model: provider.config.model,
apiUrl: provider.config.apiUrl
});
});
it('should generate chat completion with Gemini', async function() {
console.log('🤖 Testing OpenRouter Gemini');
const startTime = Date.now();
const result = await provider.createChatCompletion(testMessages.simple, {
max_tokens: 100
});
const responseTime = Date.now() - startTime;
console.log('📊 OpenRouter Gemini Test:', {
success: result.success,
responseTime: `${responseTime}ms`,
provider: result.provider
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
assert.strictEqual(result.provider, 'openrouter', 'Should identify as openrouter provider');
if (result.success) {
console.log('✅ OpenRouter Gemini completion successful');
} else {
console.log('❌ OpenRouter Gemini completion failed:', result.error);
}
});
it('should handle creative tasks with Gemini', async function() {
console.log('🎨 Testing OpenRouter Gemini creative task');
const result = await provider.createChatCompletion(testMessages.creative, {
max_tokens: 150
});
console.log('📊 OpenRouter Gemini Creative Test:', {
success: result.success,
provider: result.provider
});
if (result.success) {
console.log('✅ OpenRouter Gemini creative task successful');
} else {
console.log('❌ OpenRouter Gemini creative task failed:', result.error);
}
});
});
describe('ARK Doubao LLM Provider', function() {
let provider;
before(function() {
if (!testConfig.ARK_API_KEY) {
console.log('⚠️ Skipping ARK Doubao tests - ARK_API_KEY not found');
this.skip();
}
try {
provider = LLMProviderFactory.createProvider('ark', testConfig, testConfig);
console.log('✅ ARK Doubao Provider created successfully');
} catch (error) {
console.error('❌ Failed to create ARK Doubao provider:', error);
this.skip();
}
});
it('should initialize with correct configuration', function() {
assert(provider, 'Provider should be created');
assert.strictEqual(provider.config.model, 'doubao-seed-1-6-flash-250615', 'Should use doubao model');
assert.strictEqual(provider.config.apiUrl, 'https://ark.cn-beijing.volces.com/api/v3/chat/completions', 'Should use ARK API URL');
assert(provider.apiKey, 'Should have API key');
console.log('📋 ARK Doubao Config:', {
model: provider.config.model,
apiUrl: provider.config.apiUrl
});
});
it('should generate chat completion with Doubao', async function() {
console.log('🤖 Testing ARK Doubao');
const startTime = Date.now();
const result = await provider.createChatCompletion(testMessages.simple, {
max_tokens: 100
});
const responseTime = Date.now() - startTime;
console.log('📊 ARK Doubao Test:', {
success: result.success,
responseTime: `${responseTime}ms`,
provider: result.provider
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
assert.strictEqual(result.provider, 'ark', 'Should identify as ark provider');
if (result.success) {
console.log('✅ ARK Doubao completion successful');
} else {
console.log('❌ ARK Doubao completion failed:', result.error);
}
});
it('should handle Chinese language tasks', async function() {
console.log('🇨🇳 Testing ARK Doubao with Chinese');
const chineseMessages = [
{ role: 'system', content: '你是一个有用的AI助手。' },
{ role: 'user', content: '请用一句话介绍北京。' }
];
const result = await provider.createChatCompletion(chineseMessages, {
max_tokens: 100
});
console.log('📊 ARK Doubao Chinese Test:', {
success: result.success,
provider: result.provider
});
if (result.success) {
console.log('✅ ARK Doubao Chinese handling successful');
} else {
console.log('❌ ARK Doubao Chinese handling failed:', result.error);
}
});
});
describe('LLM Provider Performance Comparison', function() {
it('should compare provider response times', async function() {
const availableProviders = [];
if (testConfig.OPENAI_API_KEY) availableProviders.push('openai');
if (testConfig.OPENROUTER_API_KEY) {
availableProviders.push('openrouter-gpt', 'openrouter-gemini');
}
if (testConfig.ARK_API_KEY) availableProviders.push('ark');
if (availableProviders.length < 2) {
console.log('⚠️ Skipping provider comparison - need at least 2 providers');
this.skip();
}
const results = {};
console.log('🏁 Comparing LLM provider performance...');
for (const providerName of availableProviders) {
try {
const provider = LLMProviderFactory.createProvider(providerName, testConfig, testConfig);
const startTime = Date.now();
const result = await provider.createChatCompletion(testMessages.simple, {
max_tokens: 50
});
const duration = Date.now() - startTime;
results[providerName] = {
success: result.success,
duration: duration,
error: result.error
};
} catch (error) {
results[providerName] = {
success: false,
duration: 0,
error: error.message
};
}
}
console.log('📊 LLM Provider Performance Comparison:');
Object.entries(results).forEach(([provider, result]) => {
console.log(` ${provider}:`, {
success: result.success ? '✅' : '❌',
time: `${result.duration}ms`,
error: result.error || 'none'
});
});
// Find fastest successful provider
const successfulProviders = Object.entries(results).filter(([_, result]) => result.success);
if (successfulProviders.length > 0) {
const fastest = successfulProviders.reduce((prev, curr) =>
prev[1].duration < curr[1].duration ? prev : curr
);
console.log(`🏆 Fastest LLM provider: ${fastest[0]} (${fastest[1].duration}ms)`);
}
// At least one provider should work
assert(successfulProviders.length > 0, 'At least one LLM provider should be successful');
});
});
});
module.exports = {
testConfig,
testMessages,
collectStreamingResponse
};
@@ -0,0 +1,424 @@
const assert = require('assert');
const axios = require('axios');
const fs = require('fs');
const path = require('path');
// Test configuration
const testConfig = {
SILICONFLOW_API_KEY: process.env.SILICONFLOW_API_KEY,
TTS_PROVIDERS: {
siliconflow: {
apiUrl: 'https://api.siliconflow.cn/v1/audio/speech',
model: 'FunAudioLLM/CosyVoice2-0.5B',
voice: 'FunAudioLLM/CosyVoice2-0.5B:diana',
apiKey: 'SILICONFLOW_API_KEY'
}
}
};
// Test texts for different scenarios
const testTexts = {
simple: 'Hello, this is a simple text-to-speech test.',
multilingual: 'Hello world. 你好世界. こんにちは世界.',
punctuation: 'Testing punctuation: question? exclamation! comma, period.',
numbers: 'The year is 2025, and the time is 12:34 PM.',
long: 'This is a longer text to test the text-to-speech synthesis capability. It contains multiple sentences and should demonstrate the natural flow of speech generation. The quality and naturalness of the audio output will be evaluated.'
};
/**
* TTS Provider for CosyVoice2 via Siliconflow
*/
class SiliconflowTTSProvider {
constructor(config, apiKey) {
this.config = config;
this.apiKey = apiKey;
}
async synthesize(text, options = {}) {
try {
const response = await axios({
method: 'post',
url: this.config.apiUrl,
data: {
model: this.config.model,
input: text,
voice: options.voice || this.config.voice,
response_format: options.format || 'mp3',
sample_rate: options.sampleRate || 32000,
stream: options.stream || false,
speed: options.speed || 1.0,
gain: options.gain || 0
},
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
responseType: 'arraybuffer',
timeout: 60000 // 60 second timeout
});
const result = {
success: true,
audioData: Buffer.from(response.data),
format: options.format || 'mp3',
sampleRate: options.sampleRate || 32000,
provider: 'siliconflow',
timestamp: Date.now()
};
return result;
} catch (error) {
console.error('Siliconflow TTS error:', error.response?.data || error.message);
return {
success: false,
audioData: null,
error: error.response?.data?.error?.message || error.message,
provider: 'siliconflow',
timestamp: Date.now()
};
}
}
}
/**
* TTS Provider Individual Tests
*/
describe('TTS Providers - Individual Testing', function() {
this.timeout(120000); // 2 minute timeout for API calls
const tempDir = path.join(__dirname, '../temp');
before(function() {
// Ensure temp directory exists
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
});
describe('CosyVoice2 (Siliconflow) TTS Provider', function() {
let provider;
before(function() {
if (!testConfig.SILICONFLOW_API_KEY) {
console.log('⚠️ Skipping Siliconflow TTS tests - SILICONFLOW_API_KEY not found');
this.skip();
}
try {
const providerConfig = testConfig.TTS_PROVIDERS.siliconflow;
provider = new SiliconflowTTSProvider(providerConfig, testConfig.SILICONFLOW_API_KEY);
console.log('✅ Siliconflow TTS Provider created successfully');
} catch (error) {
console.error('❌ Failed to create Siliconflow TTS provider:', error);
this.skip();
}
});
it('should initialize with correct configuration', function() {
assert(provider, 'Provider should be created');
assert.strictEqual(provider.config.model, 'FunAudioLLM/CosyVoice2-0.5B', 'Should use CosyVoice2 model');
assert.strictEqual(provider.config.voice, 'FunAudioLLM/CosyVoice2-0.5B:diana', 'Should use diana voice');
assert.strictEqual(provider.config.apiUrl, 'https://api.siliconflow.cn/v1/audio/speech', 'Should use correct API URL');
assert(provider.apiKey, 'Should have API key');
console.log('📋 Siliconflow TTS Config:', {
model: provider.config.model,
voice: provider.config.voice,
apiUrl: provider.config.apiUrl
});
});
it('should synthesize simple text to speech', async function() {
console.log('🎵 Testing Siliconflow TTS with simple text');
const startTime = Date.now();
const result = await provider.synthesize(testTexts.simple);
const synthesisTime = Date.now() - startTime;
console.log('📊 Siliconflow TTS Simple Test:', {
success: result.success,
synthesisTime: `${synthesisTime}ms`,
audioSize: result.audioData ? `${result.audioData.length} bytes` : 'none',
format: result.format,
sampleRate: result.sampleRate,
provider: result.provider
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
assert.strictEqual(result.provider, 'siliconflow', 'Should identify as siliconflow provider');
if (result.success) {
assert(result.audioData, 'Should have audio data');
assert(result.audioData.length > 0, 'Audio data should not be empty');
assert.strictEqual(result.format, 'mp3', 'Should return MP3 format');
// Save test audio file
const testAudioPath = path.join(tempDir, `tts_simple_${Date.now()}.mp3`);
fs.writeFileSync(testAudioPath, result.audioData);
console.log('💾 Saved test audio to:', testAudioPath);
console.log('✅ Siliconflow TTS simple synthesis successful');
} else {
console.log('❌ Siliconflow TTS simple synthesis failed:', result.error);
}
});
it('should handle multilingual text', async function() {
console.log('🌍 Testing Siliconflow TTS with multilingual text');
const result = await provider.synthesize(testTexts.multilingual);
console.log('📊 Siliconflow TTS Multilingual Test:', {
success: result.success,
audioSize: result.audioData ? `${result.audioData.length} bytes` : 'none',
provider: result.provider
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
if (result.success) {
assert(result.audioData, 'Should have audio data for multilingual text');
assert(result.audioData.length > 0, 'Multilingual audio data should not be empty');
// Save multilingual test audio
const testAudioPath = path.join(tempDir, `tts_multilingual_${Date.now()}.mp3`);
fs.writeFileSync(testAudioPath, result.audioData);
console.log('💾 Saved multilingual audio to:', testAudioPath);
console.log('✅ Siliconflow TTS multilingual synthesis successful');
} else {
console.log('❌ Siliconflow TTS multilingual synthesis failed:', result.error);
}
});
it('should handle different audio formats and settings', async function() {
console.log('⚙️ Testing Siliconflow TTS with different settings');
const settings = [
{ format: 'mp3', sampleRate: 24000, speed: 1.0 },
{ format: 'mp3', sampleRate: 32000, speed: 1.2 },
{ format: 'mp3', sampleRate: 16000, speed: 0.8 }
];
for (const setting of settings) {
const result = await provider.synthesize(testTexts.simple, setting);
console.log(`📊 TTS Settings Test (${setting.format}, ${setting.sampleRate}Hz, ${setting.speed}x):`, {
success: result.success,
audioSize: result.audioData ? `${result.audioData.length} bytes` : 'none',
error: result.error || 'none'
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
if (result.success) {
assert(result.audioData, 'Should have audio data');
assert(result.audioData.length > 0, 'Audio data should not be empty');
// Save test audio with settings info
const filename = `tts_settings_${setting.sampleRate}hz_${setting.speed}x_${Date.now()}.mp3`;
const testAudioPath = path.join(tempDir, filename);
fs.writeFileSync(testAudioPath, result.audioData);
console.log(`💾 Saved settings test audio to: ${testAudioPath}`);
}
}
console.log('✅ Siliconflow TTS settings variation tests completed');
});
it('should handle punctuation and numbers correctly', async function() {
console.log('🔢 Testing Siliconflow TTS with punctuation and numbers');
const punctuationResult = await provider.synthesize(testTexts.punctuation);
const numbersResult = await provider.synthesize(testTexts.numbers);
console.log('📊 Siliconflow TTS Punctuation Test:', {
success: punctuationResult.success,
audioSize: punctuationResult.audioData ? `${punctuationResult.audioData.length} bytes` : 'none'
});
console.log('📊 Siliconflow TTS Numbers Test:', {
success: numbersResult.success,
audioSize: numbersResult.audioData ? `${numbersResult.audioData.length} bytes` : 'none'
});
assert(punctuationResult, 'Should handle punctuation');
assert(numbersResult, 'Should handle numbers');
if (punctuationResult.success && numbersResult.success) {
console.log('✅ Siliconflow TTS punctuation and numbers handling successful');
}
});
it('should handle longer text synthesis', async function() {
console.log('📝 Testing Siliconflow TTS with longer text');
const startTime = Date.now();
const result = await provider.synthesize(testTexts.long);
const synthesisTime = Date.now() - startTime;
console.log('📊 Siliconflow TTS Long Text Test:', {
success: result.success,
synthesisTime: `${synthesisTime}ms`,
audioSize: result.audioData ? `${result.audioData.length} bytes` : 'none',
textLength: testTexts.long.length
});
assert(result, 'Should return a result');
assert(typeof result.success === 'boolean', 'Should have success field');
if (result.success) {
assert(result.audioData, 'Should have audio data for long text');
assert(result.audioData.length > 0, 'Long text audio data should not be empty');
// Long text should produce more audio data
const expectedMinSize = 50000; // Roughly 50KB minimum for longer text
if (result.audioData.length > expectedMinSize) {
console.log('✅ Long text produced appropriate amount of audio data');
}
// Save long text audio
const testAudioPath = path.join(tempDir, `tts_long_${Date.now()}.mp3`);
fs.writeFileSync(testAudioPath, result.audioData);
console.log('💾 Saved long text audio to:', testAudioPath);
console.log('✅ Siliconflow TTS long text synthesis successful');
} else {
console.log('❌ Siliconflow TTS long text synthesis failed:', result.error);
}
});
it('should handle empty or invalid text gracefully', async function() {
console.log('🔍 Testing Siliconflow TTS with edge cases');
const emptyResult = await provider.synthesize('');
const spaceResult = await provider.synthesize(' ');
const specialResult = await provider.synthesize('!@#$%^&*()');
console.log('📊 Siliconflow TTS Edge Cases:', {
empty: { success: emptyResult.success, error: emptyResult.error || 'none' },
spaces: { success: spaceResult.success, error: spaceResult.error || 'none' },
special: { success: specialResult.success, error: specialResult.error || 'none' }
});
// These should either work or fail gracefully
assert(emptyResult, 'Should handle empty string');
assert(spaceResult, 'Should handle whitespace');
assert(specialResult, 'Should handle special characters');
console.log('✅ Siliconflow TTS edge cases handled');
});
it('should measure synthesis performance metrics', async function() {
console.log('🏁 Testing Siliconflow TTS performance metrics');
const testText = testTexts.simple;
const iterations = 3;
const results = [];
for (let i = 0; i < iterations; i++) {
const startTime = Date.now();
const result = await provider.synthesize(testText);
const duration = Date.now() - startTime;
if (result.success) {
results.push({
duration: duration,
audioSize: result.audioData.length,
success: true
});
} else {
results.push({
duration: duration,
audioSize: 0,
success: false,
error: result.error
});
}
}
const successfulResults = results.filter(r => r.success);
if (successfulResults.length > 0) {
const avgDuration = successfulResults.reduce((sum, r) => sum + r.duration, 0) / successfulResults.length;
const avgAudioSize = successfulResults.reduce((sum, r) => sum + r.audioSize, 0) / successfulResults.length;
const minDuration = Math.min(...successfulResults.map(r => r.duration));
const maxDuration = Math.max(...successfulResults.map(r => r.duration));
console.log('📊 Siliconflow TTS Performance Metrics:', {
successRate: `${successfulResults.length}/${iterations}`,
avgSynthesisTime: `${Math.round(avgDuration)}ms`,
minSynthesisTime: `${minDuration}ms`,
maxSynthesisTime: `${maxDuration}ms`,
avgAudioSize: `${Math.round(avgAudioSize)} bytes`,
textLength: testText.length
});
// Performance expectations
assert(avgDuration < 10000, 'Average synthesis time should be under 10 seconds');
assert(avgAudioSize > 1000, 'Should produce reasonable amount of audio data');
console.log('✅ Siliconflow TTS performance metrics acceptable');
} else {
console.log('❌ No successful TTS synthesis results for performance testing');
}
assert(successfulResults.length > 0, 'At least one synthesis attempt should succeed');
});
});
describe('TTS Provider Integration', function() {
it('should integrate with live audio system', function() {
if (!testConfig.SILICONFLOW_API_KEY) {
console.log('⚠️ Skipping TTS integration test - SILICONFLOW_API_KEY not found');
this.skip();
}
// Simulate the TTS configuration that would be used in the live system
const liveConfig = {
TTS_API_URL: 'https://api.siliconflow.cn/v1/audio/speech',
TTS_PROVIDERS: testConfig.TTS_PROVIDERS,
TTS_PROVIDER: 'siliconflow',
SILICONFLOW_API_KEY: testConfig.SILICONFLOW_API_KEY
};
assert(liveConfig.TTS_API_URL, 'Should have TTS API URL');
assert(liveConfig.TTS_PROVIDERS.siliconflow, 'Should have Siliconflow TTS provider config');
assert(liveConfig.SILICONFLOW_API_KEY, 'Should have Siliconflow API key');
console.log('📋 Live System TTS Integration Config:', {
provider: liveConfig.TTS_PROVIDER,
model: liveConfig.TTS_PROVIDERS.siliconflow.model,
voice: liveConfig.TTS_PROVIDERS.siliconflow.voice,
hasApiKey: !!liveConfig.SILICONFLOW_API_KEY
});
console.log('✅ TTS provider integration configuration valid');
});
});
after(function() {
// Cleanup temp audio files
try {
const files = fs.readdirSync(tempDir);
let cleanedCount = 0;
files.forEach(file => {
if (file.startsWith('tts_') && file.endsWith('.mp3')) {
fs.unlinkSync(path.join(tempDir, file));
cleanedCount++;
}
});
console.log(`🧹 Cleaned up ${cleanedCount} temporary TTS audio files`);
} catch (error) {
console.warn('⚠️ Failed to cleanup temp TTS files:', error.message);
}
});
});
module.exports = {
testConfig,
testTexts,
SiliconflowTTSProvider
};
@@ -0,0 +1,276 @@
const axios = require('axios');
const FormData = require('form-data');
const fs = require('fs');
const path = require('path');
/**
* Base ASR Provider class
*/
class BaseASRProvider {
constructor(config, apiKey) {
this.config = config;
this.apiKey = apiKey;
}
/**
* Convert raw audio buffer to WAV format
* @param {Buffer} audioBuffer - Raw PCM audio data
* @param {Object} options - Audio format options
* @returns {Buffer} WAV formatted audio data
*/
createWavBuffer(audioBuffer, options = {}) {
const sampleRate = options.sampleRate || 16000;
const channels = options.channels || 1;
const bitsPerSample = options.bitsPerSample || 16;
const byteRate = sampleRate * channels * bitsPerSample / 8;
const blockAlign = channels * bitsPerSample / 8;
const dataSize = audioBuffer.length;
const fileSize = 36 + dataSize;
const header = Buffer.alloc(44);
// RIFF header
header.write('RIFF', 0);
header.writeUInt32LE(fileSize, 4);
header.write('WAVE', 8);
// fmt chunk
header.write('fmt ', 12);
header.writeUInt32LE(16, 16); // PCM format chunk size
header.writeUInt16LE(1, 20); // PCM format
header.writeUInt16LE(channels, 22);
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(byteRate, 28);
header.writeUInt16LE(blockAlign, 32);
header.writeUInt16LE(bitsPerSample, 34);
// data chunk
header.write('data', 36);
header.writeUInt32LE(dataSize, 40);
return Buffer.concat([header, audioBuffer]);
}
async transcribe(audioBuffer, options = {}) {
throw new Error('transcribe method must be implemented by subclass');
}
}
/**
* OpenAI Whisper ASR Provider
*/
class OpenAIASRProvider extends BaseASRProvider {
async transcribe(audioBuffer, tempDir, options = {}) {
try {
// Create WAV buffer
const wavBuffer = this.createWavBuffer(audioBuffer, {
sampleRate: 16000,
channels: 1,
bitsPerSample: 16
});
// Create temporary file
const tempFileName = `audio_${Date.now()}_${Math.random().toString(36).substring(2)}.wav`;
const tempFilePath = path.join(tempDir, tempFileName);
// Write audio to temporary file
fs.writeFileSync(tempFilePath, wavBuffer);
try {
// Create form data
const formData = new FormData();
formData.append('file', fs.createReadStream(tempFilePath));
formData.append('model', this.config.model);
formData.append('response_format', 'json');
if (options.language) {
formData.append('language', options.language);
}
if (options.prompt) {
formData.append('prompt', options.prompt);
}
// Make API request
const response = await axios({
method: 'post',
url: this.config.apiUrl,
data: formData,
headers: {
'Authorization': `Bearer ${this.apiKey}`,
...formData.getHeaders()
},
timeout: 30000 // 30 second timeout
});
const result = {
success: true,
text: response.data.text || '',
language: response.data.language || 'unknown',
duration: response.data.duration || 0,
confidence: response.data.confidence || 1.0,
requestId: response.headers?.['x-request-id'] || response.headers?.['request-id'] || null,
responseModel: response.data.model || this.config.model,
timestamp: Date.now(),
provider: 'openai'
};
console.log('OpenAI ASR Result:', {
text: result.text,
language: result.language,
duration: result.duration
});
return result;
} finally {
// Clean up temporary file
try {
fs.unlinkSync(tempFilePath);
} catch (cleanupError) {
console.warn('Failed to cleanup temp file:', cleanupError.message);
}
}
} catch (error) {
console.error('OpenAI ASR error:', error.response?.data || error.message);
return {
success: false,
text: '',
error: error.response?.data?.error?.message || error.message,
timestamp: Date.now(),
provider: 'openai'
};
}
}
}
/**
* SenseVoice via Siliconflow ASR Provider
*/
class SiliconflowASRProvider extends BaseASRProvider {
async transcribe(audioBuffer, tempDir, options = {}) {
try {
// Create WAV buffer
const wavBuffer = this.createWavBuffer(audioBuffer, {
sampleRate: 16000,
channels: 1,
bitsPerSample: 16
});
// Create temporary file
const tempFileName = `audio_${Date.now()}_${Math.random().toString(36).substring(2)}.wav`;
const tempFilePath = path.join(tempDir, tempFileName);
// Write audio to temporary file
fs.writeFileSync(tempFilePath, wavBuffer);
try {
// Create form data
const formData = new FormData();
formData.append('file', fs.createReadStream(tempFilePath));
formData.append('model', this.config.model);
formData.append('response_format', 'json');
// SenseVoice specific parameters
if (options.language) {
formData.append('language', options.language);
} else {
// SenseVoice supports auto language detection
formData.append('language', 'auto');
}
if (options.prompt) {
formData.append('prompt', options.prompt);
}
// Make API request
const response = await axios({
method: 'post',
url: this.config.apiUrl,
data: formData,
headers: {
'Authorization': `Bearer ${this.apiKey}`,
...formData.getHeaders()
},
timeout: 30000 // 30 second timeout
});
const result = {
success: true,
text: response.data.text || '',
language: response.data.language || 'unknown',
duration: response.data.duration || 0,
confidence: response.data.confidence || 1.0,
requestId: response.headers?.['x-request-id'] || response.headers?.['request-id'] || null,
responseModel: response.data.model || this.config.model,
timestamp: Date.now(),
provider: 'siliconflow'
};
console.log('SenseVoice ASR Result:', {
text: result.text,
language: result.language,
duration: result.duration
});
return result;
} finally {
// Clean up temporary file
try {
fs.unlinkSync(tempFilePath);
} catch (cleanupError) {
console.warn('Failed to cleanup temp file:', cleanupError.message);
}
}
} catch (error) {
console.error('SenseVoice ASR error:', error.response?.data || error.message);
return {
success: false,
text: '',
error: error.response?.data?.error?.message || error.message,
timestamp: Date.now(),
provider: 'siliconflow'
};
}
}
}
/**
* ASR Provider Factory
*/
class ASRProviderFactory {
static createProvider(providerName, config, globalConfig) {
const providerConfig = config.ASR_PROVIDERS[providerName];
if (!providerConfig) {
throw new Error(`ASR provider ${providerName} not found in configuration`);
}
// Get API key from global config
const apiKey = globalConfig[providerConfig.apiKey];
if (!apiKey) {
throw new Error(`API key ${providerConfig.apiKey} not found in configuration`);
}
switch (providerName) {
case 'openai':
return new OpenAIASRProvider(providerConfig, apiKey);
case 'siliconflow':
return new SiliconflowASRProvider(providerConfig, apiKey);
default:
throw new Error(`Unsupported ASR provider: ${providerName}`);
}
}
}
module.exports = {
BaseASRProvider,
OpenAIASRProvider,
SiliconflowASRProvider,
ASRProviderFactory
};
@@ -0,0 +1,248 @@
const axios = require('axios');
function providerErrorMessage(error) {
const data = error.response?.data;
if (data && typeof data === 'object' && !data.readable) {
return data.error?.message || data.message || error.message;
}
return error.response?.status
? `HTTP ${error.response.status}: ${error.response.statusText || error.message}`
: error.message;
}
/**
* Base LLM Provider class
*/
class BaseLLMProvider {
constructor(config, apiKey) {
this.config = config;
this.apiKey = apiKey;
}
async createChatCompletion(messages, options = {}) {
throw new Error('createChatCompletion method must be implemented by subclass');
}
/**
* Prepare headers for API request
* @returns {Object} Headers object
*/
getHeaders() {
return {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
};
}
/**
* Prepare request payload
* @param {Array} messages - Chat messages
* @param {Object} options - Additional options
* @returns {Object} Request payload
*/
getRequestPayload(messages, options = {}) {
return {
model: this.config.model,
messages: messages,
stream: true,
max_tokens: options.max_tokens || 4096,
...options
};
}
}
/**
* OpenAI LLM Provider
*/
class OpenAILLMProvider extends BaseLLMProvider {
async createChatCompletion(messages, options = {}) {
try {
const payload = this.getRequestPayload(messages, options);
const headers = this.getHeaders();
console.log('OpenAI LLM Request:', {
model: payload.model,
messagesCount: messages.length,
stream: payload.stream
});
const response = await axios.post(
this.config.apiUrl,
payload,
{
headers: headers,
cancelToken: options.cancelToken,
responseType: 'stream'
}
);
return {
success: true,
response: response,
provider: 'openai'
};
} catch (error) {
const message = providerErrorMessage(error);
console.error('OpenAI LLM error:', message);
return {
success: false,
error: message,
provider: 'openai'
};
}
}
}
/**
* OpenRouter LLM Provider (supports both GPT-4o and Gemini)
*/
class OpenRouterLLMProvider extends BaseLLMProvider {
getHeaders() {
return {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://live-audio-chat.local',
'X-Title': 'Live Audio Chat'
};
}
async createChatCompletion(messages, options = {}) {
try {
const payload = this.getRequestPayload(messages, options);
const headers = this.getHeaders();
console.log('OpenRouter LLM Request:', {
model: payload.model,
messagesCount: messages.length,
stream: payload.stream
});
const response = await axios.post(
this.config.apiUrl,
payload,
{
headers: headers,
cancelToken: options.cancelToken,
responseType: 'stream'
}
);
return {
success: true,
response: response,
provider: 'openrouter'
};
} catch (error) {
const message = providerErrorMessage(error);
console.error('OpenRouter LLM error:', message);
return {
success: false,
error: message,
provider: 'openrouter'
};
}
}
}
/**
* ARK (Doubao) LLM Provider
*/
class ARKLLMProvider extends BaseLLMProvider {
getHeaders() {
return {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
};
}
getRequestPayload(messages, options = {}) {
// ARK API format is similar to OpenAI but may have slight differences
return {
model: this.config.model,
messages: messages,
stream: true,
max_tokens: options.max_tokens || 4096,
temperature: options.temperature || 0.7,
...options
};
}
async createChatCompletion(messages, options = {}) {
try {
const payload = this.getRequestPayload(messages, options);
const headers = this.getHeaders();
console.log('ARK (Doubao) LLM Request:', {
model: payload.model,
messagesCount: messages.length,
stream: payload.stream
});
const response = await axios.post(
this.config.apiUrl,
payload,
{
headers: headers,
cancelToken: options.cancelToken,
responseType: 'stream'
}
);
return {
success: true,
response: response,
provider: 'ark'
};
} catch (error) {
const message = providerErrorMessage(error);
console.error('ARK (Doubao) LLM error:', message);
return {
success: false,
error: message,
provider: 'ark'
};
}
}
}
/**
* LLM Provider Factory
*/
class LLMProviderFactory {
static createProvider(providerName, config, globalConfig) {
const providerConfig = config.LLM_PROVIDERS[providerName];
if (!providerConfig) {
throw new Error(`LLM provider ${providerName} not found in configuration`);
}
// Get API key from global config
const apiKey = globalConfig[providerConfig.apiKey];
if (!apiKey) {
throw new Error(`API key ${providerConfig.apiKey} not found in configuration`);
}
switch (providerName) {
case 'openai':
return new OpenAILLMProvider(providerConfig, apiKey);
case 'openrouter':
case 'openrouter-gpt':
case 'openrouter-gemini':
return new OpenRouterLLMProvider(providerConfig, apiKey);
case 'ark':
return new ARKLLMProvider(providerConfig, apiKey);
default:
throw new Error(`Unsupported LLM provider: ${providerName}`);
}
}
}
module.exports = {
BaseLLMProvider,
OpenAILLMProvider,
OpenRouterLLMProvider,
ARKLLMProvider,
LLMProviderFactory
};
@@ -0,0 +1,147 @@
const fs = require('fs');
const path = require('path');
const config = require('../config');
const { ASRProviderFactory } = require('./providers/asrProviders');
class SpeechToTextService {
constructor() {
this.tempDir = path.join(__dirname, '../temp');
this.ensureTempDirectory();
// Initialize ASR provider based on configuration
this.initializeProvider();
}
/**
* Initialize ASR provider based on configuration
*/
initializeProvider() {
try {
const providerName = config.ASR_PROVIDER || 'openai';
this.asrProvider = ASRProviderFactory.createProvider(providerName, config, config);
console.log(`ASR Provider initialized: ${providerName}`);
} catch (error) {
console.error('Failed to initialize ASR provider:', error);
throw error;
}
}
/**
* Switch ASR provider dynamically
* @param {string} providerName - Provider name to switch to
*/
switchProvider(providerName) {
try {
this.asrProvider = ASRProviderFactory.createProvider(providerName, config, config);
console.log(`ASR Provider switched to: ${providerName}`);
} catch (error) {
console.error('Failed to switch ASR provider:', error);
throw error;
}
}
/**
* Ensure temp directory exists
*/
ensureTempDirectory() {
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true });
}
}
/**
* Transcribe audio using the configured ASR provider
* @param {Buffer} audioBuffer - Raw audio data
* @param {Object} options - Transcription options
* @returns {Promise<Object>} Transcription result
*/
async transcribeAudio(audioBuffer, options = {}) {
if (!this.asrProvider) {
throw new Error('ASR provider not initialized');
}
try {
const result = await this.asrProvider.transcribe(audioBuffer, this.tempDir, options);
return result;
} catch (error) {
console.error('Transcription error:', error);
return {
success: false,
text: '',
error: error.message,
timestamp: Date.now(),
provider: this.asrProvider.config ? 'unknown' : 'uninitialized'
};
}
}
/**
* Clean up old temporary files
*/
cleanupTempFiles() {
try {
const files = fs.readdirSync(this.tempDir);
const now = Date.now();
const maxAge = 10 * 60 * 1000; // 10 minutes
files.forEach(file => {
const filePath = path.join(this.tempDir, file);
const stats = fs.statSync(filePath);
if (now - stats.mtime.getTime() > maxAge) {
try {
fs.unlinkSync(filePath);
console.log('Cleaned up old temp file:', file);
} catch (error) {
console.warn('Failed to cleanup old temp file:', file, error.message);
}
}
});
} catch (error) {
console.warn('Failed to cleanup temp directory:', error.message);
}
}
/**
* Check if audio buffer has sufficient content for transcription
* @param {Buffer} audioBuffer - Audio buffer to check
* @returns {boolean} True if buffer has sufficient content
*/
hasSufficientAudio(audioBuffer) {
if (!audioBuffer || audioBuffer.length === 0) {
return false;
}
// Check minimum duration (at least 0.1 seconds of audio)
const minSamples = config.AUDIO_SAMPLE_RATE * 0.1; // 0.1 seconds
const minBytes = minSamples * 2; // 16-bit = 2 bytes per sample
if (audioBuffer.length < minBytes) {
return false;
}
// Since we're using Silero VAD for speech detection, we trust its decision
// and only check for minimum duration. The energy check is redundant and
// can reject valid speech that Silero VAD correctly identified.
return true;
}
/**
* Get current provider information
* @returns {Object} Current provider information
*/
getProviderInfo() {
if (!this.asrProvider) {
return { provider: 'none', status: 'not initialized' };
}
return {
provider: this.asrProvider.constructor.name,
model: this.asrProvider.config.model,
apiUrl: this.asrProvider.config.apiUrl,
status: 'ready'
};
}
}
module.exports = SpeechToTextService;
@@ -0,0 +1,12 @@
const assert = require('assert');
const Module = require('module');
const orig = Module.prototype.require;
Module.prototype.require = function (id) {
if (id === 'emoji-regex') return () => /(?!)/g;
return orig.apply(this, arguments);
};
const { markdownToText } = require('./textProcessor.js');
const out = markdownToText('Call get_user_id then save_to_db');
assert.ok(out.includes('get_user_id'));
assert.ok(out.includes('save_to_db'));
console.log('ok');
@@ -0,0 +1,13 @@
const assert = require('assert');
const Module = require('module');
const orig = Module.prototype.require;
Module.prototype.require = function (id) {
if (id === 'emoji-regex') return () => /(?!)/g;
return orig.apply(this, arguments);
};
const { numberToWords } = require('./textProcessor.js');
assert.strictEqual(numberToWords(0), 'zero');
assert.ok(!numberToWords(1e12).includes('undefined'));
assert.ok(numberToWords(1e12).includes('trillion'));
assert.strictEqual(numberToWords(Infinity), 'Infinity');
console.log('ok');
@@ -0,0 +1,195 @@
const emojiRegex = require('emoji-regex');
// Remove emoji from the sentence
function removeEmoji(sentence) {
const regex = emojiRegex();
return sentence.replace(regex, ' ').trim();
}
// Convert markdown to plain text
function markdownToText(markdown) {
let text = markdown;
// Remove links, keeping only the link text
text = text.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1');
// Remove headers
text = text.replace(/^#+\s*/gm, '');
// Remove bold and italic markers
// Keep snake_case identifiers; only strip markdown __bold__ markers.
text = text.replace(/\*\*/g, '').replace(/\*/g, '')
.replace(/__/g, '');
// Remove blockquotes
text = text.replace(/^>\s*/gm, '');
// Remove horizontal rules
text = text.replace(/[-*_]{3,}/g, '');
// Remove list markers
text = text.replace(/^[-*+]\s*/gm, '');
// Remove code block markers
text = text.replace(/```/g, '');
return text.trim();
}
// Convert numbers to words
function numberToWords(num) {
const ones = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'];
const tens = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
const teens = ['ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'];
const scales = ['', 'thousand', 'million', 'billion', 'trillion', 'quadrillion'];
if (num === 0) return 'zero';
if (!Number.isFinite(num)) return String(num);
function convertGroup(n) {
let result = '';
if (n >= 100) {
result += ones[Math.floor(n / 100)] + ' hundred ';
n %= 100;
}
if (n >= 20) {
result += tens[Math.floor(n / 10)] + ' ';
n %= 10;
if (n > 0) {
result += ones[n] + ' ';
}
} else if (n >= 10) {
result += teens[n - 10] + ' ';
} else if (n > 0) {
result += ones[n] + ' ';
}
return result;
}
let result = '';
let groupIndex = 0;
while (num > 0) {
const group = num % 1000;
if (group !== 0) {
const scale = scales[groupIndex] || '';
result = convertGroup(group) + scale + ' ' + result;
}
num = Math.floor(num / 1000);
groupIndex++;
}
return result.trim();
}
// Pronounce special characters
function pronounceSpecialCharacters(text, isCodeBlock = false) {
const specialCharMap = {
'@': 'at',
'#': 'hash',
'$': 'dollar',
'%': 'percent',
'^': 'caret',
'&': 'ampersand',
'*': 'asterisk',
'_': 'underscore',
'=': 'equals',
'+': 'plus',
'[': 'left square bracket',
']': 'right square bracket',
'{': 'left curly brace',
'}': 'right curly brace',
'|': 'vertical bar',
'\\': 'backslash',
'<': 'less than',
'>': 'greater than',
'/': 'slash',
'`': 'backtick',
'~': 'tilde',
};
const punctuationMap = {
'!': 'exclamation',
'.': 'dot',
',': 'comma',
'?': 'question mark',
';': 'semicolon',
':': 'colon',
'"': 'double quote',
"'": 'single quote',
'-': 'minus',
'(': 'left parenthesis',
')': 'right parenthesis',
};
let processedText = text;
// Replace special characters
Object.entries(specialCharMap).forEach(([char, pronunciation]) => {
processedText = processedText.replace(new RegExp(char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), ` ${pronunciation} `);
});
// Replace punctuation if in code block
if (isCodeBlock) {
Object.entries(punctuationMap).forEach(([char, pronunciation]) => {
processedText = processedText.replace(new RegExp(char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), ` ${pronunciation} `);
});
}
return processedText;
}
// Pronounce numbers in text
function pronounceNumbers(text, language) {
if (language.startsWith('zh')) return text;
// Only consume the '.' when it is a real decimal point (digits follow).
// The old /(\d+\.?\d*)/ also matched "42." at the end of a sentence, which
// ate the period and emitted a dangling "point".
return text.replace(/\d+(?:\.\d+)?/g, match => {
const num = parseFloat(match);
if (isNaN(num)) return match;
if (match.includes('.')) {
const [integer, decimal] = match.split('.');
return `${numberToWords(parseInt(integer))} point ${decimal.split('').map(d => numberToWords(parseInt(d))).join(' ')}`;
}
return numberToWords(parseInt(match));
});
}
// Remove emotional indicators
function removeEmotions(text) {
return text.replace(/\*[a-zA-Z0-9 -]*\*/g, '').trim();
}
// Process code blocks
function pronounceCodeBlock(text) {
return text.replace(/`([^`\n]+)`|```(?:[\s\S]*?)```/g, (match) => {
const content = match.startsWith('```')
? match.slice(3, -3)
: match.slice(1, -1);
return pronounceSpecialCharacters(content, true);
});
}
// Main preprocessing function
function preprocessSentence(sentence, language = 'en') {
let processed = sentence;
processed = pronounceCodeBlock(processed);
processed = markdownToText(processed);
processed = pronounceNumbers(processed, language);
processed = removeEmotions(processed);
processed = pronounceSpecialCharacters(processed);
processed = removeEmoji(processed);
return processed.trim();
}
module.exports = {
preprocessSentence,
numberToWords,
markdownToText,
};
+306
View File
@@ -0,0 +1,306 @@
const config = require('../config');
const ort = require('onnxruntime-node');
const path = require('path');
const fs = require('fs');
class VoiceActivityDetector {
constructor(options = {}) {
this.threshold = options.threshold || config.VAD_THRESHOLD || 0.5;
this.frameLength = options.frameLength || config.VAD_FRAME_LENGTH || 512;
this.minSpeechDuration = options.minSpeechDuration || config.VAD_MIN_SPEECH_DURATION || 250;
this.maxSilenceDuration = options.maxSilenceDuration || config.VAD_MAX_SILENCE_DURATION || 500;
this.sampleRate = options.sampleRate || config.AUDIO_SAMPLE_RATE || 16000;
// Silero VAD specific parameters
this.sileroFrameLength = 512; // Fixed frame length for Silero VAD
this.sileroSampleRate = 16000; // Fixed sample rate for Silero VAD
// State variables
this.isSpeaking = false;
this.speechStartTime = null;
this.lastSpeechTime = null;
this.audioBuffer = Buffer.alloc(0);
this.speechBuffer = Buffer.alloc(0);
// ONNX Runtime session
this.session = null;
this.isInitialized = false;
this.initializationPromise = null;
// Silero VAD state
this.state = null;
this.sr = null;
// Initialize the Silero VAD
this.initializationPromise = this.initializeSileroVAD();
}
/**
* Initialize the Silero VAD ONNX model
*/
async initializeSileroVAD() {
try {
console.log('Initializing Silero VAD...');
// Load the ONNX model
const modelPath = path.join(__dirname, '../models/silero_vad.onnx');
if (!fs.existsSync(modelPath)) {
throw new Error(`Silero VAD model not found at ${modelPath}`);
}
// Create ONNX Runtime session
this.session = await ort.InferenceSession.create(modelPath, {
executionProviders: ['cpu'],
graphOptimizationLevel: 'all',
enableMemPattern: true
});
// Initialize state tensors for Silero VAD
this.resetSileroState();
this.isInitialized = true;
console.log('Silero VAD initialized successfully');
} catch (error) {
console.error('Failed to initialize Silero VAD:', error);
throw error;
}
}
/**
* Reset Silero VAD state tensors
*/
resetSileroState() {
// Initialize state tensor for Silero VAD
// The state tensor combines h and c LSTM hidden states
this.state = new ort.Tensor('float32', new Float32Array(2 * 1 * 128).fill(0.0), [2, 1, 128]);
this.sr = new ort.Tensor('int64', new BigInt64Array([BigInt(this.sileroSampleRate)]), [1]);
}
/**
* Convert Buffer to Float32Array for Silero VAD
* @param {Buffer} buffer - PCM 16-bit audio buffer
* @returns {Float32Array} Normalized audio samples
*/
bufferToFloat32Array(buffer) {
const samples = new Float32Array(buffer.length / 2);
for (let i = 0; i < samples.length; i++) {
// Convert 16-bit PCM to normalized float (-1 to 1)
const sample = buffer.readInt16LE(i * 2);
samples[i] = sample / 32768.0;
}
return samples;
}
/**
* Run Silero VAD inference on audio chunk
* @param {Float32Array} audioSamples - Normalized audio samples
* @returns {Promise<number>} Speech probability (0-1)
*/
async runSileroVAD(audioSamples) {
if (!this.session || !this.isInitialized) {
throw new Error('Silero VAD not initialized');
}
try {
// Create input tensor
const inputTensor = new ort.Tensor('float32', audioSamples, [1, audioSamples.length]);
// Run inference
const feeds = {
input: inputTensor,
state: this.state,
sr: this.sr
};
const results = await this.session.run(feeds);
// Update state tensor for next inference
this.state = results.stateN;
// Get speech probability
const speechProb = results.output.data[0];
return speechProb;
} catch (error) {
console.error('Error running Silero VAD inference:', error);
return 0.0; // Return silence probability on error
}
}
/**
* Process audio chunk and detect voice activity using Silero VAD
* @param {Buffer} audioChunk - Raw audio data
* @returns {Promise<Array>} VAD result with detection status and audio data
*/
async processAudioChunk(audioChunk) {
// Wait for initialization if not complete
if (!this.isInitialized) {
await this.initializationPromise;
}
const currentTime = Date.now();
const results = [];
// Add to buffer
this.audioBuffer = Buffer.concat([this.audioBuffer, audioChunk]);
// Process audio in chunks suitable for Silero VAD (512 samples)
const chunkSize = this.sileroFrameLength * 2; // 16-bit = 2 bytes per sample
while (this.audioBuffer.length >= chunkSize) {
const chunk = this.audioBuffer.slice(0, chunkSize);
this.audioBuffer = this.audioBuffer.slice(chunkSize);
try {
// Convert to Float32Array for Silero VAD
const audioSamples = this.bufferToFloat32Array(chunk);
// Run Silero VAD inference
const speechProb = await this.runSileroVAD(audioSamples);
// Check if speech is detected
const isVoiceActive = speechProb > this.threshold;
if (isVoiceActive) {
if (!this.isSpeaking) {
// Speech started
this.isSpeaking = true;
this.speechStartTime = currentTime;
this.speechBuffer = Buffer.alloc(0);
console.log('Silero VAD: Speech started (prob:', speechProb.toFixed(3), ')');
// Emit the rising edge so the server can tell the client to barge
// in. Without this the client's 'speech_start' handler is dead and
// the assistant keeps talking over the user.
results.push({ type: 'speech_start', timestamp: currentTime });
}
this.lastSpeechTime = currentTime;
this.speechBuffer = Buffer.concat([this.speechBuffer, chunk]);
} else if (this.isSpeaking) {
// Check if silence duration exceeds threshold
const silenceDuration = currentTime - this.lastSpeechTime;
if (silenceDuration > this.maxSilenceDuration) {
// Speech ended
const speechDuration = currentTime - this.speechStartTime;
if (speechDuration >= this.minSpeechDuration) {
console.log('Silero VAD: Speech ended', {
duration: speechDuration,
bufferSize: this.speechBuffer.length
});
results.push({
type: 'speech_end',
audioData: this.speechBuffer,
duration: speechDuration,
observedSilenceDuration: silenceDuration,
endpointThreshold: this.maxSilenceDuration,
timestamp: currentTime
});
}
this.isSpeaking = false;
this.speechStartTime = null;
this.lastSpeechTime = null;
this.speechBuffer = Buffer.alloc(0);
} else {
// Still in speech, add chunk to buffer
this.speechBuffer = Buffer.concat([this.speechBuffer, chunk]);
}
}
} catch (error) {
console.error('Error processing audio chunk with Silero VAD:', error);
// Continue processing other chunks
}
}
return results;
}
/**
* Force end current speech session
* @returns {Promise<Object|null>} Final speech data if available
*/
async forceEndSpeech() {
if (this.isSpeaking && this.speechBuffer.length > 0) {
const currentTime = Date.now();
const speechDuration = currentTime - this.speechStartTime;
if (speechDuration >= this.minSpeechDuration) {
const result = {
type: 'speech_end',
audioData: this.speechBuffer,
duration: speechDuration,
timestamp: currentTime
};
this.isSpeaking = false;
this.speechStartTime = null;
this.lastSpeechTime = null;
this.speechBuffer = Buffer.alloc(0);
return result;
}
}
this.reset();
return null;
}
/**
* Reset VAD state
*/
reset() {
this.isSpeaking = false;
this.speechStartTime = null;
this.lastSpeechTime = null;
this.audioBuffer = Buffer.alloc(0);
this.speechBuffer = Buffer.alloc(0);
// Reset Silero VAD state
if (this.isInitialized) {
this.resetSileroState();
}
}
/**
* Get current VAD state
* @returns {Object} Current state information
*/
getState() {
return {
isSpeaking: this.isSpeaking,
speechDuration: this.speechStartTime ? Date.now() - this.speechStartTime : 0,
bufferSize: this.speechBuffer.length,
threshold: this.threshold,
isInitialized: this.isInitialized
};
}
/**
* Cleanup resources
*/
async cleanup() {
if (this.session) {
try {
await this.session.release();
} catch (error) {
console.error('Error releasing ONNX session:', error);
}
this.session = null;
}
this.state = null;
this.sr = null;
this.isInitialized = false;
this.reset();
}
}
module.exports = VoiceActivityDetector;
@@ -0,0 +1,56 @@
{
"schema_version": 1,
"experiment": "6-3",
"run_id": "exp6-3-20260729T150939685Z",
"generated_at_utc": "2026-07-29T15:09:39.685Z",
"credentials_persisted": false,
"source_media": {
"path": "microphone_input.wav",
"capture_method": "browser_microphone_over_websocket",
"original_repository_path": "backend/recordings/recording_2025-02-06T14-51-38-205Z.wav",
"sha256": "e69d77415ffa39e1a2e00b99e6dbedce675faa9b4c2b703a256585861c6d2085",
"size_bytes": 161836,
"duration_seconds": 5.056,
"sample_rate_hz": 16000,
"channels": 1,
"bits_per_sample": 16,
"provenance_note": "Existing real microphone capture saved by live-audio/backend/server.js; replayed through the production Silero class for reproducible validation."
},
"stages": {
"vad": {
"execution": "real",
"mock": false,
"probe_only": false,
"fallback_used": false,
"implementation": "Silero VAD ONNX",
"model": "models/silero_vad.onnx",
"model_sha256": "2623a2953f6ff3d2c1e61740c6cdb7168133479b267dfef114a4a3cc5bdd788f",
"threshold": 0.5,
"max_silence_ms": 500,
"endpoint_detected": true,
"forced_endpoint": false,
"observed_trailing_silence_ms": 526,
"speech_duration_ms": 692,
"latency_seconds": 5.234941667,
"segment_path": "vad_segment.wav",
"segment_sha256": "068bab529128c19aa1b9d042c947434cca71c62043c55bdf6eb235e973991bfc",
"segment_bytes": 21548
}
},
"experiment_complete": false,
"error": "Error: Real ASR failed: You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.\n at runAsr (/Users/boj/book/ai-agent-book/chapter6/live-audio/backend/run_real_validation.js:120:67)\n at process.processTicksAndRejections (node:internal/process/task_queues:104:5)\n at async main (/Users/boj/book/ai-agent-book/chapter6/live-audio/backend/run_real_validation.js:244:27)",
"acceptance": {
"gates": {
"schema_and_scope": true,
"real_websocket_microphone_media": true,
"real_silero_vad_endpoint": true,
"real_asr": false,
"real_streaming_llm": false,
"real_tts_media": false,
"measured_stage_latencies": false,
"no_mock_probe_or_fallback": false
},
"passed": false,
"statement": "Passing proves one saved real microphone turn completed Silero VAD -> real ASR -> real LLM -> real TTS. It does not benchmark concurrency or production load."
}
}
@@ -0,0 +1,164 @@
{
"schema_version": 1,
"experiment": "6-3",
"run_id": "exp6-3-20260729T153334474Z",
"generated_at_utc": "2026-07-29T15:33:34.475Z",
"credentials_persisted": false,
"provenance": {
"host": {
"platform": "darwin",
"release": "25.3.0",
"architecture": "arm64",
"cpu_model": "Apple M2 Max",
"logical_cpu_count": 12,
"total_memory_bytes": 103079215104
},
"runtime": {
"node": "v25.6.0",
"onnxruntime_node": "^1.22.0-rev",
"axios": "^1.7.5",
"ffprobe": "ffprobe version 8.0.1 Copyright (c) 2007-2025 the FFmpeg developers"
},
"timing_clock": "process.hrtime.bigint monotonic clock"
},
"source_media": {
"path": "microphone_input.wav",
"capture_method": "browser_microphone_over_websocket",
"original_repository_path": "backend/recordings/recording_2025-02-06T14-51-38-205Z.wav",
"sha256": "e69d77415ffa39e1a2e00b99e6dbedce675faa9b4c2b703a256585861c6d2085",
"size_bytes": 161836,
"original_sha256": "e69d77415ffa39e1a2e00b99e6dbedce675faa9b4c2b703a256585861c6d2085",
"duration_seconds": 5.056,
"sample_rate_hz": 16000,
"channels": 1,
"bits_per_sample": 16,
"provenance_note": "Existing real microphone capture saved by live-audio/backend/server.js; replayed through the production Silero class for reproducible validation."
},
"stages": {
"vad": {
"execution": "real",
"mock": false,
"probe_only": false,
"fallback_used": false,
"implementation": "Silero VAD ONNX",
"model": "models/silero_vad.onnx",
"model_sha256": "2623a2953f6ff3d2c1e61740c6cdb7168133479b267dfef114a4a3cc5bdd788f",
"threshold": 0.5,
"max_silence_ms": 500,
"endpoint_detected": true,
"detected_endpoint_count": 2,
"selected_endpoint": "longest detected speech segment",
"forced_endpoint": false,
"observed_trailing_silence_ms": 509,
"speech_duration_ms": 1534,
"latency_seconds": 5.393088166,
"segment_path": "vad_segment.wav",
"segment_sha256": "61d43291d32fdbce3a32a1809c7368e12a5bf98f799f28a31e08848b8e7dda06",
"segment_bytes": 46124
},
"asr": {
"execution": "real",
"mock": false,
"probe_only": false,
"fallback_used": false,
"provider": "local-openai-whisper",
"model": "whisper-tiny",
"inference_completed": true,
"api_request_completed": false,
"external_request": false,
"latency_seconds": 2.710318042,
"transcript": "是男朋友叫这样的",
"language": "zh",
"runtime": {
"python": "3.11.4",
"torch": "2.7.0",
"openai_whisper": "20231106"
},
"model_path": "/Users/boj/.cache/whisper/tiny.pt",
"model_sha256": "65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9",
"model_load_seconds": 0.34045145800337195,
"model_inference_seconds": 1.3047717502340674,
"provider_reported_cost_usd": 0,
"cost_note": "Local open-source Whisper inference; no external ASR charge."
},
"llm": {
"execution": "real",
"mock": false,
"probe_only": false,
"fallback_used": false,
"provider": "ark",
"model": "doubao-seed-1-6-flash-250615",
"streamed": true,
"api_request_completed": true,
"external_request": true,
"first_token_seconds": 0.749332709,
"latency_seconds": 0.791579917,
"response": "那是男朋友叫这样的呀",
"provider_request_id": "021785339222980840fd37cfe82fdd108ca4a9d2a652575aa05c0",
"finish_reason": "stop",
"usage": {
"completion_tokens": 30,
"prompt_tokens": 83,
"total_tokens": 113,
"prompt_tokens_details": {
"cached_tokens": 0
},
"completion_tokens_details": {
"reasoning_tokens": 23
}
},
"provider_reported_cost_usd": null,
"cost_note": "The streamed response did not expose a monetary charge; consult the provider billing ledger."
},
"tts": {
"execution": "real",
"mock": false,
"probe_only": false,
"fallback_used": false,
"provider": "fish",
"model": "s1",
"voice": "authorized zero-shot reference",
"api_request_completed": true,
"external_request": true,
"first_audio_byte_seconds": 2.8189757498912513,
"latency_seconds": 3.2793434159830213,
"output_path": "assistant_response.mp3",
"output_sha256": "2ac0b766ac8afaafb6c6bd09e66a134eb6cc46188aa62edc403ddde32fdf92c8",
"output_bytes": 30928,
"output_duration_seconds": 1.933,
"output_format": "mp3",
"reference_id_sha256": "bb2ec197d6276bfad81bbdbcec4473040861e6bc20caf53cf4d1f8184a7416be",
"reference_id_source": "../../controllable-tts/reference_audio/manifest.json",
"billed_input_characters": 10,
"provider_reported_cost_usd": null,
"cost_note": "The Fish Audio SDK response did not expose a monetary charge; consult the provider billing ledger."
}
},
"experiment_complete": true,
"latency": {
"post_endpoint_to_first_audio_byte_seconds": 6.278626500891251,
"complete_serial_pipeline_seconds": 12.17432954098302,
"measurement_clock": "process.hrtime.bigint monotonic clock"
},
"cost": {
"paid_external_requests": 2,
"provider_reported_total_usd": 0,
"complete": false,
"note": "A zero total is not a zero-cost claim when complete=false; some providers omit per-request charges."
},
"acceptance": {
"gates": {
"schema_and_scope": true,
"real_websocket_microphone_media": true,
"real_silero_vad_endpoint": true,
"real_asr": true,
"real_streaming_llm": true,
"real_tts_media": true,
"measured_stage_latencies": true,
"provenance_complete": true,
"no_mock_probe_or_fallback": true
},
"passed": true,
"statement": "Passing proves one saved real microphone turn completed Silero VAD -> real ASR -> real LLM -> real TTS. It does not benchmark concurrency or production load."
}
}
@@ -0,0 +1,26 @@
# Experiment 6-3 real traditional-voice validation
- Run ID: `exp6-3-20260729T153334474Z`
- Complete: **true**
- Source: `microphone_input.wav` (5.056 s, saved browser microphone/WebSocket capture)
- VAD: Silero ONNX, 500 ms silence, non-forced endpoint = true
- ASR: local-openai-whisper / whisper-tiny, 2.710 s
- Transcript: 是男朋友叫这样的
- LLM: ark / doubao-seed-1-6-flash-250615, TTFT 0.749 s, total 0.792 s
- Response: 那是男朋友叫这样的呀
- TTS: fish / s1, first byte 2.819 s, total 3.279 s
- Post-endpoint time to first audio byte: 6.279 s
## Strict gates
- schema_and_scope: **true**
- real_websocket_microphone_media: **true**
- real_silero_vad_endpoint: **true**
- real_asr: **true**
- real_streaming_llm: **true**
- real_tts_media: **true**
- measured_stage_latencies: **true**
- provenance_complete: **true**
- no_mock_probe_or_fallback: **true**
Passing proves one saved real microphone turn completed Silero VAD -> real ASR -> real LLM -> real TTS. It does not benchmark concurrency or production load.
@@ -0,0 +1,102 @@
{
"schema_version": 1,
"experiment": "6-3",
"run_id": "exp6-3-20260729T153226031Z",
"generated_at_utc": "2026-07-29T15:32:26.031Z",
"credentials_persisted": false,
"provenance": {
"host": {
"platform": "darwin",
"release": "25.3.0",
"architecture": "arm64",
"cpu_model": "Apple M2 Max",
"logical_cpu_count": 12,
"total_memory_bytes": 103079215104
},
"runtime": {
"node": "v25.6.0",
"onnxruntime_node": "^1.22.0-rev",
"axios": "^1.7.5",
"ffprobe": "ffprobe version 8.0.1 Copyright (c) 2007-2025 the FFmpeg developers"
},
"timing_clock": "process.hrtime.bigint monotonic clock"
},
"source_media": {
"path": "microphone_input.wav",
"capture_method": "browser_microphone_over_websocket",
"original_repository_path": "backend/recordings/recording_2025-02-06T14-51-38-205Z.wav",
"sha256": "e69d77415ffa39e1a2e00b99e6dbedce675faa9b4c2b703a256585861c6d2085",
"size_bytes": 161836,
"original_sha256": "e69d77415ffa39e1a2e00b99e6dbedce675faa9b4c2b703a256585861c6d2085",
"duration_seconds": 5.056,
"sample_rate_hz": 16000,
"channels": 1,
"bits_per_sample": 16,
"provenance_note": "Existing real microphone capture saved by live-audio/backend/server.js; replayed through the production Silero class for reproducible validation."
},
"stages": {
"vad": {
"execution": "real",
"mock": false,
"probe_only": false,
"fallback_used": false,
"implementation": "Silero VAD ONNX",
"model": "models/silero_vad.onnx",
"model_sha256": "2623a2953f6ff3d2c1e61740c6cdb7168133479b267dfef114a4a3cc5bdd788f",
"threshold": 0.5,
"max_silence_ms": 500,
"endpoint_detected": true,
"detected_endpoint_count": 2,
"selected_endpoint": "longest detected speech segment",
"forced_endpoint": false,
"observed_trailing_silence_ms": 511,
"speech_duration_ms": 1531,
"latency_seconds": 5.362510583,
"segment_path": "vad_segment.wav",
"segment_sha256": "61d43291d32fdbce3a32a1809c7368e12a5bf98f799f28a31e08848b8e7dda06",
"segment_bytes": 46124
},
"asr": {
"execution": "real",
"mock": false,
"probe_only": false,
"fallback_used": false,
"provider": "local-openai-whisper",
"model": "whisper-tiny",
"inference_completed": true,
"api_request_completed": false,
"external_request": false,
"latency_seconds": 3.981504083,
"transcript": "倒是男生就要战争了",
"language": "zh",
"runtime": {
"python": "3.11.4",
"torch": "2.7.0",
"openai_whisper": "20231106"
},
"model_path": "/Users/boj/.cache/whisper/tiny.pt",
"model_sha256": "65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9",
"model_load_seconds": 0.34700404200702906,
"model_inference_seconds": 2.5749531253241003,
"provider_reported_cost_usd": 0,
"cost_note": "Local open-source Whisper inference; no external ASR charge."
}
},
"experiment_complete": false,
"error": "Error: Real LLM failed: Request failed with status code 401\n at runLlm (/Users/boj/book/ai-agent-book/chapter6/live-audio/backend/run_real_validation.js:234:30)\n at process.processTicksAndRejections (node:internal/process/task_queues:104:5)\n at async main (/Users/boj/book/ai-agent-book/chapter6/live-audio/backend/run_real_validation.js:407:27)",
"acceptance": {
"gates": {
"schema_and_scope": true,
"real_websocket_microphone_media": true,
"real_silero_vad_endpoint": true,
"real_asr": true,
"real_streaming_llm": false,
"real_tts_media": false,
"measured_stage_latencies": false,
"provenance_complete": true,
"no_mock_probe_or_fallback": false
},
"passed": false,
"statement": "Passing proves one saved real microphone turn completed Silero VAD -> real ASR -> real LLM -> real TTS. It does not benchmark concurrency or production load."
}
}
@@ -0,0 +1,77 @@
{
"schema_version": 1,
"experiment": "6-3",
"run_id": "exp6-3-20260729T152935363Z",
"generated_at_utc": "2026-07-29T15:29:35.363Z",
"credentials_persisted": false,
"provenance": {
"host": {
"platform": "darwin",
"release": "25.3.0",
"architecture": "arm64",
"cpu_model": "Apple M2 Max",
"logical_cpu_count": 12,
"total_memory_bytes": 103079215104
},
"runtime": {
"node": "v25.6.0",
"onnxruntime_node": "^1.22.0-rev",
"axios": "^1.7.5",
"ffprobe": "ffprobe version 8.0.1 Copyright (c) 2007-2025 the FFmpeg developers"
},
"timing_clock": "process.hrtime.bigint monotonic clock"
},
"source_media": {
"path": "microphone_input.wav",
"capture_method": "browser_microphone_over_websocket",
"original_repository_path": "backend/recordings/recording_2025-02-06T14-51-38-205Z.wav",
"sha256": "e69d77415ffa39e1a2e00b99e6dbedce675faa9b4c2b703a256585861c6d2085",
"size_bytes": 161836,
"original_sha256": "e69d77415ffa39e1a2e00b99e6dbedce675faa9b4c2b703a256585861c6d2085",
"duration_seconds": 5.056,
"sample_rate_hz": 16000,
"channels": 1,
"bits_per_sample": 16,
"provenance_note": "Existing real microphone capture saved by live-audio/backend/server.js; replayed through the production Silero class for reproducible validation."
},
"stages": {
"vad": {
"execution": "real",
"mock": false,
"probe_only": false,
"fallback_used": false,
"implementation": "Silero VAD ONNX",
"model": "models/silero_vad.onnx",
"model_sha256": "2623a2953f6ff3d2c1e61740c6cdb7168133479b267dfef114a4a3cc5bdd788f",
"threshold": 0.5,
"max_silence_ms": 500,
"endpoint_detected": true,
"detected_endpoint_count": 2,
"selected_endpoint": "longest detected speech segment",
"forced_endpoint": false,
"observed_trailing_silence_ms": 509,
"speech_duration_ms": 1532,
"latency_seconds": 5.396249041,
"segment_path": "vad_segment.wav",
"segment_sha256": "61d43291d32fdbce3a32a1809c7368e12a5bf98f799f28a31e08848b8e7dda06",
"segment_bytes": 46124
}
},
"experiment_complete": false,
"error": "Error: Real ASR failed: Request failed with status code 401\n at runAsr (/Users/boj/book/ai-agent-book/chapter6/live-audio/backend/run_real_validation.js:153:67)\n at process.processTicksAndRejections (node:internal/process/task_queues:104:5)\n at async main (/Users/boj/book/ai-agent-book/chapter6/live-audio/backend/run_real_validation.js:295:27)",
"acceptance": {
"gates": {
"schema_and_scope": true,
"real_websocket_microphone_media": true,
"real_silero_vad_endpoint": true,
"real_asr": false,
"real_streaming_llm": false,
"real_tts_media": false,
"measured_stage_latencies": false,
"provenance_complete": true,
"no_mock_probe_or_fallback": false
},
"passed": false,
"statement": "Passing proves one saved real microphone turn completed Silero VAD -> real ASR -> real LLM -> real TTS. It does not benchmark concurrency or production load."
}
}
@@ -0,0 +1,6 @@
# Frontend environment for the Live Voice Chat demo.
# Copy this file to `.env` (which is gitignored) before running `npm run dev`.
#
# WEBSOCKET_PORT must match the backend's LISTEN_PORT in backend/config.js
# (default 8848). The frontend connects to ws://localhost:${WEBSOCKET_PORT}.
WEBSOCKET_PORT=8848
@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
@@ -0,0 +1,24 @@
const path = require('path');
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'export',
env: {
WEBSOCKET_PORT: process.env.WEBSOCKET_PORT || '8848',
IS_PRODUCTION: process.env.NODE_ENV === 'production',
},
// Since we're using static export, we need to disable image optimization
images: {
unoptimized: true,
},
webpack: (config) => {
config.resolve.alias = {
...config.resolve.alias,
'@': path.resolve(__dirname),
};
return config;
}
}
module.exports = nextConfig
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "livechat-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "cross-env NODE_ENV=production next build",
"start": "cross-env NODE_ENV=production next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-slot": "^1.0.2",
"@types/node": "18.15.11",
"@types/react": "18.0.33",
"@types/react-dom": "18.0.11",
"class-variance-authority": "^0.6.0",
"clsx": "^1.2.1",
"lucide-react": "^0.130.1",
"next": "13.3.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.6.1",
"remark-gfm": "^4.0.0",
"tailwind-merge": "^1.10.0",
"tailwindcss-animate": "^1.0.5",
"typescript": "5.0.4",
"web-audio-resampler": "^1.0.1"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.15",
"autoprefixer": "^10.4.14",
"cross-env": "^7.0.3",
"eslint": "9.30.1",
"eslint-config-next": "15.3.5",
"postcss": "^8.4.21",
"tailwindcss": "^3.3.1"
}
}
@@ -0,0 +1,6 @@
import type { AppProps } from 'next/app'
import '../styles/globals.css'
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
}
@@ -0,0 +1,751 @@
import React, { useState, useRef, useEffect } from 'react';
import { Button } from '../components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
import { Mic, MicOff, Trash2 } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
interface LogEntry {
timestamp: number;
message: string;
type: 'info' | 'error' | 'latency' | 'llm';
}
interface ChatMessage {
role: 'user' | 'assistant' | 'transcript';
content: string;
isFinal?: boolean;
}
interface TabButtonProps {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}
const CodeBlock = ({ node, inline, className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
const lang = match ? match[1] : '';
if (!inline && lang) {
return (
<SyntaxHighlighter
language={lang}
style={oneDark}
customStyle={{
margin: '0.5em 0',
borderRadius: '0.375rem',
fontSize: '0.875rem',
}}
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
);
}
return <code className={className} {...props}>{children}</code>;
};
const TabButton: React.FC<TabButtonProps> = ({ active, onClick, children }) => (
<button
onClick={onClick}
className={`flex-1 py-2 text-sm font-medium border-b-2 ${
active
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{children}
</button>
);
export default function Home() {
const [isRecording, setIsRecording] = useState(false);
const [logs, setLogs] = useState<LogEntry[]>([]);
const websocketRef = useRef<WebSocket | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const workletNodeRef = useRef<AudioWorkletNode | null>(null);
const sourceNodeRef = useRef<MediaStreamAudioSourceNode | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const echoNodeRef = useRef<AudioWorkletNode | null>(null);
const audioFormatRef = useRef<any>(null);
const speechEndTimeRef = useRef<number | null>(null);
const llmStartTimeRef = useRef<number | null>(null);
const vadEndTimeRef = useRef<number | null>(null);
const hasPlaybackLatencyRef = useRef<boolean>(false);
const vadStartTimeRef = useRef<number | null>(null);
const audioQueueRef = useRef<Float32Array[]>([]);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]);
const playbackStartTimeRef = useRef<number | null>(null);
const [finalTranscripts, setFinalTranscripts] = useState<string>('');
// Add this ref to track the current valid message ID
const currentValidMessageIdRef = useRef<string | null>(null);
// Add this ref to track if audio is currently playing
const isPlayingRef = useRef<boolean>(false);
// Add these refs after other refs
const chatHistoryRef = useRef<HTMLDivElement>(null);
const logsRef = useRef<HTMLDivElement>(null);
const shouldAutoScrollChatRef = useRef(true);
const shouldAutoScrollLogsRef = useRef(true);
const [activeTab, setActiveTab] = useState<'chat' | 'logs'>('chat');
const addLog = (message: string, type: LogEntry['type'] = 'info') => {
setLogs(prev => [...prev, {
timestamp: Date.now(),
message,
type
}]);
};
const setupWebSocket = () => {
if (websocketRef.current?.readyState === WebSocket.OPEN) return;
// Determine if we're running on localhost
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
let wsUrl;
if (isLocalhost) {
// For localhost development
wsUrl = `ws://localhost:${process.env.WEBSOCKET_PORT}`;
} else {
// For production deployment
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/ws`;
}
console.log('Connecting to WebSocket:', wsUrl);
websocketRef.current = new WebSocket(wsUrl);
websocketRef.current.binaryType = 'arraybuffer';
websocketRef.current.onopen = () => {
// Start sending pings when connection opens
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
}
pingIntervalRef.current = setInterval(() => {
if (websocketRef.current?.readyState === WebSocket.OPEN) {
websocketRef.current.send(JSON.stringify({
type: 'ping',
timestamp: Date.now()
}));
}
}, 1000);
};
websocketRef.current.onmessage = async (event) => {
try {
if (event.data instanceof ArrayBuffer) {
const now = Date.now();
if (audioFormatRef.current) {
const int16Array = new Int16Array(event.data);
const float32Array = new Float32Array(int16Array.length);
for (let i = 0; i < int16Array.length; i++) {
float32Array[i] = int16Array[i] / 32768.0;
}
if (echoNodeRef.current) {
// Set playback start time when first audio chunk is received
if (!playbackStartTimeRef.current) {
playbackStartTimeRef.current = Date.now();
}
echoNodeRef.current.port.postMessage({ type: 'unmute' });
if (vadEndTimeRef.current && !hasPlaybackLatencyRef.current) {
const latency = now - vadEndTimeRef.current;
const serverVadLatency = now - (speechEndTimeRef.current || vadEndTimeRef.current);
addLog(`First audio playback latency - Browser VAD: ${latency}ms`, 'latency');
addLog(`First audio playback latency - Server VAD: ${serverVadLatency}ms`, 'latency');
hasPlaybackLatencyRef.current = true;
}
echoNodeRef.current.port.postMessage(float32Array);
}
}
} else {
const jsonData = JSON.parse(event.data);
const now = Date.now();
// Add ping handling
if (jsonData.type === 'pong') {
const latency = now - jsonData.timestamp;
addLog(`WebSocket latency: ${latency}ms`, 'latency');
return;
}
switch (jsonData.type) {
case 'speech_end':
speechEndTimeRef.current = now;
if (vadEndTimeRef.current) {
const vadToServerLatency = now - vadEndTimeRef.current;
addLog(`[Server] Speech end detected (${vadToServerLatency}ms after frontend VAD)`, 'latency');
} else {
// Use server's speech end as VAD end time if frontend didn't detect it
vadEndTimeRef.current = now;
hasPlaybackLatencyRef.current = false;
addLog('[Server] Speech end detected (using as VAD endpoint)', 'latency');
}
break;
case 'speech_start':
// Handle interrupt
handleInterrupt();
// Clear audio queue and stop playback
if (echoNodeRef.current) {
echoNodeRef.current.port.postMessage({ type: 'clear' });
}
audioQueueRef.current = [];
if (vadStartTimeRef.current) {
const vadToServerLatency = now - vadStartTimeRef.current;
addLog(`[Server] Speech start detected (${vadToServerLatency}ms after frontend VAD)`, 'latency');
} else {
vadStartTimeRef.current = now;
addLog('[Server] Speech start detected (using as VAD start point)', 'latency');
}
break;
case 'llm_start':
llmStartTimeRef.current = now;
if (vadEndTimeRef.current) {
const browserVadLatency = now - vadEndTimeRef.current;
const serverVadLatency = now - (speechEndTimeRef.current || vadEndTimeRef.current);
addLog(`Transcribe latency - Browser VAD: ${browserVadLatency}ms`, 'latency');
addLog(`Transcribe latency - Server VAD: ${serverVadLatency}ms`, 'latency');
}
break;
case 'llm_first_token':
if (llmStartTimeRef.current) {
const latency = now - llmStartTimeRef.current;
addLog(`LLM time to first token: ${latency}ms`, 'latency');
}
break;
case 'llm_first_sentence':
if (vadEndTimeRef.current) {
const llmLatency = now - llmStartTimeRef.current;
const browserVadLatency = now - vadEndTimeRef.current;
const serverVadLatency = now - (speechEndTimeRef.current || vadEndTimeRef.current);
addLog(`LLM first sentence latency - LLM: ${llmLatency}ms`, 'latency');
addLog(`LLM first sentence latency - Browser VAD: ${browserVadLatency}ms`, 'latency');
addLog(`LLM first sentence latency - Server VAD: ${serverVadLatency}ms`, 'latency');
}
break;
case 'tts_complete':
if (vadEndTimeRef.current) {
addLog(`TTS synthesis time: ${jsonData.synthesisTime}ms`, 'latency');
}
break;
case 'audio_start':
// Reset playback start time when new audio stream starts
playbackStartTimeRef.current = null;
if (echoNodeRef.current) {
echoNodeRef.current.port.postMessage({ type: 'unmute' });
}
addLog(`Audio format: ${JSON.stringify(jsonData.format)}`);
audioFormatRef.current = jsonData.format;
break;
case 'audio_end':
addLog('Audio streaming completed');
break;
case 'transcript':
if (jsonData.messageId) {
// Update the current valid message ID when receiving a transcript
currentValidMessageIdRef.current = jsonData.messageId;
}
if (jsonData.isFinal) {
addLog(`[ASR] Final transcript: "${jsonData.text}"`);
} else {
addLog(`[ASR] Interim transcript: "${jsonData.text}"`);
}
break;
case 'error':
addLog(`Error: ${jsonData.message}`, 'error');
break;
case 'llm_sentence':
if (jsonData.messageId === currentValidMessageIdRef.current) {
addLog(`LLM: "${jsonData.text}"`, 'llm');
} else {
addLog(`Ignored LLM response for recalled message: "${jsonData.text}"`, 'info');
}
break;
case 'websocket_latency':
addLog(`WebSocket RTT: ${jsonData.roundTripTime}ms`, 'latency');
break;
case 'chat_history_delta':
setChatHistory(prev => {
// Remove messages from startIndex onwards and insert new messages
return [
...prev.slice(0, jsonData.startIndex),
...jsonData.messages
];
});
break;
case 'debug_info':
addLog(jsonData.message, 'info');
break;
case 'vad_status':
// Silently ignore VAD status messages to reduce log noise
break;
default:
addLog(`Received message: ${JSON.stringify(jsonData)}`);
}
}
} catch (error) {
addLog(`Error processing message: ${error}`, 'error');
}
};
websocketRef.current.onclose = () => {
addLog('WebSocket closed');
websocketRef.current = null;
// A server restart or network blip fires onclose mid-session. Without
// tearing down, the button stays "Stop Recording", the mic keeps
// capturing, the ping interval keeps firing, and the audio worklet
// silently discards every captured buffer (it checks
// websocketRef.current?.readyState, now null) -- a "zombie recording".
// websocketRef is already null here, so stopRecording won't re-close the
// socket or re-enter onclose.
stopRecording();
};
websocketRef.current.onerror = (error) => {
addLog(`WebSocket error: ${error}`, 'error');
};
};
const startRecording = async () => {
try {
// Setup WebSocket first
setupWebSocket();
// Check if AudioContext and AudioWorklet are supported
if (!window.AudioContext && !(window as any).webkitAudioContext) {
throw new Error('AudioContext is not supported in this browser');
}
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
// Create AudioContext with specific sample rate
audioContextRef.current = new AudioContextClass({
sampleRate: 16000
});
// Check if audioWorklet is supported
if (!audioContextRef.current.audioWorklet) {
throw new Error('AudioWorklet is not supported in this browser. Please use a modern browser like Chrome or Firefox.');
}
// Resume the audio context first (needed for some browsers)
if (audioContextRef.current.state === 'suspended') {
await audioContextRef.current.resume();
}
console.log('Audio context created with sample rate:', audioContextRef.current.sampleRate);
try {
// Add the audio worklet module with the full URL path
const workletUrl = new URL('/audioWorklet.js', window.location.origin).href;
console.log('Loading audio worklet from:', workletUrl);
// Add a timeout to the worklet loading
const workletLoadPromise = audioContextRef.current.audioWorklet.addModule(workletUrl);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Audio worklet load timeout')), 5000);
});
await Promise.race([workletLoadPromise, timeoutPromise]);
console.log('Audio worklet loaded successfully');
} catch (workletError) {
console.error('Error loading audio worklet:', workletError);
throw new Error(`Failed to load audio worklet module: ${workletError.message}`);
}
// Check if getUserMedia is supported
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('getUserMedia is not supported in this browser');
}
// Get user media after worklet is loaded
try {
streamRef.current = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
sampleRate: 16000,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
} catch (mediaError) {
console.error('Error accessing microphone:', mediaError);
throw new Error(`Microphone access failed: ${mediaError.message}`);
}
try {
sourceNodeRef.current = audioContextRef.current.createMediaStreamSource(streamRef.current);
workletNodeRef.current = new AudioWorkletNode(audioContextRef.current, 'audio-processor');
echoNodeRef.current = new AudioWorkletNode(audioContextRef.current, 'echo-processor');
sourceNodeRef.current.connect(workletNodeRef.current);
echoNodeRef.current.connect(audioContextRef.current.destination);
console.log('Audio nodes connected successfully');
} catch (nodeError) {
console.error('Error setting up audio nodes:', nodeError);
throw new Error(`Audio node setup failed: ${nodeError.message}`);
}
workletNodeRef.current.port.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
if (websocketRef.current?.readyState === WebSocket.OPEN) {
websocketRef.current.send(event.data);
}
} else if (event.data.type === 'vad') {
if (event.data.status === 'speech_end') {
vadEndTimeRef.current = Date.now();
hasPlaybackLatencyRef.current = false;
addLog('[Frontend VAD] End of speech detected');
} else if (event.data.status === 'speech_start') {
vadStartTimeRef.current = Date.now();
addLog('[Frontend VAD] Start of speech detected');
}
}
};
// Add message handler for the echo node
echoNodeRef.current.port.onmessage = (event) => {
if (event.data.type === 'queue_empty' && isPlayingRef.current) {
isPlayingRef.current = false;
// Reset playback state
playbackStartTimeRef.current = null;
hasPlaybackLatencyRef.current = false;
addLog('Audio playback completed');
}
};
setIsRecording(true);
} catch (error) {
console.error('Error in startRecording:', error);
addLog(`Error: ${error.message}`, 'error');
// Clean up any partially initialized resources
stopRecording();
}
};
const stopRecording = () => {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
hasPlaybackLatencyRef.current = false;
if (workletNodeRef.current) {
workletNodeRef.current.disconnect();
}
if (echoNodeRef.current) {
echoNodeRef.current.disconnect();
}
if (sourceNodeRef.current) {
sourceNodeRef.current.disconnect();
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
}
if (audioContextRef.current) {
// Guard against closing an already-closed context (stopRecording is now
// idempotent because onclose also calls it).
if (audioContextRef.current.state !== 'closed') {
audioContextRef.current.close();
}
audioContextRef.current = null;
}
if (websocketRef.current) {
websocketRef.current.close();
websocketRef.current = null;
}
setIsRecording(false);
};
const clearLogs = () => {
setLogs([]);
};
// Add this helper function before the return statement
const formatLatencyLog = (message: string) => {
// Check if it's a latency message with ":" or "-"
const splitChar = message.includes(':') ? ':' : message.includes('-') ? '-' : null;
if (!splitChar) return message;
const [label, values] = message.split(splitChar);
return (
<div className="grid grid-cols-[1fr,auto] gap-2">
<span>{label}{splitChar}</span>
<span className="font-mono">{values}</span>
</div>
);
};
// Update the handleInterrupt function
const handleInterrupt = () => {
// Stop current audio playback and clear queue
if (echoNodeRef.current) {
echoNodeRef.current.port.postMessage({ type: 'clear' });
echoNodeRef.current.port.postMessage({ type: 'mute' });
echoNodeRef.current.disconnect();
echoNodeRef.current.connect(audioContextRef.current!.destination);
}
audioQueueRef.current = [];
// Reset playback state
playbackStartTimeRef.current = null;
hasPlaybackLatencyRef.current = false;
isPlayingRef.current = false;
};
// Add scroll event handlers
const handleChatScroll = () => {
if (!chatHistoryRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = chatHistoryRef.current;
// Consider "at bottom" if within 100 pixels of the bottom
shouldAutoScrollChatRef.current = scrollHeight - (scrollTop + clientHeight) < 100;
};
const handleLogsScroll = () => {
if (!logsRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = logsRef.current;
// Consider "at bottom" if within 100 pixels of the bottom
shouldAutoScrollLogsRef.current = scrollHeight - (scrollTop + clientHeight) < 100;
};
// Add scroll to bottom functions
const scrollChatToBottom = () => {
if (chatHistoryRef.current && shouldAutoScrollChatRef.current) {
chatHistoryRef.current.scrollTop = chatHistoryRef.current.scrollHeight;
}
};
const scrollLogsToBottom = () => {
if (logsRef.current && shouldAutoScrollLogsRef.current) {
logsRef.current.scrollTop = logsRef.current.scrollHeight;
}
};
// Update useEffect to scroll when chat history or logs change
useEffect(() => {
scrollChatToBottom();
}, [chatHistory]);
useEffect(() => {
scrollLogsToBottom();
}, [logs]);
// Tear down the mic stream, AudioContext, WebSocket and ping interval if the
// component unmounts mid-recording (client-side nav / fast refresh), instead
// of leaving the microphone active and the ping interval firing.
useEffect(() => {
return () => {
stopRecording();
};
}, []);
return (
<div className="min-h-screen flex flex-col bg-gray-100 p-2 sm:p-4">
{/* Top Controls */}
<div className="mb-4 flex flex-col sm:flex-row justify-center gap-2 sm:gap-4">
<Button
onClick={isRecording ? stopRecording : startRecording}
variant={isRecording ? "destructive" : "default"}
className={`px-4 sm:px-8 py-4 sm:py-6 text-base sm:text-lg font-semibold flex items-center justify-center gap-2 ${
!isRecording ? 'bg-green-600 hover:bg-green-700' : ''
}`}
>
{isRecording ? (
<>
<MicOff className="w-5 h-5 sm:w-6 sm:h-6" />
Stop Recording
</>
) : (
<>
<Mic className="w-5 h-5 sm:w-6 sm:h-6" />
Start Recording
</>
)}
</Button>
<Button
onClick={clearLogs}
variant="outline"
className="px-4 sm:px-8 py-4 sm:py-6 text-base sm:text-lg font-semibold flex items-center justify-center gap-2"
>
<Trash2 className="w-5 h-5 sm:w-6 sm:h-6" />
Clear Logs
</Button>
</div>
{/* Mobile Tabs - Only show on small screens */}
<div className="lg:hidden mb-2">
<div className="flex border-b border-gray-200">
<TabButton
active={activeTab === 'chat'}
onClick={() => setActiveTab('chat')}
>
Chat History
</TabButton>
<TabButton
active={activeTab === 'logs'}
onClick={() => setActiveTab('logs')}
>
Logs
</TabButton>
</div>
</div>
{/* Main Content */}
<div className="flex flex-col lg:flex-row flex-1 gap-2 sm:gap-4">
{/* Chat History Panel */}
<div className={`w-full lg:w-1/2 h-[calc(100vh-12rem)] lg:h-[calc(90vh-5rem)] ${
activeTab === 'chat' ? 'block' : 'hidden lg:block'
}`}>
<Card className="h-full">
<CardHeader className="pb-2 hidden lg:block">
<CardTitle className="text-center text-base sm:text-lg">Chat History</CardTitle>
</CardHeader>
<CardContent
ref={chatHistoryRef}
onScroll={handleChatScroll}
className="h-full lg:h-[calc(100%-3rem)] overflow-y-auto pt-4 lg:pt-0"
>
<div className="flex flex-col space-y-4">
{chatHistory.map((message, index) => (
<div
key={index}
className={`flex ${
message.role === 'assistant' ? 'bg-gray-50' :
message.role === 'transcript' ? 'bg-blue-50' :
'bg-white'
} p-3 sm:p-4 rounded-lg animate-slide-in`}
>
<div className="w-6 h-6 sm:w-8 sm:h-8 rounded-full flex-shrink-0 mr-3 sm:mr-4">
{message.role === 'assistant' ? (
<div className="w-full h-full bg-green-600 rounded-full flex items-center justify-center text-white text-xs sm:text-sm">
AI
</div>
) : message.role === 'transcript' ? (
<div className="w-full h-full bg-blue-600 rounded-full flex items-center justify-center text-white text-xs sm:text-sm">
T
</div>
) : (
<div className="w-full h-full bg-gray-600 rounded-full flex items-center justify-center text-white text-xs sm:text-sm">
U
</div>
)}
</div>
<div className="flex-1 prose prose-sm dark:prose-invert prose-p:my-3 prose-headings:mb-3 prose-headings:mt-6 prose-li:my-2 prose-pre:bg-gray-800 prose-pre:text-gray-100 max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code: CodeBlock,
}}
>
{message.content}
</ReactMarkdown>
{message.role === 'transcript' && !message.isFinal && (
<span className="text-xs text-gray-500 ml-2 animate-fade-in">(typing...)</span>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Logs Panel */}
<div className={`w-full lg:w-1/2 h-[calc(100vh-12rem)] lg:h-[calc(90vh-5rem)] ${
activeTab === 'logs' ? 'block' : 'hidden lg:block'
}`}>
<Card className="h-full overflow-hidden">
<CardHeader className="pb-2 hidden lg:block">
<CardTitle className="text-center text-base sm:text-lg">Logs</CardTitle>
</CardHeader>
<CardContent
ref={logsRef}
onScroll={handleLogsScroll}
className="h-full lg:h-[calc(100%-3rem)] bg-gray-900 p-2 lg:p-3 overflow-y-auto"
>
<div className="font-mono text-xs sm:text-sm">
{logs.map((log, index) => {
const time = new Date(log.timestamp).toISOString().split('T')[1].slice(0, -1);
const baseClasses = "mb-1 font-mono";
const typeClasses = {
error: "text-red-400",
latency: "text-cyan-400",
llm: "text-green-400",
info: "text-gray-300"
};
// Special styling for different event types
const prefixColor = {
vad: "text-purple-400",
asr: "text-yellow-400",
server: "text-blue-400"
};
let content = log.message;
let prefix = null;
// Extract prefix if message starts with [Something]
const prefixMatch = log.message.match(/^\[(.*?)\]/);
if (prefixMatch) {
prefix = prefixMatch[0];
content = log.message.slice(prefix.length);
}
return (
<div key={index} className={`${baseClasses} ${typeClasses[log.type]}`}>
<span className="text-gray-500">[{time}]</span>{' '}
{prefix && (
<span className={
Object.entries(prefixColor).find(([key]) =>
prefix?.toLowerCase().includes(key))?.[1] || typeClasses[log.type]
}>
{prefix}
</span>
)}
{log.type === 'latency' ? (
formatLatencyLog(content)
) : (
<span>{content}</span>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
@@ -0,0 +1,162 @@
class AudioProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.inputBuffer = [];
// Remove VAD parameters - now using server-side Silero VAD only
// The client-side VAD was primarily for debugging and is no longer needed
}
process(inputs, outputs, parameters) {
const input = inputs[0];
if (!input || !input[0]) return true;
// Convert to mono
const monoInput = new Float32Array(input[0].length);
for (let i = 0; i < input[0].length; i++) {
let sum = 0;
for (let channel = 0; channel < input.length; channel++) {
sum += input[channel][i];
}
monoInput[i] = sum / input.length;
}
// Process in chunks - removed VAD processing
const CHUNK_SIZE = 1024;
this.inputBuffer.push(...monoInput);
while (this.inputBuffer.length >= CHUNK_SIZE) {
const chunk = this.inputBuffer.slice(0, CHUNK_SIZE);
this.inputBuffer = this.inputBuffer.slice(CHUNK_SIZE);
// Convert to 16-bit PCM
const pcmData = new Int16Array(chunk.length);
for (let i = 0; i < chunk.length; i++) {
const s = Math.max(-1, Math.min(1, chunk[i]));
pcmData[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
// Send the data - server-side Silero VAD will handle speech detection
this.port.postMessage(pcmData.buffer, [pcmData.buffer]);
}
return true;
}
}
class EchoProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.audioBuffer = [];
this.playbackPosition = 0;
this.isPlaying = false;
this.sampleRate = 16000;
this.isMuted = false;
this.outputBufferSize = 2048;
this.outputBuffer = new Float32Array(this.outputBufferSize);
this.outputBufferPosition = 0;
this.hasNotifiedQueueEmpty = false; // Track if we've sent the queue empty notification
this.port.onmessage = (event) => {
if (event.data instanceof Float32Array) {
if (!this.isMuted) {
const audioData = event.data;
const newBuffer = new Float32Array(audioData.length);
newBuffer.set(audioData);
if (!this.isPlaying) {
this.audioBuffer = Array.from(newBuffer);
this.playbackPosition = 0;
this.outputBufferPosition = 0;
this.hasNotifiedQueueEmpty = false; // Reset notification flag when starting new playback
} else {
this.audioBuffer.push(...Array.from(newBuffer));
}
this.isPlaying = true;
}
} else if (event.data.type === 'clear') {
// Clear the buffer and stop playback
this.audioBuffer = [];
this.playbackPosition = 0;
this.outputBufferPosition = 0;
this.isPlaying = false;
this.isMuted = true;
this.hasNotifiedQueueEmpty = false;
// Notify that the queue is empty after clearing
this.port.postMessage({ type: 'queue_empty' });
} else if (event.data.type === 'unmute') {
this.isMuted = false;
this.hasNotifiedQueueEmpty = false;
} else if (event.data.type === 'mute') {
this.isMuted = true;
this.audioBuffer = [];
this.playbackPosition = 0;
this.isPlaying = false;
this.hasNotifiedQueueEmpty = false;
// Notify that the queue is empty after muting
this.port.postMessage({ type: 'queue_empty' });
}
};
}
process(inputs, outputs, parameters) {
const output = outputs[0];
// If muted or not playing, output silence
if (this.isMuted || !this.isPlaying || this.audioBuffer.length === 0) {
for (let channel = 0; channel < output.length; channel++) {
output[channel].fill(0);
}
// Send queue_empty notification if we haven't already
if (this.isPlaying && !this.hasNotifiedQueueEmpty) {
this.port.postMessage({ type: 'queue_empty' });
this.hasNotifiedQueueEmpty = true;
this.isPlaying = false;
}
return true;
}
const outputChannel = output[0];
const bufferSize = outputChannel.length;
if (this.isPlaying && this.audioBuffer.length > 0) {
// Fill the output buffer
for (let i = 0; i < bufferSize; i++) {
if (this.playbackPosition < this.audioBuffer.length) {
const sample = this.audioBuffer[this.playbackPosition];
for (let channel = 0; channel < output.length; channel++) {
output[channel][i] = sample;
}
this.playbackPosition++;
} else {
// End of buffer reached
for (let channel = 0; channel < output.length; channel++) {
output[channel][i] = 0;
}
// If we've played everything, reset and notify
if (this.playbackPosition >= this.audioBuffer.length && !this.hasNotifiedQueueEmpty) {
this.isPlaying = false;
this.playbackPosition = 0;
this.audioBuffer = [];
this.port.postMessage({ type: 'queue_empty' });
this.hasNotifiedQueueEmpty = true;
}
}
}
} else {
// Output silence if we're not playing
for (let channel = 0; channel < output.length; channel++) {
output[channel].fill(0);
}
}
return true;
}
}
registerProcessor('audio-processor', AudioProcessor);
registerProcessor('echo-processor', EchoProcessor);
@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
.font-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
}
@@ -0,0 +1,162 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./src/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
"fade-in": {
'0%': { opacity: 0 },
'100%': { opacity: 1 },
},
"slide-in": {
'0%': { transform: 'translateY(5px)', opacity: 0 },
'100%': { transform: 'translateY(0)', opacity: 1 },
}
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"fade-in": "fade-in 0.3s ease-out",
"slide-in": "slide-in 0.3s ease-out",
},
typography: (theme) => ({
DEFAULT: {
css: {
'--tw-prose-body': theme('colors.gray.900'),
'--tw-prose-headings': theme('colors.gray.900'),
'--tw-prose-links': theme('colors.blue.600'),
'--tw-prose-code': theme('colors.gray.900'),
maxWidth: 'none',
color: 'var(--tw-prose-body)',
fontSize: '1rem',
lineHeight: '1.75',
p: {
marginTop: '1em',
marginBottom: '1em',
fontSize: '1rem',
'&:first-child': {
marginTop: 0,
},
'&:last-child': {
marginBottom: 0,
},
},
'ul, ol': {
paddingLeft: '1.5em',
marginTop: '0.5em',
marginBottom: '0.5em',
},
li: {
marginTop: '0.25em',
marginBottom: '0.25em',
fontSize: '1rem',
lineHeight: '1.5',
p: {
marginTop: '0.375em',
marginBottom: '0.375em',
},
},
'h1, h2, h3, h4': {
color: 'var(--tw-prose-headings)',
marginTop: '1.5em',
marginBottom: '0.5em',
fontSize: '1.25rem',
fontWeight: '600',
lineHeight: '1.3',
'&:first-child': {
marginTop: 0,
},
},
pre: {
margin: '0.5em 0',
padding: '0.5em',
backgroundColor: 'transparent',
borderRadius: '0.375rem',
fontSize: '0.875rem',
lineHeight: '1.5',
overflowX: 'auto',
},
code: {
color: 'var(--tw-prose-code)',
backgroundColor: theme('colors.gray.100'),
padding: '0.2em 0.4em',
borderRadius: '0.25rem',
fontSize: '0.875rem',
fontWeight: '400',
},
'pre code': {
backgroundColor: 'transparent',
padding: 0,
fontSize: '0.875rem',
color: 'inherit',
fontWeight: '400',
},
blockquote: {
borderLeftWidth: '4px',
borderLeftColor: theme('colors.gray.200'),
paddingLeft: '1em',
fontStyle: 'italic',
marginTop: '1em',
marginBottom: '1em',
fontSize: '1rem',
},
hr: {
marginTop: '2em',
marginBottom: '2em',
},
a: {
color: 'var(--tw-prose-links)',
textDecoration: 'underline',
'&:hover': {
color: theme('colors.blue.700'),
},
},
table: {
width: '100%',
marginTop: '1em',
marginBottom: '1em',
borderCollapse: 'collapse',
fontSize: '0.875rem',
lineHeight: '1.5',
},
'th, td': {
padding: '0.5em',
borderWidth: '1px',
borderColor: theme('colors.gray.200'),
},
},
},
}),
},
},
plugins: [
require('@tailwindcss/typography'),
require("tailwindcss-animate")
],
}
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
},
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}