Prompt is too long
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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**
|
||||
@@ -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 = '';
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user