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
- Voice caching — Load once on mount, reuse
- Streaming — Always use
?stream=truefor real-time - History — Auto-saves to localStorage
- Batch — Process sequentially to avoid rate limits
- Waveform — Call
wavesurfer.destroy()on unmount