This major update brings comprehensive multilingual support to Orpheus-FASTAPI: • Added 16 new voice actors across 7 languages (French, German, Korean, Hindi, Mandarin, Spanish, Italian) • Released 6 language-specific optimized models for superior pronunciation and fluency • Enhanced UI with dynamic language selector and voice filtering • Updated Docker Compose workflow to simplify language-specific model deployment • Reorganized code architecture with language-aware voice mappings All voices support the same emotion tags and audio quality features as the original English voices, maintaining compatibility with existing applications while expanding international usability.
841 lines
38 KiB
HTML
841 lines
38 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en" class="h-full bg-[#0f1729]">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Orpheus FASTAPI | Advanced Text-to-Speech</title>
|
|
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script>
|
|
tailwind.config = {
|
|
darkMode: 'class',
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
primary: {
|
|
50: '#f0f9ff',
|
|
100: '#e0f2fe',
|
|
200: '#bae6fd',
|
|
300: '#7dd3fc',
|
|
400: '#38bdf8',
|
|
500: '#0ea5e9',
|
|
600: '#0284c7',
|
|
700: '#0369a1',
|
|
800: '#075985',
|
|
900: '#0c4a6e',
|
|
},
|
|
purple: {
|
|
50: '#faf5ff',
|
|
100: '#f3e8ff',
|
|
200: '#e9d5ff',
|
|
300: '#d8b4fe',
|
|
400: '#c084fc',
|
|
500: '#a855f7',
|
|
600: '#9333ea',
|
|
700: '#7e22ce',
|
|
800: '#6b21a8',
|
|
900: '#581c87',
|
|
},
|
|
dark: {
|
|
50: '#f9fafb',
|
|
100: '#f3f4f6',
|
|
200: '#e5e7eb',
|
|
300: '#d1d5db',
|
|
400: '#9ca3af',
|
|
500: '#6b7280',
|
|
600: '#4b5563',
|
|
700: '#374151',
|
|
800: '#1f2937',
|
|
900: '#111827',
|
|
950: '#030712',
|
|
1000: '#0f1729'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
<style type="text/tailwindcss">
|
|
@layer components {
|
|
.btn-primary {
|
|
@apply bg-primary-600 text-white px-4 py-2 rounded-md shadow-sm hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-dark-800 focus:ring-offset-2 transition-colors;
|
|
}
|
|
.voice-card {
|
|
@apply border border-dark-700 rounded-lg p-4 cursor-pointer transition-all hover:border-primary-400 hover:shadow-md bg-dark-800 text-dark-200;
|
|
}
|
|
.voice-card.active {
|
|
@apply border-primary-500 ring-2 ring-primary-500 bg-dark-700;
|
|
}
|
|
.audio-progress {
|
|
@apply h-2 w-full bg-dark-700 rounded-full overflow-hidden;
|
|
}
|
|
.audio-progress-bar {
|
|
@apply h-full bg-primary-500 transition-all duration-300;
|
|
}
|
|
}
|
|
</style>
|
|
<script src="https://cdn.jsdelivr.net/npm/wavesurfer.js@6/dist/wavesurfer.min.js"></script>
|
|
</head>
|
|
<body class="h-full">
|
|
<div class="min-h-full">
|
|
<!-- Navigation -->
|
|
<nav class="bg-gradient-to-r from-dark-900 to-purple-900 border-b border-purple-800 shadow-lg">
|
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
|
<div class="flex h-16 items-center justify-between">
|
|
<div class="flex items-center">
|
|
<div class="flex-shrink-0">
|
|
<span class="text-white text-xl font-bold">Orpheus FASTAPI</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center space-x-4">
|
|
<a href="/docs" class="text-primary-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium">API Docs</a>
|
|
<a href="https://github.com/lex-au" target="_blank" class="text-primary-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium">GitHub</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- Main content -->
|
|
<main>
|
|
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
|
|
<!-- Notification area -->
|
|
{% if error %}
|
|
<div class="mb-6 bg-red-50 border-l-4 border-red-400 p-4 rounded-md shadow-sm">
|
|
<div class="flex">
|
|
<div class="flex-shrink-0">
|
|
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
|
</svg>
|
|
</div>
|
|
<div class="ml-3">
|
|
<p class="text-sm text-red-700">{{ error }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if success %}
|
|
<div class="mb-6 bg-green-50 border-l-4 border-green-400 p-4 rounded-md shadow-sm">
|
|
<div class="flex">
|
|
<div class="flex-shrink-0">
|
|
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
|
</svg>
|
|
</div>
|
|
<div class="ml-3">
|
|
<p class="text-sm text-green-700">Audio generated successfully in {{ generation_time }}s!</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- TTS form -->
|
|
<div class="bg-dark-800 shadow-lg rounded-lg overflow-hidden border border-dark-700">
|
|
<form id="tts-form" class="flex flex-col">
|
|
<div class="p-6">
|
|
<h2 class="text-lg font-medium text-white mb-4">Generate Speech</h2>
|
|
|
|
<!-- Text input -->
|
|
<div class="mb-6">
|
|
<label for="text" class="block text-sm font-medium text-white mb-1">Text to speak</label>
|
|
<div class="relative">
|
|
<textarea
|
|
name="text"
|
|
id="text"
|
|
rows="4"
|
|
maxlength="8192"
|
|
class="block w-full rounded-md border-dark-600 bg-dark-700 text-white shadow-sm focus:border-primary-500 focus:ring-primary-500 focus:ring-offset-dark-800 sm:text-sm px-3 py-2"
|
|
placeholder="Enter text to convert to speech..."
|
|
required
|
|
>{{ text if text else "" }}</textarea>
|
|
<div class="absolute bottom-2 right-2 text-xs text-purple-300">
|
|
<span id="char-count">0</span> / 8192 characters
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Language selection -->
|
|
<div class="mb-6">
|
|
<label class="block text-sm font-medium text-white mb-2">Language</label>
|
|
<div class="flex flex-wrap gap-3">
|
|
{% for language in AVAILABLE_LANGUAGES %}
|
|
<div class="language-option {% if language == 'english' %}active{% endif %}" data-language="{{ language }}">
|
|
<input type="radio" name="language" value="{{ language }}" class="hidden" {% if language == 'english' %}checked{% endif %}>
|
|
<span class="px-3 py-1 rounded-full bg-dark-700 hover:bg-dark-600 text-white text-sm cursor-pointer">{{ language|capitalize }}</span>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Voice selection -->
|
|
<div class="mb-6">
|
|
<label class="block text-sm font-medium text-white mb-2">Voice</label>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{% for voice_option in voices %}
|
|
<div class="voice-card {% if voice_option == DEFAULT_VOICE %}active{% endif %}"
|
|
data-voice="{{ voice_option }}"
|
|
data-language="{{ VOICE_TO_LANGUAGE[voice_option] }}"
|
|
style="display: {% if VOICE_TO_LANGUAGE[voice_option] != 'english' %}none{% endif %}">
|
|
<input type="radio" name="voice" value="{{ voice_option }}" class="hidden" {% if voice_option == DEFAULT_VOICE %}checked{% endif %}>
|
|
<div class="flex items-center mb-2">
|
|
<span class="font-medium text-white">{{ voice_option|capitalize }}</span>
|
|
</div>
|
|
<div class="text-xs text-dark-300">
|
|
{% if voice_option == "tara" %}Female, English, conversational, clear
|
|
{% elif voice_option == "leah" %}Female, English, warm, gentle
|
|
{% elif voice_option == "jess" %}Female, English, energetic, youthful
|
|
{% elif voice_option == "leo" %}Male, English, authoritative, deep
|
|
{% elif voice_option == "dan" %}Male, English, friendly, casual
|
|
{% elif voice_option == "mia" %}Female, English, professional, articulate
|
|
{% elif voice_option == "zac" %}Male, English, enthusiastic, dynamic
|
|
{% elif voice_option == "zoe" %}Female, English, calm, soothing
|
|
|
|
<!-- French voices -->
|
|
{% elif voice_option == "pierre" %}Male, French, sophisticated
|
|
{% elif voice_option == "amelie" %}Female, French, elegant
|
|
{% elif voice_option == "marie" %}Female, French, spirited
|
|
|
|
<!-- German voices -->
|
|
{% elif voice_option == "jana" %}Female, German, clear
|
|
{% elif voice_option == "thomas" %}Male, German, authoritative
|
|
{% elif voice_option == "max" %}Male, German, energetic
|
|
|
|
<!-- Korean voices -->
|
|
{% elif voice_option == "유나" %}Female, Korean, melodic
|
|
{% elif voice_option == "준서" %}Male, Korean, confident
|
|
|
|
<!-- Hindi voice -->
|
|
{% elif voice_option == "ऋतिका" %}Female, Hindi, expressive
|
|
|
|
<!-- Mandarin voices -->
|
|
{% elif voice_option == "长乐" %}Female, Mandarin, gentle
|
|
{% elif voice_option == "白芷" %}Female, Mandarin, clear
|
|
|
|
<!-- Spanish voices -->
|
|
{% elif voice_option == "javi" %}Male, Spanish, warm
|
|
{% elif voice_option == "sergio" %}Male, Spanish, professional
|
|
{% elif voice_option == "maria" %}Female, Spanish, friendly
|
|
|
|
<!-- Italian voices -->
|
|
{% elif voice_option == "pietro" %}Male, Italian, passionate
|
|
{% elif voice_option == "giulia" %}Female, Italian, expressive
|
|
{% elif voice_option == "carlo" %}Male, Italian, refined
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Advanced options (can be expanded) -->
|
|
<div class="mb-6">
|
|
<details class="group">
|
|
<summary class="list-none flex cursor-pointer">
|
|
<span class="text-sm font-medium text-white">Advanced options</span>
|
|
<span class="ml-2 text-purple-300">
|
|
<svg class="group-open:rotate-180 h-5 w-5 transition-transform" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
|
</svg>
|
|
</span>
|
|
</summary>
|
|
|
|
<!-- Audio generation options -->
|
|
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label for="model" class="block text-sm font-medium text-white mb-1">Model</label>
|
|
<div class="relative">
|
|
<select id="model" name="model" class="block w-full rounded-md bg-dark-700 border-dark-600 text-white shadow-sm focus:border-primary-500 focus:ring-primary-500 focus:ring-offset-dark-800 focus:outline-none outline-none sm:text-sm pl-3 pr-10 py-2 appearance-none">
|
|
<option value="orpheus" selected>Orpheus 3B (0.1)</option>
|
|
</select>
|
|
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-purple-300">
|
|
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label for="speed" class="block text-sm font-medium text-white mb-1">Speed</label>
|
|
<input type="range" id="speed" name="speed" min="0.5" max="1.5" step="0.1" value="1.0"
|
|
class="mt-1 w-full h-2 bg-dark-600 rounded-lg appearance-none cursor-pointer">
|
|
<div class="flex justify-between text-xs text-purple-300 mt-1">
|
|
<span>Slower</span>
|
|
<span id="speed-value">1.0</span>
|
|
<span>Faster</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Server configuration section -->
|
|
<div class="mt-6 border-t border-dark-700 pt-4">
|
|
<h3 class="text-sm font-medium text-white mb-3">Server Configuration</h3>
|
|
<p class="text-xs text-purple-300 mb-3">These settings will be saved to a <code class="bg-dark-700 px-1 rounded">.env</code> file. Restart the server to apply changes.</p>
|
|
|
|
<!-- Form fields for all .env parameters -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<!-- Connection settings -->
|
|
<div>
|
|
<label for="api_url" class="block text-xs font-medium text-white mb-1">API URL</label>
|
|
<input type="text" id="api_url" name="ORPHEUS_API_URL"
|
|
placeholder="http://127.0.0.1:1234/v1/completions"
|
|
class="block w-full rounded-md bg-dark-700 border-dark-600 text-white text-sm focus:border-primary-500 focus:ring-primary-500 focus:ring-offset-dark-800 px-3 py-2">
|
|
</div>
|
|
|
|
<div>
|
|
<label for="api_timeout" class="block text-xs font-medium text-white mb-1">API Timeout (seconds)</label>
|
|
<input type="number" id="api_timeout" name="ORPHEUS_API_TIMEOUT" min="10" max="1800" step="1"
|
|
class="block w-full rounded-md bg-dark-700 border-dark-600 text-white text-sm focus:border-primary-500 focus:ring-primary-500 focus:ring-offset-dark-800 px-3 py-2">
|
|
</div>
|
|
|
|
<!-- Generation parameters -->
|
|
<div>
|
|
<label for="max_tokens" class="block text-xs font-medium text-white mb-1">Max Tokens</label>
|
|
<input type="number" id="max_tokens" name="ORPHEUS_MAX_TOKENS" min="100" max="200000" step="1"
|
|
class="block w-full rounded-md bg-dark-700 border-dark-600 text-white text-sm focus:border-primary-500 focus:ring-primary-500 focus:ring-offset-dark-800 px-3 py-2">
|
|
</div>
|
|
|
|
<div>
|
|
<label for="temperature" class="block text-xs font-medium text-white mb-1">Temperature</label>
|
|
<input type="number" id="temperature" name="ORPHEUS_TEMPERATURE" min="0.1" max="1.5" step="0.1"
|
|
class="block w-full rounded-md bg-dark-700 border-dark-600 text-white text-sm focus:border-primary-500 focus:ring-primary-500 focus:ring-offset-dark-800 px-3 py-2">
|
|
</div>
|
|
|
|
<div>
|
|
<label for="top_p" class="block text-xs font-medium text-white mb-1">Top P</label>
|
|
<input type="number" id="top_p" name="ORPHEUS_TOP_P" min="0.1" max="1" step="0.05"
|
|
class="block w-full rounded-md bg-dark-700 border-dark-600 text-white text-sm focus:border-primary-500 focus:ring-primary-500 focus:ring-offset-dark-800 px-3 py-2">
|
|
</div>
|
|
|
|
<div>
|
|
<div class="flex justify-between">
|
|
<label class="block text-xs font-medium text-white mb-1">Repetition Penalty</label>
|
|
<span class="text-xs text-primary-400">Fixed at 1.1</span>
|
|
</div>
|
|
<div class="bg-dark-700 text-gray-500 border-dark-600 rounded-md px-3 py-2 text-sm">
|
|
Value hardcoded to 1.1 for optimal generation quality
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Server settings -->
|
|
<div>
|
|
<label for="server_host" class="block text-xs font-medium text-white mb-1">Server Host</label>
|
|
<input type="text" id="server_host" name="ORPHEUS_HOST" placeholder="0.0.0.0"
|
|
class="block w-full rounded-md bg-dark-700 border-dark-600 text-white text-sm focus:border-primary-500 focus:ring-primary-500 focus:ring-offset-dark-800 px-3 py-2">
|
|
</div>
|
|
|
|
<div>
|
|
<label for="server_port" class="block text-xs font-medium text-white mb-1">Server Port</label>
|
|
<input type="number" id="server_port" name="ORPHEUS_PORT" min="1024" max="65535" step="1"
|
|
class="block w-full rounded-md bg-dark-700 border-dark-600 text-white text-sm focus:border-primary-500 focus:ring-primary-500 focus:ring-offset-dark-800 px-3 py-2">
|
|
</div>
|
|
|
|
<!-- Save button and restart button -->
|
|
<div class="col-span-1 md:col-span-2 mt-4 flex flex-col md:flex-row gap-4">
|
|
<button id="save-config-btn" type="button"
|
|
class="btn-primary bg-purple-600 hover:bg-purple-700 active:bg-purple-800 w-full md:w-auto">
|
|
Save Configuration
|
|
</button>
|
|
<button id="restart-server-btn" type="button"
|
|
class="btn-primary bg-red-600 hover:bg-red-700 active:bg-red-800 w-full md:w-auto hidden">
|
|
Restart Server
|
|
</button>
|
|
<p class="text-xs text-purple-300 mt-2 flex-grow">
|
|
<span id="config-status" class="hidden"></span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-dark-900 px-6 py-4 flex items-center justify-between">
|
|
<div class="text-sm text-purple-300">
|
|
<p>Supports emotion tags: <span class="font-mono text-xs"><laugh></span>, <span class="font-mono text-xs"><sigh></span>, etc.</p>
|
|
</div>
|
|
<button type="submit" id="generate-btn" class="btn-primary hover:bg-primary-600 active:bg-primary-800">
|
|
Generate Speech
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Audio player container - will be populated by JavaScript -->
|
|
<div id="audio-player-container"></div>
|
|
|
|
<!-- Recent generations (could be expanded) -->
|
|
<div class="mt-8">
|
|
<h2 class="text-lg font-medium text-white mb-4">Tips & Tricks</h2>
|
|
<div class="bg-dark-800 shadow-lg rounded-lg overflow-hidden border border-dark-700">
|
|
<div class="p-6">
|
|
<ul class="list-disc pl-5 text-sm text-purple-300 space-y-2">
|
|
<li>Use <span class="font-mono text-xs"><laugh></span> to add laughter to the speech</li>
|
|
<li>Use <span class="font-mono text-xs"><sigh></span> for a sighing sound</li>
|
|
<li>Other supported tags: <span class="font-mono text-xs"><chuckle></span>, <span class="font-mono text-xs"><cough></span>, <span class="font-mono text-xs"><sniffle></span>, <span class="font-mono text-xs"><groan></span>, <span class="font-mono text-xs"><yawn></span>, <span class="font-mono text-xs"><gasp></span></li>
|
|
<li>For longer audio, the system can generate up to 2 minutes of speech in a single request</li>
|
|
<li>For API access, use the <code class="font-mono text-xs bg-dark-600 text-primary-300 p-1 rounded">/v1/audio/speech</code> endpoint (OpenAI compatible)</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Footer -->
|
|
<footer class="bg-dark-900 border-t border-dark-700 py-6">
|
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
|
<div class="flex justify-center">
|
|
<span class="text-purple-300 text-sm">Powered by <a href="https://fastapi.tiangolo.com/" target="_blank" class="text-primary-400 hover:text-primary-300">FASTAPI</a></span>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
|
|
<!-- Loading spinner template (hidden by default) -->
|
|
<div id="loading-overlay" class="hidden fixed inset-0 bg-dark-900 bg-opacity-75 flex items-center justify-center z-50">
|
|
<div class="bg-dark-800 p-6 rounded-lg shadow-lg flex flex-col items-center">
|
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500 mb-4"></div>
|
|
<p class="text-white text-lg">Generating audio...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Audio player template (used for dynamic insertion) -->
|
|
<template id="audio-player-template">
|
|
<div class="mt-8 bg-dark-800 shadow-lg rounded-lg overflow-hidden border border-dark-700">
|
|
<div class="p-6">
|
|
<h2 class="text-lg font-medium text-white mb-4">Generated Audio</h2>
|
|
|
|
<div class="mb-6">
|
|
<div id="waveform" class="w-full h-24"></div>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
|
<div class="flex items-center space-x-4">
|
|
<button id="play-btn" class="inline-flex items-center px-4 py-2 border border-primary-700 rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-dark-800 focus:ring-offset-2">
|
|
<svg class="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
|
|
</svg>
|
|
Play
|
|
</button>
|
|
<a id="download-link" href="#" download class="inline-flex items-center px-4 py-2 border border-dark-600 rounded-md shadow-sm text-sm font-medium text-white bg-dark-700 hover:bg-dark-600 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-dark-800 focus:ring-offset-2">
|
|
<svg class="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
|
</svg>
|
|
Download
|
|
</a>
|
|
</div>
|
|
<div class="text-sm text-purple-300">
|
|
Voice: <span id="voice-name" class="font-medium"></span> •
|
|
Duration: <span id="audio-duration" class="font-medium">--:--</span> •
|
|
Generated in: <span id="generation-time" class="font-medium"></span>s
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- JavaScript for interactivity -->
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Global variables
|
|
let wavesurfer;
|
|
// Character counter
|
|
const textArea = document.getElementById('text');
|
|
const charCount = document.getElementById('char-count');
|
|
|
|
textArea.addEventListener('input', function() {
|
|
charCount.textContent = textArea.value.length;
|
|
});
|
|
|
|
// Initialize char count
|
|
charCount.textContent = textArea.value.length;
|
|
|
|
// Language selection
|
|
const languageOptions = document.querySelectorAll('.language-option');
|
|
const voiceCards = document.querySelectorAll('.voice-card');
|
|
|
|
languageOptions.forEach(option => {
|
|
option.addEventListener('click', function() {
|
|
// Unselect all language options
|
|
languageOptions.forEach(o => o.classList.remove('active'));
|
|
|
|
// Select this language option
|
|
this.classList.add('active');
|
|
|
|
// Check the radio button
|
|
const radio = this.querySelector('input[type="radio"]');
|
|
radio.checked = true;
|
|
|
|
// Get the selected language
|
|
const selectedLanguage = this.getAttribute('data-language');
|
|
|
|
// Show/hide voice cards based on language
|
|
let firstVisibleCard = null;
|
|
|
|
voiceCards.forEach(card => {
|
|
const cardLanguage = card.getAttribute('data-language');
|
|
if (cardLanguage === selectedLanguage) {
|
|
card.style.display = '';
|
|
if (!firstVisibleCard) {
|
|
firstVisibleCard = card;
|
|
}
|
|
} else {
|
|
card.style.display = 'none';
|
|
// If this hidden card was selected, unselect it
|
|
if (card.classList.contains('active')) {
|
|
card.classList.remove('active');
|
|
const cardRadio = card.querySelector('input[type="radio"]');
|
|
if (cardRadio) {
|
|
cardRadio.checked = false;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// If no voice card is selected for this language, select the first one
|
|
if (firstVisibleCard && document.querySelectorAll(`.voice-card[data-language="${selectedLanguage}"].active:not([style*="display: none"])`).length === 0) {
|
|
firstVisibleCard.classList.add('active');
|
|
const firstVisibleRadio = firstVisibleCard.querySelector('input[type="radio"]');
|
|
if (firstVisibleRadio) {
|
|
firstVisibleRadio.checked = true;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Voice card selection
|
|
voiceCards.forEach(card => {
|
|
card.addEventListener('click', function() {
|
|
// Only allow selection of cards that are visible (not display:none)
|
|
if (this.style.display !== 'none') {
|
|
// Unselect all cards with the same language
|
|
const cardLanguage = this.getAttribute('data-language');
|
|
document.querySelectorAll(`.voice-card[data-language="${cardLanguage}"]`).forEach(c => {
|
|
c.classList.remove('active');
|
|
const radio = c.querySelector('input[type="radio"]');
|
|
if (radio) {
|
|
radio.checked = false;
|
|
}
|
|
});
|
|
|
|
// Select this card
|
|
this.classList.add('active');
|
|
|
|
// Check the radio button
|
|
const radio = this.querySelector('input[type="radio"]');
|
|
if (radio) {
|
|
radio.checked = true;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Speed slider
|
|
const speedSlider = document.getElementById('speed');
|
|
const speedValue = document.getElementById('speed-value');
|
|
|
|
speedSlider.addEventListener('input', function() {
|
|
speedValue.textContent = speedSlider.value;
|
|
});
|
|
|
|
// No preview buttons in this version
|
|
|
|
// Function to initialize WaveSurfer
|
|
function initWaveSurfer(audioPath) {
|
|
// If wavesurfer already exists, destroy it to prevent memory leaks
|
|
if (wavesurfer) {
|
|
wavesurfer.destroy();
|
|
}
|
|
|
|
// Create new wavesurfer instance
|
|
wavesurfer = WaveSurfer.create({
|
|
container: '#waveform',
|
|
waveColor: '#38bdf8',
|
|
progressColor: '#0284c7',
|
|
cursorColor: '#0ea5e9',
|
|
barWidth: 3,
|
|
barRadius: 3,
|
|
cursorWidth: 1,
|
|
height: 80,
|
|
barGap: 2,
|
|
responsive: true
|
|
});
|
|
|
|
wavesurfer.load('/' + audioPath);
|
|
|
|
wavesurfer.on('ready', function() {
|
|
const duration = wavesurfer.getDuration();
|
|
const minutes = Math.floor(duration / 60);
|
|
const seconds = Math.floor(duration % 60);
|
|
document.getElementById('audio-duration').textContent =
|
|
`${minutes}:${seconds < 10 ? '0' + seconds : seconds}`;
|
|
});
|
|
|
|
// Play button
|
|
const playBtn = document.getElementById('play-btn');
|
|
playBtn.addEventListener('click', function() {
|
|
wavesurfer.playPause();
|
|
|
|
if (wavesurfer.isPlaying()) {
|
|
playBtn.innerHTML = `
|
|
<svg class="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 00-1 1v2a1 1 0 001 1h6a1 1 0 001-1V9a1 1 0 00-1-1H7z" clip-rule="evenodd" />
|
|
</svg>
|
|
Pause
|
|
`;
|
|
} else {
|
|
playBtn.innerHTML = `
|
|
<svg class="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
|
|
</svg>
|
|
Play
|
|
`;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Function to create and add the audio player to the DOM
|
|
function createAudioPlayer(response) {
|
|
const container = document.getElementById('audio-player-container');
|
|
const template = document.getElementById('audio-player-template');
|
|
|
|
// Clone the template
|
|
const audioPlayer = template.content.cloneNode(true);
|
|
|
|
// Clear previous player if exists
|
|
container.innerHTML = '';
|
|
|
|
// Add the new player
|
|
container.appendChild(audioPlayer);
|
|
|
|
// Set values
|
|
document.getElementById('voice-name').textContent = response.voice.charAt(0).toUpperCase() + response.voice.slice(1);
|
|
document.getElementById('generation-time').textContent = response.generation_time;
|
|
document.getElementById('download-link').href = '/' + response.output_file;
|
|
|
|
// Initialize waveform
|
|
initWaveSurfer(response.output_file);
|
|
}
|
|
|
|
// Form submission handler
|
|
const form = document.getElementById('tts-form');
|
|
form.addEventListener('submit', async function(event) {
|
|
// Prevent default form submission
|
|
event.preventDefault();
|
|
|
|
// Get form data
|
|
const text = document.getElementById('text').value;
|
|
const voice = document.querySelector('input[name="voice"]:checked').value;
|
|
|
|
if (!text.trim()) {
|
|
alert('Please enter some text to generate speech');
|
|
return;
|
|
}
|
|
|
|
// Show loading overlay
|
|
document.getElementById('loading-overlay').classList.remove('hidden');
|
|
|
|
try {
|
|
// Make API request to /speak endpoint
|
|
const response = await fetch('/speak', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
text: text,
|
|
voice: voice
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to generate speech');
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Create audio player with response
|
|
createAudioPlayer(data);
|
|
|
|
} catch (error) {
|
|
console.error('Error generating speech:', error);
|
|
alert('An error occurred while generating speech. Please try again.');
|
|
} finally {
|
|
// Hide loading overlay
|
|
document.getElementById('loading-overlay').classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
// Initialize audio player if output file exists from server-side rendering
|
|
{% if output_file %}
|
|
createAudioPlayer({
|
|
voice: "{{ voice if voice else DEFAULT_VOICE }}",
|
|
output_file: "{{ output_file }}",
|
|
generation_time: "{{ generation_time }}"
|
|
});
|
|
{% endif %}
|
|
|
|
// Configuration management
|
|
async function loadConfigValues() {
|
|
try {
|
|
const response = await fetch('/get_config');
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load configuration');
|
|
}
|
|
|
|
const config = await response.json();
|
|
|
|
// Populate form fields with config values
|
|
document.querySelectorAll('input[name^="ORPHEUS_"]').forEach(input => {
|
|
const name = input.name;
|
|
if (config[name] !== undefined) {
|
|
input.value = config[name];
|
|
}
|
|
});
|
|
|
|
console.log('Configuration loaded successfully');
|
|
} catch (error) {
|
|
console.error('Error loading configuration:', error);
|
|
}
|
|
}
|
|
|
|
// Save configuration
|
|
async function saveConfiguration() {
|
|
const configData = {};
|
|
|
|
// Collect values from all configuration inputs
|
|
document.querySelectorAll('input[name^="ORPHEUS_"]').forEach(input => {
|
|
configData[input.name] = input.value;
|
|
});
|
|
|
|
try {
|
|
const response = await fetch('/save_config', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(configData)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to save configuration');
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
// Show success message
|
|
const statusElem = document.getElementById('config-status');
|
|
statusElem.textContent = result.message;
|
|
statusElem.classList.remove('hidden');
|
|
statusElem.classList.add('text-green-400');
|
|
|
|
// Hide message after 5 seconds
|
|
setTimeout(() => {
|
|
statusElem.classList.add('hidden');
|
|
}, 5000);
|
|
|
|
} catch (error) {
|
|
console.error('Error saving configuration:', error);
|
|
|
|
// Show error message
|
|
const statusElem = document.getElementById('config-status');
|
|
statusElem.textContent = 'Error saving configuration. Please try again.';
|
|
statusElem.classList.remove('hidden');
|
|
statusElem.classList.add('text-red-400');
|
|
|
|
// Hide message after 5 seconds
|
|
setTimeout(() => {
|
|
statusElem.classList.add('hidden');
|
|
}, 5000);
|
|
}
|
|
}
|
|
|
|
// Function to restart the server
|
|
async function restartServer() {
|
|
const restartBtn = document.getElementById('restart-server-btn');
|
|
restartBtn.disabled = true;
|
|
restartBtn.textContent = 'Restarting...';
|
|
|
|
try {
|
|
const response = await fetch('/restart_server', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to restart server');
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
// Show server restarting message and overlay
|
|
const statusElem = document.getElementById('config-status');
|
|
statusElem.textContent = result.message;
|
|
statusElem.classList.remove('hidden');
|
|
statusElem.classList.add('text-yellow-400');
|
|
|
|
// Show loading overlay with different message
|
|
const loadingOverlay = document.getElementById('loading-overlay');
|
|
loadingOverlay.querySelector('p').textContent = 'Server is restarting...';
|
|
loadingOverlay.classList.remove('hidden');
|
|
|
|
// Poll for server readiness, then do a complete page reload with cache busting
|
|
function checkServerReady() {
|
|
console.log("Checking if server is ready...");
|
|
fetch('/get_config?cache=' + Date.now(), {
|
|
cache: 'no-store',
|
|
headers: { 'pragma': 'no-cache' }
|
|
})
|
|
.then(response => {
|
|
if (response.ok) {
|
|
console.log("Server is up and running, forcing complete page reload");
|
|
// Force a complete reload with cache busting
|
|
window.location = window.location.pathname + '?t=' + Date.now();
|
|
} else {
|
|
console.log("Server not ready yet, status: " + response.status);
|
|
setTimeout(checkServerReady, 1000);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.log("Server not responding yet, waiting...");
|
|
setTimeout(checkServerReady, 1000);
|
|
});
|
|
}
|
|
|
|
// Start checking after initial delay
|
|
setTimeout(checkServerReady, 2000);
|
|
|
|
} catch (error) {
|
|
console.error('Error restarting server:', error);
|
|
|
|
// Show error message
|
|
const statusElem = document.getElementById('config-status');
|
|
statusElem.textContent = 'Error restarting server. Please try again.';
|
|
statusElem.classList.remove('hidden');
|
|
statusElem.classList.add('text-red-400');
|
|
|
|
// Reset button
|
|
restartBtn.disabled = false;
|
|
restartBtn.textContent = 'Restart Server';
|
|
}
|
|
}
|
|
|
|
// Add event listeners
|
|
document.getElementById('save-config-btn').addEventListener('click', function() {
|
|
saveConfiguration();
|
|
// Show restart button after saving
|
|
const restartBtn = document.getElementById('restart-server-btn');
|
|
restartBtn.classList.remove('hidden');
|
|
});
|
|
|
|
document.getElementById('restart-server-btn').addEventListener('click', restartServer);
|
|
|
|
// Load configuration values when page loads
|
|
loadConfigValues();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|