A real-time voice conversation application built with Next.js and OpenAI's Realtime API.
- How It Works
- Models & Technology
- Architectural Decisions
- Scope: Current Build vs. Production
- Core Systems
- External Integrations
Azimov Voice creates real-time, bidirectional voice conversations between users and an AI persona. Here's the end-to-end flow:
-
Page Load (Server-Side)
- Vercel's edge network provides user location via IP geolocation headers
- Recent news/events are pre-fetched from Exa AI to give the agent topical context
- A random personality is selected from 1000 pre-generated personas (each with detailed backstory, interests, profession, cultural background)
-
Session Initiation (Client-Side)
- User clicks the microphone button to start
- A short-lived ephemeral API key is fetched from the server (keeps main API key secure)
- WebRTC connection is established directly with OpenAI's Realtime API
- The AI agent is configured with the personality, location context, and recent events
- Agent initiates conversation with a greeting in-character
-
Conversation Loop
- User's voice is streamed via WebRTC to OpenAI; transcript is streamed back
- If the agent needs current information, it calls the
web_searchtool (Exa API) - Response audio along with transcript is streamed back via WebRTC.
- Semantic VAD (Voice Activity Detection) handles turn-taking automatically
-
Engagement Features
- Idle timeout: If user is silent for ~1 minute, agent proactively re-engages
- Multimodal input: Users can also type messages or attach images
- Visual feedback: Animated orb shows who's speaking (purple = agent, green = user)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Browser β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β βββββββββββββββ βββββββββββββββ βββββββββββββββββββββββββββ β
β β VoiceChat β β VoiceOrb β β ChatTranscript β β
β β Component β β (Visual) β β (Conversation History) β β
β ββββββββ¬βββββββ βββββββββββββββ βββββββββββββββββββββββββββ β
β β β
β ββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β useVoiceSession Hook β β
β β - Session state management β β
β β - WebRTC transport handling β β
β β - History tracking & timestamps β β
β ββββββββ¬βββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β
β ββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β OpenAIRealtimeWebRTC + RealtimeSession β β
β β (@openai/agents/realtime) β β
β ββββββββ¬ββββββββββββββββββββββββββββββββββββββββββ¬βββββββββ β
βββββββββββΌββββββββββββββββββββββββββββββββββββββββββΌββββββββββββββ
β β
β WebRTC β
β β
βΌ βΌ
βββββββββββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββββββ
β OpenAI Realtime API β β Next.js Server β
βββββββββββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββββββ
β - gpt-realtime β β - Ephemeral key generation β
β - Semantic VAD β β - Tool execution (web_search) β
βββββββββββββββββββββββββββββββββββββ βββββββββββββββββ¬ββββββββββββββββ
β
β Search API
βΌ
βββββββββββββββββββββββββ
β Exa AI β
βββββββββββββββββββββββββ
gpt-realtime which uses whisper-1 for STT.
Voices are selected based on the persona's gender:
- Male voices: ash, cedar, echo, verse
- Female voices: alloy, ballad, coral, marin, sage, shimmer
- Next.js
- TypeScript
- OpenAI Agents SDK
- Tailwind
- shadcn-ui
- Vercel AI Elements
- Zod
Decision: Use OpenAIRealtimeWebRTC transport instead of WebSocket.
Why:
- Lower latency for audio streaming (peer-to-peer)
- Better handling of real-time bidirectional audio
- Built-in echo cancellation and noise reduction
Alternatives considered:
-
WebSocket transport
WebRTC provides lower latency and handles audio input and output automatically.
Decision: Server generates short-lived client tokens; client never sees main API key.
Why:
- Security: API key never exposed to browser
- Token rotation: Each session gets a fresh, limited-scope token
- Standard pattern for client-side API access
Alternatives considered:
-
Proxying all requests through server
Much lower latency for direct WebRTC connection.
Decision: 1000 personalities stored in a static JSON file, randomly assigned per session.
Why:
-
Consistent, high-quality personas with rich backstories
-
Uses Nvidia's Nemotron-Personas-USA dataset with an extraction script.
Dataset contains 1M personas so this can always be expanded.
Decision: Location and recent events fetched in root layout (server component).
Why:
- Data available before hydration
- No loading states for initial context
- Leverages Next.js server components
Alternatives considered:
-
Client-side fetching with loading states
- Would need to send more data to the client than necessary for the personalities.
- Since the web search is executed from the server, it will add an unnecessary round trip.
Decision: Location, RecentEvents, and Personality passed via React Context.
Why:
- Avoids prop drilling through component tree
- Data set once at root, consumed anywhere
- Clean provider pattern
- Real-time voice conversation
- Dynamic AI personalities (1000)
- Location-aware context
- Recent events context
- Web search
- Text message fallback
- Image attachment support
- Pause/resume session
- Idle re-engagement
- Response latency display
- Long-term Memory
- Authentication
- Conversation Persistence
- Better UI
- Personality Selection
- Rate Limiting
- Analytics
- Error Tracking
- Accessibility
- Choosing Voice
The useVoiceSession hook (hooks/use-voice-session.ts) is the central state manager for voice interactions:
type SessionState = "idle" | "connecting" | "connected" | "paused" | "error"
// Hook exports
{
state: SessionState, // Current connection state
history: RealtimeItem[], // Conversation history
timestamps: MessageTimestamps, // Message timing data
submittedImages: SubmittedImages, // Attached images
isAgentTurn: boolean, // Is AI currently speaking
isUserTurn: boolean, // Is user expected to speak
isPaused: boolean, // Session paused state
// Actions
connect: () => Promise<void>, // Start a new session
pause: () => void, // Pause (mute) session
resume: () => void, // Resume session
newChat: () => Promise<void>, // Clear and restart
clear: () => void, // End session completely
interrupt: () => void, // Interrupt AI response
sendMessage: (text: string) => void, // Send text message
addImage: (dataUrl: string, trigger?: boolean) => void, // Add image
storeSubmittedImages: (urls: string[]) => void, // Track images
}Idle Engagement: After ~1 minute of silence, a system message is injected and the agent attempts to re-engage with a new topic.
Latency Tracking: Messages are timestamped when received and when assistant starts responding. Latency = assistant_started - user_timestamp.
Image Handling: Files added via input/paste/drag-drop β blob URLs for preview β converted to data URLs on submit β sent via session.addImage().
The createVoiceAgent function (lib/agent.ts) constructs a RealtimeAgent with:
function createVoiceAgent(
city: string | null,
country: string | null,
recentEvents: string | null,
personality: { prompt: string; sex: string } | null,
voice: string
): RealtimeAgentSystem Instructions Include:
- Personality prompt (if assigned)
- Location and current datetime
- Conversational guidelines (emotive tone, small talk first)
- Turn-taking behavior (respond on cues, not every pause)
- Language handling (match user's language, default English)
- Recent events context for topical conversation
Tools:
web_search: Real-time web search via Exa API
Three React Context providers supply data to client components:
LocationProvider (lib/location-context.tsx): Provides user's geographic location from Vercel headers.
RecentEventsProvider (lib/recent-events-context.tsx): Pre-fetches recent news for conversation topics.
PersonalityProvider (lib/personality-context.tsx): Randomly assigned personality from 1000 profiles. Each personality includes demographics, professional/cultural background, skills, hobbies, and career goals.
Helper functions in lib/session-events.ts:
function sendStartConversation(session: RealtimeSession): void
function sendIdleEngagementPrompt(session: RealtimeSession): void
function deriveTurnState(history: RealtimeItem[], isConnected: boolean): { lastMessage, isUserTurn, isAgentTurn }
function getLastMessageId(history: RealtimeItem[]): string | nullConfiguration (lib/session-config.ts):
{
audio: {
input: {
format: "pcm16",
noiseReduction: { type: "near_field" },
transcription: { model: "whisper-1" },
turnDetection: {
type: "semantic_vad", // Smart pause detection
createResponse: true, // Auto-respond on turn end
interruptResponse: true, // Allow interruptions
eagerness: "medium",
},
},
output: { format: "pcm16" },
},
}Environment: OPENAI_API_KEY
Server Action (lib/actions.ts):
async function searchWebWithExa({
query: string,
country: string | null,
}): Promise<string>- Uses Exa's
/searchendpoint withcontentsparameter - Returns contextualized search results (max 10,000 chars)
- User location passed for relevance
Environment: EXA_API_KEY