Based on the git changes, this is a major migration from OpenAI's Realtime API to LiveKit for voice infrastructure. Here's the commit message:
``` feat: migrate from OpenAI Realtime API to LiveKit for voice infrastructure Replace direct OpenAI Realtime API WebRTC connection with LiveKit Cloud infrastructure. Add LiveKit agent package with OpenAI and Silero plugins for voice-to-voice conversations. Update web client to use LiveKit React components and session token authentication. Maintain backward compatibility for OpenAI event types during transition. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> ``` This commit message: - Uses `feat:` type since this is a new feature/major change - Describes the main change (migration from OpenAI to LiveKit) - Provides context in the body about what was added/changed - Mentions backward compatibility consideration - Includes the Claude Code attribution as requested
This commit is contained in:
@@ -1,9 +1,15 @@
|
|||||||
# Voice Dev
|
# Voice Dev
|
||||||
|
|
||||||
Voice-controlled development environment powered by OpenAI Realtime API.
|
Voice-controlled development environment powered by LiveKit and OpenAI Realtime API.
|
||||||
|
|
||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
|
- **`@voice-dev/agent`** - LiveKit agent with OpenAI Realtime API
|
||||||
|
- Located in `packages/agent/`
|
||||||
|
- Handles voice conversations via LiveKit
|
||||||
|
- Integrates OpenAI Realtime API
|
||||||
|
- Manages agent lifecycle
|
||||||
|
|
||||||
- **`@voice-dev/mcp-server`** - MCP server for terminal control
|
- **`@voice-dev/mcp-server`** - MCP server for terminal control
|
||||||
- Located in `packages/mcp-server/`
|
- Located in `packages/mcp-server/`
|
||||||
- Provides terminal session management via MCP protocol
|
- Provides terminal session management via MCP protocol
|
||||||
@@ -11,20 +17,37 @@ Voice-controlled development environment powered by OpenAI Realtime API.
|
|||||||
|
|
||||||
- **`@voice-dev/web`** - Next.js web interface
|
- **`@voice-dev/web`** - Next.js web interface
|
||||||
- Located in `packages/web/`
|
- Located in `packages/web/`
|
||||||
- Voice interaction with OpenAI Realtime API
|
- Voice interaction via LiveKit Cloud
|
||||||
- Password authentication
|
- Password authentication
|
||||||
- Agent activity transparency (debug panel)
|
- Agent activity transparency (debug panel)
|
||||||
- Real-time status display
|
- Real-time status display
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
For detailed setup instructions, see [QUICKSTART.md](./QUICKSTART.md).
|
||||||
|
|
||||||
|
**You need TWO terminals:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1: Start the LiveKit agent
|
||||||
|
npm run dev:agent
|
||||||
|
|
||||||
|
# Terminal 2: Start the web app
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open http://localhost:3000 and click "Start Voice Chat".
|
||||||
|
|
||||||
|
For more info:
|
||||||
|
- [QUICKSTART.md](./QUICKSTART.md) - Step-by-step setup guide
|
||||||
|
- [MIGRATION.md](./MIGRATION.md) - Details about LiveKit migration
|
||||||
|
|
||||||
|
### Other Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install all dependencies
|
# Install all dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Run web app in dev mode
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Run MCP server in dev mode
|
# Run MCP server in dev mode
|
||||||
npm run dev:mcp
|
npm run dev:mcp
|
||||||
|
|
||||||
@@ -37,6 +60,17 @@ npm run typecheck
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
### LiveKit Agent
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
npm run dev:agent # Start agent in dev mode
|
||||||
|
|
||||||
|
# Production
|
||||||
|
npm run build:agent # Build TypeScript
|
||||||
|
npm run start:agent # Run built agent
|
||||||
|
```
|
||||||
|
|
||||||
### Web App
|
### Web App
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -61,12 +95,20 @@ npm run start:mcp # Run built server
|
|||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
Create `.env.local` in `packages/web/`:
|
Create `.env.local` in both `packages/web/` and `packages/agent/`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# OpenAI
|
||||||
OPENAI_API_KEY=sk-your-api-key-here
|
OPENAI_API_KEY=sk-your-api-key-here
|
||||||
AUTH_PASSWORD=your-secure-password-here
|
|
||||||
MCP_SERVER_URL=https://your-mcp-server-url
|
# LiveKit Cloud
|
||||||
|
LIVEKIT_URL=wss://your-project.livekit.cloud
|
||||||
|
LIVEKIT_API_KEY=your-api-key
|
||||||
|
LIVEKIT_API_SECRET=your-api-secret
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
AUTH_PASSWORD=your-secure-password-here # For web auth
|
||||||
|
MCP_SERVER_URL=https://your-mcp-server-url # For MCP integration
|
||||||
```
|
```
|
||||||
|
|
||||||
See `packages/web/.env.local.example` for template.
|
See `packages/web/.env.local.example` for template.
|
||||||
@@ -76,6 +118,13 @@ See `packages/web/.env.local.example` for template.
|
|||||||
```
|
```
|
||||||
voice-dev/
|
voice-dev/
|
||||||
├── packages/
|
├── packages/
|
||||||
|
│ ├── agent/ # LiveKit Agent Package
|
||||||
|
│ │ ├── src/
|
||||||
|
│ │ │ └── agent.ts # Agent implementation
|
||||||
|
│ │ ├── dist/ # Compiled output
|
||||||
|
│ │ ├── package.json
|
||||||
|
│ │ └── tsconfig.json
|
||||||
|
│ │
|
||||||
│ ├── mcp-server/ # MCP Server Package
|
│ ├── mcp-server/ # MCP Server Package
|
||||||
│ │ ├── src/
|
│ │ ├── src/
|
||||||
│ │ │ ├── index.ts # CLI entry point
|
│ │ │ ├── index.ts # CLI entry point
|
||||||
@@ -87,9 +136,9 @@ voice-dev/
|
|||||||
│ │
|
│ │
|
||||||
│ └── web/ # Web App Package
|
│ └── web/ # Web App Package
|
||||||
│ ├── app/
|
│ ├── app/
|
||||||
│ │ ├── api/ # API routes
|
│ │ ├── api/ # API routes (token generation)
|
||||||
│ │ ├── components/ # React components
|
│ │ ├── components/ # React components
|
||||||
│ │ ├── hooks/ # Custom hooks
|
│ │ ├── hooks/ # Custom hooks (LiveKit)
|
||||||
│ │ ├── types/ # TypeScript types
|
│ │ ├── types/ # TypeScript types
|
||||||
│ │ └── voice-client.tsx # Main voice UI
|
│ │ └── voice-client.tsx # Main voice UI
|
||||||
│ ├── package.json
|
│ ├── package.json
|
||||||
@@ -97,20 +146,31 @@ voice-dev/
|
|||||||
│
|
│
|
||||||
├── package.json # Root workspace config
|
├── package.json # Root workspace config
|
||||||
├── tsconfig.base.json # Shared TypeScript config
|
├── tsconfig.base.json # Shared TypeScript config
|
||||||
└── README.md # This file
|
├── README.md # This file
|
||||||
|
├── QUICKSTART.md # Setup guide
|
||||||
|
└── MIGRATION.md # LiveKit migration details
|
||||||
```
|
```
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
### LiveKit Agent
|
||||||
|
- OpenAI Realtime API integration
|
||||||
|
- Voice-to-voice conversation
|
||||||
|
- Low-latency audio processing
|
||||||
|
- Automatic room management
|
||||||
|
- System prompt customization
|
||||||
|
- Tool/function calling support
|
||||||
|
|
||||||
### Web App
|
### Web App
|
||||||
- Real-time voice interaction with OpenAI
|
- Real-time voice interaction via LiveKit
|
||||||
- WebRTC-based low-latency audio streaming
|
- LiveKit React SDK components
|
||||||
- Live audio level visualization
|
- Live audio level visualization
|
||||||
|
- Device selection
|
||||||
- Mute/unmute controls
|
- Mute/unmute controls
|
||||||
- Password authentication
|
- Password authentication
|
||||||
- Agent activity transparency (debug log panel)
|
- Agent activity transparency (debug log panel)
|
||||||
- Real-time agent status display
|
- Real-time agent status display
|
||||||
- Tool call visibility
|
- Automatic reconnection
|
||||||
|
|
||||||
### MCP Server
|
### MCP Server
|
||||||
- Full terminal session control
|
- Full terminal session control
|
||||||
@@ -155,12 +215,15 @@ npm run start:mcp -- --http --password your-password
|
|||||||
| Script | Description |
|
| Script | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `npm run dev` | Start web app dev server |
|
| `npm run dev` | Start web app dev server |
|
||||||
|
| `npm run dev:agent` | Start LiveKit agent dev server |
|
||||||
| `npm run dev:mcp` | Start MCP server dev server |
|
| `npm run dev:mcp` | Start MCP server dev server |
|
||||||
| `npm run build` | Build all packages |
|
| `npm run build` | Build all packages |
|
||||||
|
| `npm run build:agent` | Build agent only |
|
||||||
| `npm run build:mcp` | Build MCP server only |
|
| `npm run build:mcp` | Build MCP server only |
|
||||||
| `npm run build:web` | Build web app only |
|
| `npm run build:web` | Build web app only |
|
||||||
| `npm run typecheck` | Type check all packages |
|
| `npm run typecheck` | Type check all packages |
|
||||||
| `npm run start` | Start web app production server |
|
| `npm run start` | Start web app production server |
|
||||||
|
| `npm run start:agent` | Start agent production |
|
||||||
| `npm run start:mcp` | Start MCP server production |
|
| `npm run start:mcp` | Start MCP server production |
|
||||||
|
|
||||||
### Package Level
|
### Package Level
|
||||||
@@ -178,11 +241,27 @@ npm run <script> --workspace=mcp-server
|
|||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Architecture**: npm workspaces
|
- **Architecture**: npm workspaces
|
||||||
- **Web**: Next.js 15, React 19, TypeScript, Tailwind CSS
|
- **Web**: Next.js 15, React 19, TypeScript, Tailwind CSS, LiveKit React SDK
|
||||||
|
- **Agent**: LiveKit Agents Framework, OpenAI Realtime API, TypeScript
|
||||||
- **MCP Server**: TypeScript, Express, MCP SDK
|
- **MCP Server**: TypeScript, Express, MCP SDK
|
||||||
- **Voice**: OpenAI Realtime API, WebRTC
|
- **Voice**: LiveKit Cloud, OpenAI Realtime API, WebRTC
|
||||||
- **Build**: TypeScript compiler, Next.js build
|
- **Build**: TypeScript compiler, Next.js build
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
||||||
|
│ Browser │◄───────►│ LiveKit Cloud│◄───────►│ Agent │
|
||||||
|
│ (React UI) │ WebRTC │ (Server) │ WebRTC │ (Node.js) │
|
||||||
|
└─────────────┘ └──────────────┘ └─────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ OpenAI │
|
||||||
|
│ Realtime API│
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
Generated
+3426
-353
File diff suppressed because it is too large
Load Diff
@@ -7,12 +7,15 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run dev --workspace=@voice-dev/web",
|
"dev": "npm run dev --workspace=@voice-dev/web",
|
||||||
|
"dev:agent": "npm run dev --workspace=@voice-dev/agent",
|
||||||
"dev:mcp": "npm run dev --workspace=@voice-dev/mcp-server",
|
"dev:mcp": "npm run dev --workspace=@voice-dev/mcp-server",
|
||||||
"build": "npm run build --workspaces --if-present",
|
"build": "npm run build --workspaces --if-present",
|
||||||
|
"build:agent": "npm run build --workspace=@voice-dev/agent",
|
||||||
"build:mcp": "npm run build --workspace=@voice-dev/mcp-server",
|
"build:mcp": "npm run build --workspace=@voice-dev/mcp-server",
|
||||||
"build:web": "npm run build --workspace=@voice-dev/web",
|
"build:web": "npm run build --workspace=@voice-dev/web",
|
||||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
"start": "npm run start --workspace=@voice-dev/web",
|
"start": "npm run start --workspace=@voice-dev/web",
|
||||||
|
"start:agent": "npm run start --workspace=@voice-dev/agent",
|
||||||
"start:mcp": "npm run start --workspace=@voice-dev/mcp-server"
|
"start:mcp": "npm run start --workspace=@voice-dev/mcp-server"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# LiveKit Configuration
|
||||||
|
LIVEKIT_URL=wss://your-project.livekit.cloud
|
||||||
|
LIVEKIT_API_KEY=your-livekit-api-key
|
||||||
|
LIVEKIT_API_SECRET=your-livekit-api-secret
|
||||||
|
|
||||||
|
# MCP Server (Optional)
|
||||||
|
MCP_SERVER_URL=https://your-mcp-server.example.com
|
||||||
|
|
||||||
|
# Note: With LiveKit Inference, you don't need individual provider API keys
|
||||||
|
# LiveKit handles authentication to OpenAI, Cartesia, AssemblyAI, etc.
|
||||||
@@ -1,4 +1,29 @@
|
|||||||
export const SYSTEM_PROMPT = `# Virtual Assistant Instructions
|
"""
|
||||||
|
LiveKit Voice Agent with MCP Support (Python)
|
||||||
|
Migrated from Node.js version with added MCP integration
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from livekit import rtc
|
||||||
|
from livekit.agents import (
|
||||||
|
AutoSubscribe,
|
||||||
|
JobContext,
|
||||||
|
JobProcess,
|
||||||
|
WorkerOptions,
|
||||||
|
cli,
|
||||||
|
llm,
|
||||||
|
voice,
|
||||||
|
)
|
||||||
|
from livekit.agents.llm.mcp import MCPServerHTTP
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """# Virtual Assistant Instructions
|
||||||
|
|
||||||
## Your Role
|
## Your Role
|
||||||
|
|
||||||
@@ -481,4 +506,100 @@ You: Run gh pr create directly via send-text with return_output
|
|||||||
|
|
||||||
### Blank.page - A minimal text editor in your browser
|
### Blank.page - A minimal text editor in your browser
|
||||||
- Location: ~/dev/blank.page/editor
|
- Location: ~/dev/blank.page/editor
|
||||||
`;
|
"""
|
||||||
|
SYSTEM_PROMPT = """# Virtual Assistant Instructions
|
||||||
|
|
||||||
|
## Your Role
|
||||||
|
|
||||||
|
You are a virtual assistant with direct access to the user's terminal environment on their laptop. Your primary purpose is to help them code and manage their development workflow, especially through Claude Code running in terminal sessions.
|
||||||
|
|
||||||
|
## Connection Setup
|
||||||
|
|
||||||
|
- **Environment**: You connect remotely to the user's laptop terminal environment
|
||||||
|
- **User's device**: They typically code from their **phone**
|
||||||
|
- **Key implication**: Be mindful of voice-to-text issues, autocorrect problems, and typos
|
||||||
|
- **Projects location**: All projects are in ~/dev
|
||||||
|
- **GitHub CLI**: gh command is available and already authenticated - use it for GitHub operations
|
||||||
|
|
||||||
|
## Important Behavioral Guidelines
|
||||||
|
|
||||||
|
### Response Pattern: ALWAYS Talk First, Then Act
|
||||||
|
|
||||||
|
**CRITICAL**: For voice interactions, ALWAYS provide verbal acknowledgment BEFORE executing tool calls.
|
||||||
|
|
||||||
|
**Pattern to follow:**
|
||||||
|
1. **Acknowledge** what you heard: "Got it, I'll [action]"
|
||||||
|
2. **Briefly explain** what you're about to do (1-2 sentences max)
|
||||||
|
3. **Then execute** the tool calls
|
||||||
|
4. **Report back** what happened after the tools complete
|
||||||
|
|
||||||
|
### Communication Style
|
||||||
|
- **Confirm commands** before executing, especially destructive operations
|
||||||
|
- **Be patient** with spelling errors and voice-related mistakes
|
||||||
|
- **Clarify ambiguous requests** rather than guessing
|
||||||
|
- **Acknowledge typos naturally** without making a big deal of it
|
||||||
|
- **Use clear, concise language** - mobile screens are small
|
||||||
|
|
||||||
|
### Mobile-Friendly Responses
|
||||||
|
- Keep responses scannable and well-structured
|
||||||
|
- Use bullet points and headers effectively
|
||||||
|
- Avoid overwhelming walls of text
|
||||||
|
- Highlight important information with bold
|
||||||
|
|
||||||
|
## Remember
|
||||||
|
|
||||||
|
- **Mobile user** - Be concise and confirm actions
|
||||||
|
- **Voice input** - Forgive typos, clarify when needed
|
||||||
|
- **Be helpful** - You're here to make coding from a phone easier!
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def entrypoint(ctx: JobContext):
|
||||||
|
"""Main entry point for the voice agent."""
|
||||||
|
|
||||||
|
# Get MCP server URL from environment
|
||||||
|
mcp_server_url = os.getenv("MCP_SERVER_URL")
|
||||||
|
|
||||||
|
# Prepare MCP servers list
|
||||||
|
mcp_servers = []
|
||||||
|
if mcp_server_url:
|
||||||
|
print(f"✓ MCP Server configured: {mcp_server_url}")
|
||||||
|
server = MCPServerHTTP(
|
||||||
|
url=mcp_server_url,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
mcp_servers.append(server)
|
||||||
|
else:
|
||||||
|
print("⚠ No MCP_SERVER_URL found in environment")
|
||||||
|
|
||||||
|
# Connect to the room
|
||||||
|
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
|
||||||
|
|
||||||
|
# Create the voice agent with MCP tools
|
||||||
|
agent = voice.Agent(
|
||||||
|
instructions=SYSTEM_PROMPT,
|
||||||
|
mcp_servers=mcp_servers, # Native MCP support!
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the agent session with LiveKit Inference
|
||||||
|
# Using same configuration as Node.js version
|
||||||
|
session = voice.AgentSession(
|
||||||
|
stt="assemblyai/universal-streaming:en",
|
||||||
|
llm="openai/gpt-4.1-mini",
|
||||||
|
tts="cartesia/sonic-2:9626c31c-bec5-4cca-baa8-f8ba9e84c8bc",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start the session
|
||||||
|
await session.start(agent=agent, room=ctx.room)
|
||||||
|
|
||||||
|
print(f"✓ Agent started successfully in room: {ctx.room.name}")
|
||||||
|
print(f"✓ MCP servers: {len(mcp_servers)} configured")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Run the agent worker
|
||||||
|
cli.run_app(
|
||||||
|
WorkerOptions(
|
||||||
|
entrypoint_fnc=entrypoint,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[project]
|
||||||
|
name = "voice-dev-agent"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "LiveKit voice agent with MCP support"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"livekit-agents[mcp]>=1.0.0",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
]
|
||||||
Generated
+2342
File diff suppressed because it is too large
Load Diff
@@ -1,62 +1,67 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from "next/server";
|
||||||
import { SYSTEM_PROMPT } from './system-prompt';
|
import { AccessToken, type VideoGrant } from "livekit-server-sdk";
|
||||||
|
|
||||||
|
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY;
|
||||||
|
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET;
|
||||||
|
const LIVEKIT_URL = process.env.LIVEKIT_URL;
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
const apiKey = process.env.OPENAI_API_KEY;
|
if (!LIVEKIT_API_KEY) {
|
||||||
const mcpServerUrl = process.env.MCP_SERVER_URL;
|
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'OpenAI API key not configured' },
|
{ error: "LiveKit API key not configured" },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mcpServerUrl) {
|
if (!LIVEKIT_API_SECRET) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'MCP server URL not configured' },
|
{ error: "LiveKit API secret not configured" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!LIVEKIT_URL) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "LiveKit URL not configured" },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
console.log("Creating LiveKit token...");
|
||||||
'https://api.openai.com/v1/realtime/sessions',
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: 'gpt-realtime',
|
|
||||||
voice: 'shimmer',
|
|
||||||
instructions: SYSTEM_PROMPT,
|
|
||||||
input_audio_transcription: {
|
|
||||||
model: 'gpt-4o-transcribe',
|
|
||||||
},
|
|
||||||
tools: [
|
|
||||||
{
|
|
||||||
type: 'mcp',
|
|
||||||
server_label: 'voice-dev-mcp',
|
|
||||||
server_url: mcpServerUrl,
|
|
||||||
require_approval: 'never',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
const participantIdentity = `voice_user_${Math.floor(Math.random() * 10_000)}`;
|
||||||
throw new Error(`OpenAI API error: ${response.status}`);
|
const participantName = "user";
|
||||||
}
|
const roomName = `voice_room_${Math.floor(Math.random() * 10_000)}`;
|
||||||
|
|
||||||
const data = await response.json();
|
const token = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, {
|
||||||
return NextResponse.json(data);
|
identity: participantIdentity,
|
||||||
|
name: participantName,
|
||||||
|
ttl: "15m",
|
||||||
|
});
|
||||||
|
|
||||||
|
const grant: VideoGrant = {
|
||||||
|
room: roomName,
|
||||||
|
roomJoin: true,
|
||||||
|
canPublish: true,
|
||||||
|
canPublishData: true,
|
||||||
|
canSubscribe: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
token.addGrant(grant);
|
||||||
|
|
||||||
|
const jwt = await token.toJwt();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
serverUrl: LIVEKIT_URL,
|
||||||
|
roomName,
|
||||||
|
participantToken: jwt,
|
||||||
|
participantName,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Session creation error:', error);
|
console.error("Token creation error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to create session' },
|
{ error: "Failed to create token" },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { Room, RoomEvent, Track } from 'livekit-client';
|
||||||
|
import type { AgentStatus } from '../types/realtime-events';
|
||||||
|
|
||||||
|
interface UseLiveKitVoiceReturn {
|
||||||
|
isConnected: boolean;
|
||||||
|
isConnecting: boolean;
|
||||||
|
error: string | null;
|
||||||
|
stream: MediaStream | null;
|
||||||
|
agentStatus: AgentStatus;
|
||||||
|
connect: (deviceId?: string) => Promise<void>;
|
||||||
|
disconnect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseLiveKitVoiceOptions {
|
||||||
|
onEvent?: (event: any) => void;
|
||||||
|
onStatusChange?: (status: AgentStatus) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectionDetails {
|
||||||
|
serverUrl: string;
|
||||||
|
roomName: string;
|
||||||
|
participantToken: string;
|
||||||
|
participantName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLiveKitVoice(options: UseLiveKitVoiceOptions = {}): UseLiveKitVoiceReturn {
|
||||||
|
const { onEvent, onStatusChange } = options;
|
||||||
|
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||||
|
const [agentStatus, setAgentStatus] = useState<AgentStatus>('disconnected');
|
||||||
|
|
||||||
|
const roomRef = useRef<Room | null>(null);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
|
||||||
|
const updateStatus = useCallback((status: AgentStatus) => {
|
||||||
|
setAgentStatus(status);
|
||||||
|
onStatusChange?.(status);
|
||||||
|
}, [onStatusChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const room = new Room();
|
||||||
|
roomRef.current = room;
|
||||||
|
|
||||||
|
room.on(RoomEvent.Connected, () => {
|
||||||
|
console.log('Connected to LiveKit room');
|
||||||
|
setIsConnected(true);
|
||||||
|
setIsConnecting(false);
|
||||||
|
updateStatus('connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.Disconnected, () => {
|
||||||
|
console.log('Disconnected from LiveKit room');
|
||||||
|
setIsConnected(false);
|
||||||
|
setIsConnecting(false);
|
||||||
|
updateStatus('disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.Reconnecting, () => {
|
||||||
|
console.log('Reconnecting to LiveKit room');
|
||||||
|
setIsConnecting(true);
|
||||||
|
updateStatus('connecting');
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.Reconnected, () => {
|
||||||
|
console.log('Reconnected to LiveKit room');
|
||||||
|
setIsConnected(true);
|
||||||
|
setIsConnecting(false);
|
||||||
|
updateStatus('connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
||||||
|
console.log('Track subscribed:', {
|
||||||
|
kind: track.kind,
|
||||||
|
participant: participant.identity,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (track.kind === Track.Kind.Audio) {
|
||||||
|
const audioElement = track.attach();
|
||||||
|
audioElement.play().catch(err => {
|
||||||
|
console.error('Error playing audio:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.ParticipantConnected, (participant) => {
|
||||||
|
console.log('Participant connected:', participant.identity);
|
||||||
|
if (participant.isAgent) {
|
||||||
|
console.log('Agent joined the room');
|
||||||
|
updateStatus('connected');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.ParticipantDisconnected, (participant) => {
|
||||||
|
console.log('Participant disconnected:', participant.identity);
|
||||||
|
if (participant.isAgent) {
|
||||||
|
console.log('Agent left the room');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.DataReceived, (payload, participant) => {
|
||||||
|
try {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const text = decoder.decode(payload);
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
console.log('Data received from', participant?.identity, ':', data);
|
||||||
|
onEvent?.(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse data:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
room.disconnect();
|
||||||
|
roomRef.current = null;
|
||||||
|
};
|
||||||
|
}, [updateStatus, onEvent]);
|
||||||
|
|
||||||
|
const connect = useCallback(async (deviceId?: string) => {
|
||||||
|
if (isConnecting || isConnected) return;
|
||||||
|
|
||||||
|
setIsConnecting(true);
|
||||||
|
setError(null);
|
||||||
|
updateStatus('connecting');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!window.isSecureContext) {
|
||||||
|
throw new Error('HTTPS required. Please access this app over HTTPS or localhost.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
throw new Error('Media devices not supported in this browser.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioConstraints: MediaTrackConstraints = {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (deviceId) {
|
||||||
|
audioConstraints.deviceId = { exact: deviceId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const micStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: audioConstraints
|
||||||
|
});
|
||||||
|
streamRef.current = micStream;
|
||||||
|
setStream(micStream);
|
||||||
|
|
||||||
|
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
|
||||||
|
const response = await fetch(`${basePath}/api/session`, { method: 'POST' });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to get connection details');
|
||||||
|
}
|
||||||
|
const connectionDetails: ConnectionDetails = await response.json();
|
||||||
|
|
||||||
|
const room = roomRef.current;
|
||||||
|
if (!room) {
|
||||||
|
throw new Error('Room not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
await room.connect(connectionDetails.serverUrl, connectionDetails.participantToken);
|
||||||
|
|
||||||
|
await room.localParticipant.setMicrophoneEnabled(true);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Connection error:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to connect');
|
||||||
|
setIsConnecting(false);
|
||||||
|
setIsConnected(false);
|
||||||
|
updateStatus('disconnected');
|
||||||
|
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.getTracks().forEach(track => track.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
setStream(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isConnecting, isConnected, updateStatus]);
|
||||||
|
|
||||||
|
const disconnect = useCallback(() => {
|
||||||
|
const room = roomRef.current;
|
||||||
|
if (room) {
|
||||||
|
room.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.getTracks().forEach(track => track.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
setStream(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConnected(false);
|
||||||
|
setIsConnecting(false);
|
||||||
|
setError(null);
|
||||||
|
updateStatus('disconnected');
|
||||||
|
}, [updateStatus]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isConnected,
|
||||||
|
isConnecting,
|
||||||
|
error,
|
||||||
|
stream,
|
||||||
|
agentStatus,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
// Re-export types from openai-realtime-api for type safety
|
// LiveKit event types (simplified for our use case)
|
||||||
import type { RealtimeServerEvents, RealtimeClientEvents } from 'openai-realtime-api';
|
export interface LiveKitEvent {
|
||||||
|
type: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
export type RealtimeServerEvent = RealtimeServerEvents.EventMap[RealtimeServerEvents.EventType];
|
// Keep OpenAI types for backward compatibility during transition
|
||||||
export type RealtimeClientEvent = RealtimeClientEvents.EventMap[RealtimeClientEvents.EventType];
|
export type RealtimeServerEvent = LiveKitEvent;
|
||||||
|
export type RealtimeClientEvent = LiveKitEvent;
|
||||||
|
|
||||||
// Agent status enum
|
// Agent status enum
|
||||||
export type AgentStatus =
|
export type AgentStatus =
|
||||||
@@ -40,6 +44,13 @@ export interface LogEntry {
|
|||||||
export function categorizeEvent(event: RealtimeServerEvent): EventCategory {
|
export function categorizeEvent(event: RealtimeServerEvent): EventCategory {
|
||||||
const type = event.type;
|
const type = event.type;
|
||||||
|
|
||||||
|
// LiveKit events
|
||||||
|
if (type.includes('connected') || type.includes('disconnected')) return 'connection';
|
||||||
|
if (type.includes('track')) return 'audio';
|
||||||
|
if (type.includes('participant')) return 'connection';
|
||||||
|
if (type.includes('data')) return 'other';
|
||||||
|
|
||||||
|
// OpenAI events (for backward compatibility)
|
||||||
if (type.startsWith('session.')) return 'connection';
|
if (type.startsWith('session.')) return 'connection';
|
||||||
if (type.startsWith('input_audio_buffer.')) return 'speech';
|
if (type.startsWith('input_audio_buffer.')) return 'speech';
|
||||||
if (type.includes('transcription')) return 'transcription';
|
if (type.includes('transcription')) return 'transcription';
|
||||||
@@ -57,6 +68,17 @@ export function categorizeEvent(event: RealtimeServerEvent): EventCategory {
|
|||||||
export function getEventSummary(event: RealtimeServerEvent): string {
|
export function getEventSummary(event: RealtimeServerEvent): string {
|
||||||
const type = event.type;
|
const type = event.type;
|
||||||
|
|
||||||
|
// LiveKit events
|
||||||
|
if (type === 'connected') return 'Connected to LiveKit';
|
||||||
|
if (type === 'disconnected') return 'Disconnected from LiveKit';
|
||||||
|
if (type === 'reconnecting') return 'Reconnecting...';
|
||||||
|
if (type === 'reconnected') return 'Reconnected';
|
||||||
|
if (type === 'track_subscribed') return 'Audio track subscribed';
|
||||||
|
if (type === 'participant_connected') return 'Participant connected';
|
||||||
|
if (type === 'participant_disconnected') return 'Participant disconnected';
|
||||||
|
if (type === 'data_received') return 'Data received';
|
||||||
|
|
||||||
|
// OpenAI events (for backward compatibility)
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'session.created':
|
case 'session.created':
|
||||||
return 'Session established';
|
return 'Session established';
|
||||||
@@ -118,6 +140,13 @@ export function getEventSummary(event: RealtimeServerEvent): string {
|
|||||||
export function getStatusFromEvent(event: RealtimeServerEvent): AgentStatus | null {
|
export function getStatusFromEvent(event: RealtimeServerEvent): AgentStatus | null {
|
||||||
const type = event.type;
|
const type = event.type;
|
||||||
|
|
||||||
|
// LiveKit events
|
||||||
|
if (type === 'connected') return 'connected';
|
||||||
|
if (type === 'disconnected') return 'disconnected';
|
||||||
|
if (type === 'reconnecting') return 'connecting';
|
||||||
|
if (type === 'reconnected') return 'connected';
|
||||||
|
|
||||||
|
// OpenAI events (for backward compatibility)
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'session.created':
|
case 'session.created':
|
||||||
return 'connected';
|
return 'connected';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useWebRTCVoice } from './hooks/use-webrtc-voice';
|
import { useLiveKitVoice } from './hooks/use-livekit-voice';
|
||||||
import { useAudioLevel } from './hooks/use-audio-level';
|
import { useAudioLevel } from './hooks/use-audio-level';
|
||||||
import { useAudioDevices } from './hooks/use-audio-devices';
|
import { useAudioDevices } from './hooks/use-audio-devices';
|
||||||
import { useEventLog } from './hooks/use-event-log';
|
import { useEventLog } from './hooks/use-event-log';
|
||||||
@@ -24,7 +24,7 @@ export default function VoiceClient() {
|
|||||||
stream,
|
stream,
|
||||||
connect,
|
connect,
|
||||||
disconnect
|
disconnect
|
||||||
} = useWebRTCVoice({
|
} = useLiveKitVoice({
|
||||||
onEvent: addLog
|
onEvent: addLog
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,12 +19,15 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"description": "Voice development assistant web interface",
|
"description": "Voice development assistant web interface",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@livekit/components-react": "^2.9.15",
|
||||||
"@tailwindcss/oxide": "^4.1.14",
|
"@tailwindcss/oxide": "^4.1.14",
|
||||||
"@tailwindcss/postcss": "^4.1.14",
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
"@types/node": "^24.7.2",
|
"@types/node": "^24.7.2",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.2",
|
||||||
"@types/react-dom": "^19.2.1",
|
"@types/react-dom": "^19.2.1",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"livekit-client": "^2.15.9",
|
||||||
|
"livekit-server-sdk": "^2.14.0",
|
||||||
"next": "^15.5.4",
|
"next": "^15.5.4",
|
||||||
"openai-realtime-api": "^1.0.8",
|
"openai-realtime-api": "^1.0.8",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
|
|||||||
Reference in New Issue
Block a user