hfstudio / frontend /src /routes /+page.svelte
GitHub Action
Sync from GitHub: 51dbb867483d1a2553fca48afc94b526a765cd1e
dbfc0e6
<script>
import {
Play,
Download,
Loader2,
AlertCircle,
ChevronDown,
Copy,
Share,
MoreHorizontal,
Shuffle,
Pause,
X,
Code,
Layout,
} from 'lucide-svelte';
import { onMount } from 'svelte';
import Prism from 'prismjs';
import 'prismjs/components/prism-python';
import 'prismjs/components/prism-bash';
let text = `In a hole in the ground, there lived a hobbit. Not a nasty, dirty, wet hole, filled with the ends of worms and an oozy smell, nor yet a dry, bare, sandy hole with nothing in it to sit down on or to eat: it was a hobbit-hole, and that means comfort.`;
let selectedVoice = 'Andrew';
let selectedModel = 'Chatterbox';
let modelDropdownOpen = false;
let voiceDropdownOpen = false;
let isGenerating = false;
let audioUrl = null;
let generationTime = 0;
let exaggeration = 0.25;
let temperature = 0.7;
let isPlaying = false;
let currentTime = 0;
let duration = 0;
let audioTitle = '';
let audioElement = null;
let sampleAudioElement = null;
let playingSampleVoice = null;
let showErrorModal = false;
let errorMessage = '';
let errorDetails = '';
let historyCount = 0;
let userVoices = [];
let isLoadingVoices = false;
let showLoginPrompt = false;
let copyNotification = null;
let mode = 'api';
let settingsExpanded = false;
// Live code variables
let setupCode = '';
let pythonCode = '';
let codeUpdateCounter = 0;
const famousBookOpeners = [
'It was the best of times, it was the worst of times. It was the age of wisdom, it was the age of foolishness.',
'It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife.',
'All happy families are alike; each unhappy family is unhappy in its own way.',
'In a hole in the ground, there lived a hobbit. Not a nasty, dirty, wet hole, filled with the ends of worms and an oozy smell, nor yet a dry, bare, sandy hole with nothing in it to sit down on or to eat: it was a hobbit-hole, and that means comfort.',
];
let currentBookIndex = 0;
const models = [
{ id: 'chatterbox', name: 'Chatterbox', badge: 'recommended' },
{ id: 'kokoro', name: 'Kokoro', badge: 'coming soon', disabled: true },
];
const voices = [
{
id: 'andrew',
name: 'Andrew',
description: 'Older British man who speaks clearly and warmly.',
sample: '/voices/andrew.mp3',
preview_url:
'https://huggingface.co/spaces/abidlabs/hfstudio/resolve/main/frontend/static/voices/andrew.mp3',
},
{
id: 'lily',
name: 'Lily',
description: 'Friendly, conversational tone of a woman in her 30s',
sample: '/voices/lily.mp3',
preview_url:
'https://huggingface.co/spaces/abidlabs/hfstudio/resolve/main/frontend/static/voices/lily.mp3',
},
{
id: 'pirate',
name: 'Pirate',
description: 'Young male pirate-y voice that speaks gruffly and with excitement',
sample: '/voices/pirate.mp3',
preview_url:
'https://huggingface.co/spaces/abidlabs/hfstudio/resolve/main/frontend/static/voices/pirate.mp3',
},
{
id: 'fairy',
name: 'Fairy',
description: 'High and airy female voice that bursts with excitement',
sample: '/voices/fairy.mp3',
preview_url:
'https://huggingface.co/spaces/abidlabs/hfstudio/resolve/main/frontend/static/voices/fairy.mp3',
},
];
async function generateSpeech() {
if (!text.trim()) return;
const response = await fetch('/api/auth/user', { credentials: 'include' });
if (!response.ok) {
showLoginPrompt = true;
return;
}
isGenerating = true;
audioUrl = null;
currentTime = 0;
isPlaying = false;
audioTitle = text.length > 30 ? text.substring(0, 30) + '...' : text;
try {
// Get voice URL for ALL voices (both built-in and cloned)
let voiceUrl = null;
if (selectedVoice === 'Yours' && userVoices.length > 0) {
// Cloned voice
const userVoice = userVoices[0];
if (userVoice && userVoice.voice_url) {
voiceUrl = userVoice.voice_url; // Use external URL directly
}
} else {
// Built-in voice
const voice = voices.find((v) => v.name === selectedVoice);
if (voice && voice.preview_url) {
voiceUrl = voice.preview_url;
}
}
const requestBody = {
text: text,
voice_id: selectedVoice.toLowerCase(),
model_id: selectedModel.toLowerCase(),
mode: 'api',
parameters: {
exaggeration: exaggeration,
temperature: temperature,
},
voice_url: voiceUrl, // Always send voice URL
};
const response = await fetch('/api/tts/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error! status: ${response.status}, body: ${errorText}`);
}
const result = await response.json();
if (result.success && result.audio_url) {
audioUrl = result.audio_url;
generationTime = result.generation_time || 0;
// Save to code recorder history
await saveToHistory(requestBody, result);
setTimeout(() => {
if (audioElement) {
audioElement.play().catch(() => {
// Ignore autoplay failures
});
}
}, 100);
} else {
const errorMessage = result.error || 'Unknown error occurred';
showError('Generation Failed', errorMessage);
audioUrl = null;
}
} catch (error) {
showError(
'Network Error',
'Failed to connect to the server. Please check your connection and try again.'
);
audioUrl = null;
} finally {
isGenerating = false;
}
}
function togglePlayPause() {
if (audioElement) {
if (isPlaying) {
audioElement.pause();
} else {
audioElement.play();
}
}
}
function handleAudioLoad() {
if (audioElement) {
duration = audioElement.duration;
}
}
function handleTimeUpdate() {
if (audioElement) {
currentTime = audioElement.currentTime;
}
}
function handlePlay() {
isPlaying = true;
}
function handlePause() {
isPlaying = false;
}
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function downloadAudio() {
if (audioUrl) {
const a = document.createElement('a');
a.href = audioUrl;
a.download = 'speech.wav';
a.click();
}
}
function shareAudio() {}
function playSampleVoice(voice, event) {
event.stopPropagation();
if (playingSampleVoice === voice.name) {
if (sampleAudioElement) {
sampleAudioElement.pause();
sampleAudioElement.currentTime = 0;
}
playingSampleVoice = null;
} else {
if (sampleAudioElement) {
sampleAudioElement.pause();
}
playingSampleVoice = voice.name;
const sampleUrl = voice.sample || '/samples/harvard.wav';
if (!sampleAudioElement) {
sampleAudioElement = new Audio(sampleUrl);
sampleAudioElement.addEventListener('ended', () => {
playingSampleVoice = null;
});
} else {
sampleAudioElement.src = sampleUrl;
}
sampleAudioElement.play().catch((err) => {
playingSampleVoice = null;
});
}
}
function handleKeyDown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
generateSpeech();
}
if (event.key === 'Escape') {
modelDropdownOpen = false;
voiceDropdownOpen = false;
}
}
function handleClickOutside(event) {
if (!event.target.closest('.model-dropdown')) {
modelDropdownOpen = false;
}
if (!event.target.closest('.voice-dropdown')) {
voiceDropdownOpen = false;
}
}
function showError(message, details = '') {
errorMessage = message;
errorDetails = details;
showErrorModal = true;
}
function closeErrorModal() {
showErrorModal = false;
errorMessage = '';
errorDetails = '';
}
function refreshText() {
currentBookIndex = (currentBookIndex + 1) % famousBookOpeners.length;
text = famousBookOpeners[currentBookIndex];
}
async function saveToHistory(requestBody, result) {
try {
// Get the actual voice URL
let voiceUrl = null;
// Check if this is a user voice (cloned voice)
const userVoice = userVoices.find((v) => v.voice_name === selectedVoice);
if (userVoice && userVoice.voice_url) {
voiceUrl = userVoice.voice_url; // Use external URL directly
} else {
// Check built-in voices
const builtInVoice = voices.find((v) => v.name === selectedVoice);
if (builtInVoice && builtInVoice.preview_url) {
voiceUrl = builtInVoice.preview_url;
}
}
const pythonCode = `audio_bytes = client.text_to_speech(
"${requestBody.text.replace(/"/g, '\\"')}",
extra_body={
"exaggeration": ${requestBody.parameters.exaggeration},
"temperature": ${requestBody.parameters.temperature}${
voiceUrl
? `,
"audio_url": "${voiceUrl}"`
: ''
}
}
)`;
await fetch('/api/history/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
code: pythonCode,
result_type: 'audio',
result_data: {
url: result.audio_url,
title: audioTitle,
type: 'audio',
},
entry_type: 'generation',
}),
});
// Update history count
await loadHistoryCount();
} catch (error) {
console.error('Error saving to history:', error);
}
}
async function loadHistoryCount() {
try {
const response = await fetch('/api/history/load', {
method: 'GET',
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
const generationEntries = data.entries.filter((e) => e.entry_type === 'generation');
historyCount = generationEntries.length;
}
} catch (error) {
console.error('Error loading history count:', error);
historyCount = 0;
}
}
async function loadUserVoices() {
try {
isLoadingVoices = true;
const response = await fetch('/api/voice/user-voices', {
method: 'GET',
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
userVoices = data.voices || [];
} else {
userVoices = [];
}
} catch (error) {
console.error('Error loading user voices:', error);
userVoices = [];
} finally {
isLoadingVoices = false;
}
}
function handleAuthAction() {
// Get OAuth config and redirect to HuggingFace OAuth
const clientId = '4831a493-1dbc-4dd4-9bb3-c3b41d2e96ba';
const scopes = 'inference-api manage-repos';
// Store current path to return to after auth
const returnPath = window.location.pathname;
// Determine the correct callback URL based on environment
let redirectUri;
if (window.location.hostname === 'localhost') {
// Development: use backend port for callback
redirectUri = 'http://localhost:7860/auth/callback';
} else {
// Production: use current origin
redirectUri = `${window.location.origin}/auth/callback`;
}
const authUrl = `https://huggingface.co/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scopes)}&response_type=code&state=${encodeURIComponent(returnPath)}`;
window.location.href = authUrl;
}
function generateSetupCode() {
if (mode === 'local') {
return `pip install huggingface-hub hfstudio uv
hfstudio start ${selectedModel.toLowerCase()} --port 7861`;
} else {
return `pip install huggingface-hub`;
}
}
function generateClientInitCode() {
if (mode === 'local') {
const port = 7861;
return `client = InferenceClient(base_url="http://localhost:${port}/api/v1")`;
} else {
const endpointModel =
selectedModel.toLowerCase() === 'chatterbox'
? 'ResembleAI/chatterbox'
: selectedModel.toLowerCase();
return `client = InferenceClient(
api_key="YOUR_HF_TOKEN", # Get your token from https://huggingface.co/settings/tokens
model="${endpointModel}",
)`;
}
}
function generateImportCode() {
const clientCode = generateClientInitCode();
if (mode === 'local') {
return `from huggingface_hub import InferenceClient
${clientCode}`;
} else {
return `from huggingface_hub import InferenceClient
${clientCode}`;
}
}
function generatePythonCode() {
// Get the actual voice URL
let voiceUrl = null;
// Check if this is a user voice (cloned voice)
if (selectedVoice === 'Yours' && userVoices.length > 0) {
const userVoice = userVoices[0]; // Use the first (latest) user voice
if (userVoice && userVoice.voice_url) {
voiceUrl = userVoice.voice_url; // Use external URL directly
}
} else {
// Check built-in voices
const builtInVoice = voices.find((v) => v.name === selectedVoice);
if (builtInVoice && builtInVoice.preview_url) {
voiceUrl = builtInVoice.preview_url;
}
}
const currentText = text || 'Hello, this is a sample text.';
// Generate imports directly here to avoid function call issues
const clientCode = generateClientInitCode();
const imports = `from huggingface_hub import InferenceClient
${clientCode}`;
return `${imports}
audio_bytes = client.text_to_speech(
"${currentText.replace(/"/g, '\\"')}",
extra_body={
"exaggeration": ${exaggeration},
"temperature": ${temperature}${
voiceUrl
? `,
"audio_url": "${voiceUrl}"`
: ''
}
}
)`;
}
function copyToClipboard(textToCopy, message = 'Copied to clipboard!') {
navigator.clipboard.writeText(textToCopy).then(() => {
copyNotification = message;
setTimeout(() => {
copyNotification = null;
}, 2000);
});
}
function deployAsSpace() {
// Get voice URL for the current selection
let voiceUrl = null;
if (selectedVoice === 'Yours' && userVoices.length > 0) {
const userVoice = userVoices[0];
if (userVoice && userVoice.voice_url) {
voiceUrl = userVoice.voice_url; // Use external URL directly
}
} else {
const voice = voices.find((v) => v.name === selectedVoice);
if (voice && voice.preview_url) {
voiceUrl = voice.preview_url;
}
}
// Generate the app.py content
const appCode = `from huggingface_hub import InferenceClient
import gradio as gr
import os
client = InferenceClient(api_key=os.getenv("HF_TOKEN"))
def generate_speech(text):
if not text.strip():
return None
try:
audio_bytes = client.text_to_speech(
text,
extra_body={
"exaggeration": ${exaggeration},
"temperature": ${temperature},
${voiceUrl ? `"audio_url": "${voiceUrl}",` : ''}
}
)
return audio_bytes
except Exception as e:
raise gr.Error(f"Error generating speech: {str(e)}")
# Create the Gradio interface
with gr.Blocks(title="Text to Speech") as demo:
gr.Markdown("# Text to Speech")
gr.Markdown("Convert text to speech using the Chatterbox model.")
with gr.Row():
with gr.Column():
text_input = gr.Textbox(
label="Text to convert to speech",
placeholder="${text.replace(/"/g, '\\"').substring(0, 100)}...",
lines=5,
value="${text.replace(/"/g, '\\"')}"
)
generate_btn = gr.Button("Generate Speech", variant="primary")
with gr.Column():
audio_output = gr.Audio(label="Generated Speech")
generate_btn.click(
fn=generate_speech,
inputs=[text_input],
outputs=[audio_output]
)
if __name__ == "__main__":
demo.launch()`;
const requirementsContent = `huggingface_hub
gradio`;
// Create the deployment URL
const baseUrl = 'https://huggingface.co/new-space';
const params = new URLSearchParams({
name: `text-to-speech-${selectedVoice.toLowerCase()}-${Date.now()}`,
sdk: 'gradio',
'files[0][path]': 'app.py',
'files[0][content]': appCode,
'files[1][path]': 'requirements.txt',
'files[1][content]': requirementsContent,
});
window.open(`${baseUrl}?${params.toString()}`, '_blank');
}
function copyAllCode() {
const parts = [];
if (setupCode) {
const isTerminalCommand =
setupCode.includes('pip install') || setupCode.includes('hfstudio start');
const language = isTerminalCommand ? 'bash' : '';
parts.push(`## Setup (Run in Terminal)\n\n\`\`\`${language}\n${setupCode}\n\`\`\``);
}
if (pythonCode) {
parts.push(`## Python Code\n\n\`\`\`python\n${pythonCode}\n\`\`\``);
}
const markdownContent = parts.join('\n\n');
copyToClipboard(markdownContent, 'All code copied as Markdown!');
}
// Reactive statements for live code updates
$: {
// This block will re-run whenever any of these variables change
(text, selectedVoice, selectedModel, exaggeration, temperature, userVoices, mode);
setupCode = generateSetupCode();
pythonCode = generatePythonCode();
codeUpdateCounter++;
}
onMount(async () => {
await loadHistoryCount();
await loadUserVoices();
});
</script>
<svelte:head>
<title>Text to Speech - HFStudio</title>
</svelte:head>
<div
class="flex flex-col h-full"
on:click={handleClickOutside}
on:keydown={handleKeyDown}
role="main"
tabindex="-1"
>
<!-- Main content area -->
<div class="flex-1 flex">
<!-- Main content area -->
<div class="flex-1 flex flex-col p-6">
<!-- Text input area -->
<div class="relative mb-4">
<div class="absolute top-3 left-3 flex items-center gap-2 z-10">
<span class="text-sm text-gray-400">Text to speak</span>
<button
on:click={refreshText}
class="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
title="Refresh with famous book opening"
>
<Shuffle size={16} />
</button>
</div>
<div class="absolute top-3 right-3 flex items-center gap-2 z-10">
<span class="text-sm text-gray-400">
{text.length.toLocaleString()} / 1,000 characters
</span>
</div>
<div class="relative">
<textarea
bind:value={text}
maxlength="1000"
class="w-full h-96 pt-10 px-6 pb-16 bg-white resize-none border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent text-gray-900 text-lg leading-relaxed"
placeholder="Type the text you'd like to convert to spoken audio here..."
on:keydown={handleKeyDown}
autofocus
/>
<!-- Generate button embedded in textarea -->
<button
on:click={generateSpeech}
disabled={isGenerating || !text.trim()}
class="absolute bottom-4 right-4 px-5 py-2.5 bg-gradient-to-r from-amber-400 to-orange-500 text-white rounded-lg font-medium hover:from-amber-500 hover:to-orange-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-1.5 shadow-sm text-base"
>
{#if isGenerating}
<Loader2 size={16} class="animate-spin" />
Generating...
{:else}
<Play size={16} />
Generate speech
{/if}
</button>
</div>
</div>
<!-- Settings panel -->
<div class="p-4 border border-gray-200 rounded-lg bg-white mb-6">
<div class="grid grid-cols-1 lg:grid-cols-[1fr_1.4fr_1fr] gap-6">
<!-- Model selector -->
<div class="model-dropdown">
<h3 class="text-sm font-medium text-gray-900 mb-2">Model</h3>
<div class="relative">
<button
on:click={() => (modelDropdownOpen = !modelDropdownOpen)}
class="w-full p-3 border bg-white text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent shadow-sm text-left flex items-center justify-between {modelDropdownOpen
? 'rounded-t-lg border-b-0 border-black'
: 'rounded-lg border-black'}"
>
<span>
{#each models as model}
{#if model.name === selectedModel}
{model.name}{#if model.badge}&nbsp;<span class="text-sm text-gray-500"
>({model.badge})</span
>{/if}
{/if}
{/each}
</span>
<ChevronDown
size={14}
class="text-gray-500 transition-transform {modelDropdownOpen ? 'rotate-180' : ''}"
/>
</button>
{#if modelDropdownOpen}
<div
class="absolute top-full left-0 right-0 border border-gray-200 border-t-0 bg-white shadow-lg rounded-b-lg overflow-hidden z-20"
>
{#each models as model}
<button
class="w-full px-3 py-2.5 text-left transition-colors text-sm {model.disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50'} {model.name === selectedModel ? 'bg-gray-100' : ''}"
disabled={model.disabled}
on:click={() => {
if (!model.disabled) {
selectedModel = model.name;
modelDropdownOpen = false;
}
}}
>
{model.name}{#if model.badge}&nbsp;<span class="text-sm text-gray-500"
>({model.badge})</span
>{/if}
</button>
{/each}
</div>
{/if}
</div>
<!-- Pricing info -->
<div class="mt-1.5 text-xs text-gray-500 text-right">
~$0.025 per generation • <a
href="https://huggingface.co/settings/billing"
target="_blank"
class="text-amber-600 hover:text-amber-700 underline">Billing ⤴</a
>
</div>
</div>
<!-- Voice selector -->
<div>
<h3 class="text-sm font-medium text-gray-900 mb-2">Voice</h3>
<div class="grid grid-cols-2 gap-2">
<!-- Andrew -->
<button
class="p-3 border rounded-lg transition-colors text-left hover:bg-gray-50 {selectedVoice ===
'Andrew'
? 'border-black'
: 'border-gray-200'}"
on:click={() => (selectedVoice = 'Andrew')}
>
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-2">
<div
class="w-6 h-6 bg-gradient-to-br from-amber-400 to-orange-500 rounded-full flex items-center justify-center text-white text-xs font-semibold"
>
A
</div>
<span class="text-sm font-medium">Andrew</span>
</div>
<button
on:click={(e) =>
playSampleVoice({ name: 'Andrew', sample: '/voices/andrew.mp3' }, e)}
class="p-1 rounded-full hover:bg-gray-200 transition-colors w-5 h-5 flex items-center justify-center"
title="Play sample"
>
{#if playingSampleVoice === 'Andrew'}
<Pause size={10} class="text-gray-600" />
{:else}
<Play size={10} class="text-gray-600" />
{/if}
</button>
</div>
</button>
<!-- Lily -->
<button
class="p-3 border rounded-lg transition-colors text-left hover:bg-gray-50 {selectedVoice ===
'Lily'
? 'border-black'
: 'border-gray-200'}"
on:click={() => (selectedVoice = 'Lily')}
>
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-2">
<div
class="w-6 h-6 bg-gradient-to-br from-amber-400 to-orange-500 rounded-full flex items-center justify-center text-white text-xs font-semibold"
>
L
</div>
<span class="text-sm font-medium">Lily</span>
</div>
<button
on:click={(e) =>
playSampleVoice({ name: 'Lily', sample: '/voices/lily.mp3' }, e)}
class="p-1 rounded-full hover:bg-gray-200 transition-colors w-5 h-5 flex items-center justify-center"
title="Play sample"
>
{#if playingSampleVoice === 'Lily'}
<Pause size={10} class="text-gray-600" />
{:else}
<Play size={10} class="text-gray-600" />
{/if}
</button>
</div>
</button>
<!-- Pirate -->
<button
class="p-3 border rounded-lg transition-colors text-left hover:bg-gray-50 {selectedVoice ===
'Pirate'
? 'border-black'
: 'border-gray-200'}"
on:click={() => (selectedVoice = 'Pirate')}
>
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-2">
<div
class="w-6 h-6 bg-gradient-to-br from-amber-400 to-orange-500 rounded-full flex items-center justify-center text-white text-xs font-semibold"
>
P
</div>
<span class="text-sm font-medium">Pirate</span>
</div>
<button
on:click={(e) =>
playSampleVoice({ name: 'Pirate', sample: '/voices/pirate.mp3' }, e)}
class="p-1 rounded-full hover:bg-gray-200 transition-colors w-5 h-5 flex items-center justify-center"
title="Play sample"
>
{#if playingSampleVoice === 'Pirate'}
<Pause size={10} class="text-gray-600" />
{:else}
<Play size={10} class="text-gray-600" />
{/if}
</button>
</div>
</button>
<!-- User Voice or Clone CTA -->
{#if userVoices.length > 0}
<button
class="p-3 border rounded-lg transition-colors text-left hover:bg-gray-50 {selectedVoice ===
'Yours'
? 'border-purple-400 bg-purple-50'
: 'border-gray-200'}"
on:click={() => (selectedVoice = 'Yours')}
>
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-2">
<div
class="w-6 h-6 bg-gradient-to-br from-purple-400 to-pink-500 rounded-full flex items-center justify-center text-white text-xs"
>
🎤
</div>
<span class="text-xs font-medium">Your cloned voice</span>
</div>
<button
on:click={(e) =>
playSampleVoice({ name: 'Yours', sample: userVoices[0].voice_url }, e)}
class="p-1 rounded-full hover:bg-gray-200 transition-colors w-5 h-5 flex items-center justify-center"
title="Play sample"
>
{#if playingSampleVoice === 'Yours'}
<Pause size={10} class="text-gray-600" />
{:else}
<Play size={10} class="text-gray-600" />
{/if}
</button>
</div>
</button>
{:else}
<!-- Clone CTA -->
<a
href="/voice-cloning"
class="p-3 border border-purple-200 rounded-lg transition-colors text-left hover:bg-purple-50 bg-purple-25"
>
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-2">
<div
class="w-6 h-6 bg-gradient-to-br from-purple-400 to-pink-500 rounded-full flex items-center justify-center text-white text-xs font-semibold"
>
🎤
</div>
<span class="text-xs font-medium text-purple-900">Clone your voice</span>
</div>
<div class="w-5 h-5 flex items-center justify-center">
<svg
class="w-3 h-3 text-purple-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
</a>
{/if}
</div>
</div>
<!-- Settings controls -->
<div class="space-y-3">
<!-- Exaggeration control -->
<div>
<div class="mb-1">
<label for="exaggeration-slider" class="text-sm font-medium text-gray-900"
>Exaggeration</label
>
</div>
<input
id="exaggeration-slider"
type="range"
bind:value={exaggeration}
min="0"
max="1"
step="0.01"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider-hf"
/>
<div class="flex justify-between text-xs text-gray-400 mt-1">
<span>None</span>
<span>More</span>
</div>
</div>
<!-- Stability control -->
<div>
<div class="mb-1">
<label for="temperature-slider" class="text-sm font-medium text-gray-900"
>Stability</label
>
</div>
<input
id="temperature-slider"
type="range"
bind:value={temperature}
min="0"
max="1"
step="0.01"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider-hf"
/>
<div class="flex justify-between text-xs text-gray-400 mt-1">
<span>More stable</span>
<span>More variable</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Right sidebar - Live Code Display -->
<div class="w-96 border-l border-gray-200 bg-white h-full overflow-hidden">
<div class="p-4 h-full overflow-y-auto">
<!-- Login prompt if not authenticated -->
{#if showLoginPrompt}
<div
class="mb-4 px-3 py-2 bg-gradient-to-r from-amber-50 to-orange-50 rounded-lg border border-amber-200 relative"
>
<!-- Close button -->
<button
on:click={() => (showLoginPrompt = false)}
class="absolute top-2 right-2 text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Dismiss"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<p class="text-sm font-medium text-gray-700 mb-1 pr-4">
Hugging Face <span
class="bg-gradient-to-r from-purple-500 via-pink-500 via-green-500 to-blue-500 bg-clip-text text-transparent font-bold"
>PRO</span
>
</p>
<p class="text-sm text-gray-600 pr-4">
Sign in to with your Hugging Face <a
href="https://huggingface.co/pro"
target="_blank"
class="text-amber-600 hover:text-amber-700 underline font-medium">PRO account</a
> to get started with $2 of free API credits per month. You can add a billing method for
additional pay-as-you-go usage ⤴
</p>
</div>
{/if}
<!-- Header -->
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-900 mb-1">Results & Live Documentation</h3>
<p class="text-sm text-gray-600">The code below will update as you adjust the UI ✨</p>
</div>
<!-- Toggle and Copy All button row -->
<div class="flex items-center justify-between mb-4">
<!-- API/Local Mode Toggle -->
<div class="flex items-center bg-gray-100 rounded-md p-0.5">
<button
class="px-2 py-1 text-xs font-medium rounded transition-colors {mode === 'api'
? 'bg-white shadow-sm'
: 'text-gray-600'}"
on:click={() => (mode = 'api')}
>
API
</button>
<button
class="px-2 py-1 text-xs font-medium rounded transition-colors {mode === 'local'
? 'bg-white shadow-sm'
: 'text-gray-600'}"
on:click={() => (mode = 'local')}
>
Local
</button>
</div>
<div class="flex items-center gap-2">
<button
on:click={copyAllCode}
class="flex items-center bg-gray-100 hover:bg-gray-200 rounded-md px-2 py-1 transition-colors"
>
<Copy size={12} class="text-gray-600" />
<span class="ml-1 text-xs font-medium text-gray-600">Copy all</span>
</button>
<button
on:click={deployAsSpace}
class="flex items-center bg-amber-100 hover:bg-amber-200 rounded-md px-2 py-1 transition-colors"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-amber-700"
>
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
<span class="ml-1 text-xs font-medium text-amber-700">Deploy as Space</span>
</button>
</div>
</div>
<!-- Code sections -->
<div class="space-y-4">
<!-- Setup Section -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div
class="flex items-center justify-between px-3 py-2 bg-blue-50 border-b border-blue-200"
>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-blue-900">Install in Terminal</span>
</div>
<button
on:click={() => copyToClipboard(setupCode)}
class="p-1 hover:bg-blue-100 rounded transition-colors"
title="Copy setup code"
>
<Copy size={12} class="text-blue-600" />
</button>
</div>
<div class="relative">
{#key codeUpdateCounter}
<pre class="p-3 overflow-x-auto bg-gray-50 text-xs"><code class="language-bash"
>{@html Prism.highlight(setupCode, Prism.languages.bash, 'bash')}</code
></pre>
{/key}
</div>
</div>
<!-- Python Code Section (Imports + Generation) -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div
class="flex items-center justify-between px-3 py-2 bg-amber-50 border-b border-amber-200"
>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-amber-900">Python Code</span>
<span class="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded">Live</span>
</div>
<button
on:click={() => copyToClipboard(pythonCode)}
class="p-1 hover:bg-amber-100 rounded transition-colors"
title="Copy Python code"
>
<Copy size={12} class="text-amber-600" />
</button>
</div>
<div class="relative">
{#key codeUpdateCounter}
<pre class="p-3 overflow-x-auto bg-gray-50 text-xs"><code class="language-python"
>{@html Prism.highlight(pythonCode, Prism.languages.python, 'python')}</code
></pre>
{/key}
</div>
</div>
<!-- Generated Audio Section -->
{#if audioUrl}
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mt-4">
<div
class="flex items-center justify-between px-3 py-2 bg-green-50 border-b border-green-200"
>
<div class="flex items-center gap-2">
<div class="w-1.5 h-1.5 bg-green-500 rounded-full"></div>
<span class="text-xs font-medium text-green-900">Generated Audio</span>
{#if generationTime > 0 && mode !== 'local'}
<span class="text-xs text-green-700">(took {generationTime.toFixed(1)}s)</span>
{/if}
</div>
<div class="flex items-center gap-1">
<button
on:click={downloadAudio}
class="flex items-center gap-1 px-2 py-1 hover:bg-green-100 rounded transition-colors"
title="Download audio"
>
<span class="text-xs text-green-700">Download</span>
<Download size={12} class="text-green-600" />
</button>
</div>
</div>
<div class="p-3">
<!-- Audio info -->
<div class="mb-3">
<h4 class="font-medium text-gray-900 text-xs">{audioTitle}</h4>
<p class="text-xs text-gray-500">{selectedVoice} • {formatTime(duration)}</p>
</div>
<!-- Audio controls -->
<div class="flex items-center gap-2">
<!-- Play/Pause button -->
<button
on:click={togglePlayPause}
class="w-6 h-6 bg-black rounded-full flex items-center justify-center hover:bg-gray-800 transition-colors flex-shrink-0"
>
{#if isPlaying}
<div class="pause-filled text-white text-xs"></div>
{:else}
<Play size={10} class="text-white ml-0.5" />
{/if}
</button>
<!-- Progress bar -->
<div class="flex-1 flex items-center gap-1">
<span class="text-xs text-gray-500 font-mono">{formatTime(currentTime)}</span>
<div class="flex-1 h-1 bg-gray-200 rounded-full cursor-pointer">
<div
class="h-full bg-gradient-to-r from-amber-400 to-orange-500 rounded-full transition-all"
style="width: {(currentTime / duration) * 100}%"
></div>
</div>
<span class="text-xs text-gray-500 font-mono">{formatTime(duration)}</span>
</div>
</div>
<!-- Hidden audio element -->
{#if audioUrl}
<audio
bind:this={audioElement}
src={audioUrl}
on:loadedmetadata={handleAudioLoad}
on:timeupdate={handleTimeUpdate}
on:play={handlePlay}
on:pause={handlePause}
style="display: none;"
/>
{/if}
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
</div>
<!-- Error Modal -->
{#if showErrorModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[80vh] flex flex-col">
<!-- Header -->
<div
class="flex items-center justify-between p-6 border-b border-gray-200 bg-red-50 flex-shrink-0"
>
<div class="flex items-center gap-3 min-w-0">
<div
class="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0"
>
<AlertCircle size={20} class="text-red-600" />
</div>
<div class="min-w-0">
<h3 class="text-lg font-semibold text-gray-900 truncate">{errorMessage}</h3>
<p class="text-sm text-gray-600">An error occurred while processing your request</p>
</div>
</div>
<button
on:click={closeErrorModal}
class="p-2 hover:bg-red-100 rounded-full transition-colors flex-shrink-0"
title="Close"
>
<X size={20} class="text-gray-500" />
</button>
</div>
<!-- Content -->
<div class="p-6 overflow-y-auto flex-1 min-h-0">
{#if errorDetails}
<div class="bg-gray-50 rounded-lg p-4 border">
<h4 class="text-sm font-medium text-gray-900 mb-2">Error Details:</h4>
<pre
class="text-xs text-gray-700 whitespace-pre-wrap font-mono leading-relaxed break-words">{#if errorDetails.includes('exceeded your monthly included credits')}{@html errorDetails.replace(
'Subscribe to PRO',
'<a href="https://huggingface.co/settings/billing" target="_blank" class="text-amber-600 hover:text-amber-700 underline font-medium">Subscribe to PRO</a>'
)}{:else}{errorDetails}{/if}</pre>
</div>
{/if}
</div>
<!-- Footer -->
<div
class="flex items-center justify-end gap-3 p-6 border-t border-gray-200 bg-gray-50 flex-shrink-0"
>
<button
on:click={closeErrorModal}
class="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Close
</button>
</div>
</div>
</div>
{/if}
<!-- Copy notification toast -->
{#if copyNotification}
<div
class="fixed bottom-4 right-4 px-4 py-2 bg-gray-900 text-white rounded-lg shadow-lg z-50 animate-fade-in"
>
{copyNotification}
</div>
{/if}
<style>
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
.pause-filled::after {
content: '';
width: 3px;
height: 12px;
background: currentColor;
display: inline-block;
margin-right: 2px;
}
.pause-filled::before {
content: '';
width: 3px;
height: 12px;
background: currentColor;
display: inline-block;
margin-right: 2px;
}
</style>