+page.svelte•15 kB
<script lang="ts">
    import AudioPlayer from '$lib/components/AudioPlayer.svelte';
    import DebugInfo from '$lib/components/DebugInfo.svelte';
    import LoadingSpinner from '$lib/components/LoadingSpinner.svelte';
    import type { AudioGenerationResponse, ScriptPart, Voice } from '$lib/elevenlabs-client';
    import { onMount } from 'svelte';
    let scriptParts: ScriptPart[] = [{ text: '', voice_id: 'dQn9HIMKSXWzKBGkbhfP', actor: '' }];
    let expandedVoiceDetails: { [key: number]: boolean } = {};
    let loading = false;
    let result: AudioGenerationResponse | null = null;
    let voices: Voice[] = [];
    onMount(async () => {
        try {
            const response = await fetch('/api/voices');
            voices = await response.json();
        } catch (error) {
            console.error('Error loading voices:', error);
        }
    });
    function addPart() {
        scriptParts = [...scriptParts, { text: '', voice_id: '', actor: '' }];
    }
    function removePart(index: number) {
        scriptParts = scriptParts.filter((_, i) => i !== index);
    }
    function getSelectedVoice(voiceId: string) {
        return voices.find(v => v.voice_id === voiceId);
    }
    
    $: isFormInvalid = scriptParts.length === 0 || scriptParts.some(part => !part.text || !part.voice_id);
    
    async function generateAudio() {
        if (scriptParts.length === 0 || scriptParts.every(part => !part.text)) return;
        
        loading = true;
        result = null;
        
        try {
            const response = await fetch('/api/tts', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({
                    type: 'script',
                    script: { script: scriptParts }
                })
            });
            
            const data = await response.json() as AudioGenerationResponse;
            
            if (!data.success) {
                throw new Error(data.message);
            }
            
            result = data;
        } catch (error) {
            result = {
                success: false,
                message: error instanceof Error ? error.message : String(error),
                debugInfo: []
            };
        } finally {
            loading = false;
        }
    }
