Self-hosted ElevenLabs TTS playground. SvelteKit 5 + Skeleton UI + real-time audio streaming.


🎯 Architecture Overview

eleven3/
├── src/
│   ├── lib/
│   │   ├── components/      # UI components
│   │   │   ├── VoiceSelect.svelte
│   │   │   ├── Waveform.svelte      # WaveSurfer.js
│   │   │   ├── TextInput.svelte
│   │   │   ├── SettingsSliders.svelte
│   │   │   ├── DialogueMode.svelte
│   │   │   ├── SoundEffects.svelte
│   │   │   ├── VoiceDesigner.svelte
│   │   │   ├── BatchProcessor.svelte
│   │   │   ├── VoiceLibrary.svelte
│   │   │   └── History.svelte
│   │   ├── stores/
│   │   │   └── eleven.svelte.ts     # All state
│   │   └── utils/
│   │       └── types.ts
│   ├── routes/
│   │   ├── +page.svelte             # Main UI
│   │   └── api/
│   │       ├── voices/+server.ts
│   │       └── elevenlabs/
│   │           ├── tts/+server.ts
│   │           ├── dialogue/+server.ts
│   │           ├── sfx/+server.ts
│   │           └── voice-design/+server.ts
│   └── hooks.server.ts              # Auth

🔄 State Management

Core Stores (eleven.svelte.ts)

// Voice state
export const voiceState = $state({
  voices: [] as Voice[],
  selectedVoice: null as Voice | null,
  loading: false,
  error: null as string | null,
})
 
// TTS settings
export const ttsSettings = $state({
  text: "",
  modelId: "eleven_v3",
  stability: 0.5,
  similarityBoost: 0.75,
  speed: 1.0,
  outputFormat: "mp3_44100_128",
})
 
// Player state
export const playerState = $state({
  isPlaying: false,
  isStreaming: false,
  audioUrl: null as string | null,
  progress: 0,
  duration: 0,
  error: null as string | null,
  currentVoiceName: null as string | null,
  currentType: "tts" as "tts" | "sfx" | "dialogue",
})
 
// Dialogue mode
export const dialogueState = $state({
  enabled: false,
  speakers: [] as DialogueSpeaker[],
  script: "",
})
 
// Batch processing
export const batchState = $state({
  items: [] as BatchItem[],
  processing: false,
  currentIndex: 0,
  results: [] as BatchResult[],
})
 
// History
export const historyState = $state({
  items: [] as HistoryItem[],
  maxItems: 50,
})

Actions

// Add to history
export function addToHistory(item: Omit<HistoryItem, "id" | "timestamp">) {
  const newItem: HistoryItem = {
    ...item,
    id: generateId(),
    timestamp: Date.now(),
  }
  historyState.items = [newItem, ...historyState.items].slice(0, historyState.maxItems)
  saveHistoryToStorage()
}
 
// Dialogue speakers
export function addSpeaker(voice: Voice) {
  dialogueState.speakers = [
    ...dialogueState.speakers,
    {
      id: generateId(),
      voice,
      name: voice.name,
    },
  ]
}
 
export function removeSpeaker(id: string) {
  dialogueState.speakers = dialogueState.speakers.filter((s) => s.id !== id)
}

🔊 Audio Generation Flow

TTS Generation

<!-- Component -->
<script>
  async function generateTTS() {
    if (!voiceState.selectedVoice || !ttsSettings.text.trim()) return
 
    generating = true
    playerState.isStreaming = true
 
    try {
      const res = await fetch('/api/elevenlabs/tts?stream=true', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          voice_id: voiceState.selectedVoice.voice_id,
          text: ttsSettings.text,
          model_id: ttsSettings.modelId,
          voice_settings: {
            stability: ttsSettings.stability,
            similarity_boost: ttsSettings.similarityBoost
          }
        })
      })
 
      if (!res.ok) throw new Error('Generation failed')
 
      const blob = await res.blob()
      const url = URL.createObjectURL(blob)
 
      // Update player
      playerState.audioUrl = url
      playerState.currentVoiceName = voiceState.selectedVoice.name
 
      // Save to history
      addToHistory({
        text: ttsSettings.text,
        voiceId: voiceState.selectedVoice.voice_id,
        voiceName: voiceState.selectedVoice.name,
        audioUrl: url,
        settings: { ... }
      })
    } finally {
      generating = false
      playerState.isStreaming = false
    }
  }
</script>

API Proxy

// src/routes/api/elevenlabs/tts/+server.ts
import { ELEVENLABS_API_KEY } from "$env/static/private"
 
export const POST: RequestHandler = async ({ request, url }) => {
  const body = await request.json()
  const stream = url.searchParams.get("stream") === "true"
 
  const apiUrl = new URL("https://api.elevenlabs.io/v1/text-to-speech")
  apiUrl.pathname += `/${body.voice_id}/stream`
 
  const response = await fetch(apiUrl.toString(), {
    method: "POST",
    headers: {
      "xi-api-key": ELEVENLABS_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      text: body.text,
      model_id: body.model_id,
      voice_settings: body.voice_settings,
    }),
  })
 
  return new Response(response.body, {
    headers: { "Content-Type": "audio/mpeg" },
  })
}

🎵 Waveform Player (WaveSurfer.js)

