The real-time debate arena. A voice-first, streaming interface where a human debates a competitive AI opponent under formal parliamentary rules, with WUDC-style adjudication delivered live.
Architecture · User Flow · Engineering Decisions · WebSocket Protocol · Getting Started
- Overview
- Product Capabilities
- Product Tour
- Architecture
- User Flow
- Application Routes
- Engineering Decisions
- State Management
- Real-Time Audio Pipeline
- WebSocket Protocol
- Authentication
- Project Structure
- Tech Stack
- Getting Started
- Environment Variables
- Development
- Deployment
- Browser Compatibility
- Troubleshooting
- Contributing
Agora is a competitive parliamentary debate platform. The product allows a user to:
- Compete in Asian Parliamentary (AP) or British Parliamentary (BP) formats against an AI opponent that respects WUDC role duties.
- Speak naturally into their microphone with sub-second transcription.
- Receive streaming, voice-synthesized responses from the AI in real time.
- Get tournament-grade adjudication after each match — clash extraction, weighted matrix, WUDC pillar scoring, and per-speaker grading with verbatim quotes.
This repository is the web client. It owns the visual interface, the real-time client state, and the bridge between browser media APIs and the platform's backend.
Agora is composed of three services. This repository is the user-facing one.
| Service | Responsibility | Stack |
|---|---|---|
| agora-frontend (this repo) | Browser UI, WebSocket lifecycle, microphone capture, audio playback queue | Next.js, React, TypeScript, Zustand |
| agora-gateway | WebSocket broker, STT/TTS multiplexer, reverse proxy, Redis state mutator | Go, Gorilla, Redis |
| agora-ai-engine | Four-phase debater, five-phase adjudicator, RAG, persistence | Python, FastAPI, LangChain, pgvector |
The client talks exclusively to the gateway. The gateway talks to the AI engine through Redis and a reverse HTTP proxy.
Live voice streams. Audio is captured at 250 ms slices via MediaRecorder, transmitted as binary frames over an authenticated WebSocket, and transcribed by Deepgram with confidence metrics surfaced to the UI.
Adaptive AI opponents. The AI's skill level is configurable per match across three independent levers — retrieval depth, memory drop probability, and persona temperature. The client surfaces these as Beginner, Intermediate, and Advanced.
Two tournament formats. Asian Parliamentary (six speakers) and British Parliamentary (eight speakers, four teams) are first-class. Role enums, speaker schedules, and team labels are enforced end-to-end.
Sequential audio playback. Multiple TTS chunks arrive concurrently but are queued and played serially through the Web Audio API. No clipping, no overlap, no perceptible gaps between sentence chunks.
Streaming token UI. Each AI token is appended to the transcript as it arrives. Framer Motion handles layout shifts so the DOM grows fluidly without jank or scroll-snap artifacts.
Persistent real-time state. The WebSocket connection and the audio queue live outside the React component lifecycle. Page navigation, component unmounts, and tab focus changes do not sever an active debate.
Edge-level authentication. Supabase JWTs are validated by Next.js middleware on the Edge Network before any client bundle is shipped to unauthenticated requesters.
Full match history and analytics. Users can review every match, drill into per-speaker breakdowns, and track win rate, average score, and best score across both formats.
graph TD
subgraph Browser["Browser — this repository"]
UI[Next.js App Router]
Z[Zustand store]
AC[AudioContext + queue]
MR[MediaRecorder · 250 ms]
UI <--> Z
Z <--> AC
Z <--> MR
end
subgraph Edge["Edge"]
MW[middleware.ts<br/>Supabase JWT gate]
end
subgraph Backend["Microservices"]
GW[Go Gateway · :8080]
AI[Python AI Engine · :8000]
REDIS[(Redis Pub/Sub)]
DB[(Postgres + pgvector)]
GW <--> REDIS
AI <--> REDIS
AI <--> DB
end
subgraph External["External services"]
SB[(Supabase Auth)]
DG[Deepgram STT/TTS]
GROQ[Groq · LLaMA-3]
COH[Cohere Embeddings]
end
Browser <-->|WSS · binary + JSON| GW
Browser <-->|HTTPS · REST| GW
Browser --> MW
MW <--> SB
GW <-->|WSS| DG
GW -->|HTTPS| DG
AI <-->|HTTPS| GROQ
AI <-->|HTTPS| COH
A thick React client owns the WebSocket and the Web Audio queue in a Zustand store, server components handle non-real-time data fetching, and Edge middleware gates authentication before any bundle leaves the CDN.
In the live production environment, the system is distributed across multiple hosting tiers to optimize for real-time performance, low latency, and secure streaming:
- Host: Deployed serverlessly at
https://agora-frontend-alpha.vercel.app. - Role: Delivers the responsive, responsive web interface, handles Client state (Zustand), captures human microphone audio via browser
MediaRecorder, schedules TTS playback buffers sequentially using the Web Audio API, and interacts directly with Supabase Cloud for user sign-in/sign-up sessions.
- Host: AWS EC2 instance running Amazon Linux 2023 (
16.171.42.39.nip.io). - Nginx Reverse Proxy: Serves as the SSL/TLS termination gate (ports 80/443). Cryptographically decrypts incoming secure HTTPS/WSS traffic using a Let's Encrypt authority certificate, proxying connection queries locally to the Go gateway on
http://localhost:8080. - Go Gateway (Port 8080): A highly concurrent reverse proxy terminating long-lived WebSocket connections, validating Supabase JWT tokens, multiplexing binary audio slices to Deepgram, and coordinating Redis message routing.
- Python AI Engine (Port 8000): FastAPI server orchestrating the 4-phase debate agent, LangChain/Groq LLaMA models, pgvector searches, and WUDC adjudication.
- Redis Event Broker: Active in a Docker container acting as a Pub/Sub queue to stream token arrays instantaneously between the AI Engine and Go Gateway.
- Host: PostgreSQL + pgvector databases deployed in AWS region
ap-southeast-2(Sydney). - Role: Handles secure Supabase OAuth and stores tables containing debates, match configurations, speaker grades, and case-prep embeddings. Connects to backend containers via the dedicated, highly stable production pooler host (
aws-1-ap-southeast-2.pooler.supabase.com).
flowchart TD
A[Landing Page] --> B{Authenticated?}
B -->|No| C[Sign In / Sign Up]
C -->|OAuth or Email| D[Supabase Auth]
D --> E[Dashboard]
B -->|Yes| E
E --> F[Match Configuration]
F --> F1[1. Choose format · AP or BP]
F1 --> F2[2. Pick speaker role]
F2 --> F3[3. Enter or generate motion]
F3 --> F4[4. Choose AI difficulty]
F4 --> G[Case Preparation]
G --> G1[Review AI-generated brief]
G1 --> H[Live Arena]
H --> H1[Tap to speak · MediaRecorder]
H1 --> H2[AI thinks · 4-phase RAG]
H2 --> H3[AI streams text + voice]
H3 --> H4[Optional POI exchange]
H4 --> H5[End turn · timing captured]
H5 -->|More speakers| H1
H5 -->|All complete| I[Adjudication]
I --> I1[1. Clashes]
I1 --> I2[2. Weighted Matrix]
I2 --> I3[3. WUDC Pillars]
I3 --> I4[4. Speakers]
I4 --> I5[5. Verdict]
I5 --> J[Results]
J --> K{Next?}
K -->|Debate again| F
K -->|Match history| L[History]
K -->|Dashboard| E
| Route | Render | Auth | Purpose |
|---|---|---|---|
/ |
Client | Public | Landing — product overview, calls to action |
/auth/login |
Client | Public | Email, Google, GitHub authentication |
/auth/signup |
Client | Public | Account creation with email confirmation |
/auth/callback |
Route Handler | — | OAuth code-to-session exchange |
/dashboard |
Server | Required | Stats, recent matches, format breakdown |
/debate/setup |
Client | Required | Four-step match configuration |
/debate/[matchId]/prep |
Client | Required | AI-generated case-prep brief |
/debate/[matchId] |
Client | Required | Live Arena — voice and streaming UI |
/results/[matchId] |
Client | Required | Adjudication, WCM, pillars, speakers |
/history |
Server | Required | All debates with AP/BP filters |
/profile |
Client | Required | User profile, avatar upload |
The following are the non-trivial technical decisions made while building this client. Each is framed as a problem encountered and the constraint that drove the solution.
Problem. React components mount and unmount on navigation, state changes, and Suspense boundaries. A naive implementation that stored the WebSocket in component state would tear down the connection every time the user opened a modal, switched tabs, or triggered a hot-reload. Audio chunks in flight would be lost.
Solution. The WebSocket reference and audio queue live in a Zustand store, with the active AudioBufferSourceNode and AudioContext hoisted to module scope. Zustand subscribes outside React's reconciliation, so updates fire without re-rendering the tree, and the singletons are immune to component unmount cascades.
Implementation. src/store/arenaStore.ts
// Module-scope singletons — outlive any component
let _audioCtx: AudioContext | null = null;
let _activeSource: AudioBufferSourceNode | null = null;
let _activeSourceStartTime: number = 0;
let _totalTurnAudioPlayed: number = 0;Problem. The gateway sends TTS audio at sentence boundaries. For a long AI speech, ten or more audio chunks arrive within seconds. Playing them as they arrive produces an audio collision — sentence one and sentence three overlap, producing garbled speech.
Solution. Maintain an ArrayBuffer[] queue. When a chunk arrives, push and trigger processAudioQueue(). The processor decodes a single buffer through AudioContext.decodeAudioData, creates a fresh BufferSource, sets onended to recursively process the next chunk, and only then calls source.start(). Playback is strictly serial, gapless, and reorder-safe.
Implementation.
processAudioQueue: () => {
if (state.isPlayingAudio || state.audioQueue.length === 0) return;
set({ isPlayingAudio: true });
audioCtx.decodeAudioData(buffer, (decoded) => {
const source = audioCtx.createBufferSource();
source.buffer = decoded;
source.connect(audioCtx.destination);
source.onended = () => {
_totalTurnAudioPlayed += decoded.duration;
set((s) => ({ audioQueue: s.audioQueue.slice(1), isPlayingAudio: false }));
get().processAudioQueue();
};
source.start();
});
};Problem. AI speech duration is a metric that affects scoring and analytics. The Go gateway forwards audio bytes; the Python engine generates the text. Neither knows precisely when audio actually played in the user's browser, because network latency and TTS round-trip times are non-deterministic.
Solution. The browser measures everything. The frontend captures audio.onplay() and audio.onended() timestamps and includes them in the END_TURN event sent to the gateway. The gateway forwards the timing untouched. The AI engine persists exactly what the frontend reported.
Rationale. The browser is the only system that knows the moment audio hardware actually produced sound. Measuring anywhere else introduces network and clock-skew error.
Problem. A naive Next.js app ships its JavaScript bundle first and authenticates inside React after hydration. This means unauthenticated users have already downloaded the protected client code.
Solution. middleware.ts runs on the Edge Network. Supabase JWT cookies are validated against the auth project before any bundle is served. Unauthenticated requesters to protected routes are redirected to /auth/login at the edge.
Implementation. middleware.ts uses @supabase/ssr (not @supabase/supabase-js) to handle cookies safely in the Edge runtime.
Problem. Streaming a debate speech token-by-token means hundreds of DOM mutations per second. CSS height transitions snap. auto-height containers produce flickering scroll. Naive autoscroll fights user scroll position.
Solution. Each transcript entry is a Framer Motion component. Layout shifts are interpolated by spring physics (stiffness: 200, damping: 20) so growth animates smoothly. The transcript scroll container uses controlled programmatic scroll only when the user is already at the bottom — manual scrollback is respected.
Problem. Zustand expects serializable state. AudioContext and AudioBufferSourceNode are mutable, non-serializable browser singletons. Reducing them into Zustand state produces stale closures and broken onended callbacks.
Solution. Audio primitives live at module scope outside Zustand. The store exposes a small typed API (processAudioQueue, stopAllAudio, pauseAudio, resumeAudio, skipAiSpeech, getAudioProgress) that mutates these singletons under controlled conditions. The store still holds the queue itself, which is serializable, for the UI to observe.
Problem. When does an AI turn actually end? When tokens stop arriving? When audio finishes playing? When the engine sends a completion event? Each on its own is racy.
Solution. All four conditions must be true simultaneously:
currentSpeaker === "ai"
&& aiThoughtComplete === true
&& pendingAudioBlobs === 0
&& audioQueue.length === 0
&& !isPlayingAudio
When the predicate holds, the client sends END_TURN with measured timing. This is the only place in the codebase that ends an AI turn — eliminating duplicate END_TURN emissions and stale-event races.
The arena is governed by a single Zustand store at src/store/arenaStore.ts.
interface ArenaState {
// Connection
socket: WebSocket | null;
connected: boolean;
matchId: string | null;
// Speaker tracking
currentSpeaker: "ai" | "human" | null;
currentSpeakerRole: string | null;
// Streaming buffers (committed to transcript on TURN_STARTED)
aiBufferedText: string;
humanBufferedText: string;
aiThoughtComplete: boolean;
transcript: TranscriptEntry[];
// Audio queue
audioQueue: ArrayBuffer[];
isPlayingAudio: boolean;
isAudioPaused: boolean;
audioProgress: number; // 0..1 progress within current chunk
audioChunkDuration: number;
pendingAudioBlobs: number; // FileReader operations in flight
// Match lifecycle
isMatchComplete: boolean;
verdict: ArenaEvent | null;
adjudicationComplete: boolean;
adjudicationMessage: string | null;
// Timing (forwarded to gateway in END_TURN)
aiSpeechStartTime: number | null;
humanTurnStartTime: number | null;
}| Inbound event | Action |
|---|---|
AI_TOKEN |
Append text to aiBufferedText |
HUMAN_TRANSCRIPT_CHUNK |
Append text to humanBufferedText |
HUMAN_TRANSCRIPT |
Flush humanBufferedText to transcript |
TURN_STARTED |
Commit both buffers to transcript, set new speaker, reset audio counters |
AI_THOUGHT_COMPLETE |
Set aiThoughtComplete = true, evaluate checkAiTurnComplete() |
MATCH_COMPLETE |
Set isMatchComplete, capture verdict |
ADJUDICATION_STARTED |
Update adjudication message |
ADJUDICATION_COMPLETE |
Set adjudicationComplete, trigger redirect |
Blob (binary) |
FileReader → push ArrayBuffer to queue → processAudioQueue() |
sequenceDiagram
participant Mic as Microphone
participant MR as MediaRecorder
participant WS as WebSocket
participant GW as Go Gateway
participant DG as Deepgram STT
Note over MR: getUserMedia({ audio: true })
Note over MR: new MediaRecorder(stream, { mimeType: "audio/webm" })
Note over MR: recorder.start(250)
loop Every 250 ms
Mic->>MR: audio buffer
MR->>WS: ondataavailable, send(blob)
WS->>GW: BinaryMessage
GW->>DG: forward stream
DG-->>GW: transcript + confidence
GW->>WS: HUMAN_TRANSCRIPT_CHUNK
WS->>MR: render in UI
end
Note over MR: User taps End Turn
MR->>WS: { action: STOP_MIC }
MR->>WS: { action: END_TURN, human_speech_*_utc }
Note over MR: stream.getTracks().forEach(t => t.stop())
sequenceDiagram
participant AI as Python AI Engine
participant R as Redis
participant GW as Go Gateway
participant TTS as Deepgram TTS
participant WS as WebSocket
participant Z as Zustand
participant Spk as Speakers
loop Each generated token
AI->>R: PUBLISH AI_TOKEN word
R->>GW: subscriber notify
GW->>WS: forward AI_TOKEN event
WS->>Z: appendAiToken(word)
Z->>WS: trigger DOM update
end
Note over GW: aiBuffer accumulates until . ? !
GW->>TTS: TextToSpeech(sentence)
TTS-->>GW: PCM 24 kHz binary
GW->>WS: BinaryMessage(audio)
WS->>Z: queue ArrayBuffer
Z->>Z: processAudioQueue()
Z->>Spk: source.start()
AI->>R: PUBLISH AI_THOUGHT_COMPLETE
R->>GW: subscriber notify
GW->>WS: forward
WS->>Z: aiThoughtComplete = true
Note over Z: onended chain ends
Z->>WS: { action: END_TURN, ai_speech_*_utc }
wss://{NEXT_PUBLIC_WS_BASE_URL}/ws/live?match_id={matchId}&token={JWT}
| Direction | Event | Payload |
|---|---|---|
| JSON | START_MATCH |
{ "action": "START_MATCH" } |
| JSON | STOP_MIC |
{ "action": "STOP_MIC" } |
| JSON | END_TURN |
{ "action": "END_TURN", "human_speech_start_time_utc": ..., "human_speech_end_time_utc": ..., "human_speech_duration_ms": ... } |
| JSON | POI_OFFERED |
{ "action": "POI_OFFERED", "text": "..." } |
| Binary | Audio chunk | Blob (250 ms audio/webm) |
| Direction | Event | Payload |
|---|---|---|
| JSON | TURN_STARTED |
{ event, speaker: "ai" | "human", role, side, turn_index } |
| JSON | AI_TOKEN |
{ event, text } |
| JSON | AI_THOUGHT_COMPLETE |
{ event } |
| JSON | HUMAN_TRANSCRIPT_CHUNK |
{ event, text, confidence } |
| JSON | HUMAN_TRANSCRIPT |
{ event, text } |
| JSON | POI_ACCEPTED / POI_DECLINED |
{ event } |
| JSON | MATCH_COMPLETE |
{ event, match_id, message } |
| JSON | ADJUDICATION_STARTED |
{ event } |
| JSON | ADJUDICATION_COMPLETE |
{ event, verdict, gov_total_score, opp_total_score, ... } |
| JSON | ADJUDICATION_ERROR / AI_ERROR |
{ event, error_message } |
| Binary | TTS audio | PCM 24 kHz linear16 |
Supabase Auth with three sign-in methods: email and password, Google OAuth, and GitHub OAuth.
middleware.ts runs on every request matching the protected matcher. It uses @supabase/ssr (the cookie-safe SSR client) to validate the session.
Protected routes: /dashboard, /debate/*, /results/*, /history, /profile
Reverse-protected: /auth/login, /auth/signup (signed-in users are redirected away)
Browser
│
├── Supabase Auth ── JWT
│
├── Auth cookie via @supabase/ssr
│
├── REST: Authorization: Bearer {JWT}
│
└── WS: ?token={JWT}
agora-frontend/
├── assets/ README screenshots
├── public/ Static assets
├── middleware.ts Edge auth gate (Supabase SSR)
├── next.config.ts
├── components.json shadcn/ui config
│
├── src/
│ ├── app/ Next.js App Router
│ │ ├── layout.tsx Root layout, AuthProvider, Toaster
│ │ ├── page.tsx Landing
│ │ ├── auth/
│ │ │ ├── login/page.tsx
│ │ │ ├── signup/page.tsx
│ │ │ └── callback/route.ts OAuth exchange
│ │ ├── dashboard/page.tsx Server component, stats and matches
│ │ ├── debate/
│ │ │ ├── setup/page.tsx Four-step config form
│ │ │ └── [matchId]/
│ │ │ ├── page.tsx Live Arena (client)
│ │ │ └── prep/page.tsx Case-prep review
│ │ ├── results/[matchId]/page.tsx
│ │ ├── history/page.tsx
│ │ └── profile/page.tsx
│ │
│ ├── store/
│ │ └── arenaStore.ts WebSocket + audio queue state
│ │
│ ├── lib/
│ │ ├── api.ts REST client + role enums
│ │ ├── supabase/
│ │ │ ├── client.ts createBrowserClient
│ │ │ └── server.ts createServerClient (cookies)
│ │ └── utils.ts cn() helpers
│ │
│ ├── components/
│ │ ├── providers/
│ │ │ └── AuthProvider.tsx Session context, onAuthStateChange
│ │ └── ui/ shadcn/ui primitives
│ │
│ ├── hooks/ Custom hooks
│ └── types/ Shared TypeScript types
│
├── package.json
├── tsconfig.json
├── eslint.config.mjs
└── postcss.config.mjs
| Technology | Version | Purpose |
|---|---|---|
| Next.js | 16.2.3 | App Router, Server Components, Edge Middleware |
| React | 19.2.4 | Concurrent rendering |
| TypeScript | 5 | Strict typing through socket events and API responses |
| Technology | Purpose |
|---|---|
| Zustand 5 | Out-of-React state for socket and audio |
| @supabase/ssr | Cookie-safe SSR auth client |
| @supabase/supabase-js | Browser client for OAuth and storage |
| Technology | Purpose |
|---|---|
| Tailwind CSS 4 | Utility-first styling |
| shadcn/ui | Radix-based, copy-in component primitives |
| Framer Motion 12 | Spring-modeled layout transitions |
| Lucide React | Iconography |
| Sonner | Toast notifications |
| next-themes | Dark-mode handling |
| API | Purpose |
|---|---|
| MediaRecorder | 250 ms audio/webm chunked microphone capture |
| AudioContext | decodeAudioData + BufferSource playback chain |
| WebSocket | Bidirectional binary and JSON channel to gateway |
- Node.js 18 or later (LTS recommended)
- npm, yarn, or pnpm
- A running Go gateway on
localhost:8080 - A running Python AI engine on
localhost:8000 - A Supabase project (URL and anon key)
git clone <repository-url>
cd agora-frontend
npm installcp .env.example .env.local
# Edit .env.local with your credentialsnpm run dev
# Open http://localhost:3000Create .env.local from .env.example:
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOi...
# Backend
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
NEXT_PUBLIC_WS_BASE_URL=ws://localhost:8080
# Public site URL (used for OAuth redirect)
NEXT_PUBLIC_SITE_URL=http://localhost:3000All NEXT_PUBLIC_* variables are exposed to the browser. Do not place service-role keys here.
| Command | Effect |
|---|---|
npm run dev |
Start Next.js dev server with HMR |
npm run build |
Production build with type-check |
npm start |
Run the production server |
npm run lint |
ESLint via eslint-config-next |
- Default to server components. Switch to
"use client"only when browser APIs or event handlers require it. - All WebSocket events pass through the discriminated union
ArenaEventinarenaStore.ts. - Compose Tailwind classes through
cn()fromlib/utils.ts(clsx+tailwind-merge). - Prefer Framer Motion components over CSS transitions for any layout that animates during streaming.
- Extend the
EventTypeunion insrc/store/arenaStore.ts. - Add the optional field to
ArenaEvent. - Add a case in the
socket.onmessagehandler. - Wire the UI consumer via
useArenaStore(state => state.<field>).
The application is a standard Next.js project and runs anywhere Next.js runs. The recommended target is Vercel, since middleware.ts is designed to execute on the Edge runtime.
The frontend is live at https://agora-frontend-alpha.vercel.app. To deploy your own frontend instance:
- Push your repository to GitHub.
- Import the project in Vercel.
- Configure target Environment Variables in Project Settings → Environment Variables:
NEXT_PUBLIC_SUPABASE_URL: Your Supabase project URL.NEXT_PUBLIC_SUPABASE_ANON_KEY: Your Supabase anonymous key.NEXT_PUBLIC_API_BASE_URL: The secure production API backend (https://16.171.42.39.nip.io).NEXT_PUBLIC_WS_BASE_URL: The secure production WebSocket backend (wss://16.171.42.39.nip.io).NEXT_PUBLIC_SITE_URL: Your Vercel frontend domain (https://your-app.vercel.app).
- Ensure the backend domain uses HTTPS. Browsers enforce strict security constraints on raw microphone capture (
getUserMedia) and Web Audio APIs, blocking them on insecure HTTP origins (exceptlocalhost).
Important
Mixed Content Blocks Resolved:
In early deployments, the frontend experienced Failed to generate motion errors due to "Mixed Content" security rules. Because the frontend was served over secure HTTPS on Vercel, modern browsers strictly blocked API requests and WebSocket upgrades to the EC2 backend's unencrypted IP address (http://16.171.42.39:8080).
We resolved this production blocker by:
- Setting up a dynamic DNS hostname utilizing the
nip.iowildcard mapping to map16.171.42.39to16.171.42.39.nip.io. - Placing an Nginx proxy on the EC2 machine listening on Ports 80 and 443.
- Running Certbot to generate and auto-renew a valid, browser-trusted Let's Encrypt SSL Certificate for the hostname.
- Pointing Vercel's environment variables to the new secure
https://andwss://dynamic domain gateways.
npm run build
npm startIf the application is fronted by a reverse proxy, ensure the proxy forwards the standard Next.js routes — including the middleware-protected routes and the OAuth callback at /auth/callback. No special WebSocket handling is required at the frontend tier; the WebSocket connection terminates at the Go gateway, not at this service.
| Capability | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
MediaRecorder (audio/webm) |
Yes | Yes | iOS lacks WebM container; an MP4 shim is required for production | Yes |
| AudioContext | Yes | Yes | Requires a user gesture before resume | Yes |
| WebSocket | Yes | Yes | Yes | Yes |
| Supabase OAuth cookies | Yes | Yes | Yes | Yes |
Safari autoplay policy keeps the AudioContext suspended until a user gesture. The Arena's microphone button is the trusted gesture that unlocks playback.
Microphone permission is denied
Browsers block getUserMedia over plain HTTP except on localhost. Develop on localhost:3000 and deploy on HTTPS.
AI tokens stream but no audio plays
The AudioContext is suspended (Safari autoplay policy). Click the microphone or any user-gesture button — the next processAudioQueue() call will resume the context.
Session is dropped on localhost reload
Supabase cookies are flagged SameSite=Lax and Secure in some configurations. Local HTTP browsers may discard them on hard reload. Deploy to HTTPS to resolve.
WebSocket connects then closes immediately
- Confirm the Go gateway is reachable at
NEXT_PUBLIC_WS_BASE_URL. - Verify the JWT in the query string is well-formed.
- Open DevTools, Network, WS frame: inspect the close code (1006 indicates no auth, 1011 indicates a backend crash).
Audio chunks play out of order or overlap
The recursive onended chain serializes playback. If overlap occurs, the queue was mutated outside Zustand. Audit any component that touches audioQueue directly and confirm it routes through processAudioQueue().
main production
develop integration
feature/<name> features
bugfix/<id> bug fixes
feat: add waveform visualizer
fix: resolve audio overlap on rapid POI
docs: update WebSocket protocol table
refactor: extract audio queue into module-scope
npm run lintpassesnpm run buildsucceeds- No
console.logstatements remain - Screenshots attached for UI changes
- agora-frontend-architecture.md — detailed design analysis
agora-gateway— sibling Go socket brokeragora-ai-engine— sibling Python AI engine- Next.js App Router
- Zustand patterns
- Supabase SSR
Built with ⚡ by the Agora team