</script>
<main>
    <h2>Multi-part Script to Speech</h2>
    <p class="page-description">Create a script with multiple parts, each with its own text, voice, and actor.</p>
    
    <form on:submit|preventDefault={generateAudio} class="script-form">
        {#each scriptParts as part, i}
            <div class="script-part">
                <div class="part-header">
                    <h3>Part {i + 1}</h3>
                    {#if scriptParts.length > 1}
                        <button 
                            type="button" 
                            class="remove-button"
                            on:click={() => removePart(i)}
                            aria-label="Remove part"
                        >
                            ✕
                        </button>
                    {/if}
                </div>
                
                <div class="form-group">
                    <label for="text-{i}">Text</label>
                    <textarea
                        id="text-{i}"
                        bind:value={part.text}
                        placeholder="Enter text for this part..."
                        rows="3"
                        required
                    ></textarea>
                </div>
                
                <div class="part-details">
                    <div class="form-group">
                        <label for="voice-{i}">Voice</label>
                        <select
                            id="voice-{i}"
                            bind:value={part.voice_id}
                            required
                        >
                            {#each voices as voice}
                                <option value={voice.voice_id}>
                                    {voice.name} ({voice.category})
                                </option>
                            {/each}
                        </select>
                    </div>
                    
                    <div class="form-group">
                        <label for="actor-{i}">Actor Name</label>
                        <input
                            id="actor-{i}"
                            type="text"
                            bind:value={part.actor}
                            placeholder="Enter actor name..."
                        />
                    </div>
                </div>
                {#if part.voice_id && getSelectedVoice(part.voice_id)}
                    {@const voice = getSelectedVoice(part.voice_id)}
                    <div class="voice-details">
                        <button 
                            type="button" 
                            class="toggle-details"
                            on:click={() => expandedVoiceDetails[i] = !expandedVoiceDetails[i]}
                            aria-expanded={expandedVoiceDetails[i]}
                        >
                            <h4>Voice Details</h4>
                            <span class="toggle-icon">{expandedVoiceDetails[i] ? '▼' : '▶'}</span>
                        </button>
                        {#if expandedVoiceDetails[i]}
                        <div class="voice-info">
                            <div class="info-group">
                                <span class="label">Name:</span>
                                <span>{voice ? voice.name : 'Unknown Voice'}</span>
                            </div>
                            {#if voice && voice.description}
                                <div class="info-group">
                                    <span class="label">Description:</span>
                                    <span>{voice.description}</span>
                                </div>
                            {/if}
                            {#if voice && voice.labels}
                                <div class="info-group">
                                    <span class="label">Labels:</span>
                                    <span>{Object.entries(voice.labels)
                                        .map(([key, value]) => `${key}: ${value}`)
                                        .join(', ')}</span>
                                </div>
                            {/if}
                            {#if voice && voice.preview_url}
                                <div class="preview-audio">
                                    <span class="label">Preview:</span>
                                    <audio controls src={voice.preview_url}>
                                        <track kind="captions">
                                        Your browser does not support the audio element.
                                    </audio>
                                </div>
                            {/if}
                        </div>
                        {/if}
                    </div>
                {/if}
            </div>
        {/each}
        
        <div class="form-actions">
            <button 
                type="button" 
                class="secondary-button"
                on:click={addPart}
            >
                Add Part
            </button>
            
            <button 
                type="submit" 
                class="primary-button"
                disabled={loading || isFormInvalid}
            >
                {#if loading}
                    <LoadingSpinner size={16} />
                    Generating...
                {:else}
                    Generate Audio
                {/if}
            </button>
        </div>
    </form>
    
    {#if result}
        <div class="result">
            {#if result.success && result.audioData}
                <AudioPlayer 
                    audioData={result.audioData.data}
                    name={result.audioData.name}
                />
            {:else}
                <p class="error">{result.message}</p>
            {/if}
            
            <DebugInfo info={result.debugInfo} />
        </div>
    {/if}
</main>
<style>
    main {
        max-width: 800px;
        margin: 0 auto;
        padding: var(--spacing-8);
    }
    
    h2 {
        margin-bottom: var(--spacing-2);
        color: var(--color-text);
        font-size: var(--font-size-2xl);
        text-align: center;
    }
    .page-description {
        text-align: center;
        color: var(--color-text-light);
        margin-bottom: var(--spacing-8);
    }
    
    .script-form {
        display: flex;
        flex-direction: column;
        gap: var(--spacing-6);
        margin-bottom: var(--spacing-8);
    }
    
    .script-part {
        padding: var(--spacing-6);
        background: var(--color-surface);
        border: 1px solid var(--border-color);
        border-radius: var(--border-radius-lg);
        box-shadow: var(--shadow-base);
    }
    
    .part-header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin-bottom: var(--spacing-4);
    }
    
    h3 {
        margin: 0;
        color: var(--color-text);
        font-size: var(--font-size-lg);
    }
    
    .remove-button {
        padding: var(--spacing-1) var(--spacing-2);
        background: var(--color-error);
        color: white;
        border: none;
        border-radius: var(--border-radius-sm);
        cursor: pointer;
        font-size: var(--font-size-sm);
        transition: all var(--transition-base);
    }
    
    .remove-button:hover {
        opacity: 0.9;
        transform: translateY(-1px);
    }
    
    .form-group {
        display: flex;
        flex-direction: column;
        gap: var(--spacing-2);
        margin-bottom: var(--spacing-4);
    }
    .voice-details {
        margin-top: var(--spacing-4);
        background: var(--color-surface-light);
        padding: var(--spacing-4);
        border-radius: var(--border-radius-base);
        border: 1px solid var(--border-color);
    }
    .toggle-details {
        width: 100%;
        display: flex;
        justify-content: space-between;
        align-items: center;
        background: none;
        border: none;
        padding: 0;
        margin-bottom: var(--spacing-3);
        cursor: pointer;
        color: var(--color-text);
    }
    .toggle-details:hover {
        opacity: 0.8;
    }
    .toggle-details h4 {
        font-size: var(--font-size-base);
        margin: 0;
        color: inherit;
    }
    .toggle-icon {
        font-size: var(--font-size-lg);
        font-weight: bold;
        color: var(--color-text-light);
    }
    .voice-info {
        display: flex;
        flex-direction: column;
        gap: var(--spacing-3);
    }
    .info-group {
        display: flex;
        flex-direction: column;
        gap: var(--spacing-1);
    }
    .preview-audio {
        display: flex;
        flex-direction: column;
        gap: var(--spacing-2);
        margin-top: var(--spacing-2);
    }
    .preview-audio audio {
        width: 100%;
        margin-top: var(--spacing-1);
    }
    .label {
        font-weight: 500;
        color: var(--color-text-light);
        font-size: var(--font-size-sm);
    }
    
    .part-details {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: var(--spacing-4);
    }
    
    label {
        font-weight: 500;
        color: var(--color-text);
        font-size: var(--font-size-sm);
    }
    
    textarea, input, select {
        padding: var(--spacing-3);
        border: 1px solid var(--border-color);
        border-radius: var(--border-radius-base);
        font-size: var(--font-size-base);
        background: var(--color-background);
        transition: all var(--transition-base);
    }
    
    textarea:focus, input:focus, select:focus {
        outline: none;
        border-color: var(--color-primary);
        box-shadow: var(--shadow-sm);
    }
    
    .form-actions {
        display: flex;
        gap: var(--spacing-4);
        justify-content: flex-end;
        margin-top: var(--spacing-4);
    }
    
    button {
        padding: var(--spacing-3) var(--spacing-6);
        border: none;
        border-radius: var(--border-radius-base);
        font-size: var(--font-size-base);
        font-weight: 500;
        cursor: pointer;
        display: flex;
        align-items: center;
        justify-content: center;
        gap: var(--spacing-2);
        transition: all var(--transition-base);
        box-shadow: var(--shadow-sm);
    }
    
    .primary-button {
        background: var(--color-primary);
        color: var(--color-surface);
    }
    
    .secondary-button {
        background: var(--color-secondary-light);
        color: var(--color-text);
    }
    
    button:disabled {
        opacity: 0.7;
        cursor: not-allowed;
        transform: none;
    }
    
    button:not(:disabled):hover {
        transform: translateY(-1px);
        box-shadow: var(--shadow-base);
    }
    .primary-button:not(:disabled):hover {
        background: var(--color-primary-dark);
    }
    .secondary-button:not(:disabled):hover {
        background: var(--color-secondary);
        color: var(--color-surface);
    }
    
    .result {
        margin-top: var(--spacing-8);
        background: var(--color-surface);
        padding: var(--spacing-6);
        border-radius: var(--border-radius-lg);
        box-shadow: var(--shadow-base);
    }
    
    .error {
        color: var(--color-error);
        padding: var(--spacing-4);
        background: #fef2f2;
        border: 1px solid #fee2e2;
        border-radius: var(--border-radius-base);
        margin-bottom: var(--spacing-4);
    }
    @media (max-width: 640px) {
        main {
            padding: var(--spacing-4);
        }
        .voice-details {
            padding: var(--spacing-3);
            margin-top: var(--spacing-3);
        }
        .voice-details h4 {
            font-size: var(--font-size-sm);
            margin-bottom: var(--spacing-2);
        }
        .info-group {
            font-size: var(--font-size-sm);
        }
        h2 {
            font-size: var(--font-size-xl);
            margin-bottom: var(--spacing-2);
        }
        .page-description {
            font-size: var(--font-size-sm);
            margin-bottom: var(--spacing-6);
        }
        .script-part {
            padding: var(--spacing-4);
        }
        .part-details {
            grid-template-columns: 1fr;
        }
        .form-actions {
            flex-direction: column;
        }
        .form-actions button {
            width: 100%;
        }
        .result {
            padding: var(--spacing-4);
        }
    }
</style>