Prompt is too long

This commit is contained in:
Mohamed Boudra
2025-10-19 16:23:13 +02:00
parent 40b199e329
commit c3049b8fa0
67 changed files with 504 additions and 17776 deletions
@@ -1,153 +0,0 @@
# Audio Processing Architecture
## Overview
This application uses **browser-side WebRTC audio processing** with **WebSocket transport**. We do NOT use WebRTC for peer-to-peer connections.
## Why This Architecture?
### Browser-Side WebRTC Processing
- ✅ Echo cancellation - Removes feedback from speakers
- ✅ Noise suppression - Filters background noise
- ✅ Auto gain control - Normalizes volume levels
- ✅ No server-side processing needed
### WebSocket Transport
- ✅ Simple and reliable
- ✅ Works with existing infrastructure
- ✅ No NAT traversal issues
- ✅ Easy to debug
### No Server-Side WebRTC
- ❌ Server doesn't need to be a WebRTC peer
- ❌ No complex signaling protocol needed
- ❌ No audio encoding/decoding on server
- ❌ Simpler architecture
## Audio Flow
### Voice Input (STT)
1. Browser: `getUserMedia()` with audio constraints
2. Browser: MediaRecorder captures processed audio
3. Browser → Server: WebSocket (base64-encoded audio)
4. Server: OpenAI Whisper transcription
5. Server: LLM processing
6. Server: Generate TTS response
### Voice Output (TTS)
1. Server: OpenAI TTS generates MP3
2. Server → Browser: WebSocket (base64-encoded MP3)
3. Browser: HTMLAudioElement playback
## Audio Constraints
The browser applies these constraints to audio capture:
```javascript
{
echoCancellation: true, // Remove echo
noiseSuppression: true, // Remove noise
autoGainControl: true, // Normalize volume
sampleRate: 16000, // Optimal for speech
channelCount: 1, // Mono
}
```
These constraints are applied via the Web Audio API in `getUserMedia()`, which uses the browser's built-in audio processing pipeline (same as WebRTC uses internally).
## Audio Format
- **Input Format**: WebM/Opus (preferred) or browser's best available codec
- **Sample Rate**: 16kHz (optimal for speech recognition)
- **Channels**: Mono (sufficient for voice)
- **Output Format**: MP3 (from OpenAI TTS)
## Browser Compatibility
- ✅ Chrome/Edge - Full support
- ✅ Firefox - Full support
- ✅ Safari - Full support (iOS requires https)
- ✅ Mobile browsers - Supported
## Testing Audio Processing
To verify echo cancellation and noise suppression work:
1. Play music or make noise near your microphone
2. Start recording
3. Speak while noise is present
4. Stop recording and check transcription
5. Background noise should be filtered out
## Performance
- **Latency**: 2-4 seconds total
- STT: 1-2s (Whisper API)
- LLM: 0.5-1s (OpenAI GPT)
- TTS: 0.5-1s (OpenAI TTS)
- **Bandwidth**: ~10-20 KB/s for audio (both directions)
- **Audio Quality**: Excellent for speech (16kHz mono)
## Implementation Details
### Browser Side (`src/ui/lib/audio-capture.ts`)
The `createAudioRecorder()` function:
1. Requests microphone access with WebRTC audio constraints
2. Creates a MediaRecorder to capture processed audio
3. Collects audio chunks during recording
4. Returns a Blob when recording stops
Key features:
- Logs actual applied audio settings for debugging
- Prefers WebM/Opus codec (best for speech)
- Falls back to other supported codecs if needed
- Stops all audio tracks on completion
### Server Side (`src/server/websocket-server.ts`)
The WebSocket server:
1. Receives base64-encoded audio chunks
2. Buffers chunks until recording completes
3. Passes complete audio to STT handler
4. Broadcasts transcription and TTS response
Key features:
- Simple message-based protocol
- No complex state management
- Broadcasts to all connected clients
## Troubleshooting
### Microphone Permission Denied
- Ensure HTTPS is used (required on mobile)
- Check browser permissions settings
- Try in a different browser
### Poor Audio Quality
- Check microphone settings in OS
- Verify audio constraints are applied (check console logs)
- Test microphone in other applications
### High Latency
- Check network connection
- Verify server is responding quickly
- Monitor OpenAI API response times
## Future Enhancements
Potential improvements to consider:
1. **Voice Activity Detection (VAD)**: Automatically start/stop recording based on speech detection
2. **Audio Preprocessing**: Additional client-side filtering before sending to server
3. **Compression**: Use more aggressive compression for lower bandwidth
4. **Streaming STT**: Stream audio to server for real-time transcription
5. **Audio Visualization**: Show waveform or volume meter during recording
## References
- [MDN: getUserMedia](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia)
- [MDN: MediaRecorder](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder)
- [Web Audio API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API)
- [OpenAI Whisper API](https://platform.openai.com/docs/guides/speech-to-text)
- [OpenAI TTS API](https://platform.openai.com/docs/guides/text-to-speech)
@@ -1,496 +0,0 @@
# Voice Assistant - Implementation Plan
## Project Overview
**Goal**: Build a voice-controlled terminal assistant that runs locally as a single Express service.
**Architecture**:
- Single Express process serving everything
- Vite React UI bundled and served from Express
- WebSocket for control messages (activity log, status updates)
- Agent logic (STT/TTS/LLM pipeline using APIs)
- Daemon logic (terminal control using tmux) - same process, direct function calls
- Audio streaming solution (Phase 8)
## Technology Stack
### Server
- **Runtime**: Node.js with TypeScript
- **Framework**: Express
- **WebSocket**: ws library
- **Terminal Control**: tmux (via child_process)
- **APIs**:
- OpenAI (GPT-4 Turbo for LLM, TTS)
- Deepgram (Streaming STT)
### Client
- **Framework**: React 18
- **Build Tool**: Vite
- **Styling**: CSS (custom)
- **WebSocket**: Native WebSocket API
## Package Structure
```
packages/voice-assistant/
├── package.json
├── tsconfig.json # UI TypeScript config
├── tsconfig.server.json # Server TypeScript config
├── tsconfig.node.json # Vite config TypeScript
├── vite.config.ts # Vite bundler config
├── .env.example
├── .gitignore
├── IMPLEMENTATION_PLAN.md # This file
│
├── src/
│ ├── server/
│ │ ├── index.ts # ✅ Express + HTTP server entry point
│ │ ├── types.ts # ✅ Shared server types
│ │ ├── websocket-server.ts # ✅ WebSocket server
│ │ │
│ │ ├── agent/
│ │ │ ├── orchestrator.ts # Agent pipeline coordinator
│ │ │ ├── stt-deepgram.ts # Deepgram streaming STT
│ │ │ ├── llm-openai.ts # OpenAI GPT-4 with tools
│ │ │ ├── tts-openai.ts # OpenAI TTS (stream)
│ │ │ └── system-prompt.ts # Load from agent-prompt.md
│ │ │
│ │ └── daemon/
│ │ ├── terminal-manager.ts # Terminal control API
│ │ ├── tmux.ts # tmux primitives (from mcp-server)
│ │ └── tool-definitions.ts # LLM tool schemas
│ │
│ └── ui/
│ ├── index.html # ✅ HTML entry point
│ ├── main.tsx # ✅ React root
│ ├── App.tsx # ✅ Main app component
│ ├── App.css # ✅ App styles
│ ├── index.css # ✅ Global styles
│ │
│ ├── components/
│ │ ├── VoiceInterface.tsx # Main voice UI
│ │ ├── ActivityLog.tsx # Conversation log
│ │ ├── ConnectionIndicator.tsx # Connection status
│ │ └── AudioVisualizer.tsx # Audio waveform
│ │
│ ├── hooks/
│ │ ├── useWebSocket.ts # ✅ WebSocket client
│ │ └── useWebRTC.ts # WebRTC client (Phase 8)
│ │
│ └── lib/
│ └── audio-utils.ts # Audio processing utilities
│
└── dist/
├── server/ # Compiled TypeScript
└── ui/ # Built Vite app
```
---
## Implementation Phases
### ✅ Phase 1: Foundation (COMPLETED)
**Tasks**:
1. ✅ Create package structure and configuration files
2. ✅ Set up package.json with dependencies and scripts
3. ✅ Create TypeScript configurations (tsconfig.json, tsconfig.server.json)
4. ✅ Create Vite configuration (vite.config.ts)
5. ✅ Create basic Express server (src/server/index.ts)
6. ✅ Create basic Vite React app (src/ui/)
7. ✅ Install dependencies and test dev servers
**Key Files Created**:
- `src/server/index.ts` - Express server with production static file serving
- `src/server/types.ts` - Shared TypeScript types
- `src/ui/App.tsx` - Main React component
- `src/ui/index.html` - HTML entry point
- `vite.config.ts` - Vite build configuration
**Development Workflow**:
```bash
npm run dev # Runs both servers (concurrently)
npm run dev:server # Express on port 3000
npm run dev:ui # Vite on port 5173
```
**Production Build**:
```bash
npm run build # Builds UI + server
npm start # Serves from dist/
```
---
### ✅ Phase 2: WebSocket Communication (COMPLETED)
**Tasks**:
1. ✅ Add WebSocket server (websocket-server.ts)
2. ✅ Add WebSocket client hook (useWebSocket.ts)
3. ✅ Test WebSocket ping/pong communication
**Key Features**:
- WebSocket server on `/ws` path
- Client-side `useWebSocket` hook with auto-reconnect
- Message handler registration system
- Ping/pong messaging for connection testing
- Activity log UI component
- Real-time connection status indicator
**Key Files Created**:
- `src/server/websocket-server.ts` - VoiceAssistantWebSocketServer class
- `src/ui/hooks/useWebSocket.ts` - Client WebSocket hook
- Updated `src/ui/App.tsx` - WebSocket integration & activity log
**WebSocket Message Types**:
```typescript
interface WebSocketMessage {
type: 'activity_log' | 'status' | 'webrtc_signal' | 'ping' | 'pong';
payload: unknown;
}
```
**Testing**:
1. Start both servers: `npm run dev`
2. Open http://localhost:5173
3. Click "Send Ping" button
4. See "Received pong from server" in activity log
---
### ⏳ Phase 3: Terminal Control (Daemon)
**Tasks**:
1. ⏳ Copy tmux.ts from mcp-server package
2. ⏳ Create terminal-manager.ts with tool functions
3. ⏳ Create tool-definitions.ts for LLM schemas
4. ⏳ Test terminal operations (list, create, capture)
**Objectives**:
- Reuse existing tmux primitives from mcp-server
- Create high-level terminal management API
- Define LLM tool schemas for terminal operations
- Test terminal creation, command execution, output capture
**Terminal Tools** (7 core operations):
```typescript
// Tool functions to implement in terminal-manager.ts
1. listTerminals() → TerminalInfo[]
2. createTerminal(name, workingDirectory, initialCommand?)
3. captureTerminal(terminalId, lines?, wait?)
4. sendText(terminalId, text, pressEnter?, return_output?)
5. sendKeys(terminalId, keys, repeat?, return_output?)
6. renameTerminal(terminalId, name)
7. killTerminal(terminalId)
```
**Terminal Model**:
- Default tmux session: `voice-dev`
- Terminal = tmux window (single pane)
- Terminal ID = window ID (format: `@123`)
- Working directory set on creation
---
### ⏳ Phase 4: LLM Integration
**Tasks**:
1. ⏳ Copy agent-prompt.md from agent-python
2. ⏳ Create system-prompt.ts to load prompt file
3. ⏳ Create llm-openai.ts (GPT-4 with function calling)
4. ⏳ Test LLM integration with tool calls
**Objectives**:
- Reuse existing system prompt from Python agent
- Implement OpenAI GPT-4 Turbo with function calling
- Define terminal tools as OpenAI functions
- Test: "create a terminal called test" → executes createTerminal()
**LLM Configuration**:
```typescript
{
model: "gpt-4-turbo",
tools: terminalToolDefinitions,
tool_choice: "auto",
stream: true
}
```
---
### ⏳ Phase 5: Agent Orchestrator
**Tasks**:
1. ⏳ Create agent orchestrator.ts
2. ⏳ Wire text input → LLM → tool execution → response
3. ⏳ Emit activity log events to WebSocket
4. ⏳ Test end-to-end text-based commands
**Objectives**:
- Create main agent loop
- Handle: user input → LLM → tool calls → terminal → response
- Broadcast activity to WebSocket clients
- Test complete text-based interaction flow
**Agent Loop**:
```
1. Receive user input (text/transcript)
2. Add to conversation context
3. Call LLM with tools
4. If tool calls:
a. Execute each tool via terminal-manager
b. Add results to context
c. Call LLM again with results
5. Return final response
6. Broadcast all events to WebSocket
```
---
### ⏳ Phase 6: Speech-to-Text (Deepgram)
**Tasks**:
1. ⏳ Add Deepgram STT integration (stt-deepgram.ts)
2. ⏳ Test STT with audio input
**Objectives**:
- Implement Deepgram streaming STT
- Handle audio chunks from client
- Return transcript fragments in real-time
- Detect end-of-utterance
**Deepgram Configuration**:
```typescript
{
model: "nova-2",
language: "en",
smart_format: true,
punctuate: true,
interim_results: true
}
```
---
### ⏳ Phase 7: Text-to-Speech (OpenAI)
**Tasks**:
1. ⏳ Add OpenAI TTS integration (tts-openai.ts)
2. ⏳ Test TTS with text input
**Objectives**:
- Implement OpenAI TTS API
- Convert text responses to audio
- Stream audio chunks to client
- Handle playback in browser
**TTS Configuration**:
```typescript
{
model: "tts-1",
voice: "alloy",
response_format: "pcm" // or "opus" for streaming
}
```
---
### ⏳ Phase 8: Audio Streaming
**Tasks**:
1. ⏳ Research WebRTC alternatives for audio streaming
2. ⏳ Implement audio streaming solution
3. ⏳ Test end-to-end voice interaction
**Objectives**:
- Implement bidirectional audio streaming
- Browser → Server: Microphone input
- Server → Browser: TTS output
- Consider: WebRTC (browser native) or WebSocket audio
**Note**:
- `wrtc` package removed due to native compilation issues
- Alternative options:
1. **WebRTC (browser-to-browser via signaling)** - Most robust
2. **WebSocket audio streaming** - Simpler, less efficient
3. **simple-peer** - WebRTC wrapper library
4. **MediaRecorder API** - Send audio chunks via WebSocket
---
### ⏳ Phase 9: UI Polish
**Tasks**:
1. ⏳ Create ActivityLog component (proper component)
2. ⏳ Create ConnectionIndicator component
3. ⏳ Add mute/unmute controls
4. ⏳ Polish UI and test full application
**Objectives**:
- Extract activity log into reusable component
- Add microphone mute/unmute button
- Add visual audio level indicator
- Improve styling and UX
- End-to-end testing
---
## Environment Variables
`.env` file (copy from `.env.example`):
```bash
# OpenAI API Key (for GPT-4 and TTS)
OPENAI_API_KEY=sk-...
# Deepgram API Key (for streaming STT)
DEEPGRAM_API_KEY=...
# Server Configuration
PORT=3000
NODE_ENV=development
```
---
## Development Commands
```bash
# Install dependencies
npm install
# Development (runs both servers)
npm run dev
# Development (individual servers)
npm run dev:server # Express on port 3000
npm run dev:ui # Vite on port 5173
# Build
npm run build:ui # Build Vite UI → dist/ui/
npm run build:server # Compile TS → dist/server/
npm run build # Build both
# Production
npm start # Serve from dist/
# Type checking
npm run typecheck # Check both UI and server types
```
---
## Testing Strategy
### Phase 1-2 (Foundation + WebSocket)
- ✅ Manual: Start servers, check endpoints
- ✅ Manual: Open UI, test ping/pong
### Phase 3 (Terminal Control)
- Manual: Call terminal-manager functions from server
- Manual: Verify tmux windows created/destroyed
- Manual: Check command execution and output capture
### Phase 4 (LLM)
- Manual: Send text prompts, check tool calls
- Manual: Verify tool execution results in context
### Phase 5 (Orchestrator)
- Manual: Send "create a terminal called test"
- Verify: LLM calls createTerminal tool
- Verify: Activity log shows tool call and result
### Phase 6-7 (STT/TTS)
- Manual: Record audio, check transcript
- Manual: Send text, check audio playback
### Phase 8 (Audio Streaming)
- Manual: Speak into microphone
- Verify: Real-time transcription
- Verify: Agent response plays back
### Phase 9 (Full E2E)
- Manual: Complete voice interaction loop
- Test: Various terminal commands via voice
- Test: Error handling and edge cases
---
## Key Decisions & Trade-offs
### 1. **Single Process Architecture**
- **Decision**: Run agent + daemon in same process
- **Why**: Simpler V1, avoid IPC complexity
- **Future**: Can split into microservices later
### 2. **OpenAI All-in-One**
- **Decision**: Use OpenAI for both LLM and TTS, Deepgram for STT
- **Why**: Simpler integration, fewer providers
- **Note**: Deepgram required because OpenAI Whisper doesn't support streaming
### 3. **Removed wrtc Package**
- **Decision**: Defer WebRTC server implementation to Phase 8
- **Why**: `wrtc` has native compilation issues
- **Alternative**: Will use browser-native WebRTC or WebSocket audio
### 4. **Auto-Execute Terminal Commands**
- **Decision**: No permission prompts in V1
- **Why**: Faster development, simpler UX
- **Future**: Add permission system in V2
### 5. **Reuse Existing Code**
- **Decision**: Copy tmux.ts and agent-prompt.md from existing packages
- **Why**: Proven, tested code
- **Benefit**: Consistent terminal model across packages
---
## Current Progress
**✅ Completed**: 10/33 tasks (30%)
- Phase 1: Foundation (7 tasks)
- Phase 2: WebSocket (3 tasks)
**⏳ In Progress**: Phase 3 (Terminal Control)
**📋 Remaining**: 23 tasks
- Phase 3: 4 tasks
- Phase 4: 4 tasks
- Phase 5: 4 tasks
- Phase 6: 2 tasks
- Phase 7: 2 tasks
- Phase 8: 3 tasks
- Phase 9: 4 tasks
---
## Next Steps
1. **Phase 3**: Copy `tmux.ts` from mcp-server
2. **Phase 3**: Create `terminal-manager.ts` with 7 core tools
3. **Phase 3**: Define OpenAI function schemas for tools
4. **Phase 3**: Test terminal operations manually
5. **Phase 4**: Copy `agent-prompt.md` and set up LLM
---
## References
- **Existing Packages**:
- `packages/mcp-server/src/tmux.ts` - Terminal control primitives
- `packages/agent-python/agent-prompt.md` - System prompt
- `packages/web/app/hooks/use-livekit-voice.ts` - LiveKit reference
- **APIs**:
- [OpenAI API Docs](https://platform.openai.com/docs)
- [Deepgram API Docs](https://developers.deepgram.com/)
- [WebRTC API Docs](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API)
---
## Notes
- This is a **V1 implementation** focused on getting a working prototype
- Prioritize simplicity over optimization
- Each phase should be independently testable
- WebSocket infrastructure is ready for real-time updates
- Audio streaming is the most complex remaining piece
@@ -1,438 +0,0 @@
# Voice Assistant Implementation Summary
## Phases 6 & 7: Speech-to-Text (STT) and Text-to-Speech (TTS)
### Implementation Complete ✅
All tasks have been successfully implemented. The voice assistant now supports full voice interaction using OpenAI's Whisper API for STT and OpenAI's TTS API for speech synthesis.
---
## Research Findings
### OpenAI Whisper API (STT)
- **Streaming**: NOT supported - requires complete audio files
- **Implementation Model**: Push-to-talk (record → stop → transcribe)
- **Supported Formats**: webm, ogg, mp3, wav, m4a, mp4, mpeg, mpga, oga, flac
- **File Size Limit**: 25 MB
- **Latency**: < 3 seconds for typical voice commands
### OpenAI TTS API
- **Streaming**: Supported via chunk transfer encoding
- **Implementation**: Buffer-and-play model (simpler, reliable)
- **Voices**: alloy, echo, fable, onyx, nova, shimmer
- **Models**: tts-1 (fast), tts-1-hd (high quality)
- **Formats**: mp3, opus, aac, flac, wav, pcm
- **Default**: mp3 format with "alloy" voice
### Browser Audio APIs
- **MediaRecorder API**: Capture audio from microphone (webm/opus preferred)
- **Web Audio API**: Playback using HTMLAudioElement with blob URLs
- **Browser Support**: Chrome, Firefox, Safari, Edge (2021+)
- **HTTPS Required**: Yes (for microphone access on non-localhost)
---
## Architecture
### STT Flow (Push-to-Talk)
```
1. User clicks microphone button in UI
2. Browser captures audio via MediaRecorder API (webm/opus format)
3. Audio buffered in browser
4. User clicks stop button
5. Complete audio sent to server via WebSocket (base64 encoded)
6. Server receives audio, saves to temp file
7. Server calls OpenAI Whisper API
8. Transcript returned to server
9. Server broadcasts transcript to client
10. Server feeds transcript to orchestrator (LLM processing)
11. Assistant response generated and displayed
```
### TTS Flow (Buffer-and-Play)
```
1. Orchestrator generates LLM response (streaming text)
2. Server buffers complete response text
3. Server calls OpenAI TTS API with complete text
4. TTS returns audio (MP3, streamed from OpenAI)
5. Server buffers complete audio
6. Server sends audio to browser via WebSocket (base64 encoded)
7. Browser decodes audio, creates Blob URL
8. Browser plays audio using HTMLAudioElement
9. Queue management: next audio plays when current finishes
```
---
## Files Created
### Server-Side
1. **`src/server/agent/stt-openai.ts`** - STT integration module
- `initializeSTT()` - Initialize OpenAI Whisper client
- `transcribeAudio()` - Transcribe audio buffer to text
- Handles temporary file creation/cleanup
- Format detection (webm, ogg, mp3, etc.)
2. **`src/server/agent/tts-openai.ts`** - TTS integration module
- `initializeTTS()` - Initialize OpenAI TTS client
- `synthesizeSpeech()` - Convert text to speech
- Configurable voice and model
- Streaming response to buffer conversion
### Client-Side
3. **`src/ui/lib/audio-capture.ts`** - Audio recording utility
- `createAudioRecorder()` - Factory for audio recorder
- Microphone permission handling
- Format detection (webm/opus, ogg/opus)
- MediaRecorder API integration
- Chunk buffering and blob creation
4. **`src/ui/lib/audio-playback.ts`** - Audio playback utility
- `createAudioPlayer()` - Factory for audio player
- Queue management (sequential playback)
- Blob URL creation and cleanup
- HTMLAudioElement wrapper
5. **`src/ui/components/VoiceControls.tsx`** - Voice UI component
- Microphone recording button
- Recording/processing/playing states
- Visual feedback (animations, indicators)
- Permission handling
- Error display
### Modified Files
6. **`src/server/types.ts`** - Added audio message types
- `AudioChunkPayload`
- `RecordingStatePayload`
- `TranscriptionResultPayload`
- `AudioOutputPayload`
7. **`src/server/websocket-server.ts`** - Audio message handling
- `setAudioHandler()` - Register audio processing handler
- `handleAudioChunk()` - Buffer and process audio chunks
- Audio buffering for multiple chunks
- Base64 decoding
8. **`src/server/index.ts`** - Wire STT/TTS to server
- Initialize STT and TTS clients
- Audio handler for voice input (STT → orchestrator)
- TTS generation for voice responses
- Helper function `processMessageWithOptionalTTS()`
9. **`src/server/agent/orchestrator.ts`** - Added enableTTS param
- Optional TTS synthesis parameter
- (Not used directly, TTS handled in server index)
10. **`src/ui/App.tsx`** - Integrated voice controls
- Added VoiceControls component
- Audio recording handler
- Audio playback handler
- State management (processing, playing)
- WebSocket message listeners
11. **`src/ui/App.css`** - Voice control styles
- Voice button styles
- Recording indicator animation
- Processing spinner animation
- Error/permission messages
- Playing indicator
12. **`.env.example`** - Updated with TTS configuration
- `TTS_VOICE` (default: alloy)
- `TTS_MODEL` (default: tts-1)
---
## Configuration
### Environment Variables
```bash
# Required
OPENAI_API_KEY=sk-...
# Optional (with defaults)
TTS_VOICE=alloy # alloy, echo, fable, onyx, nova, shimmer
TTS_MODEL=tts-1 # tts-1 or tts-1-hd
PORT=3000
NODE_ENV=development
```
---
## Usage Instructions
### Setup
1. **Install dependencies** (already done):
```bash
npm install
```
2. **Create `.env` file**:
```bash
cp .env.example .env
# Add your OPENAI_API_KEY
```
3. **Start the server**:
```bash
npm run dev
```
4. **Open browser**:
- Dev mode: http://localhost:5173 (Vite UI)
- Production: http://localhost:3000
### Voice Interaction
1. **Grant microphone permission** when prompted
2. **Click "Record" button** to start recording
3. **Speak your command**: e.g., "list all terminals"
4. **Click "Recording..." button** to stop
5. **Wait for transcription** (< 3 seconds)
6. **LLM processes** your request (executes tools if needed)
7. **Response is spoken** via TTS automatically
8. **Text is also displayed** in activity log
### Text Interaction (Still Works)
- Type message in text input
- Press "Send"
- Response displayed as text only (no TTS)
---
## Testing Scenarios
### Test 1: Basic STT
**Steps:**
1. Click "Record"
2. Say: "list terminals"
3. Click stop
**Expected:**
- ✅ Transcription appears in log
- ✅ Tool execution (list-terminals)
- ✅ Response displayed
### Test 2: Basic TTS
**Steps:**
1. Click "Record"
2. Say: "what terminals are available?"
3. Click stop
**Expected:**
- ✅ Transcription appears
- ✅ Text response streams
- ✅ Audio playback starts
- ✅ Speech matches text
### Test 3: Full Voice Conversation
**Steps:**
1. Say: "create a terminal called voice-test in /tmp"
2. Wait for response
3. Say: "list all terminals"
**Expected:**
- ✅ First command transcribed
- ✅ Terminal created
- ✅ TTS response played
- ✅ Second command works
- ✅ Terminal appears in list
### Test 4: Error Handling
**Steps:**
1. Deny microphone permission
2. Try to record
**Expected:**
- ✅ Permission error shown
- ✅ Record button disabled
### Test 5: Format Compatibility
**Test in:**
- Chrome (webm/opus)
- Firefox (ogg/opus)
- Safari (if available)
**Expected:**
- ✅ Format detected correctly
- ✅ Recording works
- ✅ Transcription succeeds
---
## Technical Details
### WebSocket Message Types
**Client → Server:**
```typescript
{
type: 'audio_chunk',
payload: {
audio: string, // base64 encoded
format: string, // 'audio/webm;codecs=opus'
isLast: boolean // true when recording complete
}
}
```
**Server → Client:**
```typescript
{
type: 'transcription_result',
payload: {
text: string,
language?: string,
duration?: number
}
}
{
type: 'audio_output',
payload: {
id: string, // unique ID
audio: string, // base64 encoded MP3
format: string // 'mp3'
}
}
```
### Audio Processing
**STT:**
- Accepts: Buffer (audio data), string (format)
- Creates temporary file for OpenAI API
- Cleans up temp file after transcription
- Returns: `{ text, language, duration }`
**TTS:**
- Accepts: string (text to synthesize)
- Streams response from OpenAI
- Converts stream to buffer
- Returns: `{ audio: Buffer, format: string }`
### Browser Audio
**Recording:**
- MediaRecorder with 100ms chunk interval
- Echo cancellation, noise suppression enabled
- Auto-detects supported mime type
- Buffers all chunks until stop
**Playback:**
- Creates blob URL from audio data
- Uses HTMLAudioElement for playback
- Sequential queue (one at a time)
- Cleans up blob URLs after playback
---
## Performance
### Latency Measurements
- **STT**: ~1-3 seconds (depends on audio length)
- **TTS**: ~1-2 seconds (depends on text length)
- **Total Voice Round-Trip**: ~3-5 seconds
- **Browser Recording**: Real-time, no delay
- **Browser Playback**: Immediate after buffer
### Resource Usage
- **Server Memory**: +50MB for OpenAI clients
- **Temp Files**: ~500KB per recording (auto-cleaned)
- **Network**:
- Upload: ~50KB per second of audio
- Download: ~10KB per second of speech
- **Browser**: Minimal (MediaRecorder, HTMLAudioElement)
---
## Known Limitations
1. **No Real-time Streaming STT**: Must wait for complete recording
2. **No Voice Activity Detection**: Manual stop required
3. **No Interrupt Capability**: Cannot interrupt playback
4. **Single Conversation**: One conversation per server instance
5. **No Audio Visualization**: No waveform display
6. **HTTPS Required**: For production (microphone access)
---
## Future Enhancements (Out of Scope)
1. **OpenAI Realtime API**: True streaming STT+LLM+TTS
2. **Voice Activity Detection**: Auto-stop on silence
3. **Interrupt Playback**: Stop TTS to speak again
4. **Audio Visualization**: Waveform/spectrum display
5. **Custom Wake Words**: "Hey Assistant..."
6. **Speaker Diarization**: Multiple speakers
7. **Noise Cancellation**: Advanced audio processing
8. **Multi-language**: Language detection and switching
---
## Success Criteria ✅
- ✅ User can record voice and get accurate transcription
- ✅ Transcription feeds into existing text chat flow
- ✅ Assistant responses are spoken via TTS
- ✅ Audio quality is acceptable (clear, natural)
- ✅ Latency is acceptable (< 5 seconds total)
- ✅ Error handling is graceful
- ✅ Works in Chrome and Firefox
- ✅ No new dependencies required
- ✅ Code is maintainable and well-structured
- ✅ TypeScript typechecks pass
---
## Dependencies Used
**No new dependencies added!** All using existing packages:
- `openai`: ^4.20.0 (STT and TTS APIs)
- `ws`: ^8.14.2 (WebSocket communication)
- `uuid`: ^9.0.1 (Unique IDs)
**Browser APIs:**
- MediaRecorder API (recording)
- HTMLAudioElement (playback)
- Navigator.mediaDevices (microphone access)
---
## Conclusion
Phases 6 & 7 (STT and TTS) have been successfully implemented. The voice assistant now supports:
1. **Voice Input**: Push-to-talk recording with OpenAI Whisper transcription
2. **Voice Output**: OpenAI TTS synthesis with automatic playback
3. **Full Voice Conversations**: Speak → transcribe → process → synthesize → play
4. **Graceful Degradation**: Text chat still works alongside voice
5. **Error Handling**: Microphone permissions, transcription errors, playback failures
6. **Cross-Browser**: Chrome and Firefox support confirmed
The implementation follows the push-to-talk model due to Whisper API limitations (no streaming STT). This is a reliable, production-ready approach that provides excellent voice interaction with minimal latency.
**Ready for testing with a real OpenAI API key!**
To test:
1. Create `.env` file with `OPENAI_API_KEY`
2. Run `npm run dev`
3. Open http://localhost:5173
4. Grant microphone permission
5. Click "Record" and speak
---
**Implementation Date**: 2025-10-19
**Status**: ✅ Complete
**Next Phase**: Testing and refinement based on user feedback
-226
View File
@@ -1,226 +0,0 @@
# LLM Integration Guide
This document describes Phase 4 of the voice-assistant package: LLM Integration with OpenAI GPT-4.
## Overview
The LLM integration enables the voice assistant to understand natural language commands and execute terminal operations using OpenAI's function calling capabilities.
## Components
### 1. System Prompt (`agent-prompt.md`)
The system prompt defines the agent's personality and behavior. It's loaded from the package root and instructs the agent to:
- Acknowledge requests verbally before acting
- Report tool execution results back to the user
- Handle voice-to-text errors gracefully
- Be concise for mobile users
- Use the simplified "terminals" model for all terminal interactions
**Location**: `/packages/voice-assistant/agent-prompt.md`
### 2. System Prompt Loader (`src/server/agent/system-prompt.ts`)
Provides functions to load and cache the system prompt:
- `loadSystemPrompt()`: Loads the prompt from disk
- `getSystemPrompt()`: Returns cached prompt (loads if not cached)
### 3. OpenAI Integration (`src/server/agent/llm-openai.ts`)
Main LLM integration module with the following functions:
#### `initializeOpenAI(apiKey: string)`
Initializes the OpenAI client with your API key.
#### `callLLM(params: CallLLMParams): Promise<LLMResponse>`
Calls OpenAI's chat completions API with:
- Messages (conversation history)
- Tools (terminal operations)
- Optional streaming callback
Returns the LLM's response including any tool calls.
#### `executeToolCall(toolCall: ToolCall): Promise<string>`
Executes a tool call by mapping to terminal manager functions:
- `list_terminals` → `listTerminals()`
- `create_terminal` → `createTerminal(args)`
- `capture_terminal` → `captureTerminal(args.terminalId, args.lines, args.wait)`
- `send_text` → `sendText(args.terminalId, args.text, args.pressEnter, args.return_output)`
- `rename_terminal` → `renameTerminal(args.terminalId, args.newName)`
- `kill_terminal` → `killTerminal(args.terminalId)`
Returns the result as a JSON string.
#### `getTerminalTools(): ChatCompletionTool[]`
Returns the terminal tools in OpenAI's function calling format.
## Usage
### Environment Setup
Create a `.env` file in the package root:
```bash
OPENAI_API_KEY=sk-...
PORT=3000
NODE_ENV=development
```
### Testing the Integration
The package includes a test endpoint at `/api/test-llm` that demonstrates the LLM integration.
#### Manual Testing with curl
```bash
# Test 1: Create a terminal
curl -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "create a terminal called llm-test"}' | jq .
# Test 2: List all terminals
curl -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "list all terminals"}' | jq .
# Test 3: Run a command
curl -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "run echo hello in the llm-test terminal"}' | jq .
```
#### Automated Testing
Run the provided test script:
```bash
./test-llm.sh
```
## API Response Format
The `/api/test-llm` endpoint returns:
```typescript
{
success: boolean;
userMessage: string;
llmResponse: string; // The LLM's text response
toolCalls?: Array<{
tool: string; // Tool name (e.g., "create_terminal")
arguments: any; // Tool arguments
result: string; // JSON result from tool execution
}>;
}
```
## Example Flow
1. **User sends message**: "create a terminal called web-dev at ~/projects/web"
2. **LLM processes request**:
- System prompt provides context about terminal operations
- User message is analyzed
- LLM decides to call `create_terminal` tool
3. **Tool execution**:
- Arguments: `{name: "web-dev", workingDirectory: "~/projects/web"}`
- `createTerminal()` is called
- Result returned as JSON
4. **Response to user**:
```json
{
"success": true,
"userMessage": "create a terminal called web-dev at ~/projects/web",
"llmResponse": "I'll create a terminal for you...",
"toolCalls": [{
"tool": "create_terminal",
"arguments": {"name": "web-dev", "workingDirectory": "~/projects/web"},
"result": "{\"id\":\"@123\",\"name\":\"web-dev\",\"workingDirectory\":\"/Users/user/projects/web\",...}"
}]
}
```
## Integration with Voice Assistant
The LLM integration is designed to work with the voice assistant's WebSocket server. Future phases will:
1. Connect WebSocket messages to LLM processing
2. Stream LLM responses back to the client
3. Handle multi-turn conversations
4. Maintain conversation context
## Error Handling
The LLM integration includes comprehensive error handling:
- Missing API key: Returns 500 error with message
- Tool execution failures: Returns error JSON in tool result
- Network errors: Caught and returned as 500 errors
- Invalid tool arguments: Caught by terminal manager functions
## Configuration
### Model Selection
Currently using `gpt-4o` (specified in `llm-openai.ts`). You can change this to:
- `gpt-4-turbo`: Faster, cheaper GPT-4
- `gpt-4`: Standard GPT-4
- `gpt-3.5-turbo`: Cheaper option (may have reduced performance)
### Streaming
Streaming is supported but not enabled by default in the test endpoint. To enable:
```typescript
const response = await callLLM({
messages,
tools: getTerminalTools(),
onChunk: (chunk) => {
// Handle streaming chunks
console.log(chunk);
}
});
```
## Development
### Building
```bash
npm run build
```
### Running in Development
```bash
npm start
```
The server will start on port 3000 (or `PORT` from `.env`).
## Troubleshooting
### "OpenAI client not initialized" error
Make sure you call `initializeOpenAI(apiKey)` before `callLLM()`.
### "OPENAI_API_KEY not set" error
Ensure you have a `.env` file with a valid `OPENAI_API_KEY`.
### Tool execution errors
Check that:
1. The default tmux session is initialized
2. Terminal IDs are valid (get them from `list_terminals`)
3. Arguments match the tool schema
### TypeScript errors
Run `npm run build` to check for compilation errors.
## Next Steps
Phase 5 will integrate the LLM with the WebSocket server to enable real-time voice conversations with terminal control.
-106
View File
@@ -1,106 +0,0 @@
# Quick Start: LLM Integration
## Setup
1. **Install dependencies** (if not already done):
```bash
npm install
```
2. **Create `.env` file**:
```bash
cp .env.example .env
# Edit .env and add your OPENAI_API_KEY
```
3. **Build the project**:
```bash
npm run build
```
4. **Start the server**:
```bash
npm start
```
## Testing
### Quick Test
```bash
# Make sure server is running on http://localhost:3000
curl -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "list all terminals"}' | jq .
```
### Run All Tests
```bash
./test-llm.sh
```
## Example Commands
Try these natural language commands:
```bash
# Create a terminal
curl -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "create a terminal called web-dev at ~/projects/web"}' | jq .
# List terminals
curl -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "show me all terminals"}' | jq .
# Run a command
curl -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "run npm install in the web-dev terminal"}' | jq .
# Get terminal output
curl -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "show me the output from web-dev terminal"}' | jq .
# Kill a terminal
curl -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "close the web-dev terminal"}' | jq .
```
## What's Next?
Phase 5 will integrate this LLM functionality with the WebSocket server to enable:
- Real-time voice conversations
- Streaming responses
- Multi-turn context
- Voice-to-terminal control
## Files Created
- `/agent-prompt.md` - System prompt defining agent behavior
- `/src/server/agent/system-prompt.ts` - Loads and caches system prompt
- `/src/server/agent/llm-openai.ts` - OpenAI integration with tool execution
- `/src/server/index.ts` - Added `/api/test-llm` endpoint
- `/LLM_INTEGRATION.md` - Comprehensive integration guide
- `/QUICK_START.md` - This file
- `/test-llm.sh` - Automated test script
## Troubleshooting
**Q: "OPENAI_API_KEY not set" error**
A: Create a `.env` file with `OPENAI_API_KEY=sk-...`
**Q: Server won't start**
A: Make sure tmux is installed and running
**Q: Tool calls failing**
A: Check that the default tmux session is initialized (happens automatically on server start)
**Q: Build errors**
A: Run `npm run build` and check the error messages
For more details, see `LLM_INTEGRATION.md`.
@@ -1,382 +0,0 @@
# Voice Implementation Plan (Phases 6 & 7)
## Research Summary
### OpenAI Whisper API (STT)
**Key Findings:**
- **No Native Streaming Support**: OpenAI Whisper API does NOT support real-time streaming transcription
- **Batch Processing Only**: Must send complete audio files after recording stops
- **Supported Formats**: flac, m4a, mp3, mp4, mpeg, mpga, oga, ogg, wav, webm
- **File Size Limit**: 25 MB maximum
- **API Endpoint**: `/v1/audio/transcriptions`
- **Strategy**: Push-to-talk (click to record, click to stop, then transcribe)
**Alternative Considered:**
- OpenAI Realtime API provides true streaming STT+LLM+TTS in one WebSocket connection
- However, it's a different architecture (conversational AI vs tool-calling assistant)
- For this phase, we'll use Whisper API with push-to-talk model
### OpenAI TTS API
**Key Findings:**
- **Streaming Supported**: Yes, via chunk transfer encoding
- **Supported Formats**: mp3, opus, aac, flac, wav, pcm
- **Available Voices**: alloy, echo, fable, onyx, nova, shimmer
- **Quality Models**:
- `tts-1`: Faster, lower latency, normal quality
- `tts-1-hd`: Higher quality, slower
- **API Endpoint**: `/v1/audio/speech`
- **Default Format**: MP3
### Browser Audio APIs
**MediaRecorder API (Audio Capture):**
- **Widely Supported**: Chrome, Firefox, Safari, Edge (since 2021)
- **Format Support Varies**:
- Chrome/Opera: `audio/webm;codecs=opus`
- Firefox: `audio/ogg;codecs=opus` (also supports webm since Firefox 63)
- Need to check `MediaRecorder.isTypeSupported()` at runtime
- **Recommended Approach**: Capture as WebM/Opus, convert if needed
- **Events**: `dataavailable` for chunks, `stop` for complete recording
**Web Audio API (Audio Playback):**
- **Challenge**: `decodeAudioData()` requires complete files, not chunks
- **Solution Options**:
1. Use `<audio>` element with blob URLs (simpler, works with MP3)
2. Use MediaSource Extensions for true streaming
3. Buffer complete audio before playback
- **Recommended**: Option 1 for simplicity - buffer complete TTS response, then play
## Architecture Decisions
### STT Flow (Push-to-Talk Model)
```
User clicks microphone button
↓
Browser captures audio via MediaRecorder
↓
Audio buffered in browser as WebM/Opus
↓
User clicks stop button
↓
Complete audio sent to server via WebSocket (base64 encoded)
↓
Server saves as temporary file (WebM)
↓
Server converts to MP3 if needed (using FFmpeg or accept WebM directly)
↓
Server calls OpenAI Whisper API with audio file
↓
Transcript returned and fed to orchestrator
↓
processUserMessage() handles as normal text input
```
### TTS Flow (Buffer-and-Play Model)
```
Orchestrator generates LLM response (streaming text)
↓
Server buffers complete response text
↓
Server calls OpenAI TTS API with complete text
↓
TTS returns audio (MP3 format, streamed from OpenAI)
↓
Server buffers complete audio
↓
Server sends complete audio to browser via WebSocket (base64 encoded)
↓
Browser creates Blob URL from audio data
↓
Browser plays using <audio> element
↓
Queue management: only play next when current finishes
```
**Rationale for Buffer-and-Play:**
- Simpler implementation (no streaming audio chunks)
- Avoids "clicks" between chunks
- Works reliably across all browsers
- TTS API is fast enough that latency is acceptable
- Can be upgraded to true streaming later if needed
## Implementation Plan
### Phase 6A: STT Integration
#### 1. Server-side STT Module (`src/server/agent/stt-openai.ts`)
```typescript
export interface STTConfig {
apiKey: string;
model?: 'whisper-1';
}
export interface TranscriptionResult {
text: string;
language?: string;
duration?: number;
}
export function initializeSTT(config: STTConfig): void;
export async function transcribeAudio(audioBuffer: Buffer, format: string): Promise<TranscriptionResult>;
```
**Implementation Notes:**
- Use `openai` package (already installed)
- Accept audio buffer and format (webm, mp3, etc.)
- Call `/v1/audio/transcriptions` endpoint
- Return transcript text
- Handle errors gracefully
#### 2. Update WebSocket Message Types (`src/server/types.ts`)
```typescript
export interface WebSocketMessage {
type: 'activity_log' | 'status' | 'webrtc_signal' | 'ping' | 'pong'
| 'user_message' | 'assistant_chunk'
| 'audio_chunk' | 'audio_output' | 'recording_state' | 'transcription_result';
payload: unknown;
}
export interface AudioChunkPayload {
audio: string; // base64 encoded audio data
format: string; // 'webm', 'ogg', etc.
isLast: boolean; // true when recording stopped
}
export interface RecordingStatePayload {
isRecording: boolean;
}
export interface TranscriptionResultPayload {
text: string;
duration?: number;
}
```
#### 3. Add Audio Handling to WebSocket Server (`src/server/websocket-server.ts`)
- Add handler for `audio_chunk` messages
- Buffer audio chunks until `isLast: true`
- Call STT transcription
- Send `transcription_result` back to client
- Feed transcript to orchestrator
#### 4. Wire STT to Orchestrator (`src/server/agent/orchestrator.ts`)
- Add `processAudioInput()` function that wraps `processUserMessage()`
- Add activity log entries for STT events
### Phase 6B: Browser Audio Capture
#### 1. Audio Capture Utility (`src/ui/lib/audio-capture.ts`)
```typescript
export interface AudioCaptureConfig {
mimeType?: string;
audioBitsPerSecond?: number;
}
export interface AudioRecorder {
start(): Promise<void>;
stop(): Promise<Blob>;
isRecording(): boolean;
getSupportedMimeType(): string | null;
}
export function createAudioRecorder(config?: AudioCaptureConfig): AudioRecorder;
```
**Implementation Notes:**
- Request microphone permission
- Detect supported mime type (prefer webm/opus)
- Use MediaRecorder API
- Buffer chunks in array
- Return complete Blob on stop
#### 2. Voice Controls Component (`src/ui/components/VoiceControls.tsx`)
```typescript
interface VoiceControlsProps {
onAudioRecorded: (audio: Blob, format: string) => void;
isProcessing: boolean;
}
```
**UI Elements:**
- Microphone button (toggle recording)
- Recording indicator (red dot animation)
- Processing state (spinning loader)
- Visual feedback (waveform or simple animation)
#### 3. Update Main App (`src/ui/App.tsx`)
- Add voice controls section
- Handle `audio_chunk` sending
- Display transcription results
- Show recording/processing states
### Phase 7A: TTS Integration
#### 1. Server-side TTS Module (`src/server/agent/tts-openai.ts`)
```typescript
export interface TTSConfig {
apiKey: string;
model?: 'tts-1' | 'tts-1-hd';
voice?: 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
responseFormat?: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm';
}
export interface SpeechResult {
audio: Buffer;
format: string;
}
export function initializeTTS(config: TTSConfig): void;
export async function synthesizeSpeech(text: string): Promise<SpeechResult>;
```
**Implementation Notes:**
- Use `openai` package streaming support
- Default to `tts-1` model with `alloy` voice
- Stream from OpenAI and buffer complete audio
- Return as Buffer with format info
#### 2. Update WebSocket Message Types
```typescript
export interface AudioOutputPayload {
audio: string; // base64 encoded audio data (complete)
format: string; // 'mp3'
id: string; // unique ID for queue management
}
```
#### 3. Wire TTS to Orchestrator (`src/server/agent/orchestrator.ts`)
- Modify `processUserMessage()` to optionally synthesize response
- Add `enableTTS: boolean` parameter
- After complete assistant response, call TTS
- Send `audio_output` message to WebSocket clients
- Keep text display alongside audio
### Phase 7B: Browser Audio Playback
#### 1. Audio Playback Utility (`src/ui/lib/audio-playback.ts`)
```typescript
export interface AudioPlayer {
play(audioData: Blob): Promise<void>;
stop(): void;
isPlaying(): boolean;
}
export function createAudioPlayer(): AudioPlayer;
```
**Implementation Notes:**
- Create blob URLs from audio data
- Use `<audio>` element for playback
- Clean up blob URLs after playback
- Simple queue: play next when current finishes
#### 2. Update Voice Controls
- Add speaker indicator (playing state)
- Add stop playback button
- Visual feedback (audio wave or icon animation)
#### 3. Update Main App
- Handle `audio_output` messages
- Queue audio playback
- Show playing state in UI
## Testing Strategy
### Unit Tests (Optional for now)
- Test audio format detection
- Test buffer encoding/decoding
- Test queue management
### Manual Integration Tests
**Test 1: Basic STT**
1. Open UI, ensure WebSocket connected
2. Click microphone button
3. Speak: "list terminals"
4. Click stop recording
5. Verify: Transcription appears in activity log
6. Verify: Tool execution happens
7. Verify: Response displayed
**Test 2: Basic TTS**
1. Send text message: "what terminals are available?"
2. Verify: Text response streams as normal
3. Verify: Audio playback starts automatically
4. Verify: Audio matches text response
5. Verify: Text remains visible during playback
**Test 3: Full Voice Conversation**
1. Record: "create a terminal called voice-test in /tmp"
2. Verify: Transcription → tool call → response → TTS playback
3. Record: "list all terminals"
4. Verify: Second interaction works correctly
5. Verify: Terminal was actually created in tmux
**Test 4: Error Handling**
1. Try recording without microphone permission
2. Try sending empty audio
3. Try sending very long audio (>1 minute)
4. Verify: Graceful error messages
**Test 5: Audio Format Compatibility**
1. Test in Chrome (webm/opus)
2. Test in Firefox (ogg/opus)
3. Test in Safari (if possible)
4. Verify: Format detection works correctly
## Environment Configuration
Add to `.env` (or `.env.example`):
```bash
# OpenAI API Configuration
OPENAI_API_KEY=sk-...
# STT Configuration (optional overrides)
WHISPER_MODEL=whisper-1
# TTS Configuration (optional overrides)
TTS_MODEL=tts-1
TTS_VOICE=alloy
TTS_FORMAT=mp3
```
## Dependencies (Already Installed)
- `openai`: ^4.20.0 ✓
- `ws`: ^8.14.2 ✓
- `uuid`: ^9.0.1 ✓
**No additional dependencies needed!**
## Implementation Order
1. **STT Server Module** - Core transcription logic
2. **Browser Audio Capture** - Get microphone working
3. **WebSocket Audio Messages** - Wire STT to UI
4. **STT Integration Test** - Verify speech-to-text works end-to-end
5. **TTS Server Module** - Core synthesis logic
6. **Browser Audio Playback** - Get speaker working
7. **TTS Integration Test** - Verify text-to-speech works end-to-end
8. **Polish & Error Handling** - Edge cases, loading states, errors
9. **End-to-End Voice Test** - Full conversation flow
## Future Enhancements (Out of Scope)
- Real-time streaming STT (requires different API or third-party service)
- Voice Activity Detection (VAD) for automatic recording stop
- Speaker diarization (multiple speakers)
- Custom wake words
- Interrupt playback to speak
- Noise cancellation
- Audio visualization (waveforms)
- OpenAI Realtime API migration (full duplex voice)
## Success Criteria
✅ User can record voice and get accurate transcription
✅ Transcription feeds into existing text chat flow
✅ Assistant responses are spoken via TTS
✅ Audio quality is acceptable (clear, natural)
✅ Latency is acceptable (< 3 seconds for STT, < 2 seconds for TTS)
✅ Error handling is graceful
✅ Works in Chrome and Firefox
✅ No new dependencies required
✅ Code is maintainable and well-structured
@@ -1,840 +0,0 @@
# WebRTC Audio Streaming Implementation Plan
**Date:** 2025-10-19
**Phase:** 8 - WebRTC Migration
**Status:** ~~Planning Complete~~ **SUPERSEDED - See Note Below**
---
## ⚠️ ARCHITECTURAL PIVOT - OCTOBER 2025
**This implementation plan has been superseded by a simpler architecture.**
We initially planned to use server-side WebRTC (via werift) to handle bidirectional audio streaming. However, we realized that **server-side WebRTC is not necessary** for our use case.
### What We Actually Need
1. **Browser-side WebRTC audio processing** - Echo cancellation, noise suppression, auto gain control
2. **Simple WebSocket transport** - Send/receive audio between browser and server
### Why Server-Side WebRTC is Unnecessary
- The server doesn't need to be a WebRTC peer
- We only need the browser's audio processing features (available via `getUserMedia()`)
- WebSocket provides simple, reliable transport for audio data
- No need for complex signaling, ICE, STUN, or TURN servers
### Current Architecture (Implemented)
```
Browser Server
↓ ↓
[getUserMedia] → WebRTC Audio Processing → [MediaRecorder]
↓ ↓
[Base64 Encode] → WebSocket → [Decode] → STT → LLM → TTS
↓
[Audio Element] ← WebSocket ← [Base64 Encode] ← [MP3]
```
**Key Benefits:**
- Simple architecture (no server-side WebRTC complexity)
- Browser handles all audio processing (echo cancel, noise suppress, AGC)
- WebSocket transport is reliable and easy to debug
- No NAT traversal issues
- Works perfectly for our use case
### See Updated Documentation
For current audio processing architecture, see:
- `/Users/moboudra/dev/voice-dev/packages/voice-assistant/AUDIO_PROCESSING.md`
---
## Original Plan (For Historical Reference)
## Executive Summary
This document outlines the plan to migrate from WebSocket-based audio transport to WebRTC for real-time bidirectional audio streaming. The implementation uses a **dual-connection architecture**:
1. **WebSocket** - Control channel for signaling, text messages, tool notifications, and status updates
2. **WebRTC** - Media channel for low-latency bidirectional audio streaming
---
## Research Findings
### Node.js WebRTC Library Evaluation
After extensive research (avoiding `wrtc` due to native compilation issues), here are the viable options:
#### Option 1: **werift-webrtc** (RECOMMENDED)
- **Repository**: https://github.com/shinyoshiaki/werift-webrtc
- **Status**: Actively maintained (latest: 0.22.2, 3 months ago)
- **Type**: Pure TypeScript/JavaScript implementation
- **Pros**:
- No native dependencies (no compilation issues)
- Full TypeScript support
- Includes ICE/DTLS/SCTP/RTP/SRTP
- Works in Node.js
- Active development
- **Cons**:
- Still maturing (some features incomplete)
- ICE restart not yet available
- TURN ICE TLS/TCP not supported
- No simulcast (send) support yet
- **Verdict**: Best choice for our use case (simple peer-to-peer audio)
#### Option 2: **simple-peer**
- **Repository**: https://github.com/feross/simple-peer
- **Status**: Popular but unmaintained (last update 2021)
- **Type**: Browser + Node.js wrapper around WebRTC
- **Pros**:
- Simple API
- Well-documented
- 312 projects using it
- **Cons**:
- Requires `wrtc` for Node.js (which we're avoiding)
- Not updated in 4 years
- Would need `@roamhq/wrtc` fork
- **Verdict**: Not suitable (requires wrtc)
#### Option 3: **Alternative Approach - Signaling Only**
- **Concept**: Browser handles both WebRTC peers
- **Architecture**: Server acts as signaling relay only
- **Pros**:
- No Node.js WebRTC library needed
- Server complexity stays low
- Browser WebRTC is mature and reliable
- **Cons**:
- Cannot process audio server-side (defeats our purpose)
- Cannot wire to STT/TTS on server
- **Verdict**: Not applicable to our use case
### Recommended Approach: **werift-webrtc**
We will use `werift-webrtc` for server-side WebRTC peer connection management. This gives us:
1. Pure JavaScript implementation (no native compilation)
2. Full control over audio streams on the server
3. Ability to wire WebRTC audio tracks to STT/TTS pipelines
4. TypeScript support for better development experience
---
## Architecture Design
### Current Architecture (WebSocket Audio)
```
Browser Server
↓ ↓
[MediaRecorder] → WebSocket → [Buffer] → STT → LLM → TTS → [Buffer] → WebSocket → [Audio Element]
↑ ↓
[Microphone] [Speakers]
```
**Issues:**
- High latency (base64 encoding/decoding)
- Buffering required (push-to-talk)
- Not real-time
- Inefficient for audio transport
### New Architecture (WebRTC + WebSocket)
```
Browser Server
↓ ↓
[RTCPeerConnection] ←─────────────→ [werift.RTCPeerConnection]
↑ WebRTC Audio ↓
↑ ↓
[Microphone] [Audio Track] → STT Stream → LLM
↑
[Audio Track] ← TTS Stream
↓ ↓
[Speakers] ←─────────────────────┘
WebSocket (signaling only)
↓ ↓
[WebSocket] ←──── SDP/ICE ────→ [WebSocket Server]
←─── Messages ────→
←── Tool Logs ────→
```
**Benefits:**
- Real-time audio streaming (low latency)
- No base64 encoding overhead
- Native browser media handling
- Continuous streaming (no push-to-talk requirement)
- Efficient bandwidth usage
---
## Implementation Plan
### Phase 1: Backend Setup
#### Task 1.1: Install werift-webrtc
```bash
cd /Users/moboudra/dev/voice-dev/packages/voice-assistant
npm install werift
```
#### Task 1.2: Create WebRTC Server Module
**File**: `src/server/webrtc/peer-connection.ts`
```typescript
import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'werift';
export interface WebRTCPeerConfig {
iceServers?: Array<{
urls: string | string[];
username?: string;
credential?: string;
}>;
}
export interface WebRTCPeer {
id: string;
connection: RTCPeerConnection;
onAudioTrack: (track: MediaStreamTrack) => void;
addAudioTrack: (track: MediaStreamTrack) => void;
close: () => void;
}
export function createWebRTCPeer(config?: WebRTCPeerConfig): WebRTCPeer;
```
**Responsibilities:**
- Create RTCPeerConnection with STUN/TURN configuration
- Handle ICE candidate gathering
- Manage audio track addition/reception
- Connection state management
- Clean shutdown
#### Task 1.3: Create WebRTC Manager
**File**: `src/server/webrtc/manager.ts`
```typescript
import type { VoiceAssistantWebSocketServer } from '../websocket-server.js';
import type { WebRTCPeer } from './peer-connection.js';
export interface WebRTCManager {
createPeerForClient(clientId: string): Promise<WebRTCPeer>;
handleOffer(clientId: string, offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit>;
handleAnswer(clientId: string, answer: RTCSessionDescriptionInit): Promise<void>;
handleIceCandidate(clientId: string, candidate: RTCIceCandidateInit): Promise<void>;
removePeer(clientId: string): void;
}
export function createWebRTCManager(wsServer: VoiceAssistantWebSocketServer): WebRTCManager;
```
**Responsibilities:**
- Manage multiple peer connections (one per client)
- Coordinate signaling via WebSocket
- Track connection state
- Clean up disconnected peers
#### Task 1.4: Integrate Signaling with WebSocket
**File**: `src/server/websocket-server.ts` (modifications)
Add handlers for:
- `webrtc_offer` - Client sends SDP offer
- `webrtc_answer` - Client sends SDP answer
- `webrtc_ice_candidate` - Client sends ICE candidate
Add methods:
- `sendWebRTCSignal(clientId, signal)` - Send signaling to specific client
#### Task 1.5: Wire Audio Input to STT
**File**: `src/server/audio/stream-processor.ts` (new)
```typescript
export interface AudioStreamProcessor {
processInputStream(track: MediaStreamTrack, onTranscript: (text: string) => void): void;
stop(): void;
}
export function createAudioStreamProcessor(): AudioStreamProcessor;
```
**Responsibilities:**
- Receive WebRTC audio track
- Convert to format suitable for streaming STT
- Buffer audio chunks
- Feed to STT API (may need streaming STT service)
- Emit transcripts as they arrive
**Challenge**: OpenAI Whisper doesn't support streaming. Options:
1. **Option A**: Buffer audio chunks (e.g., 3-second segments) and transcribe incrementally
2. **Option B**: Use streaming STT service (Deepgram, AssemblyAI, Google Speech-to-Text)
3. **Option C**: Keep hybrid model: WebRTC for TTS output, WebSocket for STT input
**Recommendation**: Start with **Option A** (buffered chunks), evaluate **Option B** if latency is too high.
#### Task 1.6: Wire TTS Output to WebRTC
**File**: `src/server/audio/tts-stream.ts` (new)
```typescript
export interface TTSAudioStream {
sendToTrack(audioBuffer: Buffer, track: MediaStreamTrack): Promise<void>;
close(): void;
}
export function createTTSAudioStream(): TTSAudioStream;
```
**Responsibilities:**
- Receive TTS audio buffer from OpenAI
- Convert to WebRTC audio frames (RTP packets)
- Send via WebRTC audio track to client
- Handle backpressure and buffering
---
### Phase 2: Frontend Setup
#### Task 2.1: Create WebRTC Client Module
**File**: `src/ui/lib/webrtc-client.ts`
```typescript
export interface WebRTCClient {
initialize(): Promise<void>;
createOffer(): Promise<RTCSessionDescriptionInit>;
handleAnswer(answer: RTCSessionDescriptionInit): Promise<void>;
handleIceCandidate(candidate: RTCIceCandidateInit): Promise<void>;
addLocalAudioTrack(stream: MediaStream): void;
onRemoteTrack(callback: (track: MediaStreamTrack) => void): void;
close(): void;
}
export function createWebRTCClient(
onIceCandidate: (candidate: RTCIceCandidate) => void,
onConnectionStateChange: (state: RTCPeerConnectionState) => void
): WebRTCClient;
```
**Responsibilities:**
- Create browser RTCPeerConnection
- Manage local media stream (microphone)
- Handle remote audio track (TTS output)
- Emit ICE candidates for signaling
- Track connection state
#### Task 2.2: Integrate Signaling with WebSocket Hook
**File**: `src/ui/hooks/useWebSocket.ts` (modifications)
Add message handlers:
- `webrtc_offer` - Server sends offer (if server initiates)
- `webrtc_answer` - Server sends answer
- `webrtc_ice_candidate` - Server sends ICE candidate
Add methods:
- `sendWebRTCSignal(type, data)` - Send signaling to server
#### Task 2.3: Create WebRTC Connection Hook
**File**: `src/ui/hooks/useWebRTC.ts` (new)
```typescript
export interface UseWebRTCResult {
connectionState: RTCPeerConnectionState;
localStream: MediaStream | null;
remoteStream: MediaStream | null;
startConnection: () => Promise<void>;
stopConnection: () => void;
error: string | null;
}
export function useWebRTC(wsClient: WebSocketClient): UseWebRTCResult;
```
**Responsibilities:**
- Manage WebRTC client lifecycle
- Coordinate with WebSocket for signaling
- Provide connection state to UI
- Handle errors and reconnection
#### Task 2.4: Update VoiceControls Component
**File**: `src/ui/components/VoiceControls.tsx` (modifications)
Changes:
- Remove `createAudioRecorder()` usage
- Remove push-to-talk button logic
- Add connection toggle: "Connect Voice" / "Disconnect Voice"
- Display connection state
- Use WebRTC local/remote streams instead of MediaRecorder
New UI states:
- Disconnected
- Connecting
- Connected (streaming)
- Error
#### Task 2.5: Handle Remote Audio Playback
**File**: `src/ui/lib/webrtc-playback.ts` (new)
```typescript
export function playRemoteAudioTrack(track: MediaStreamTrack): HTMLAudioElement;
```
**Responsibilities:**
- Create Audio element for remote track
- Attach MediaStream to Audio element
- Auto-play TTS audio as it arrives
- Clean up on disconnect
---
### Phase 3: Integration and Testing
#### Task 3.1: Connection Flow Testing
**Test Case 1: Establish WebRTC Connection**
1. Open browser UI
2. Click "Connect Voice"
3. Grant microphone permission
4. Verify: WebSocket signaling messages exchanged
5. Verify: ICE candidates gathered and exchanged
6. Verify: WebRTC connection state = "connected"
7. Verify: No errors in console
**Expected Latency**: < 2 seconds to establish connection
#### Task 3.2: Audio Input Testing
**Test Case 2: Microphone to Server**
1. Establish WebRTC connection
2. Speak into microphone: "list terminals"
3. Verify: Audio track received on server
4. Verify: Audio data flowing (check logs)
5. Verify: STT processing triggered
6. Verify: Transcript appears in activity log
7. Verify: LLM response generated
**Expected Latency**: < 1 second for STT (with buffered chunks)
#### Task 3.3: Audio Output Testing
**Test Case 3: TTS to Browser**
1. Establish WebRTC connection
2. Speak: "what terminals are available?"
3. Verify: LLM response generated
4. Verify: TTS audio synthesized
5. Verify: Audio sent via WebRTC track
6. Verify: Audio plays in browser automatically
7. Verify: Clear, natural speech quality
**Expected Latency**: < 1 second for TTS
#### Task 3.4: Full Conversation Testing
**Test Case 4: Continuous Voice Conversation**
1. Establish connection
2. Speak: "create a terminal called test in /tmp"
3. Wait for response (spoken)
4. Speak: "list all terminals"
5. Verify: Both interactions work smoothly
6. Verify: No audio cutoff or overlap
7. Verify: Terminal actually created in tmux
**Expected Total Round-Trip**: < 3 seconds
#### Task 3.5: Error Handling Testing
**Test Case 5: Connection Failures**
1. Deny microphone permission → graceful error
2. Kill WebSocket connection → WebRTC reconnect attempt
3. ICE gathering fails → show connection error
4. Send invalid SDP → handle error gracefully
**Test Case 6: Audio Processing Failures**
1. STT API error → show error, allow retry
2. TTS API error → text-only fallback
3. Audio codec mismatch → log warning, attempt recovery
---
## WebRTC Message Types
### Updated `src/server/types.ts`
```typescript
export interface WebSocketMessage {
type: 'activity_log' | 'status' | 'ping' | 'pong' | 'user_message' | 'assistant_chunk'
| 'audio_chunk' | 'audio_output' | 'recording_state' | 'transcription_result'
// New WebRTC signaling types:
| 'webrtc_offer' | 'webrtc_answer' | 'webrtc_ice_candidate';
payload: unknown;
}
export interface WebRTCOfferPayload {
sdp: string;
type: 'offer';
}
export interface WebRTCAnswerPayload {
sdp: string;
type: 'answer';
}
export interface WebRTCIceCandidatePayload {
candidate: string;
sdpMLineIndex: number | null;
sdpMid: string | null;
}
```
---
## Signaling Flow Diagram
```
Browser Server
| |
|--- Connect to WebSocket --------------->|
|<-- WebSocket connected -----------------|
| |
|--- Click "Connect Voice" --------------->|
| |
|--- getUserMedia (microphone) ---------->|
|<-- MediaStream acquired -----------------|
| |
|--- Create RTCPeerConnection ------------>|
|--- Add audio track --------------------->|
|--- createOffer() ----------------------->|
| |
|--- WS: webrtc_offer -------------------->|
| SDP offer |
| |--- Create RTCPeerConnection
| |--- setRemoteDescription(offer)
| |--- createAnswer()
| |
|<-- WS: webrtc_answer --------------------|
| SDP answer |
| |
|--- setRemoteDescription(answer) -------->|
| |
|<-- WS: webrtc_ice_candidate -------------|
|--- WS: webrtc_ice_candidate ------------>|
|<-- WS: webrtc_ice_candidate -------------|
|--- WS: webrtc_ice_candidate ------------>|
| (multiple ICE exchanges) |
| |
|========= WebRTC Connection Established ==|
| |
|--- Audio streaming ---------------------->|--- Audio track received
| |--- STT processing
| |--- LLM response
| |--- TTS synthesis
|<-- Audio streaming ----------------------|--- Send via audio track
| |
```
---
## Configuration
### Environment Variables
```bash
# Existing
OPENAI_API_KEY=sk-...
TTS_VOICE=alloy
TTS_MODEL=tts-1
# New (optional)
WEBRTC_STUN_SERVER=stun:stun.l.google.com:19302
WEBRTC_TURN_SERVER= # Optional TURN server
WEBRTC_TURN_USERNAME= # Optional
WEBRTC_TURN_CREDENTIAL= # Optional
```
### Default STUN/TURN Configuration
```typescript
const defaultIceServers = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
];
```
For most peer-to-peer scenarios, STUN is sufficient. TURN is only needed for restrictive NAT/firewall environments.
---
## Migration Strategy
### Hybrid Approach (Recommended)
To minimize risk, implement WebRTC in parallel with existing WebSocket audio:
**Phase 8A: WebRTC for TTS Only**
- Keep WebSocket for STT input (existing push-to-talk)
- Use WebRTC for TTS output (lower latency playback)
- Test and validate
**Phase 8B: WebRTC for STT (Buffered Chunks)**
- Add WebRTC audio input
- Buffer 2-3 second chunks
- Send chunks to Whisper API
- Compare latency with WebSocket approach
**Phase 8C: Full WebRTC (Optional Streaming STT)**
- Evaluate streaming STT services (Deepgram, AssemblyAI)
- If beneficial, replace Whisper with streaming STT
- Remove WebSocket audio code
**Phase 8D: Cleanup**
- Remove old WebSocket audio handlers
- Remove MediaRecorder code
- Remove audio-capture.ts and audio-playback.ts
- Update documentation
---
## STT Streaming Strategy
### Challenge: OpenAI Whisper is Not Streaming
**Current State**: Whisper requires complete audio files.
**Options for WebRTC Audio Input**:
1. **Option A: Buffered Chunks (Simple)**
- Buffer WebRTC audio track for N seconds (e.g., 3 seconds)
- Send buffer to Whisper API
- Repeat for continuous transcription
- **Pros**: Works with existing Whisper API, simple
- **Cons**: Still has buffering delay, not true real-time
2. **Option B: Streaming STT Service (Advanced)**
- Replace Whisper with streaming-capable service:
- **Deepgram**: WebSocket streaming, excellent accuracy
- **AssemblyAI**: Universal Streaming model
- **Google Speech-to-Text**: Streaming recognition
- **Azure Speech**: Real-time transcription
- **Pros**: True real-time transcription, lower latency
- **Cons**: Additional service, potential cost increase
3. **Option C: Hybrid Model (Conservative)**
- Use WebRTC for TTS output only (low-latency playback)
- Keep WebSocket for STT input (push-to-talk)
- **Pros**: Simple migration, reuses existing STT
- **Cons**: Doesn't achieve full real-time input
**Recommendation**: Start with **Option C** (hybrid), then evaluate **Option A** (buffered chunks). Consider **Option B** (streaming STT) if latency is critical.
### Deepgram Integration (If Pursuing Option B)
```bash
npm install @deepgram/sdk
```
```typescript
import { createClient } from '@deepgram/sdk';
const deepgram = createClient(process.env.DEEPGRAM_API_KEY);
const connection = deepgram.listen.live({
model: 'nova-2',
language: 'en-US',
smart_format: true,
});
connection.on('transcript', (data) => {
console.log('Transcript:', data.channel.alternatives[0].transcript);
});
// Send audio chunks from WebRTC track
audioTrack.on('data', (chunk) => {
connection.send(chunk);
});
```
**Note**: The Deepgram SDK is already installed in the project (`@deepgram/sdk: ^3.4.0`).
---
## Dependencies
### New Dependencies
```json
{
"dependencies": {
"werift": "^0.22.2"
}
}
```
### Existing Dependencies (No Changes)
- `openai`: ^4.20.0
- `ws`: ^8.14.2
- `@deepgram/sdk`: ^3.4.0 (already installed, may use for streaming STT)
- `uuid`: ^9.0.1
---
## File Structure
```
src/
├── server/
│ ├── webrtc/
│ │ ├── peer-connection.ts # NEW: WebRTC peer wrapper
│ │ ├── manager.ts # NEW: Peer manager
│ │ └── signaling.ts # NEW: Signaling coordination
│ ├── audio/
│ │ ├── stream-processor.ts # NEW: Audio input processing
│ │ └── tts-stream.ts # NEW: TTS to WebRTC track
│ ├── websocket-server.ts # MODIFIED: Add WebRTC signaling
│ ├── types.ts # MODIFIED: Add WebRTC types
│ └── index.ts # MODIFIED: Initialize WebRTC
├── ui/
│ ├── lib/
│ │ ├── webrtc-client.ts # NEW: Browser WebRTC client
│ │ └── webrtc-playback.ts # NEW: Remote audio playback
│ ├── hooks/
│ │ ├── useWebRTC.ts # NEW: WebRTC connection hook
│ │ └── useWebSocket.ts # MODIFIED: Add signaling
│ ├── components/
│ │ └── VoiceControls.tsx # MODIFIED: WebRTC UI
│ └── App.tsx # MODIFIED: Integrate WebRTC
```
---
## Performance Expectations
### Latency Comparison
| Metric | WebSocket (Current) | WebRTC (Target) |
|-------------------------|---------------------|-----------------|
| STT Latency | 1-3 seconds | 0.5-2 seconds |
| TTS Latency | 1-2 seconds | 0.5-1 second |
| Total Round-Trip | 3-5 seconds | 1-3 seconds |
| Audio Quality | Good | Excellent |
| Bandwidth Efficiency | Low (base64) | High (binary) |
### Expected Improvements
- **50-60% reduction** in total latency
- **30-40% reduction** in bandwidth usage
- **Improved audio quality** (no encoding overhead)
- **True streaming** capability (with streaming STT)
---
## Known Limitations and Risks
### Technical Risks
1. **werift Maturity**: Library is still maturing
- Mitigation: Extensive testing, fallback to WebSocket
2. **Browser Compatibility**: WebRTC support varies
- Mitigation: Feature detection, graceful degradation
3. **NAT/Firewall Issues**: May require TURN server
- Mitigation: Provide TURN configuration option
4. **STT Streaming**: OpenAI Whisper doesn't stream
- Mitigation: Hybrid approach or alternative STT service
### Operational Risks
1. **TURN Server Costs**: If needed for production
- Mitigation: Use free STUN initially, add TURN only if needed
2. **Increased Complexity**: Two connection types to manage
- Mitigation: Clear separation of concerns, good documentation
3. **Debugging Difficulty**: WebRTC is complex to debug
- Mitigation: Extensive logging, use `chrome://webrtc-internals/`
---
## Success Criteria
- ✅ WebRTC connection established reliably between browser and server
- ✅ Microphone audio streams to server via WebRTC in real-time
- ✅ TTS audio streams from server to browser via WebRTC in real-time
- ✅ Total latency < 3 seconds for full voice round-trip
- ✅ Audio quality is excellent (no artifacts, clear speech)
- ✅ WebSocket still handles text messages and tool notifications
- ✅ Connection is stable (no disconnects or audio dropouts)
- ✅ Error handling is graceful (reconnection, fallback)
- ✅ Works in Chrome and Firefox
- ✅ No regression in existing text chat functionality
- ✅ Code is maintainable and well-documented
---
## Implementation Timeline
### Conservative Estimate (Phased Approach)
- **Phase 8A** (WebRTC TTS Only): 2-3 days
- Install werift, setup basic peer connection
- Implement signaling
- Wire TTS to WebRTC output
- Test playback
- **Phase 8B** (WebRTC STT Buffered): 2-3 days
- Implement audio input capture
- Buffer and send chunks to Whisper
- Test transcription accuracy
- **Phase 8C** (Streaming STT - Optional): 1-2 days
- Integrate Deepgram or similar
- Wire WebRTC audio to streaming STT
- Test real-time transcription
- **Phase 8D** (Cleanup): 1 day
- Remove old WebSocket audio code
- Update documentation
- Final testing
**Total**: 6-9 days
### Aggressive Estimate (Full Implementation)
- **All Phases**: 4-5 days
- Parallel development of client/server
- Accept some technical debt
- Minimal testing between phases
---
## Next Steps
1. **Confirm Approach**: Verify architecture decisions
2. **Install Dependencies**: `npm install werift`
3. **Start with Phase 8A**: Implement TTS via WebRTC first
4. **Iterative Testing**: Test each phase before proceeding
5. **Document Learnings**: Update this plan as we learn
---
## References
- **werift-webrtc**: https://github.com/shinyoshiaki/werift-webrtc
- **WebRTC API (MDN)**: https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API
- **Deepgram Streaming**: https://developers.deepgram.com/docs/streaming
- **WebRTC Signaling**: https://blog.logrocket.com/webrtc-signaling-websocket-node-js/
---
**End of Plan**
+4 -2
View File
@@ -12,11 +12,13 @@
"typecheck": "tsc --noEmit && tsc -p tsconfig.server.json --noEmit"
},
"dependencies": {
"@ai-sdk/openai": "^1.0.10",
"@ai-sdk/openai": "^2.0.52",
"@deepgram/sdk": "^3.4.0",
"ai": "^3.4.33",
"@openrouter/ai-sdk-provider": "^1.2.0",
"ai": "^5.0.76",
"express": "^4.18.2",
"openai": "^4.20.0",
"tiny-invariant": "^1.3.3",
"uuid": "^9.0.1",
"ws": "^8.14.2",
"zod": "^3.23.8"
@@ -1,6 +1,6 @@
import { streamText, tool } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { z } from 'zod';
import { stepCountIs, streamText, tool } from "ai";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { z } from "zod";
import {
listTerminals,
createTerminal,
@@ -8,16 +8,8 @@ import {
sendText,
renameTerminal,
killTerminal,
} from '../daemon/terminal-manager.js';
let openaiClient: ReturnType<typeof createOpenAI> | null = null;
/**
* Initialize OpenAI client with API key
*/
export function initializeOpenAI(apiKey: string): void {
openaiClient = createOpenAI({ apiKey });
}
} from "../daemon/terminal-manager.js";
import invariant from "tiny-invariant";
/**
* Terminal tools using Vercel AI SDK tool() function
@@ -25,8 +17,8 @@ export function initializeOpenAI(apiKey: string): void {
*/
export const terminalTools = {
list_terminals: tool({
description: 'List all available terminals in the voice-dev session',
parameters: z.object({}),
description: "List all available terminals in the voice-dev session",
inputSchema: z.object({}),
execute: async () => {
const terminals = await listTerminals();
return { terminals };
@@ -34,24 +26,38 @@ export const terminalTools = {
}),
create_terminal: tool({
description: 'Create a new terminal with specified name and working directory',
parameters: z.object({
name: z.string().describe('Unique name for the terminal'),
workingDirectory: z.string().describe('Working directory path'),
initialCommand: z.string().optional().describe('Optional command to run on creation'),
description:
"Create a new terminal with specified name and working directory",
inputSchema: z.object({
name: z.string().describe("Unique name for the terminal"),
workingDirectory: z.string().describe("Working directory path"),
initialCommand: z
.string()
.optional()
.describe("Optional command to run on creation"),
}),
execute: async ({ name, workingDirectory, initialCommand }) => {
const terminal = await createTerminal({ name, workingDirectory, initialCommand });
const terminal = await createTerminal({
name,
workingDirectory,
initialCommand,
});
return { terminal };
},
}),
capture_terminal: tool({
description: 'Capture and return the output from a terminal',
parameters: z.object({
terminalId: z.string().describe('Terminal ID (e.g., @123)'),
lines: z.number().optional().describe('Number of lines to capture (default: 200)'),
wait: z.number().optional().describe('Milliseconds to wait before capture'),
description: "Capture and return the output from a terminal",
inputSchema: z.object({
terminalId: z.string().describe("Terminal ID (e.g., @123)"),
lines: z
.number()
.optional()
.describe("Number of lines to capture (default: 200)"),
wait: z
.number()
.optional()
.describe("Milliseconds to wait before capture"),
}),
execute: async ({ terminalId, lines, wait }) => {
const output = await captureTerminal(terminalId, lines, wait);
@@ -60,30 +66,38 @@ export const terminalTools = {
}),
send_text: tool({
description: 'Send text or commands to a terminal',
parameters: z.object({
terminalId: z.string().describe('Terminal ID (e.g., @123)'),
text: z.string().describe('Text to send to the terminal'),
pressEnter: z.boolean().optional().describe('Whether to press Enter after text'),
description: "Send text or commands to a terminal",
inputSchema: z.object({
terminalId: z.string().describe("Terminal ID (e.g., @123)"),
text: z.string().describe("Text to send to the terminal"),
pressEnter: z
.boolean()
.optional()
.describe("Whether to press Enter after text"),
return_output: z
.object({
lines: z.number().optional(),
wait: z.number().optional(),
})
.optional()
.describe('Capture output after sending'),
.describe("Capture output after sending"),
}),
execute: async ({ terminalId, text, pressEnter, return_output }) => {
const output = await sendText(terminalId, text, pressEnter, return_output);
const output = await sendText(
terminalId,
text,
pressEnter,
return_output
);
return { output };
},
}),
rename_terminal: tool({
description: 'Rename an existing terminal',
parameters: z.object({
terminalId: z.string().describe('Terminal ID to rename'),
newName: z.string().describe('New unique name for the terminal'),
description: "Rename an existing terminal",
inputSchema: z.object({
terminalId: z.string().describe("Terminal ID to rename"),
newName: z.string().describe("New unique name for the terminal"),
}),
execute: async ({ terminalId, newName }) => {
await renameTerminal(terminalId, newName);
@@ -92,9 +106,9 @@ export const terminalTools = {
}),
kill_terminal: tool({
description: 'Close and destroy a terminal',
parameters: z.object({
terminalId: z.string().describe('Terminal ID to kill'),
description: "Close and destroy a terminal",
inputSchema: z.object({
terminalId: z.string().describe("Terminal ID to kill"),
}),
execute: async ({ terminalId }) => {
await killTerminal(terminalId);
@@ -107,7 +121,7 @@ export const terminalTools = {
* Message interface for conversation
*/
export interface Message {
role: 'system' | 'user' | 'assistant';
role: "system" | "user" | "assistant";
content: string;
}
@@ -126,21 +140,23 @@ export interface StreamLLMParams {
* Stream LLM response with automatic tool execution
*/
export async function streamLLM(params: StreamLLMParams): Promise<string> {
if (!openaiClient) {
throw new Error('OpenAI client not initialized. Call initializeOpenAI() first.');
}
invariant(process.env.OPENROUTER_API_KEY, "OPENROUTER_API_KEY is required");
const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
});
const result = await streamText({
model: openaiClient('gpt-4o') as any, // Type assertion to work around AI SDK version conflicts
model: openrouter("anthropic/claude-haiku-4.5"),
messages: params.messages,
tools: terminalTools,
maxSteps: 5, // Allow up to 5 tool executions per turn
stopWhen: stepCountIs(10),
onStepFinish: async (event) => {
// Called after each step (tool call or text generation)
if (event.toolCalls && event.toolCalls.length > 0) {
for (const toolCall of event.toolCalls) {
if (params.onToolCall) {
params.onToolCall(toolCall.toolName, toolCall.args);
params.onToolCall(toolCall.toolName, toolCall.input);
}
}
}
@@ -148,14 +164,14 @@ export async function streamLLM(params: StreamLLMParams): Promise<string> {
if (event.toolResults && event.toolResults.length > 0) {
for (const toolResult of event.toolResults) {
if (params.onToolResult) {
params.onToolResult(toolResult.toolName, toolResult.result);
params.onToolResult(toolResult.toolName, toolResult.output);
}
}
}
},
});
let fullText = '';
let fullText = "";
// Stream text chunks to the caller
for await (const chunk of result.textStream) {
@@ -59,15 +59,8 @@ export async function processUserMessage(params: {
});
conversation.lastActivity = new Date();
// Broadcast user message to WebSocket clients
if (params.wsServer) {
params.wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'transcript',
content: params.message,
});
}
// Note: User message is already broadcast by the caller (e.g., after STT in index.ts)
// No need to broadcast again here to avoid duplication
let assistantResponse = '';
+145 -104
View File
@@ -1,13 +1,17 @@
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
import { createServer as createHTTPServer } from 'http';
import { readFile } from 'fs/promises';
import { v4 as uuidv4 } from 'uuid';
import { createServer as createViteServer } from 'vite';
import type { ViteDevServer } from 'vite';
import type { ServerConfig } from './types.js';
import { VoiceAssistantWebSocketServer } from './websocket-server.js';
import express from "express";
import path from "path";
import { fileURLToPath } from "url";
import {
createServer as createHTTPServer,
Server as HttpServer,
RequestListener,
} from "http";
import { readFile } from "fs/promises";
import { v4 as uuidv4 } from "uuid";
import { createServer as createViteServer } from "vite";
import type { ViteDevServer } from "vite";
import type { ServerConfig } from "./types.js";
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
import {
initializeDefaultSession,
listTerminals,
@@ -15,94 +19,105 @@ import {
sendText,
captureTerminal,
killTerminal,
} from './daemon/terminal-manager.js';
import { initializeOpenAI } from './agent/llm-openai.js';
import { initializeSTT, transcribeAudio } from './agent/stt-openai.js';
import { initializeTTS, synthesizeSpeech } from './agent/tts-openai.js';
} from "./daemon/terminal-manager.js";
import { initializeOpenAI } from "./agent/llm-openai.js";
import { initializeSTT, transcribeAudio } from "./agent/stt-openai.js";
import { initializeTTS, synthesizeSpeech } from "./agent/tts-openai.js";
import {
createConversation,
processUserMessage,
cleanupConversations,
} from './agent/orchestrator.js';
} from "./agent/orchestrator.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function createServer(config: ServerConfig) {
async function createServer(httpServer: HttpServer, config: ServerConfig) {
const app = express();
// Middleware
app.use(express.json());
// Health check endpoint
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// Test endpoint for LLM integration (removed - using orchestrator now)
// Test endpoint for terminal operations
app.get('/api/test-terminal', async (_req, res) => {
app.get("/api/test-terminal", async (_req, res) => {
try {
const testResults: string[] = [];
// 1. Initialize default session
testResults.push('1. Initializing default session...');
testResults.push("1. Initializing default session...");
await initializeDefaultSession();
testResults.push(' ✓ Session initialized');
testResults.push(" ✓ Session initialized");
// 2. List existing terminals
testResults.push('2. Listing existing terminals...');
testResults.push("2. Listing existing terminals...");
const beforeTerminals = await listTerminals();
testResults.push(` ✓ Found ${beforeTerminals.length} existing terminals`);
testResults.push(
` ✓ Found ${beforeTerminals.length} existing terminals`
);
// 3. Create a test terminal
testResults.push('3. Creating test terminal...');
testResults.push("3. Creating test terminal...");
const terminal = await createTerminal({
name: 'test-terminal',
workingDirectory: '~',
name: "test-terminal",
workingDirectory: "~",
initialCommand: undefined,
});
testResults.push(` ✓ Created terminal: ${terminal.id} (${terminal.name})`);
testResults.push(
` ✓ Created terminal: ${terminal.id} (${terminal.name})`
);
// 4. Send a command to the terminal
testResults.push('4. Sending command "echo hello world"...');
await sendText(terminal.id, 'echo "hello world"', true, { lines: 20, wait: 500 });
testResults.push(' ✓ Command sent');
await sendText(terminal.id, 'echo "hello world"', true, {
lines: 20,
wait: 500,
});
testResults.push(" ✓ Command sent");
// 5. Capture output
testResults.push('5. Capturing terminal output...');
testResults.push("5. Capturing terminal output...");
const output = await captureTerminal(terminal.id, 20);
testResults.push(` ✓ Captured ${output.split('\n').length} lines`);
testResults.push(` ✓ Captured ${output.split("\n").length} lines`);
testResults.push(` Output preview: ${output.substring(0, 100)}...`);
// 6. List terminals again
testResults.push('6. Listing terminals again...');
testResults.push("6. Listing terminals again...");
const afterTerminals = await listTerminals();
testResults.push(` ✓ Found ${afterTerminals.length} terminals`);
const testTerminal = afterTerminals.find((t) => t.name === 'test-terminal');
const testTerminal = afterTerminals.find(
(t) => t.name === "test-terminal"
);
if (testTerminal) {
testResults.push(` ✓ Found test-terminal: ${testTerminal.id}`);
}
// 7. Kill the test terminal
testResults.push('7. Killing test terminal...');
testResults.push("7. Killing test terminal...");
await killTerminal(terminal.id);
testResults.push(' ✓ Terminal killed');
testResults.push(" ✓ Terminal killed");
// 8. Verify it's gone
testResults.push('8. Verifying terminal was removed...');
testResults.push("8. Verifying terminal was removed...");
const finalTerminals = await listTerminals();
const stillExists = finalTerminals.find((t) => t.name === 'test-terminal');
const stillExists = finalTerminals.find(
(t) => t.name === "test-terminal"
);
if (!stillExists) {
testResults.push(' ✓ Terminal successfully removed');
testResults.push(" ✓ Terminal successfully removed");
} else {
testResults.push(' ✗ Terminal still exists!');
testResults.push(" ✗ Terminal still exists!");
}
res.json({
success: true,
message: 'All terminal operations completed successfully',
message: "All terminal operations completed successfully",
results: testResults,
});
} catch (error: any) {
@@ -119,37 +134,42 @@ async function createServer(config: ServerConfig) {
if (config.isDev) {
// Development: Create Vite server in middleware mode
vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom',
server: {
middlewareMode: true,
allowedHosts: ["localhost", "mohameds-macbook-pro.tail8fe838.ts.net"],
port: 3000,
hmr: false,
},
appType: "custom",
});
// Use Vite's middleware to handle HMR, module transformation, etc.
app.use(vite.middlewares);
} else {
// Production: Serve static files from dist/ui
const uiPath = path.join(__dirname, '../ui');
const uiPath = path.join(__dirname, "../ui");
app.use(express.static(uiPath));
}
// SPA fallback - serve index.html for all non-API routes
app.use('*', async (req, res, next) => {
if (req.originalUrl.startsWith('/api')) {
return res.status(404).json({ error: 'Not found' });
app.use("*", async (req, res, next) => {
if (req.originalUrl.startsWith("/api")) {
return res.status(404).json({ error: "Not found" });
}
try {
const indexHtmlPath = config.isDev
? path.resolve(__dirname, '../../src/ui/index.html')
: path.join(__dirname, '../ui/index.html');
? path.resolve(__dirname, "../../src/ui/index.html")
: path.join(__dirname, "../ui/index.html");
let html = await readFile(indexHtmlPath, 'utf-8');
let html = await readFile(indexHtmlPath, "utf-8");
// In development, transform the HTML with Vite
if (config.isDev && vite) {
html = await vite.transformIndexHtml(req.originalUrl, html);
}
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
res.status(200).set({ "Content-Type": "text/html" }).end(html);
} catch (e) {
if (config.isDev && vite) {
vite.ssrFixStacktrace(e as Error);
@@ -162,14 +182,18 @@ async function createServer(config: ServerConfig) {
}
async function main() {
const port = parseInt(process.env.PORT || '3000', 10);
const isDev = process.env.NODE_ENV !== 'production';
const port = parseInt(process.env.PORT || "3000", 10);
const isDev = process.env.NODE_ENV !== "production";
const config: ServerConfig = { port, isDev };
const { app, vite } = await createServer(config);
const httpServer = createHTTPServer((req, res) => {
app(req, res);
});
const { app, vite } = await createServer(httpServer, config);
// Create HTTP server
const httpServer = createHTTPServer(app);
// Initialize WebSocket server
const wsServer = new VoiceAssistantWebSocketServer(httpServer);
@@ -178,22 +202,30 @@ async function main() {
const apiKey = process.env.OPENAI_API_KEY;
if (apiKey) {
initializeOpenAI(apiKey);
console.log('✓ OpenAI client initialized');
console.log("✓ OpenAI client initialized");
// Initialize STT (Whisper)
initializeSTT({ apiKey });
// Initialize TTS
const ttsVoice = (process.env.TTS_VOICE || 'alloy') as 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';
const ttsModel = (process.env.TTS_MODEL || 'tts-1') as 'tts-1' | 'tts-1-hd';
const ttsVoice = (process.env.TTS_VOICE || "alloy") as
| "alloy"
| "echo"
| "fable"
| "onyx"
| "nova"
| "shimmer";
const ttsModel = (process.env.TTS_MODEL || "tts-1") as "tts-1" | "tts-1-hd";
initializeTTS({
apiKey,
voice: ttsVoice,
model: ttsModel,
responseFormat: 'mp3',
responseFormat: "mp3",
});
} else {
console.warn('⚠ OPENAI_API_KEY not set - LLM, STT, and TTS features will not work');
console.warn(
"⚠ OPENAI_API_KEY not set - LLM, STT, and TTS features will not work"
);
}
// Create default conversation
@@ -201,7 +233,10 @@ async function main() {
console.log(`✓ Default conversation created: ${defaultConversationId}`);
// Helper function to process message and optionally generate TTS
async function processMessageWithOptionalTTS(message: string, enableTTS: boolean = false): Promise<string> {
async function processMessageWithOptionalTTS(
message: string,
enableTTS: boolean = false
): Promise<string> {
const response = await processUserMessage({
conversationId: defaultConversationId,
message,
@@ -211,15 +246,15 @@ async function main() {
// Generate TTS for the response if enabled
if (enableTTS && apiKey) {
try {
console.log('[TTS] Generating speech for assistant response...');
console.log("[TTS] Generating speech for assistant response...");
const speechResult = await synthesizeSpeech(response);
// Convert audio buffer to base64
const base64Audio = speechResult.audio.toString('base64');
const base64Audio = speechResult.audio.toString("base64");
// Send audio to client via WebSocket
wsServer.broadcast({
type: 'audio_output',
type: "audio_output",
payload: {
id: uuidv4(),
audio: base64Audio,
@@ -227,13 +262,15 @@ async function main() {
},
});
console.log(`[TTS] Sent audio to client via WebSocket (${speechResult.audio.length} bytes)`);
console.log(
`[TTS] Sent audio to client via WebSocket (${speechResult.audio.length} bytes)`
);
} catch (error: any) {
console.error('[TTS] Error generating speech:', error);
console.error("[TTS] Error generating speech:", error);
wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'error',
type: "error",
content: `TTS error: ${error.message}`,
});
}
@@ -251,50 +288,52 @@ async function main() {
wsServer,
});
} catch (error: any) {
console.error('[Orchestrator] Error processing message:', error);
console.error("[Orchestrator] Error processing message:", error);
wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'error',
type: "error",
content: `Error: ${error.message}`,
});
}
});
// Wire audio handler to WebSocket for voice input (STT)
wsServer.setAudioHandler(async (audio: Buffer, format: string): Promise<string> => {
try {
// Transcribe audio using OpenAI Whisper
const result = await transcribeAudio(audio, format);
wsServer.setAudioHandler(
async (audio: Buffer, format: string): Promise<string> => {
try {
// Transcribe audio using OpenAI Whisper
const result = await transcribeAudio(audio, format);
// Broadcast transcription result as activity log
wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'transcript',
content: result.text,
metadata: {
language: result.language,
duration: result.duration,
},
});
// Broadcast transcription result as activity log
wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: "transcript",
content: result.text,
metadata: {
language: result.language,
duration: result.duration,
},
});
// Process the transcribed text through the orchestrator WITH TTS enabled
// Since this came from voice input, respond with voice output
await processMessageWithOptionalTTS(result.text, true);
// Process the transcribed text through the orchestrator WITH TTS enabled
// Since this came from voice input, respond with voice output
await processMessageWithOptionalTTS(result.text, true);
return result.text;
} catch (error: any) {
console.error('[STT] Error transcribing audio:', error);
wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: 'error',
content: `Transcription error: ${error.message}`,
});
throw error;
return result.text;
} catch (error: any) {
console.error("[STT] Error transcribing audio:", error);
wsServer.broadcastActivityLog({
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Transcription error: ${error.message}`,
});
throw error;
}
}
});
);
// Start conversation cleanup interval (every 10 minutes)
setInterval(() => {
@@ -304,33 +343,35 @@ async function main() {
// Initialize default tmux session for terminal control
try {
await initializeDefaultSession();
console.log('✓ Default tmux session initialized');
console.log("✓ Default tmux session initialized");
} catch (error) {
console.error('Failed to initialize tmux session:', error);
console.error("Failed to initialize tmux session:", error);
}
httpServer.listen(port, () => {
console.log(`\n✓ Voice Assistant server running on http://localhost:${port}`);
console.log(
`\n✓ Voice Assistant server running on http://localhost:${port}`
);
if (isDev) {
console.log(`✓ Vite dev server integrated (HMR enabled)\n`);
}
});
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('\nSIGTERM received, shutting down gracefully...');
process.on("SIGTERM", async () => {
console.log("\nSIGTERM received, shutting down gracefully...");
wsServer.close();
if (vite) {
await vite.close();
}
httpServer.close(() => {
console.log('Server closed');
console.log("Server closed");
process.exit(0);
});
});
}
main().catch((error) => {
console.error('Failed to start server:', error);
console.error("Failed to start server:", error);
process.exit(1);
});
@@ -1,55 +1,63 @@
import { WebSocketServer, WebSocket } from 'ws';
import { Server as HTTPServer } from 'http';
import { WebSocketServer, WebSocket } from "ws";
import { Server as HTTPServer } from "http";
import type {
WebSocketMessage,
ActivityLogEntry,
AudioChunkPayload,
} from './types.js';
} from "./types.js";
export class VoiceAssistantWebSocketServer {
private wss: WebSocketServer;
private clients: Map<WebSocket, string> = new Map(); // Map ws to client ID
private messageHandler?: (message: string) => Promise<void>;
private audioHandler?: (audio: Buffer, format: string) => Promise<string>;
private audioBuffers: Map<string, { chunks: Buffer[]; format: string }> = new Map();
private audioBuffers: Map<string, { chunks: Buffer[]; format: string }> =
new Map();
private clientIdCounter: number = 0;
constructor(server: HTTPServer) {
this.wss = new WebSocketServer({ server, path: '/ws' });
this.wss = new WebSocketServer({ server, path: "/ws" });
this.wss.on('connection', (ws) => {
this.wss.on("connection", (ws) => {
this.handleConnection(ws);
});
console.log('✓ WebSocket server initialized on /ws');
console.log("✓ WebSocket server initialized on /ws");
}
private handleConnection(ws: WebSocket): void {
// Generate unique client ID
const clientId = `client-${++this.clientIdCounter}`;
this.clients.set(ws, clientId);
console.log(`[WS] Client connected: ${clientId} (total: ${this.clients.size})`);
console.log(
`[WS] Client connected: ${clientId} (total: ${this.clients.size})`
);
// Send welcome message
this.sendToClient(ws, {
type: 'status',
payload: { status: 'connected', message: 'WebSocket connection established' },
type: "status",
payload: {
status: "connected",
message: "WebSocket connection established",
},
});
ws.on('message', (data) => {
ws.on("message", (data) => {
this.handleMessage(ws, data);
});
ws.on('close', () => {
ws.on("close", () => {
const clientId = this.clients.get(ws);
if (clientId) {
this.clients.delete(ws);
console.log(`[WS] Client disconnected: ${clientId} (total: ${this.clients.size})`);
console.log(
`[WS] Client disconnected: ${clientId} (total: ${this.clients.size})`
);
}
});
ws.on('error', (error) => {
console.error('[WS] Client error:', error);
ws.on("error", (error) => {
console.error("[WS] Client error:", error);
const clientId = this.clients.get(ws);
if (clientId) {
this.clients.delete(ws);
@@ -57,28 +65,31 @@ export class VoiceAssistantWebSocketServer {
});
}
private async handleMessage(ws: WebSocket, data: Buffer | ArrayBuffer | Buffer[]): Promise<void> {
private async handleMessage(
ws: WebSocket,
data: Buffer | ArrayBuffer | Buffer[]
): Promise<void> {
try {
const message = JSON.parse(data.toString()) as WebSocketMessage;
console.log(`[WS] Received message type: ${message.type}`);
switch (message.type) {
case 'ping':
this.sendToClient(ws, { type: 'pong', payload: {} });
case "ping":
this.sendToClient(ws, { type: "pong", payload: {} });
break;
case 'user_message':
case "user_message":
// Handle user message through orchestrator
const payload = message.payload as { message: string };
if (this.messageHandler) {
await this.messageHandler(payload.message);
} else {
console.warn('[WS] No message handler registered');
console.warn("[WS] No message handler registered");
}
break;
case 'audio_chunk':
case "audio_chunk":
// Handle audio chunk for STT
await this.handleAudioChunk(ws, message.payload as AudioChunkPayload);
break;
@@ -87,12 +98,13 @@ export class VoiceAssistantWebSocketServer {
console.warn(`[WS] Unknown message type: ${message.type}`);
}
} catch (error) {
console.error('[WS] Failed to parse message:', error);
console.error("[WS] Failed to parse message:", error);
}
}
private sendToClient(ws: WebSocket, message: WebSocketMessage): void {
if (ws.readyState === 1) { // WebSocket.OPEN = 1
if (ws.readyState === 1) {
// WebSocket.OPEN = 1
ws.send(JSON.stringify(message));
}
}
@@ -100,7 +112,8 @@ export class VoiceAssistantWebSocketServer {
public broadcast(message: WebSocketMessage): void {
const payload = JSON.stringify(message);
this.clients.forEach((_clientId, client) => {
if (client.readyState === 1) { // WebSocket.OPEN = 1
if (client.readyState === 1) {
// WebSocket.OPEN = 1
client.send(payload);
}
});
@@ -108,14 +121,17 @@ export class VoiceAssistantWebSocketServer {
public broadcastActivityLog(entry: ActivityLogEntry): void {
this.broadcast({
type: 'activity_log',
type: "activity_log",
payload: entry,
});
}
public broadcastStatus(status: string, metadata?: Record<string, unknown>): void {
public broadcastStatus(
status: string,
metadata?: Record<string, unknown>
): void {
this.broadcast({
type: 'status',
type: "status",
payload: { status, ...metadata },
});
}
@@ -124,35 +140,49 @@ export class VoiceAssistantWebSocketServer {
this.messageHandler = handler;
}
public setAudioHandler(handler: (audio: Buffer, format: string) => Promise<string>): void {
public setAudioHandler(
handler: (audio: Buffer, format: string) => Promise<string>
): void {
this.audioHandler = handler;
}
private async handleAudioChunk(ws: WebSocket, payload: AudioChunkPayload): Promise<void> {
private async handleAudioChunk(
ws: WebSocket,
payload: AudioChunkPayload
): Promise<void> {
try {
// Use a client-specific key for buffering (in case multiple clients)
const clientId = 'default'; // Could be enhanced with per-client tracking
const clientId = "default"; // Could be enhanced with per-client tracking
// Decode base64 audio data
const audioBuffer = Buffer.from(payload.audio, 'base64');
const audioBuffer = Buffer.from(payload.audio, "base64");
if (!payload.isLast) {
// Buffer the chunk
if (!this.audioBuffers.has(clientId)) {
this.audioBuffers.set(clientId, { chunks: [], format: payload.format });
this.audioBuffers.set(clientId, {
chunks: [],
format: payload.format,
});
}
const buffer = this.audioBuffers.get(clientId)!;
buffer.chunks.push(audioBuffer);
console.log(`[WS] Buffered audio chunk (${audioBuffer.length} bytes, total chunks: ${buffer.chunks.length})`);
console.log(
`[WS] Buffered audio chunk (${audioBuffer.length} bytes, total chunks: ${buffer.chunks.length})`
);
} else {
// Last chunk - process complete audio
const buffer = this.audioBuffers.get(clientId);
const allChunks = buffer ? [...buffer.chunks, audioBuffer] : [audioBuffer];
const allChunks = buffer
? [...buffer.chunks, audioBuffer]
: [audioBuffer];
const format = buffer?.format || payload.format;
// Concatenate all chunks
const completeAudio = Buffer.concat(allChunks);
console.log(`[WS] Complete audio received (${completeAudio.length} bytes, ${allChunks.length} chunks)`);
console.log(
`[WS] Complete audio received (${completeAudio.length} bytes, ${allChunks.length} chunks)`
);
// Clear buffer
this.audioBuffers.delete(clientId);
@@ -162,27 +192,27 @@ export class VoiceAssistantWebSocketServer {
this.broadcastActivityLog({
id: Date.now().toString(),
timestamp: new Date(),
type: 'system',
content: 'Transcribing audio...',
type: "system",
content: "Transcribing audio...",
});
const transcript = await this.audioHandler(completeAudio, format);
// Send transcription result back to client
this.sendToClient(ws, {
type: 'transcription_result',
type: "transcription_result",
payload: { text: transcript },
});
} else {
console.warn('[WS] No audio handler registered');
console.warn("[WS] No audio handler registered");
}
}
} catch (error: any) {
console.error('[WS] Audio chunk handling error:', error);
console.error("[WS] Audio chunk handling error:", error);
this.broadcastActivityLog({
id: Date.now().toString(),
timestamp: new Date(),
type: 'error',
type: "error",
content: `Audio processing error: ${error.message}`,
});
}
+97 -53
View File
@@ -1,59 +1,72 @@
import { useState, useEffect, useRef } from 'react';
import { useWebSocket } from './hooks/useWebSocket';
import { VoiceControls } from './components/VoiceControls';
import { createAudioPlayer } from './lib/audio-playback';
import './App.css';
import { useState, useEffect, useRef } from "react";
import { useWebSocket } from "./hooks/useWebSocket";
import { VoiceControls } from "./components/VoiceControls";
import { createAudioPlayer } from "./lib/audio-playback";
import "./App.css";
interface LogEntry {
id: string;
timestamp: number;
type: 'system' | 'info' | 'success' | 'error' | 'user' | 'assistant' | 'tool';
type: "system" | "info" | "success" | "error" | "user" | "assistant" | "tool";
message: string;
metadata?: Record<string, unknown>;
}
function App() {
const [logs, setLogs] = useState<LogEntry[]>([
{ id: '1', timestamp: Date.now(), type: 'system', message: 'System initialized' },
{
id: "1",
timestamp: Date.now(),
type: "system",
message: "System initialized",
},
]);
const [userInput, setUserInput] = useState('');
const [currentAssistantMessage, setCurrentAssistantMessage] = useState('');
const [userInput, setUserInput] = useState("");
const [currentAssistantMessage, setCurrentAssistantMessage] = useState("");
const [isProcessingAudio, setIsProcessingAudio] = useState(false);
const [isPlayingAudio, setIsPlayingAudio] = useState(false);
const logEndRef = useRef<HTMLDivElement>(null);
const audioPlayerRef = useRef(createAudioPlayer());
// WebSocket URL - use ws://localhost:3000/ws in dev, or construct from current host in prod
const wsUrl = `ws://${window.location.host}/ws`;
const wsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${
window.location.host
}/ws`;
const ws = useWebSocket(wsUrl);
const addLog = (
type: LogEntry['type'],
type: LogEntry["type"],
message: string,
metadata?: Record<string, unknown>
) => {
console.trace("addLog", type, message, metadata);
setLogs((prev) => [
...prev,
{ id: Date.now().toString(), timestamp: Date.now(), type, message, metadata },
{
id: Date.now().toString(),
timestamp: Date.now(),
type,
message,
metadata,
},
]);
};
useEffect(() => {
// Listen for status messages
const unsubStatus = ws.on('status', (payload: unknown) => {
const unsubStatus = ws.on("status", (payload: unknown) => {
const data = payload as { status: string; message?: string };
addLog('info', data.message || `Status: ${data.status}`);
addLog("info", data.message || `Status: ${data.status}`);
});
// Listen for pong messages
const unsubPong = ws.on('pong', () => {
addLog('success', 'Received pong from server');
const unsubPong = ws.on("pong", () => {
addLog("success", "Received pong from server");
});
// Listen for activity log messages
const unsubActivity = ws.on('activity_log', (payload: unknown) => {
const unsubActivity = ws.on("activity_log", (payload: unknown) => {
const data = payload as {
type: string;
content: string;
@@ -61,34 +74,43 @@ function App() {
};
// Map activity log types to UI log types
let logType: LogEntry['type'] = 'info';
if (data.type === 'transcript') logType = 'user';
else if (data.type === 'assistant') logType = 'assistant';
else if (data.type === 'tool_call' || data.type === 'tool_result') logType = 'tool';
else if (data.type === 'error') logType = 'error';
let logType: LogEntry["type"] = "info";
if (data.type === "transcript") logType = "user";
else if (data.type === "assistant") logType = "assistant";
else if (data.type === "tool_call" || data.type === "tool_result")
logType = "tool";
else if (data.type === "error") logType = "error";
addLog(logType, data.content, data.metadata);
// Clear streaming state when complete assistant message arrives
if (data.type === "assistant") {
setCurrentAssistantMessage("");
}
});
// Listen for streaming assistant chunks
const unsubChunk = ws.on('assistant_chunk', (payload: unknown) => {
const unsubChunk = ws.on("assistant_chunk", (payload: unknown) => {
const data = payload as { chunk: string };
setCurrentAssistantMessage((prev) => prev + data.chunk);
});
// Listen for transcription results
const unsubTranscription = ws.on('transcription_result', (payload: unknown) => {
const data = payload as { text: string };
addLog('info', `Transcription: ${data.text}`);
setIsProcessingAudio(false);
});
const unsubTranscription = ws.on(
"transcription_result",
(payload: unknown) => {
// Note: Transcription is already broadcast as activity_log with type "transcript"
// No need to log it again here to avoid duplication
setIsProcessingAudio(false);
}
);
// Listen for audio output (TTS)
const unsubAudioOutput = ws.on('audio_output', async (payload: unknown) => {
const unsubAudioOutput = ws.on("audio_output", async (payload: unknown) => {
const data = payload as { audio: string; format: string; id: string };
try {
addLog('info', 'Playing assistant audio response...');
addLog("info", "Playing assistant audio response...");
setIsPlayingAudio(true);
// Decode base64 audio
@@ -99,17 +121,18 @@ function App() {
}
// Create blob with correct mime type
const mimeType = data.format === 'mp3' ? 'audio/mpeg' : `audio/${data.format}`;
const mimeType =
data.format === "mp3" ? "audio/mpeg" : `audio/${data.format}`;
const audioBlob = new Blob([bytes], { type: mimeType });
// Play audio
await audioPlayerRef.current.play(audioBlob);
setIsPlayingAudio(false);
addLog('success', 'Audio playback complete');
addLog("success", "Audio playback complete");
} catch (error: any) {
console.error('[App] Audio playback error:', error);
addLog('error', `Audio playback failed: ${error.message}`);
console.error("[App] Audio playback error:", error);
addLog("error", `Audio playback failed: ${error.message}`);
setIsPlayingAudio(false);
}
});
@@ -126,20 +149,20 @@ function App() {
useEffect(() => {
if (ws.isConnected) {
addLog('success', 'WebSocket connected');
addLog("success", "WebSocket connected");
} else {
addLog('system', 'WebSocket disconnected');
addLog("system", "WebSocket disconnected");
}
}, [ws.isConnected]);
// Auto-scroll to bottom when new logs are added
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [logs, currentAssistantMessage]);
const handlePing = () => {
ws.sendPing();
addLog('info', 'Sent ping to server');
addLog("info", "Sent ping to server");
};
const handleSendMessage = (e: React.FormEvent) => {
@@ -150,29 +173,32 @@ function App() {
ws.sendUserMessage(userInput);
// Clear input and reset streaming state
setUserInput('');
setCurrentAssistantMessage('');
setUserInput("");
setCurrentAssistantMessage("");
};
const handleAudioRecorded = async (audioBlob: Blob, format: string) => {
if (!ws.isConnected) {
addLog('error', 'Cannot send audio - not connected to server');
addLog("error", "Cannot send audio - not connected to server");
return;
}
try {
setIsProcessingAudio(true);
addLog('info', 'Sending audio to server...');
addLog("info", "Sending audio to server...");
// Convert blob to base64
const arrayBuffer = await audioBlob.arrayBuffer();
const base64Audio = btoa(
new Uint8Array(arrayBuffer).reduce((data, byte) => data + String.fromCharCode(byte), '')
new Uint8Array(arrayBuffer).reduce(
(data, byte) => data + String.fromCharCode(byte),
""
)
);
// Send audio chunk via WebSocket
ws.send({
type: 'audio_chunk',
type: "audio_chunk",
payload: {
audio: base64Audio,
format: format,
@@ -180,10 +206,12 @@ function App() {
},
});
console.log(`[App] Sent audio: ${audioBlob.size} bytes, format: ${format}`);
console.log(
`[App] Sent audio: ${audioBlob.size} bytes, format: ${format}`
);
} catch (error: any) {
console.error('[App] Error sending audio:', error);
addLog('error', `Failed to send audio: ${error.message}`);
console.error("[App] Error sending audio:", error);
addLog("error", `Failed to send audio: ${error.message}`);
setIsProcessingAudio(false);
}
};
@@ -192,8 +220,12 @@ function App() {
<div className="app">
<header className="header">
<h1>Voice Assistant</h1>
<div className={`status-indicator ${ws.isConnected ? 'connected' : 'disconnected'}`}>
{ws.isConnected ? 'connected' : 'disconnected'}
<div
className={`status-indicator ${
ws.isConnected ? "connected" : "disconnected"
}`}
>
{ws.isConnected ? "connected" : "disconnected"}
</div>
</header>
@@ -211,7 +243,9 @@ function App() {
<div className="log-entries">
{logs.map((log) => (
<div key={log.id} className={`log-entry ${log.type}`}>
<span className="log-time">{new Date(log.timestamp).toLocaleTimeString()}</span>
<span className="log-time">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span className="log-message">{log.message}</span>
{log.metadata && (
<details className="log-metadata">
@@ -223,7 +257,9 @@ function App() {
))}
{currentAssistantMessage && (
<div className="log-entry assistant streaming">
<span className="log-time">{new Date().toLocaleTimeString()}</span>
<span className="log-time">
{new Date().toLocaleTimeString()}
</span>
<span className="log-message">{currentAssistantMessage}</span>
<span className="streaming-indicator">...</span>
</div>
@@ -241,13 +277,21 @@ function App() {
disabled={!ws.isConnected}
className="message-input"
/>
<button type="submit" disabled={!ws.isConnected || !userInput.trim()} className="send-button">
<button
type="submit"
disabled={!ws.isConnected || !userInput.trim()}
className="send-button"
>
Send
</button>
</form>
<div className="test-controls">
<button onClick={handlePing} disabled={!ws.isConnected} className="ping-button">
<button
onClick={handlePing}
disabled={!ws.isConnected}
className="ping-button"
>
Send Ping (Test)
</button>
</div>
-52
View File
@@ -1,52 +0,0 @@
#!/bin/bash
# Test script for LLM integration
# This script demonstrates how to use the /api/test-llm endpoint
echo "Testing LLM Integration"
echo "======================"
echo ""
# Check if server is running
if ! curl -s http://localhost:3000/api/health > /dev/null; then
echo "❌ Server is not running on port 3000"
echo "Please start the server with: npm start"
exit 1
fi
echo "✓ Server is running"
echo ""
# Test 1: Create a terminal
echo "Test 1: Create a terminal called 'llm-test'"
echo "--------------------------------------------"
curl -s -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "create a terminal called llm-test"}' | jq .
echo ""
# Test 2: List all terminals
echo ""
echo "Test 2: List all terminals"
echo "--------------------------"
curl -s -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "list all terminals"}' | jq .
echo ""
# Test 3: Run a command in the terminal
echo ""
echo "Test 3: Run 'echo hello from LLM' in the llm-test terminal"
echo "-----------------------------------------------------------"
curl -s -X POST http://localhost:3000/api/test-llm \
-H "Content-Type: application/json" \
-d '{"message": "run echo \"hello from LLM\" in the llm-test terminal"}' | jq .
echo ""
echo ""
echo "✓ All tests completed!"
echo ""
echo "Note: You can also test manually with:"
echo "curl -X POST http://localhost:3000/api/test-llm \\"
echo " -H 'Content-Type: application/json' \\"
echo " -d '{\"message\": \"your message here\"}' | jq ."
@@ -1,55 +0,0 @@
/**
* Simple test script to verify the orchestrator and LLM integration
* Run with: OPENAI_API_KEY=your_key node test-orchestrator.js
*/
import { initializeOpenAI, streamLLM } from './dist/server/agent/llm-openai.js';
import { getSystemPrompt } from './dist/server/agent/system-prompt.js';
async function main() {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
console.error('ERROR: OPENAI_API_KEY environment variable is not set');
console.error('Usage: OPENAI_API_KEY=your_key node test-orchestrator.js');
process.exit(1);
}
console.log('Initializing OpenAI client...');
initializeOpenAI(apiKey);
console.log('\nStarting conversation with assistant...');
console.log('User: "list all terminals"\n');
const messages = [
{ role: 'system', content: getSystemPrompt() },
{ role: 'user', content: 'list all terminals' }
];
console.log('Assistant response:');
try {
const response = await streamLLM({
messages,
onChunk: (chunk) => {
process.stdout.write(chunk);
},
onToolCall: (toolName, args) => {
console.log(`\n[Tool Call] ${toolName}`, JSON.stringify(args, null, 2));
},
onToolResult: (toolName, result) => {
console.log(`[Tool Result] ${toolName}:`, JSON.stringify(result, null, 2));
},
onFinish: () => {
console.log('\n\nConversation complete!');
}
});
console.log('\n\nFinal response:', response);
} catch (error) {
console.error('\nError:', error.message);
console.error(error.stack);
}
}
main();
+6 -6
View File
@@ -1,13 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
root: 'src/ui',
root: "src/ui",
build: {
outDir: path.resolve(__dirname, 'dist/ui'),
outDir: path.resolve(__dirname, "dist/ui"),
emptyOutDir: true,
},
})
});