<script>
  import WaveSurfer from 'wavesurfer.js'
 
  let container: HTMLDivElement
  let wavesurfer: WaveSurfer | null = null
 
  onMount(() => {
    wavesurfer = WaveSurfer.create({
      container,
      waveColor: 'rgb(139, 92, 246)',
      progressColor: 'rgb(168, 85, 247)',
      cursorColor: 'rgb(236, 72, 153)',
      barWidth: 3,
      barGap: 2,
      height: 80,
      normalize: true
    })
 
    wavesurfer.on('play', () => playerState.isPlaying = true)
    wavesurfer.on('pause', () => playerState.isPlaying = false)
    wavesurfer.on('ready', () => {
      playerState.duration = wavesurfer?.getDuration() || 0
    })
 
    return () => wavesurfer?.destroy()
  })
 
  // Load audio when URL changes
  $effect(() => {
    if (wavesurfer && playerState.audioUrl) {
      wavesurfer.load(playerState.audioUrl)
    }
  })
</script>
 
<div bind:this={container} class="rounded-xl bg-surface-100 p-3" />

🎭 Dialogue Mode

Script Parsing

function parseScript(): { voice_id: string; text: string }[] {
  const lines = dialogueState.script.split("\n").filter((l) => l.trim())
  const dialogue: { voice_id: string; text: string }[] = []
 
  for (const line of lines) {
    const match = line.match(/^(.+?):\s*(.+)$/)
    if (match) {
      const [, speakerName, text] = match
      const speaker = dialogueState.speakers.find(
        (s) => s.name.toLowerCase() === speakerName.trim().toLowerCase(),
      )
      if (speaker) {
        dialogue.push({
          voice_id: speaker.voice.voice_id,
          text: text.trim(),
        })
      }
    }
  }
 
  return dialogue
}

Format

Alice: Hello, how are you?
Bob: [excited] I'm doing great!
Alice: [laughs] That's wonderful!

🏷️ Audio Tags

export const audioTags = [
  { tag: "[laughs]", description: "Natural laughter" },
  { tag: "[whispers]", description: "Soft whisper" },
  { tag: "[sighs]", description: "Emotional sigh" },
  { tag: "[sarcastic]", description: "Sarcastic tone" },
  { tag: "[excited]", description: "Excited delivery" },
  { tag: "[sad]", description: "Sad emotion" },
  { tag: "[angry]", description: "Angry tone" },
  { tag: "[slowly]", description: "Slower pace" },
  { tag: "[quickly]", description: "Faster pace" },
  { tag: "[singing]", description: "Singing voice" },
]

🔐 Simple Auth

// src/hooks.server.ts
const PASSWORD = "eleventhree"
 
export const handle: Handle = async ({ event, resolve }) => {
  const isAuthRoute = event.url.pathname === "/auth"
  const authCookie = event.cookies.get("eleven3_auth")
 
  // Handle login
  if (isAuthRoute && event.request.method === "POST") {
    const form = await event.request.formData()
    if (form.get("password") === PASSWORD) {
      return new Response(null, {
        status: 303,
        headers: {
          Location: "/",
          "Set-Cookie": `eleven3_auth=valid; Path=/; HttpOnly; Max-Age=${60 * 60 * 24 * 30}`,
        },
      })
    }
  }
 
  // Protect routes
  if (!isAuthRoute && authCookie !== "valid") {
    return new Response(null, {
      status: 303,
      headers: { Location: "/auth" },
    })
  }
 
  return resolve(event)
}

🎨 UI Patterns

Tab Structure

<Tabs value={activeTab} onValueChange={(e) => activeTab = e.value}>
  <Tabs.List class="bg-surface-100/50 rounded-xl p-1.5">
    <Tabs.Trigger value="tts">
      <MicIcon class="size-4" />
      <span class="hidden sm:inline">Speech</span>
    </Tabs.Trigger>
    <!-- More tabs... -->
  </Tabs.List>
 
  <Tabs.Content value="tts">
    <div class="card preset-filled p-5">
      <VoiceSelect />
      <TextInput onGenerate={generateTTS} />
    </div>
  </Tabs.Content>
</Tabs>

Card Pattern

<div class="card preset-filled-surface-100-900 p-5 space-y-4">
  <header class="flex items-center gap-2">
    <Icon class="text-primary-500" />
    <h3 class="font-semibold">Title</h3>
  </header>
  <!-- Content -->
</div>

📦 Types

// src/lib/utils/types.ts
export interface Voice {
  voice_id: string
  name: string
  category?: string
  description?: string
  labels?: Record<string, string>
  preview_url?: string
}
 
export interface DialogueSpeaker {
  id: string
  voice: Voice
  name: string
}
 
export interface HistoryItem {
  id: string
  timestamp: number
  text: string
  voiceId: string
  voiceName: string
  modelId: string
  audioUrl?: string
  settings: {
    stability: number
    similarityBoost: number
    speed: number
  }
  isDialogue?: boolean
}
 
export const MODELS = [
  { id: "eleven_v3", name: "Eleven v3 (Alpha)", description: "Latest, most expressive" },
  { id: "eleven_multilingual_v2", name: "Multilingual v2", description: "29 languages" },
  { id: "eleven_turbo_v2_5", name: "Turbo v2.5", description: "Fast, low latency" },
]
 
export const OUTPUT_FORMATS = [
  { value: "mp3_44100_128", label: "MP3 44.1kHz 128kbps" },
  { value: "mp3_44100_192", label: "MP3 44.1kHz 192kbps" },
  { value: "pcm_24000", label: "PCM 24kHz" },
]

🚀 Commands

# Install
cd /home/dv/dev/eleven3
npm install
 
# Dev server
npm run dev          # http://localhost:5173
 
# Build
npm run build
 
# Preview production
npm run preview
 
# Type check
npm run check

🔑 Environment

# .env
ELEVENLABS_API_KEY=your_api_key_here

💡 Tips

  1. Voice caching — Load once on mount, reuse
  2. Streaming — Always use ?stream=true for real-time
  3. History — Auto-saves to localStorage
  4. Batch — Process sequentially to avoid rate limits
  5. Waveform — Call wavesurfer.destroy() on unmount