|
|
<script> |
|
|
import { Play, Download, Loader2, AlertCircle, ChevronDown, Copy, RefreshCw, Share, MoreHorizontal, Settings, Sliders, Pause, SkipBack, SkipForward, Layout, Code, X } from 'lucide-svelte'; |
|
|
|
|
|
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 = 'Lily'; |
|
|
let selectedModel = 'Chatterbox'; |
|
|
let mode = 'api'; |
|
|
let viewMode = 'ui'; |
|
|
let modelDropdownOpen = false; |
|
|
let isGenerating = false; |
|
|
let codeCells = []; |
|
|
let audioUrl = null; |
|
|
let copyNotification = null; |
|
|
let codeButtonFlash = false; |
|
|
let exaggeration = 0.25; |
|
|
let temperature = 0.7; |
|
|
let showSettings = true; |
|
|
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 = ''; |
|
|
|
|
|
const models = [ |
|
|
{ id: 'chatterbox', name: 'Chatterbox', badge: 'recommended' }, |
|
|
{ id: 'kokoro', name: 'Kokoro', badge: 'faster but lower quality' }, |
|
|
]; |
|
|
|
|
|
const voices = [ |
|
|
{ id: 'lily', name: 'Lily', description: 'Warm, conversational voice from a female in her 30s', sample: '/voices/lily.mp3' }, |
|
|
{ id: 'andrew', name: 'Andrew', description: 'Older British man who speaks clearly and kindly', sample: '/voices/andrew.mp3' }, |
|
|
{ id: 'fairy', name: 'Fairy', description: 'High and airy female voice that bursts with excitement', sample: '/voices/fairy.mp3' }, |
|
|
{ id: 'pirate', name: 'Pirate', description: 'Young pirate that speaks gruffly and passionately', sample: '/voices/pirate.mp3' }, |
|
|
]; |
|
|
|
|
|
async function generateSpeech() { |
|
|
if (!text.trim()) return; |
|
|
|
|
|
const accessToken = getAccessToken(); |
|
|
if (!accessToken) { |
|
|
window.dispatchEvent(new CustomEvent('show-signin-popover')); |
|
|
return; |
|
|
} |
|
|
|
|
|
isGenerating = true; |
|
|
audioUrl = null; |
|
|
currentTime = 0; |
|
|
|
|
|
if (codeCells.length === 0) { |
|
|
addCodeCell('Setup and Import', generateSetupCode()); |
|
|
} |
|
|
addCodeCell('Generate Speech', generateTTSCode()); |
|
|
|
|
|
if (viewMode === 'ui') { |
|
|
codeButtonFlash = true; |
|
|
setTimeout(() => { |
|
|
codeButtonFlash = false; |
|
|
}, 2500); |
|
|
} |
|
|
isPlaying = false; |
|
|
|
|
|
audioTitle = text.length > 30 ? text.substring(0, 30) + '...' : text; |
|
|
|
|
|
try { |
|
|
const accessToken = getAccessToken(); |
|
|
|
|
|
const requestBody = { |
|
|
text: text, |
|
|
voice_id: selectedVoice.toLowerCase(), |
|
|
model_id: selectedModel.toLowerCase(), |
|
|
mode: mode, |
|
|
access_token: accessToken, |
|
|
parameters: { |
|
|
exaggeration: exaggeration, |
|
|
temperature: temperature |
|
|
} |
|
|
}; |
|
|
|
|
|
const response = await fetch('/api/tts/generate', { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Content-Type': 'application/json', |
|
|
}, |
|
|
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; |
|
|
addCodeCell('Save Audio Output', generateSaveCode()); |
|
|
|
|
|
if (viewMode === 'ui') { |
|
|
codeButtonFlash = true; |
|
|
setTimeout(() => { |
|
|
codeButtonFlash = false; |
|
|
}, 2500); |
|
|
} |
|
|
} 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 getAccessToken() { |
|
|
if (typeof window !== 'undefined' && window.gradio && window.gradio.auth_token) { |
|
|
return window.gradio.auth_token; |
|
|
} |
|
|
|
|
|
const metaToken = document.querySelector('meta[name="hf-oauth-token"]'); |
|
|
if (metaToken) { |
|
|
const token = metaToken.getAttribute('content'); |
|
|
if (token) { |
|
|
return token; |
|
|
} |
|
|
} |
|
|
|
|
|
const possibleKeys = [ |
|
|
'hf_access_token', |
|
|
'hf_token', |
|
|
'huggingface_token', |
|
|
'oauth_token', |
|
|
'access_token' |
|
|
]; |
|
|
|
|
|
for (const key of possibleKeys) { |
|
|
const token = localStorage.getItem(key); |
|
|
if (token) { |
|
|
return token; |
|
|
} |
|
|
} |
|
|
|
|
|
for (const key of possibleKeys) { |
|
|
const token = sessionStorage.getItem(key); |
|
|
if (token) { |
|
|
return token; |
|
|
} |
|
|
} |
|
|
|
|
|
const cookies = document.cookie.split(';'); |
|
|
for (const cookie of cookies) { |
|
|
const [name, value] = cookie.trim().split('='); |
|
|
if (name && (name.includes('token') || name.includes('hf') || name.includes('oauth'))) { |
|
|
return decodeURIComponent(value); |
|
|
} |
|
|
} |
|
|
|
|
|
try { |
|
|
const authHeader = document.querySelector('script[data-hf-token]'); |
|
|
if (authHeader) { |
|
|
const token = authHeader.getAttribute('data-hf-token'); |
|
|
if (token) { |
|
|
return token; |
|
|
} |
|
|
} |
|
|
} catch (e) {} |
|
|
|
|
|
return null; |
|
|
} |
|
|
|
|
|
function togglePlayPause() { |
|
|
if (audioElement) { |
|
|
if (isPlaying) { |
|
|
audioElement.pause(); |
|
|
} else { |
|
|
audioElement.play(); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
function handleAudioLoad() { |
|
|
if (audioElement) { |
|
|
duration = audioElement.duration; |
|
|
audioElement.play(); |
|
|
} |
|
|
} |
|
|
|
|
|
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(); |
|
|
} |
|
|
} |
|
|
|
|
|
function handleClickOutside(event) { |
|
|
if (!event.target.closest('.model-dropdown')) { |
|
|
modelDropdownOpen = false; |
|
|
} |
|
|
} |
|
|
|
|
|
function addCodeCell(title, code, output = null) { |
|
|
const cell = { |
|
|
id: Date.now() + Math.random(), |
|
|
title, |
|
|
code, |
|
|
output, |
|
|
timestamp: new Date().toLocaleTimeString() |
|
|
}; |
|
|
codeCells = [...codeCells, cell]; |
|
|
return cell; |
|
|
} |
|
|
|
|
|
function generateSetupCode() { |
|
|
return `# Install required packages |
|
|
# pip install huggingface-hub |
|
|
|
|
|
from huggingface_hub import InferenceClient |
|
|
import base64 |
|
|
import io |
|
|
|
|
|
# Initialize the client |
|
|
client = InferenceClient( |
|
|
provider="fal-ai", |
|
|
api_key="YOUR_HF_TOKEN", # Get your token from https://huggingface.co/settings/tokens |
|
|
)`; |
|
|
} |
|
|
|
|
|
function generateTTSCode() { |
|
|
return `# Text to convert to speech |
|
|
text = """${text}""" |
|
|
|
|
|
# Voice and model settings |
|
|
model = "ResembleAI/chatterbox" |
|
|
voice = "${selectedVoice.toLowerCase()}" |
|
|
exaggeration = ${exaggeration} |
|
|
temperature = ${temperature} |
|
|
|
|
|
# Generate speech |
|
|
print("Generating speech...") |
|
|
try: |
|
|
audio_bytes = client.text_to_speech( |
|
|
text, |
|
|
model=model, |
|
|
# Note: Voice and other parameters may vary by model |
|
|
) |
|
|
print(f"✓ Generated {len(audio_bytes)} bytes of audio") |
|
|
except Exception as e: |
|
|
print(f"Error: {e}")`; |
|
|
} |
|
|
|
|
|
function generateSaveCode() { |
|
|
return `# Save the audio to a file |
|
|
output_filename = "output_speech.wav" |
|
|
|
|
|
with open(output_filename, "wb") as f: |
|
|
f.write(audio_bytes) |
|
|
|
|
|
print(f"✓ Audio saved to {output_filename}") |
|
|
|
|
|
# Optional: Play the audio (requires additional packages) |
|
|
# from playsound import playsound |
|
|
# playsound(output_filename)`; |
|
|
} |
|
|
|
|
|
function copyToClipboard(text, message = 'Copied to clipboard!') { |
|
|
navigator.clipboard.writeText(text).then(() => { |
|
|
copyNotification = message; |
|
|
setTimeout(() => { |
|
|
copyNotification = null; |
|
|
}, 2000); |
|
|
}); |
|
|
} |
|
|
|
|
|
function showError(message, details = '') { |
|
|
errorMessage = message; |
|
|
errorDetails = details; |
|
|
showErrorModal = true; |
|
|
} |
|
|
|
|
|
function closeErrorModal() { |
|
|
showErrorModal = false; |
|
|
errorMessage = ''; |
|
|
errorDetails = ''; |
|
|
} |
|
|
|
|
|
function copyErrorMessage() { |
|
|
const fullError = errorDetails ? `${errorMessage}\n\nDetails:\n${errorDetails}` : errorMessage; |
|
|
copyToClipboard(fullError, 'Error message copied!'); |
|
|
} |
|
|
|
|
|
function copyAllCode() { |
|
|
const allCode = codeCells.map(cell => `# ${cell.title}\n${cell.code}`).join('\n\n'); |
|
|
copyToClipboard(allCode, 'All code copied!'); |
|
|
} |
|
|
|
|
|
$: if (codeCells.length === 0 && viewMode === 'code') { |
|
|
addCodeCell('Setup and Import', generateSetupCode()); |
|
|
} |
|
|
|
|
|
let previousText = text; |
|
|
$: if (text !== previousText && codeCells.length > 0) { |
|
|
previousText = text; |
|
|
} |
|
|
|
|
|
$: if (viewMode === 'ui') { |
|
|
codeCells = []; |
|
|
} |
|
|
|
|
|
</script> |
|
|
|
|
|
<div class="flex flex-col h-full" on:click={handleClickOutside}> |
|
|
<!-- Header --> |
|
|
<header class="border-b border-gray-200 bg-white"> |
|
|
<div class="flex items-center justify-end px-4 py-2"> |
|
|
<div class="flex items-center gap-2"> |
|
|
|
|
|
<div class="flex items-center bg-gray-100 rounded-md p-0.5"> |
|
|
<button |
|
|
class="flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded transition-colors {viewMode === 'ui' ? 'bg-white shadow-sm' : 'text-gray-600'}" |
|
|
on:click={() => viewMode = 'ui'} |
|
|
> |
|
|
<Layout size={14} /> |
|
|
UI |
|
|
</button> |
|
|
<button |
|
|
class="flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded transition-colors relative overflow-hidden {viewMode === 'code' ? 'bg-white shadow-sm' : 'text-gray-600'} {codeButtonFlash ? 'code-flash' : ''}" |
|
|
on:click={() => viewMode = 'code'} |
|
|
> |
|
|
<Code size={14} /> |
|
|
Code Recorder |
|
|
{#if codeButtonFlash} |
|
|
<span class="flash-sweep"></span> |
|
|
{/if} |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</header> |
|
|
|
|
|
<!-- Main content area --> |
|
|
{#if viewMode === 'ui'} |
|
|
<div class="flex-1 flex"> |
|
|
|
|
|
<div class="flex-1 flex flex-col p-6"> |
|
|
{#if mode === 'local'} |
|
|
<div class="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-start gap-2"> |
|
|
<AlertCircle size={18} class="text-blue-600 mt-0.5 flex-shrink-0" /> |
|
|
<div class="text-sm"> |
|
|
<p class="font-medium text-blue-900">To run locally:</p> |
|
|
<code class="text-xs bg-blue-100 px-1.5 py-0.5 rounded">pip install hfstudio</code> |
|
|
<span class="text-blue-700"> and run </span> |
|
|
<code class="text-xs bg-blue-100 px-1.5 py-0.5 rounded">hfstudio</code> |
|
|
<span class="text-blue-700"> from your terminal</span> |
|
|
</div> |
|
|
</div> |
|
|
{/if} |
|
|
|
|
|
|
|
|
<div class="flex-1 pb-24"> |
|
|
<textarea |
|
|
bind:value={text} |
|
|
class="w-full h-full p-6 bg-white resize-none border-0 focus:outline-none text-gray-900 text-base leading-relaxed" |
|
|
placeholder="Welcome to our text to speech demo. This technology can transform any written content into natural sounding audio." |
|
|
/> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="fixed bottom-0 left-56 right-80 p-4 bg-white border-t border-gray-200"> |
|
|
<div class="flex items-center justify-between mb-3"> |
|
|
<span class="text-sm text-gray-500">{text.length} / 5,000 characters</span> |
|
|
</div> |
|
|
<button |
|
|
on:click={generateSpeech} |
|
|
disabled={isGenerating || !text.trim()} |
|
|
class="w-full px-6 py-3 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-2 shadow-sm" |
|
|
> |
|
|
{#if isGenerating} |
|
|
<Loader2 size={20} class="animate-spin" /> |
|
|
Generating... |
|
|
{:else} |
|
|
<Play size={20} /> |
|
|
Generate speech |
|
|
{/if} |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
|
|
|
{#if audioUrl} |
|
|
<div class="p-4 border border-gray-200 rounded-lg bg-white"> |
|
|
|
|
|
<div class="flex items-center gap-3 mb-4"> |
|
|
<div class="w-2 h-2 bg-green-500 rounded-full"></div> |
|
|
<div class="flex-1"> |
|
|
<h3 class="font-medium text-gray-900 text-sm">{audioTitle}</h3> |
|
|
<p class="text-xs text-gray-500">{selectedVoice} • Created 1 second ago</p> |
|
|
</div> |
|
|
|
|
|
<div class="flex items-center gap-2"> |
|
|
<button |
|
|
on:click={shareAudio} |
|
|
class="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-200 rounded-md hover:bg-gray-50 transition-colors" |
|
|
> |
|
|
<Share size={14} class="text-gray-600" /> |
|
|
<span class="text-gray-700">Share</span> |
|
|
</button> |
|
|
<button |
|
|
on:click={downloadAudio} |
|
|
class="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-200 rounded-md hover:bg-gray-50 transition-colors" |
|
|
> |
|
|
<span class="text-gray-700">Download</span> |
|
|
<Download size={14} class="text-gray-600" /> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Mini audio controls --> |
|
|
<div class="flex items-center gap-3 mb-4"> |
|
|
|
|
|
<button |
|
|
on:click={togglePlayPause} |
|
|
class="w-8 h-8 bg-black rounded-full flex items-center justify-center hover:bg-gray-800 transition-colors" |
|
|
> |
|
|
{#if isPlaying} |
|
|
<div class="pause-filled text-white"></div> |
|
|
{:else} |
|
|
<Play size={14} class="text-white ml-0.5" /> |
|
|
{/if} |
|
|
</button> |
|
|
|
|
|
<!-- Progress bar --> |
|
|
<div class="flex-1 flex items-center gap-2"> |
|
|
<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> |
|
|
|
|
|
<!-- Full audio player controls --> |
|
|
<div class="flex items-center gap-4 mb-4"> |
|
|
|
|
|
<button class="p-2 hover:bg-gray-100 rounded-full" title="Skip back"> |
|
|
<SkipBack size={20} class="text-gray-600" /> |
|
|
</button> |
|
|
|
|
|
|
|
|
<button |
|
|
on:click={togglePlayPause} |
|
|
class="w-12 h-12 bg-black rounded-full flex items-center justify-center hover:bg-gray-800 transition-colors" |
|
|
> |
|
|
{#if isPlaying} |
|
|
<div class="pause-filled text-white scale-150"></div> |
|
|
{:else} |
|
|
<Play size={20} class="text-white ml-0.5" /> |
|
|
{/if} |
|
|
</button> |
|
|
|
|
|
<!-- Skip forward button --> |
|
|
<button class="p-2 hover:bg-gray-100 rounded-full" title="Skip forward"> |
|
|
<SkipForward size={20} class="text-gray-600" /> |
|
|
</button> |
|
|
|
|
|
<!-- Progress bar --> |
|
|
<div class="flex-1 flex items-center gap-3"> |
|
|
<span class="text-xs text-gray-500 font-mono">{formatTime(currentTime)}</span> |
|
|
<div class="flex-1 h-1 bg-gray-200 rounded-full"> |
|
|
<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> |
|
|
|
|
|
<!-- Action buttons --> |
|
|
<div class="flex items-center gap-2"> |
|
|
<button |
|
|
on:click={shareAudio} |
|
|
class="flex items-center gap-2 px-3 py-1.5 text-sm border border-gray-200 rounded-md hover:bg-gray-50" |
|
|
> |
|
|
<Share size={14} /> |
|
|
Share |
|
|
</button> |
|
|
<button |
|
|
on:click={downloadAudio} |
|
|
class="p-2 hover:bg-gray-100 rounded-md" |
|
|
title="Download" |
|
|
> |
|
|
<Download size={16} class="text-gray-600" /> |
|
|
</button> |
|
|
<button class="p-2 hover:bg-gray-100 rounded-md" title="More options"> |
|
|
<MoreHorizontal size={16} class="text-gray-600" /> |
|
|
</button> |
|
|
</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> |
|
|
{/if} |
|
|
</div> |
|
|
|
|
|
<!-- Right panel --> |
|
|
<div class="w-80 border-l border-gray-200 bg-white p-4 overflow-y-auto"> |
|
|
|
|
|
<div class="mb-6 relative model-dropdown"> |
|
|
<h3 class="font-medium text-gray-900 mb-3">Model</h3> |
|
|
<button |
|
|
on:click={() => modelDropdownOpen = !modelDropdownOpen} |
|
|
class="w-full p-3 border border-gray-200 rounded-lg bg-white text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent appearance-none bg-no-repeat bg-right pr-10 shadow-sm text-left flex items-center justify-between" |
|
|
> |
|
|
<span> |
|
|
{#each models as model} |
|
|
{#if model.name === selectedModel} |
|
|
{model.name}{#if model.badge} <span class="text-xs text-gray-500">({model.badge})</span>{/if} |
|
|
{/if} |
|
|
{/each} |
|
|
</span> |
|
|
<ChevronDown size={16} class="text-gray-500" /> |
|
|
</button> |
|
|
|
|
|
{#if modelDropdownOpen} |
|
|
<div class="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-10"> |
|
|
{#each models as model} |
|
|
<button |
|
|
class="w-full px-3 py-2 text-left hover:bg-gray-50 transition-colors text-sm {model.name === selectedModel ? 'bg-gray-100' : ''}" |
|
|
on:click={() => { |
|
|
selectedModel = model.name; |
|
|
modelDropdownOpen = false; |
|
|
}} |
|
|
> |
|
|
{model.name}{#if model.badge} <span class="text-xs text-gray-500">({model.badge})</span>{/if} |
|
|
</button> |
|
|
{/each} |
|
|
</div> |
|
|
{/if} |
|
|
</div> |
|
|
|
|
|
<div class="mb-6"> |
|
|
<div class="mb-3"> |
|
|
<h3 class="font-medium text-gray-900">Voice</h3> |
|
|
</div> |
|
|
|
|
|
<div class="space-y-2"> |
|
|
{#each voices as voice} |
|
|
<button |
|
|
class="w-full flex items-center justify-between p-2 rounded-lg hover:bg-gray-50 transition-colors text-left group border border-transparent |
|
|
{voice.name === selectedVoice ? 'bg-gray-100 border-gray-200' : ''}" |
|
|
on:click={() => selectedVoice = voice.name} |
|
|
> |
|
|
<div class="flex items-center gap-3 flex-1 min-w-0"> |
|
|
<div class="w-10 h-10 bg-gradient-to-br from-amber-400 to-orange-500 rounded-full flex items-center justify-center text-white text-sm font-semibold flex-shrink-0"> |
|
|
{voice.name[0]} |
|
|
</div> |
|
|
<div class="flex-1 min-w-0"> |
|
|
<div class="text-sm font-medium text-gray-900 mb-1">{voice.name}</div> |
|
|
<div class="text-xs text-gray-500 leading-relaxed"> |
|
|
{voice.description} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<button |
|
|
on:click={(e) => playSampleVoice(voice, e)} |
|
|
class="p-2 rounded-full hover:bg-gray-200 transition-colors flex-shrink-0 ml-2" |
|
|
title="Play sample" |
|
|
> |
|
|
{#if playingSampleVoice === voice.name} |
|
|
<Pause size={16} class="text-gray-600" /> |
|
|
{:else} |
|
|
<Play size={16} class="text-gray-600" /> |
|
|
{/if} |
|
|
</button> |
|
|
</button> |
|
|
{/each} |
|
|
|
|
|
<!-- Clone voice option --> |
|
|
<button |
|
|
class="w-full flex items-center justify-between p-2 rounded-lg opacity-50 cursor-not-allowed text-left border border-transparent" |
|
|
disabled |
|
|
> |
|
|
<div class="flex items-center gap-3 flex-1 min-w-0"> |
|
|
<div class="w-10 h-10 bg-gray-400 rounded-full flex items-center justify-center text-white text-sm font-medium flex-shrink-0"> |
|
|
+ |
|
|
</div> |
|
|
<div class="flex-1 min-w-0"> |
|
|
<div class="text-sm font-medium text-gray-600 mb-1">Clone your voice</div> |
|
|
<div class="text-xs text-gray-400"> |
|
|
(coming soon) |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="space-y-4 pt-4 border-t border-gray-200"> |
|
|
|
|
|
<div> |
|
|
<div class="flex justify-between mb-1"> |
|
|
<label for="exaggeration-slider" class="text-sm font-medium text-gray-700">Exaggeration</label> |
|
|
<span class="text-sm text-gray-500">{exaggeration.toFixed(2)}</span> |
|
|
</div> |
|
|
<input |
|
|
id="exaggeration-slider" |
|
|
type="range" |
|
|
bind:value={exaggeration} |
|
|
min="0" |
|
|
max="1" |
|
|
step="0.01" |
|
|
class="w-full h-1.5 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>Exaggerated</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div> |
|
|
<div class="flex justify-between mb-1"> |
|
|
<label for="temperature-slider" class="text-sm font-medium text-gray-700">Stability</label> |
|
|
<span class="text-sm text-gray-500">{temperature.toFixed(2)}</span> |
|
|
</div> |
|
|
<input |
|
|
id="temperature-slider" |
|
|
type="range" |
|
|
bind:value={temperature} |
|
|
min="0" |
|
|
max="1" |
|
|
step="0.01" |
|
|
class="w-full h-1.5 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> |
|
|
{:else} |
|
|
<!-- Code view --> |
|
|
<div class="flex-1 bg-gray-50 overflow-y-auto"> |
|
|
<div class="max-w-4xl mx-auto p-8"> |
|
|
|
|
|
<div class="mb-6"> |
|
|
<div> |
|
|
<h2 class="text-2xl font-semibold text-gray-900">Integration Code</h2> |
|
|
<p class="text-sm text-gray-600 mt-1">Python code to reproduce your actions via the API</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="flex items-center justify-between mt-4"> |
|
|
|
|
|
<div class="flex items-center bg-gray-100 rounded-md p-0.5"> |
|
|
<button |
|
|
class="px-3 py-1 text-sm font-medium rounded transition-colors {mode === 'api' ? 'bg-white shadow-sm' : 'text-gray-600'}" |
|
|
on:click={() => mode = 'api'} |
|
|
> |
|
|
API |
|
|
</button> |
|
|
<button |
|
|
class="px-3 py-1 text-sm font-medium rounded transition-colors {mode === 'local' ? 'bg-white shadow-sm' : 'text-gray-600'}" |
|
|
on:click={() => mode = 'local'} |
|
|
> |
|
|
Local |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
{#if codeCells.length > 0} |
|
|
<button |
|
|
on:click={copyAllCode} |
|
|
class="flex items-center bg-gray-100 rounded-md p-0.5" |
|
|
> |
|
|
<span class="px-3 py-1 text-sm font-medium text-gray-600 flex items-center gap-2"> |
|
|
<Copy size={16} /> |
|
|
Copy All |
|
|
</span> |
|
|
</button> |
|
|
{/if} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Code cells --> |
|
|
{#if codeCells.length === 0} |
|
|
<div class="bg-white rounded-lg border border-gray-200 p-8 text-center"> |
|
|
<p class="text-gray-500">Start using the UI to see generated code here</p> |
|
|
</div> |
|
|
{:else} |
|
|
<div class="space-y-4"> |
|
|
{#each codeCells as cell (cell.id)} |
|
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden"> |
|
|
|
|
|
<div class="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-200"> |
|
|
<div class="flex items-center gap-3"> |
|
|
<span class="text-sm font-medium text-gray-700">{cell.title}</span> |
|
|
<span class="text-xs text-gray-500">{cell.timestamp}</span> |
|
|
</div> |
|
|
<button |
|
|
on:click={() => copyToClipboard(cell.code)} |
|
|
class="p-1.5 hover:bg-gray-200 rounded transition-colors" |
|
|
title="Copy code" |
|
|
> |
|
|
<Copy size={14} class="text-gray-600" /> |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="relative"> |
|
|
<pre class="p-4 overflow-x-auto bg-gray-50 text-gray-900"><code class="language-python text-sm font-mono">{cell.code}</code></pre> |
|
|
</div> |
|
|
|
|
|
<!-- Output (if any) --> |
|
|
{#if cell.output} |
|
|
<div class="px-4 py-2 bg-gray-900 text-green-400 font-mono text-xs border-t border-gray-200"> |
|
|
{cell.output} |
|
|
</div> |
|
|
{/if} |
|
|
</div> |
|
|
{/each} |
|
|
</div> |
|
|
{/if} |
|
|
</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} |
|
|
|
|
|
<!-- 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"> |
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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">{errorDetails}</pre> |
|
|
</div> |
|
|
{/if} |
|
|
</div> |
|
|
|
|
|
|
|
|
<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={copyErrorMessage} |
|
|
class="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-200 rounded-lg transition-colors" |
|
|
> |
|
|
<Copy size={16} /> |
|
|
Copy Error |
|
|
</button> |
|
|
<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} |
|
|
</div> |
|
|
|
|
|
<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; |
|
|
} |
|
|
|
|
|
@keyframes sweep { |
|
|
0% { |
|
|
left: -100%; |
|
|
} |
|
|
20% { |
|
|
left: -100%; |
|
|
} |
|
|
80% { |
|
|
left: 100%; |
|
|
} |
|
|
100% { |
|
|
left: 100%; |
|
|
} |
|
|
} |
|
|
|
|
|
.flash-sweep { |
|
|
position: absolute; |
|
|
top: 0; |
|
|
left: -100%; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
background: linear-gradient(90deg, |
|
|
transparent 0%, |
|
|
rgba(251, 191, 36, 0.5) 25%, |
|
|
rgba(249, 115, 22, 0.8) 50%, |
|
|
rgba(251, 191, 36, 0.5) 75%, |
|
|
transparent 100%); |
|
|
animation: sweep 2s ease-in-out; |
|
|
pointer-events: none; |
|
|
} |
|
|
|
|
|
.code-flash { |
|
|
animation: pulse 0.5s ease-out; |
|
|
} |
|
|
|
|
|
@keyframes pulse { |
|
|
0% { |
|
|
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0); |
|
|
} |
|
|
50% { |
|
|
box-shadow: 0 0 0 6px rgba(251, 191, 36, 0.4); |
|
|
} |
|
|
100% { |
|
|
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0); |
|
|
} |
|
|
} |
|
|
|
|
|
</style> |