Akool Demo Architecture — Synthesis & Learnings

🆔 t_762c0294 📅 Completed May 25, 2026 📊 11 Sections | 25KB | 2 Repos Analyzed 🔗 Session: 20260525_092845_854f1e

Akool Demo Architecture — Synthesis & Learnings

For building a generic avatar platform that can swap faces and personas.


1. Executive Summary

Akool provides two public React demos that together reveal their full platform architecture:

Streaming Avatar Talking Avatar
Repo akool-streaming-avatar-react-demo akool-talking-avatar-demo
Interaction Real-time, bidirectional Batch, one-shot
Audio in Browser mic (getUserMedia) Script text → TTS API
Audio out WebRTC stream from Akool REST-generated video
Protocol WebRTC (Agora/LiveKit/TRTC) REST (axios) + Webhook
API version v4 v3
Backend None (client-only) Flask webhook (127 lines)
State Zustand + Context + EventBus 25+ useState hooks in one file
Source files 100+ (well-structured) ~6 (monolith App.tsx)

The Streaming Avatar demo is the architectural blueprint. The Talking Avatar is a thin reference client. Neither supports dynamic face/persona swapping, but the Streaming demo's provider abstraction layer is directly adaptable to that goal.


2. API Integration Patterns

2.1 Two-Phase Connection Model

Akool uses a universal pattern across both demos:

Phase 1: REST (configuration)
  → create session/get token/list avatars/list voices
  → Bearer or x-api-key auth
  → Returns RTC credentials

Phase 2: WebRTC (real-time)
  → Join RTC channel with credentials from Phase 1
  → All media flows peer-to-peer via the RTC provider
  → Text messages flow over the data channel

2.2 Streaming Avatar API (v4) — src/apiService.ts

A typed ApiService class (182 lines) wrapping fetch:

POST /api/open/v4/liveAvatar/session/create   → Session { _id, credentials }
POST /api/open/v4/liveAvatar/session/close    → void
GET  /api/open/v3/language/list               → Language[]
GET  /api/open/v4/knowledge/list              → Knowledge[]
GET  /api/open/v4/voice/list?type=1|2         → Voice[]
GET  /api/open/v4/liveAvatar/avatar/list      → Avatar[]

Auth: Dual-mode — Bearer {token} or x-api-key: {key}, selected at construction time. All responses follow { code: 1000, msg: "ok", data: ... }. Non-1000 codes throw.

Session create is the critical call. It takes avatar_id, stream_type (agora/livekit/trtc), voice_id, knowledge_id, mode_type, language, duration, and returns provider-specific RTC credentials (agora_app_id, agora_token, agora_channel, agora_uid — or LiveKit/TRTC equivalents).

2.3 Talking Avatar API (v3) — Raw axios

POST /api/open/v3/getToken                    → { token }
GET  /api/open/v3/avatar/list                 → Avatar[]
GET  /api/open/v3/voice/list                  → Voice[]
POST /api/open/v3/tts/audio/create            → audio data (TTS)
POST /api/open/v3/talkingavatar/create        → async video generation
POST /api/open/v3/face-swap/create            → face swap result
POST /api/open/v3/liveAvatar/avatar/create    → create custom avatar
GET  /api/open/v3/liveAvatar/avatar/detail    → avatar status/details

Critical insight: The talking avatar demo has face-swap (/v3/face-swap/create) and avatar-creation (/v3/liveAvatar/avatar/create) endpoints that the streaming demo does NOT expose. These are the endpoints needed for dynamic persona creation.

2.4 Webhook Pattern (Talking Avatar)

The Flask webhook (talking_avatar_webhook/app.py, 127 lines) handles async callbacks:

  1. Akool POSTs encrypted payload to /api/webhook
  2. Flask decrypts with AES-CBC (client_secret as key, client_id as IV)
  3. Parses JSON: { type: "avatar", status: 3, url: "..." }
  4. Forwards via Socket.IO to the browser
  5. Status 3 = complete, status 1-2 = processing

For our platform: This pattern is essential for any async operation (face generation, avatar training). We'd replace the Flask/Socket.IO relay with a more robust solution (server-side WebSocket or SSE), but the AES-CBC decryption logic is reusable as-is.

2.5 What We'd Change

Akool pattern Our adaptation
Direct browser → Akool REST Proxy through our backend for credential safety
Webhook → Flask → Socket.IO Webhook → our backend → WebSocket/SSE to client
Fixed vendor endpoints Abstract into provider interface (same pattern they use for RTC!)
Token in localStorage/Zustand Server-side session, never expose to client

