14 KiB
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)
{
"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:
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:
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:
// 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:
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:
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:
interface VolumeBarProps {
volume: number; // 0-100
isMuted?: boolean;
}
Visual Design:
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:
interface MuteButtonProps {
isMuted: boolean;
onToggle: () => void;
disabled?: boolean;
}
Implementation:
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:
'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 stateisConnected- from WebRTC hookvolume- from audio level hookstream- MediaStream from WebRTC hook
User Flow:
- Click "Start Voice Chat" → requests mic permission → connects
- Volume bar shows audio level
- Click "Mute" → disables audio track, grays out volume bar
- Click "Unmute" → re-enables audio track
- Click "Disconnect" → closes connection, stops mic
Phase 7: Main Page
File: app/page.tsx
Purpose: Entry point, server component wrapper
Implementation:
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:
{
"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)
- Verify Next.js project initialized
- Verify OPENAI_API_KEY in .env.local
- Ensure TypeScript configured
Step 2: Backend (10 min)
- Create
app/api/session/route.ts - Test endpoint with curl or Postman
- Verify token generation
Step 3: Components (15 min)
- Create
app/components/volume-bar.tsx - Create
app/components/mute-button.tsx - Test with mock data
Step 4: Hooks (30 min)
- Create
app/hooks/use-audio-level.ts - Test with microphone input
- Create
app/hooks/use-webrtc-voice.ts - Test WebRTC connection
Step 5: Integration (15 min)
- Create
app/voice-client.tsx - Update
app/page.tsx - Wire all components together
Step 6: Testing (20 min)
- Test complete user flow
- Test error cases
- Verify audio quality
- Check performance
Step 7: Polish (10 min)
- Refine styling
- Improve error messages
- 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