616 lines
14 KiB
Markdown
616 lines
14 KiB
Markdown
# Real-Time Voice App Implementation Plan
|
|
|
|
## Overview
|
|
Build a Next.js application that connects directly to OpenAI's Realtime API using WebRTC for voice interaction with mute/unmute controls and audio visualization.
|
|
|
|
## Tech Stack
|
|
- Next.js 14+ (App Router)
|
|
- TypeScript
|
|
- OpenAI Realtime API (WebRTC)
|
|
- Web Audio API (for visualization)
|
|
- Native browser WebRTC APIs
|
|
|
|
## Project Structure
|
|
|
|
```
|
|
app/
|
|
├── page.tsx # Main page (server component)
|
|
├── voice-client.tsx # Main client component
|
|
├── components/
|
|
│ ├── volume-bar.tsx # Audio level visualization
|
|
│ └── mute-button.tsx # Mute/unmute control
|
|
├── hooks/
|
|
│ ├── use-audio-level.ts # Audio analysis
|
|
│ └── use-webrtc-voice.ts # WebRTC connection logic
|
|
└── api/
|
|
└── session/
|
|
└── route.ts # Generate ephemeral tokens
|
|
```
|
|
|
|
## Environment Setup
|
|
|
|
### Required Environment Variables
|
|
```
|
|
OPENAI_API_KEY=sk-...
|
|
```
|
|
|
|
### Dependencies (package.json additions)
|
|
```json
|
|
{
|
|
"dependencies": {
|
|
"next": "^14.0.0",
|
|
"react": "^18.0.0",
|
|
"react-dom": "^18.0.0",
|
|
"typescript": "^5.0.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
**No additional dependencies needed** - using native browser APIs only.
|
|
|
|
## Implementation Steps
|
|
|
|
### Phase 1: Backend - Ephemeral Token Generation
|
|
|
|
#### File: `app/api/session/route.ts`
|
|
|
|
**Purpose:** Securely generate ephemeral tokens for WebRTC connection
|
|
|
|
**Implementation:**
|
|
```typescript
|
|
import { NextResponse } from 'next/server';
|
|
|
|
export async function POST() {
|
|
const apiKey = process.env.OPENAI_API_KEY;
|
|
|
|
if (!apiKey) {
|
|
return NextResponse.json(
|
|
{ error: 'OpenAI API key not configured' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(
|
|
'https://api.openai.com/v1/realtime/sessions',
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'gpt-4o-realtime-preview-2024-12-17',
|
|
voice: 'alloy',
|
|
}),
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`OpenAI API error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return NextResponse.json(data);
|
|
} catch (error) {
|
|
console.error('Session creation error:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Failed to create session' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key Details:**
|
|
- POST endpoint only
|
|
- Returns: `{ client_secret: { value: string, expires_at: number } }`
|
|
- Ephemeral token valid for 60 seconds
|
|
- Keep API key secure on server
|
|
|
|
---
|
|
|
|
### Phase 2: WebRTC Connection Hook
|
|
|
|
#### File: `app/hooks/use-webrtc-voice.ts`
|
|
|
|
**Purpose:** Manage WebRTC connection to OpenAI Realtime API
|
|
|
|
**State Management:**
|
|
```typescript
|
|
interface UseWebRTCVoiceReturn {
|
|
isConnected: boolean;
|
|
isConnecting: boolean;
|
|
error: string | null;
|
|
stream: MediaStream | null;
|
|
connect: () => Promise<void>;
|
|
disconnect: () => void;
|
|
}
|
|
```
|
|
|
|
**Implementation Flow:**
|
|
```
|
|
1. Request microphone access
|
|
2. Fetch ephemeral token from /api/session
|
|
3. Create RTCPeerConnection
|
|
4. Add microphone audio track to peer connection
|
|
5. Create data channel for events
|
|
6. Create and set local SDP offer
|
|
7. Send offer to OpenAI and get answer
|
|
8. Set remote SDP answer
|
|
9. Connection established
|
|
```
|
|
|
|
**Key Code Patterns:**
|
|
```typescript
|
|
// 1. Get microphone
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
audio: true
|
|
});
|
|
|
|
// 2. Get ephemeral token
|
|
const response = await fetch('/api/session', { method: 'POST' });
|
|
const { client_secret } = await response.json();
|
|
|
|
// 3. Create peer connection
|
|
const pc = new RTCPeerConnection();
|
|
|
|
// 4. Add audio track
|
|
stream.getTracks().forEach(track => pc.addTrack(track, stream));
|
|
|
|
// 5. Create data channel
|
|
const dc = pc.createDataChannel('oai-events');
|
|
|
|
// 6. Handle incoming audio
|
|
pc.ontrack = (event) => {
|
|
const audioElement = new Audio();
|
|
audioElement.srcObject = event.streams[0];
|
|
audioElement.play();
|
|
};
|
|
|
|
// 7. Create offer
|
|
const offer = await pc.createOffer();
|
|
await pc.setLocalDescription(offer);
|
|
|
|
// 8. Send to OpenAI
|
|
const sdpResponse = await fetch(
|
|
`https://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-12-17`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${client_secret.value}`,
|
|
'Content-Type': 'application/sdp',
|
|
},
|
|
body: offer.sdp,
|
|
}
|
|
);
|
|
|
|
// 9. Set answer
|
|
const answer = {
|
|
type: 'answer' as RTCSdpType,
|
|
sdp: await sdpResponse.text(),
|
|
};
|
|
await pc.setRemoteDescription(answer);
|
|
```
|
|
|
|
**Cleanup:**
|
|
```typescript
|
|
function disconnect() {
|
|
dataChannel?.close();
|
|
peerConnection?.close();
|
|
stream?.getTracks().forEach(track => track.stop());
|
|
if (audioElement) {
|
|
audioElement.pause();
|
|
audioElement.srcObject = null;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Error Handling:**
|
|
- Microphone permission denied
|
|
- Network errors during token fetch
|
|
- WebRTC connection failures
|
|
- Token expiration
|
|
|
|
---
|
|
|
|
### Phase 3: Audio Level Hook
|
|
|
|
#### File: `app/hooks/use-audio-level.ts`
|
|
|
|
**Purpose:** Calculate real-time audio levels from microphone stream
|
|
|
|
**Implementation:**
|
|
```typescript
|
|
function useAudioLevel(stream: MediaStream | null): number {
|
|
const [volume, setVolume] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!stream) {
|
|
setVolume(0);
|
|
return;
|
|
}
|
|
|
|
const audioContext = new AudioContext();
|
|
const analyser = audioContext.createAnalyser();
|
|
analyser.fftSize = 2048;
|
|
|
|
const source = audioContext.createMediaStreamSource(stream);
|
|
source.connect(analyser);
|
|
|
|
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
|
let animationId: number;
|
|
|
|
function updateVolume() {
|
|
analyser.getByteTimeDomainData(dataArray);
|
|
|
|
const rms = Math.sqrt(
|
|
dataArray.reduce((sum, val) => sum + val * val, 0) / dataArray.length
|
|
);
|
|
|
|
const normalized = (rms / 255) * 100;
|
|
setVolume(Math.min(100, normalized * 1.5));
|
|
|
|
animationId = requestAnimationFrame(updateVolume);
|
|
}
|
|
|
|
updateVolume();
|
|
|
|
return () => {
|
|
cancelAnimationFrame(animationId);
|
|
source.disconnect();
|
|
audioContext.close();
|
|
};
|
|
}, [stream]);
|
|
|
|
return volume;
|
|
}
|
|
```
|
|
|
|
**Returns:** Volume level 0-100
|
|
|
|
---
|
|
|
|
### Phase 4: Volume Bar Component
|
|
|
|
#### File: `app/components/volume-bar.tsx`
|
|
|
|
**Purpose:** Visual representation of audio level
|
|
|
|
**Props:**
|
|
```typescript
|
|
interface VolumeBarProps {
|
|
volume: number; // 0-100
|
|
isMuted?: boolean;
|
|
}
|
|
```
|
|
|
|
**Visual Design:**
|
|
```typescript
|
|
function VolumeBar({ volume, isMuted }: VolumeBarProps) {
|
|
const getColor = () => {
|
|
if (isMuted) return 'bg-gray-300';
|
|
if (volume > 80) return 'bg-red-500';
|
|
if (volume > 60) return 'bg-yellow-400';
|
|
return 'bg-green-500';
|
|
};
|
|
|
|
return (
|
|
<div className="w-10 h-48 bg-gray-200 rounded-lg overflow-hidden flex flex-col-reverse">
|
|
<div
|
|
className={`${getColor()} transition-all duration-100 ease-out`}
|
|
style={{ height: `${isMuted ? 0 : volume}%` }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Styling:**
|
|
- Vertical bar, 40px wide, 192px tall
|
|
- Color zones: green → yellow → red
|
|
- Smooth transitions (100ms)
|
|
- Gray when muted
|
|
|
|
---
|
|
|
|
### Phase 5: Mute Button Component
|
|
|
|
#### File: `app/components/mute-button.tsx`
|
|
|
|
**Purpose:** Toggle microphone mute state
|
|
|
|
**Props:**
|
|
```typescript
|
|
interface MuteButtonProps {
|
|
isMuted: boolean;
|
|
onToggle: () => void;
|
|
disabled?: boolean;
|
|
}
|
|
```
|
|
|
|
**Implementation:**
|
|
```typescript
|
|
function MuteButton({ isMuted, onToggle, disabled }: MuteButtonProps) {
|
|
return (
|
|
<button
|
|
onClick={onToggle}
|
|
disabled={disabled}
|
|
className={`
|
|
px-6 py-3 rounded-lg font-medium transition-colors
|
|
${isMuted
|
|
? 'bg-red-500 hover:bg-red-600 text-white'
|
|
: 'bg-blue-500 hover:bg-blue-600 text-white'}
|
|
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
|
`}
|
|
>
|
|
{isMuted ? 'Unmute' : 'Mute'}
|
|
</button>
|
|
);
|
|
}
|
|
```
|
|
|
|
**States:**
|
|
- Active (not muted): Blue background
|
|
- Muted: Red background
|
|
- Disabled: Grayed out when not connected
|
|
|
|
---
|
|
|
|
### Phase 6: Main Voice Client Component
|
|
|
|
#### File: `app/voice-client.tsx`
|
|
|
|
**Purpose:** Main client component orchestrating all functionality
|
|
|
|
**Implementation:**
|
|
```typescript
|
|
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useWebRTCVoice } from './hooks/use-webrtc-voice';
|
|
import { useAudioLevel } from './hooks/use-audio-level';
|
|
import VolumeBar from './components/volume-bar';
|
|
import MuteButton from './components/mute-button';
|
|
|
|
export default function VoiceClient() {
|
|
const [isMuted, setIsMuted] = useState(false);
|
|
const {
|
|
isConnected,
|
|
isConnecting,
|
|
error,
|
|
stream,
|
|
connect,
|
|
disconnect
|
|
} = useWebRTCVoice();
|
|
|
|
const volume = useAudioLevel(stream);
|
|
|
|
function handleMuteToggle() {
|
|
if (!stream) return;
|
|
|
|
stream.getAudioTracks().forEach(track => {
|
|
track.enabled = !isMuted;
|
|
});
|
|
|
|
setIsMuted(!isMuted);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col items-center justify-center min-h-screen gap-8 p-8">
|
|
<h1 className="text-4xl font-bold">Real-Time Voice</h1>
|
|
|
|
{error && (
|
|
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-end gap-8">
|
|
<VolumeBar volume={volume} isMuted={isMuted} />
|
|
|
|
<div className="flex flex-col gap-4">
|
|
{!isConnected ? (
|
|
<button
|
|
onClick={connect}
|
|
disabled={isConnecting}
|
|
className="px-8 py-4 bg-green-500 hover:bg-green-600 text-white rounded-lg font-medium disabled:opacity-50"
|
|
>
|
|
{isConnecting ? 'Connecting...' : 'Start Voice Chat'}
|
|
</button>
|
|
) : (
|
|
<>
|
|
<MuteButton
|
|
isMuted={isMuted}
|
|
onToggle={handleMuteToggle}
|
|
disabled={!isConnected}
|
|
/>
|
|
<button
|
|
onClick={disconnect}
|
|
className="px-8 py-4 bg-gray-500 hover:bg-gray-600 text-white rounded-lg font-medium"
|
|
>
|
|
Disconnect
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isConnected && (
|
|
<p className="text-green-600 font-medium">
|
|
Connected - Speak to interact with AI
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**State Management:**
|
|
- `isMuted` - local mute state
|
|
- `isConnected` - from WebRTC hook
|
|
- `volume` - from audio level hook
|
|
- `stream` - MediaStream from WebRTC hook
|
|
|
|
**User Flow:**
|
|
1. Click "Start Voice Chat" → requests mic permission → connects
|
|
2. Volume bar shows audio level
|
|
3. Click "Mute" → disables audio track, grays out volume bar
|
|
4. Click "Unmute" → re-enables audio track
|
|
5. Click "Disconnect" → closes connection, stops mic
|
|
|
|
---
|
|
|
|
### Phase 7: Main Page
|
|
|
|
#### File: `app/page.tsx`
|
|
|
|
**Purpose:** Entry point, server component wrapper
|
|
|
|
**Implementation:**
|
|
```typescript
|
|
import VoiceClient from './voice-client';
|
|
|
|
export default function Home() {
|
|
return <VoiceClient />;
|
|
}
|
|
```
|
|
|
|
**Simple wrapper** - all logic in client component
|
|
|
|
---
|
|
|
|
## Configuration Files
|
|
|
|
### TypeScript Config (`tsconfig.json`)
|
|
Ensure these compiler options:
|
|
```json
|
|
{
|
|
"compilerOptions": {
|
|
"lib": ["dom", "dom.iterable", "esnext"],
|
|
"target": "ES2017",
|
|
"strict": true
|
|
}
|
|
}
|
|
```
|
|
|
|
### Tailwind Config
|
|
Standard Next.js Tailwind setup - no special configuration needed
|
|
|
|
---
|
|
|
|
## Testing Checklist
|
|
|
|
### Functionality
|
|
- [ ] Microphone permission request works
|
|
- [ ] Connection establishes successfully
|
|
- [ ] Can hear AI responses through speakers
|
|
- [ ] AI can hear user input
|
|
- [ ] Mute button disables microphone
|
|
- [ ] Unmute button re-enables microphone
|
|
- [ ] Volume bar reflects audio level
|
|
- [ ] Volume bar grays out when muted
|
|
- [ ] Disconnect stops all streams
|
|
- [ ] Error messages display correctly
|
|
|
|
### Browser Testing
|
|
- [ ] Chrome/Edge (primary target)
|
|
- [ ] Firefox
|
|
- [ ] Safari (note: may need user gesture for AudioContext)
|
|
|
|
### Edge Cases
|
|
- [ ] Microphone permission denied
|
|
- [ ] No microphone available
|
|
- [ ] Network errors during connection
|
|
- [ ] Token expiration (60 second timeout)
|
|
- [ ] Rapid mute/unmute toggles
|
|
- [ ] Disconnect and reconnect
|
|
- [ ] Browser tab backgrounded
|
|
|
|
---
|
|
|
|
## Implementation Order
|
|
|
|
### Step 1: Setup (5 min)
|
|
1. Verify Next.js project initialized
|
|
2. Verify OPENAI_API_KEY in .env.local
|
|
3. Ensure TypeScript configured
|
|
|
|
### Step 2: Backend (10 min)
|
|
1. Create `app/api/session/route.ts`
|
|
2. Test endpoint with curl or Postman
|
|
3. Verify token generation
|
|
|
|
### Step 3: Components (15 min)
|
|
1. Create `app/components/volume-bar.tsx`
|
|
2. Create `app/components/mute-button.tsx`
|
|
3. Test with mock data
|
|
|
|
### Step 4: Hooks (30 min)
|
|
1. Create `app/hooks/use-audio-level.ts`
|
|
2. Test with microphone input
|
|
3. Create `app/hooks/use-webrtc-voice.ts`
|
|
4. Test WebRTC connection
|
|
|
|
### Step 5: Integration (15 min)
|
|
1. Create `app/voice-client.tsx`
|
|
2. Update `app/page.tsx`
|
|
3. Wire all components together
|
|
|
|
### Step 6: Testing (20 min)
|
|
1. Test complete user flow
|
|
2. Test error cases
|
|
3. Verify audio quality
|
|
4. Check performance
|
|
|
|
### Step 7: Polish (10 min)
|
|
1. Refine styling
|
|
2. Improve error messages
|
|
3. Add loading states
|
|
|
|
**Total Estimated Time: ~2 hours**
|
|
|
|
---
|
|
|
|
## Common Issues & Solutions
|
|
|
|
### Issue: AudioContext suspended
|
|
**Solution:** Safari requires user gesture. Ensure AudioContext created after user clicks connect.
|
|
|
|
### Issue: No audio output
|
|
**Solution:** Check if Audio element is created and `play()` called. Verify speakers/volume.
|
|
|
|
### Issue: Microphone not working
|
|
**Solution:** Check browser permissions. Ensure HTTPS (required for getUserMedia).
|
|
|
|
### Issue: Connection fails
|
|
**Solution:** Verify token not expired (60s limit). Check network console for errors.
|
|
|
|
### Issue: High latency
|
|
**Solution:** WebRTC should be low-latency by default. Check network connection quality.
|
|
|
|
---
|
|
|
|
## Future Enhancements (Not in Initial Scope)
|
|
|
|
- Session configuration (change voice, temperature)
|
|
- Text transcript display
|
|
- Function calling support
|
|
- Audio recording/playback
|
|
- Multiple conversation modes
|
|
- Voice activity detection threshold
|
|
- Connection quality indicator
|
|
- Reconnection logic on disconnect
|
|
|
|
---
|
|
|
|
## Success Criteria
|
|
|
|
✅ User can click connect and start talking to AI
|
|
✅ AI responses are audible and clear
|
|
✅ Mute/unmute works correctly
|
|
✅ Volume bar shows real-time audio levels
|
|
✅ Error states handled gracefully
|
|
✅ Connection can be cleanly disconnected
|
|
✅ Code is clean, typed, and maintainable
|