3. Voice Input Flow

3.1 Streaming Avatar — Full Pipeline

[Browser mic]
     │
     ▼
getUserMedia({ audio: true })
     │
     ▼
AgoraRTC.createMicrophoneAudioTrack()
     │
     ▼
AI Denoiser (Agora SDK built-in)
     │
     ▼
client.publish(audioTrack)
     │
     ▼
═══ WebRTC to Agora channel ═══
     │
     ▼
[Akool server receives audio track]
     │
     ├── STT (speech-to-text)
     ├── LLM (knowledge-contextualized response)
     └── TTS (voice-cloned speech)
     │
     ▼
═══ WebRTC back to browser ═══
     │
     ▼
[Browser receives remote audio + video tracks]
     │
     ├── Audio → <audio> element
     └── Video  → <video> element (avatar animation)

Text messages take a parallel path: typed text → JSON v2 protocol ({ type, content, from, ... }) → WebRTC data channel → Akool processes → returns TTS audio + avatar video via WebRTC media tracks.

Key files:
- src/hooks/useProviderAudioControls.ts — mic toggle, noise reduction
- src/hooks/useNoiseReduction.ts — Agora AI denoiser integration
- src/providers/agora/controllers/AgoraAudioController.ts — audio publish/unpublish
- src/providers/common/CommonMessageController.ts — data channel messaging
- src/providers/common/types/message.types.ts — StreamMessage v2 schema

3.2 Talking Avatar — No Mic

No real-time voice input. Text script → TTS REST API (/v3/tts/audio/create) → returns audio file → played in browser. Video is generated server-side via /v3/talkingavatar/create and returned as a URL via webhook.

3.3 What We'd Change

Akool approach Our approach
Agora AI denoiser (vendor-locked) Browser-native noise suppression (RNNoise) or pluggable denoiser
Audio sent raw to Akool's STT We control STT — Whisper, Deepgram, or browser Speech API
Text messages on WebRTC data channel Keep this pattern — works well for real-time text chat
No wake-word / VAD in demo Add VAD for hands-free operation (Scott's preference)
Single voice per session Allow voice switching mid-session (persona swapping)

4. Avatar State Management

4.1 Five-Layer Architecture (Streaming Avatar)

┌─────────────────────────────────────┐
│ LAYER 1: Zustand Store              │  Persistent config (API keys, selected avatar/voice)
│ configurationStore.ts (235 lines)   │  localStorage-backed via zustand/middleware persist
├─────────────────────────────────────┤
│ LAYER 2: React Context              │  Streaming lifecycle state
│ StreamingContext.tsx (374 lines)    │  isJoined, isConnecting, isSpeaking, error, messages
├─────────────────────────────────────┤
│ LAYER 3: Custom Hooks               │  Orchestration logic
│ useStreamingSession.ts (381 lines)  │  Session creation → connect → setAvatarParams
│ useProviderAudioControls.ts         │  Mic/camera lifecycle
│ useProviderVideoCamera.ts           │  Camera capture
├─────────────────────────────────────┤
│ LAYER 4: ProviderManager            │  Singleton — switch/connect/disconnect providers
│ ProviderManager.ts (112 lines)     │  Bridges L3 ↔ L5 via EventBus
│ StreamingProviderFactory.ts         │  Creates typed providers from credentials
├─────────────────────────────────────┤
│ LAYER 5: Concrete Providers         │  Agora / LiveKit / TRTC
│ BaseStreamingProvider.ts (154 lines)│  Abstract class — state, EventBus, resource cleanup
│ AgoraStreamingProvider.ts           │  Agora SDK integration (6 controllers)
│ LiveKitStreamingProvider.ts         │  LiveKit SDK integration
│ TRTCStreamingProvider.ts            │  TRTC SDK integration
└─────────────────────────────────────┘

StreamingState (the core state object):

{
  isJoined: boolean,           // Connected to RTC channel?
  isConnecting: boolean,       // In progress?
  isSpeaking: boolean,         // Avatar currently speaking?
  participants: Participant[], // Who's in the channel?
  localParticipant: Participant | null,
  networkQuality: ConnectionQuality | null,
  error: Error | null,
}

4.2 EventBus (Pub/Sub)

src/core/EventBus.ts (117 lines) — a typed pub/sub system:

// Typed event map (30+ events)
StreamingEventMap = {
  'connection:connected' | 'connection:disconnected' | 'connection:failed'
  'participant:joined'  | 'participant:left' | 'participant:speaking-changed'
  'media:video-published' | 'media:audio-published'
  'message:received' | 'message:sent'
  'avatar:response-interrupted'
  'system:error' | 'system:warning' | 'system:info'
}

Each provider publishes events; the ProviderManager and StreamingContext subscribe. Events bubble up: Provider → EventBus → ProviderManager → StreamingContext → React state → UI re-render.

4.3 Callback Multiplexer Pattern

The StreamingContext uses useRef<Set<>> for callbacks instead of arrays — prevents re-render storms during registration:

// Instead of: const [callbacks, setCallbacks] = useState([])
const messageCallbacks = useRef<Set<(msg: ChatMessage) => void>>(new Set());
// Adding a listener does NOT trigger re-render

4.4 Talking Avatar — Flat useState

25+ useState calls in a single 889-line App.tsx. No stores, no context, no event bus. Appropriate for a demo but not scalable.

4.5 What We'd Add

Neither demo has a formal state machine for avatar visual states. We should add:

IDLE → LISTENING → PROCESSING → SPEAKING → IDLE
  │        │           │           │
  └────────┴───────────┴───────────┴── ERROR → IDLE

Each state maps to visual behavior:
- IDLE: Avatar breathing animation, slight movement
- LISTENING: Avatar leaning forward, "listening" indicator
- PROCESSING: Avatar thinking animation
- SPEAKING: Lip-sync animation driven by audio
- ERROR: Fallback expression + retry UI

For persona swapping, add: currentPersona to StreamingState, with persona-specific voice, appearance, knowledge base, and personality prompt.


5. Provider Abstraction — The Crown Jewel

5.1 Architecture

This is the most valuable pattern in the entire codebase:

              ┌──────────────────┐
              │  ProviderManager  │  Singleton — lifecycle orchestration
              │  (singleton)      │  switchProvider(type, creds, handlers)
              └────────┬─────────┘
                       │
              ┌────────▼─────────┐
              │ ProviderFactory   │  Registry pattern
              │ (singleton)       │  createProviderWithCredentials(type, creds)
              └────────┬─────────┘
                       │
         ┌─────────────┼─────────────┐
         ▼             ▼             ▼
    ┌─────────┐  ┌──────────┐  ┌──────────┐
    │ Agora   │  │ LiveKit  │  │  TRTC    │
    │Provider │  │ Provider │  │ Provider │
    └────┬────┘  └────┬─────┘  └────┬─────┘
         │            │             │
    ┌────▼────┐  ┌────▼─────┐  ┌────▼─────┐
    │6 Ctrls  │  │6 Ctrls   │  │6 Ctrls   │
    │3 Strat. │  │3 Strat.  │  │3 Strat.  │
    └─────────┘  └──────────┘  └──────────┘

5.2 Pattern Decomposition

Each provider is an instance of BaseStreamingProvider (abstract class) with:

Controllers (per-provider implementations):
- ConnectionController — join/leave channel, token management
- AudioController — mic publish/unpublish, noise reduction
- VideoController — camera publish/unpublish, remote video rendering
- EventController — SDK event → EventBus translation
- ParticipantController — join/leave/speaking detection
- StatsController — network quality, bitrate, latency

Strategies (pluggable behaviors):
- AudioStrategy — how audio tracks are created and configured
- VideoStrategy — how video tracks are created and configured
- RemoteVideoStrategy — how remote video is rendered (React component per provider)

Adapters:
- MessageAdapter — translating provider-specific data channel formats to the common StreamMessage v2 protocol

5.3 How It Maps to Our Needs

Akool's provider abstraction swaps RTC backends (Agora ↔ LiveKit ↔ TRTC). We can extend this exact pattern to swap:

Akool's pattern:              Our extension:
───────────────              ──────────────
StreamingProvider             AvatarProvider (interface)
├── AgoraProvider             ├── AkoolProvider
├── LiveKitProvider           ├── HeyGenProvider
└── TRTCProvider              ├── D-IDProvider
                              └── CustomThreeJSProvider (our own renderer)

The same Factory + Manager + Controller architecture works. The StreamingState interface is already generic enough:
- isJoined → isConnected
- isSpeaking → isAnimating
- participants → activePersonas

5.4 ResourceManager

src/core/ResourceManager.ts — registers providers for cleanup on page unload. Simple register(obj, { cleanup }) pattern. Essential for any long-lived WebRTC or WebSocket connection.


6. Key Components (Streaming Avatar)

6.1 Component Tree

App.tsx (181 lines — thin orchestrator)
├── ConfigurationPanel        API keys, avatar/voice selection, provider picker
├── VideoDisplay
│   ├── MainVideoArea
│   │   ├── AvatarContent      Renders remote avatar video stream
│   │   └── RemoteVideo         Provider-specific rendering (strategy pattern)
│   └── VideoOverlay            Speaking indicator, connection status
├── ChatInterface
│   ├── MessageList             Chat history
│   ├── ChatControls            Text input, send, interrupt
│   └── useMessageHandlers      Message routing hook
├── NetworkQuality              Connection quality indicator
├── ModalContainer              JSON editor modals, voice previews
└── NotificationContainer       Error/warning toasts

6.2 Shared UI Components

Well-factored reusable components:
- DraggableOverlay — drag + resize overlay container
- ResizableContainer — resizable panel wrapper
- SpeakingIndicator — animated speaking dot
- IconButton — accessible icon button
- Tooltip — hover tooltip
- PlaceholderDisplay — empty state display

All potentially reusable with minimal modification.


7. What's Missing for Face/Persona Swapping

Neither demo supports what we want, but they expose the building blocks:

Feature Status Where to look
Dynamic face swap ❌ Not in demos /v3/face-swap/create endpoint exists (Talking demo)
Custom avatar creation ❌ Not in demos /v3/liveAvatar/avatar/create endpoint exists
Multi-avatar session ❌ One avatar per session Session create takes single avatar_id
Persona switching mid-session ❌ No support Would need re-create session or server-side support
Voice switching mid-session ❌ Config only at start setAvatarParameters() picks voice at connect time
Knowledge base per persona ⚠️ One KB per session knowledge_id in session options
VAD (voice activity detection) ❌ No Agora has activeSpeaker detection but not used
Wake word ❌ No Not in scope for these demos
Lip-sync ✅ Server-side Akool handles this; we'd need our own
Background replacement ✅ Configurable background_url in session options
4K/quality selection ❌ No videoQuality in config but not wired to SDK

7.1 The Face Swap Endpoint (Hidden Gem)

The Talking Avatar demo includes but barely uses:

POST /api/open/v3/face-swap/create
  { target_image, source_image, token }
→ async result with swapped face image

This combined with /v3/liveAvatar/avatar/create could enable:
1. Upload a face photo → generate custom avatar
2. Create session with that avatar
3. Stream with the uploaded face

This is the pipeline for dynamic persona creation.


8. Architectural Recommendations for Our Platform

8.1 Adopt: Provider Abstraction Pattern

The Streaming Avatar's 5-layer architecture (Zustand → Context → Hooks → ProviderManager → Providers) is the single most valuable pattern. Extend it from "RTC provider" to "Avatar provider":

interface AvatarProvider {
  // Lifecycle
  connect(config: AvatarConfig): Promise<void>;
  disconnect(): Promise<void>;

  // Persona management
  setPersona(persona: Persona): Promise<void>;
  getPersonas(): Persona[];

  // Media
  sendAudio(track: AudioTrack): Promise<void>;
  sendText(message: string): Promise<void>;

  // State
  state: AvatarState;  // { isConnected, isSpeaking, currentPersona, ... }
  subscribe(callback: (state: AvatarState) => void): () => void;
}

Implementations: AkoolAvatarProvider, HeyGenAvatarProvider, LocalThreeJSProvider, etc.

8.2 Adopt: EventBus with Typed Events

Copy EventBus.ts directly. The typed event map pattern prevents runtime errors from misspelled event names and gives full IDE autocomplete. The once() + auto-cleanup pattern is production-grade.

8.3 Adopt: Zustand + Persist for Configuration

configurationStore.ts with zustand/middleware/persist is the right pattern for user preferences (API keys, selected provider, voice/avatar preferences). Add partialize to exclude sensitive data from localStorage.

8.4 Adopt: Callback Multiplexer

useRef<Set<>> for callback registration — avoids re-render storms. Use everywhere you have multiple subscribers to a single event source.

8.5 Replace: Direct API Calls from Browser

Akool's demos put API credentials directly in the browser (Zustand store, env vars). For production, route all API calls through a backend proxy that:
- Holds credentials server-side
- Adds rate limiting / abuse prevention
- Transforms between vendor-specific and our unified API format
- Handles webhook callbacks securely

8.6 Replace: Flask Webhook Relay

The AES-CBC decryption logic is fine, but Flask + Socket.IO is fragile. Use a proper backend (Node/Python with persistent WebSocket or SSE).

8.7 Add: Formal State Machine

Neither demo has one. For persona swapping, we need explicit states with defined transitions. Use XState or a simple reducer:

IDLE → LISTENING → THINKING → SPEAKING
                    ↑            │
                    └────────────┘ (auto-return after speech)
Any state → PERSONA_SWITCHING → IDLE (with new persona)
Any state → ERROR → IDLE (after timeout or user action)

8.8 Add: Persona Data Model

The demos have flat avatar_id + voice_id. We need:

interface Persona {
  id: string;
  name: string;
  avatarConfig: {
    provider: string;        // 'akool' | 'heygen' | 'local'
    avatarId: string;        // Provider-specific ID
    faceImage?: string;      // For dynamic face swap
  };
  voiceConfig: {
    provider: string;
    voiceId: string;
    speed?: number;
    pitch?: number;
  };
  knowledgeBase: {
    id: string;
    systemPrompt: string;    // Personality definition
    context?: string;        // Background knowledge
  };
  visualConfig: {
    background?: string;
    animationStyle?: 'talking' | 'gesturing' | 'idle-only';
  };
}

9. Concrete Reuse Assessment

Artifact Reuse? Effort Notes
EventBus.ts (117 lines) Copy directly None Zero dependencies, typed, production-quality
BaseStreamingProvider.ts (154 lines) Adapt Low Change "streaming" → "avatar", keep state/subscribe/cleanup
ProviderManager.ts (112 lines) Adapt Low Change to manage AvatarProviders instead of StreamingProviders
StreamingProviderFactory.ts Adapt Low Registry pattern — swap provider types
configurationStore.ts (235 lines) Adapt Medium Add persona config, remove streaming-specific fields
StreamingContext.tsx (374 lines) Adapt Medium Replace with AvatarContext, similar shape
useStreamingSession.ts (381 lines) Adapt Medium Replace with useAvatarSession, similar lifecycle
ResourceManager.ts Copy directly None Generic resource cleanup utility
Logger.ts Copy directly None Structured logging wrapper
StreamingState type Adapt Low Add currentPersona, visual states
UI components (shared/) Reuse Low DraggableOverlay, Tooltip, IconButton, SpeakingIndicator
Flask webhook (127 lines) Reference only — AES-CBC decrypt logic useful; architecture replaced
Talking Avatar App.tsx Don't reuse — Monolithic useState — use only as API endpoint reference

10. Source File Index

Streaming Avatar (key files)

src/apiService.ts                    — Typed REST client (Bearer/x-api-key)
src/App.tsx                          — Thin orchestrator (181 lines)
src/core/EventBus.ts                 — Typed pub/sub (copy directly)
src/core/ResourceManager.ts          — Cleanup registry (copy directly)
src/core/Logger.ts                   — Structured logging (copy directly)
src/stores/configurationStore.ts     — Zustand + persist config
src/contexts/StreamingContext.tsx     — React Context for streaming lifecycle
src/hooks/useStreamingSession.ts     — Session orchestration hook
src/hooks/useProviderAudioControls.ts— Mic lifecycle
src/providers/BaseStreamingProvider.ts— Abstract provider (adapt)
src/providers/ProviderManager.ts     — Singleton provider lifecycle
src/providers/StreamingProviderFactory.ts— Provider registry
src/providers/agora/                 — Agora provider (6 controllers, 3 strategies)
src/providers/livekit/               — LiveKit provider
src/providers/trtc/                  — TRTC provider
src/providers/common/                — Shared controllers/strategies/types
src/types/event.types.ts             — Typed event map
src/types/streaming.types.ts         — Core state types
src/types/provider.interfaces.ts     — Provider interface contract

Talking Avatar (key files)

talking-avatar-frontend/src/App.tsx          — 889-line monolith (API reference only)
talking-avatar-frontend/src/components/      — LanguageSelector only
talking_avatar_webhook/app.py                — Flask webhook (127 lines, AES decrypt)

11. Next Steps

  1. Clone the provider abstraction — copy EventBus, ResourceManager, Logger, then adapt BaseStreamingProvider → AvatarProviderBase
  2. Design the Persona data model — schema above, with provider-agnostic interface
  3. Build the backend proxy — wraps Akool/HeyGen/D-ID APIs behind a unified REST + WebSocket interface
  4. Add the state machine — formalize avatar visual states with transitions
  5. Implement VAD + wake word — needed for Scott's hands-free voice-first mode
  6. Prototype dynamic persona swapping — Akool's face-swap + avatar-create endpoints as first target

Generated from analysis of:
- akool-streaming-avatar-react-demo (100+ source files)
- akool-talking-avatar-demo (6 source files + Flask webhook)
- 4 child research tasks (API, architecture, voice flow, state management)