Spaces:
Paused
Paused
| <html lang="en" data-theme="light"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>Voice Cloning</title> | |
| <style> | |
| :root { | |
| /* Base colors */ | |
| --bg-color: #ffffff; | |
| --text-color: #333333; | |
| --card-bg: #ffffff; | |
| --card-border: #e2e8f0; | |
| --card-shadow: rgba(0,0,0,0.1); | |
| /* Primary colors */ | |
| --primary-color: #4f46e5; | |
| --primary-hover: #4338ca; | |
| --primary-light: rgba(79, 70, 229, 0.1); | |
| /* Secondary colors */ | |
| --secondary-color: #6b7280; | |
| --secondary-hover: #4b5563; | |
| /* Accent colors */ | |
| --success-color: #10b981; | |
| --success-hover: #059669; | |
| --danger-color: #ef4444; | |
| --danger-hover: #dc2626; | |
| --warning-color: #f59e0b; | |
| --warning-hover: #d97706; | |
| /* Input elements */ | |
| --input-bg: #ffffff; | |
| --input-border: #d1d5db; | |
| --input-focus-border: #4f46e5; | |
| --input-focus-shadow: rgba(79, 70, 229, 0.2); | |
| /* Voice cards */ | |
| --voice-card-bg: #f9fafb; | |
| --voice-card-border: #e5e7eb; | |
| --voice-card-shadow: rgba(0,0,0,0.05); | |
| /* Tabs */ | |
| --tab-border: #e5e7eb; | |
| --tab-text: #4b5563; | |
| --tab-active: #4f46e5; | |
| --tab-active-bg: rgba(79, 70, 229, 0.1); | |
| /* Toggle */ | |
| --toggle-bg: #e5e7eb; | |
| --toggle-active: #4f46e5; | |
| --toggle-circle: #ffffff; | |
| /* Status indicators */ | |
| --status-success-bg: #dcfce7; | |
| --status-success-text: #166534; | |
| --status-error-bg: #fee2e2; | |
| --status-error-text: #b91c1c; | |
| --status-warning-bg: #fff7ed; | |
| --status-warning-text: #c2410c; | |
| --status-info-bg: #eff6ff; | |
| --status-info-text: #1e40af; | |
| } | |
| [data-theme="dark"] { | |
| /* Base colors */ | |
| --bg-color: #111827; | |
| --text-color: #f3f4f6; | |
| --card-bg: #1f2937; | |
| --card-border: #374151; | |
| --card-shadow: rgba(0,0,0,0.3); | |
| /* Primary colors */ | |
| --primary-color: #6366f1; | |
| --primary-hover: #4f46e5; | |
| --primary-light: rgba(99, 102, 241, 0.2); | |
| /* Secondary colors */ | |
| --secondary-color: #9ca3af; | |
| --secondary-hover: #6b7280; | |
| /* Accent colors - slightly brighter for dark theme */ | |
| --success-color: #34d399; | |
| --success-hover: #10b981; | |
| --danger-color: #f87171; | |
| --danger-hover: #ef4444; | |
| --warning-color: #fbbf24; | |
| --warning-hover: #f59e0b; | |
| /* Input elements */ | |
| --input-bg: #374151; | |
| --input-border: #4b5563; | |
| --input-focus-border: #6366f1; | |
| --input-focus-shadow: rgba(99, 102, 241, 0.3); | |
| /* Voice cards */ | |
| --voice-card-bg: #1f2937; | |
| --voice-card-border: #374151; | |
| --voice-card-shadow: rgba(0,0,0,0.2); | |
| /* Tabs */ | |
| --tab-border: #374151; | |
| --tab-text: #9ca3af; | |
| --tab-active: #6366f1; | |
| --tab-active-bg: rgba(99, 102, 241, 0.2); | |
| /* Toggle */ | |
| --toggle-bg: #4b5563; | |
| --toggle-active: #6366f1; | |
| --toggle-circle: #e5e7eb; | |
| /* Status indicators */ | |
| --status-success-bg: rgba(16, 185, 129, 0.2); | |
| --status-success-text: #34d399; | |
| --status-error-bg: rgba(239, 68, 68, 0.2); | |
| --status-error-text: #f87171; | |
| --status-warning-bg: rgba(245, 158, 11, 0.2); | |
| --status-warning-text: #fbbf24; | |
| --status-info-bg: rgba(59, 130, 246, 0.2); | |
| --status-info-text: #60a5fa; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| html, body { | |
| margin: 0; | |
| padding: 0; | |
| overflow-x: hidden; | |
| width: 100%; | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; | |
| max-width: 100%; | |
| padding: 16px; | |
| line-height: 1.6; | |
| background-color: var(--bg-color); | |
| color: var(--text-color); | |
| transition: background-color 0.3s, color 0.3s; | |
| } | |
| .container { | |
| width: 100%; | |
| max-width: 800px; | |
| margin: 0 auto; | |
| padding: 0 8px; | |
| } | |
| h1, h2, h3 { | |
| color: var(--text-color); | |
| margin-top: 0; | |
| } | |
| h1 { | |
| font-size: 1.8rem; | |
| margin-bottom: 1rem; | |
| text-align: center; | |
| } | |
| h2 { | |
| font-size: 1.4rem; | |
| margin-bottom: 1rem; | |
| } | |
| .card { | |
| border: 1px solid var(--card-border); | |
| border-radius: 12px; | |
| padding: 20px; | |
| margin-bottom: 20px; | |
| box-shadow: 0 4px 6px var(--card-shadow); | |
| background-color: var(--card-bg); | |
| transition: box-shadow 0.3s ease; | |
| } | |
| .card:hover { | |
| box-shadow: 0 6px 12px var(--card-shadow); | |
| } | |
| .form-group { | |
| margin-bottom: 20px; | |
| } | |
| label { | |
| display: block; | |
| margin-bottom: 6px; | |
| font-weight: 500; | |
| font-size: 0.95rem; | |
| } | |
| input, textarea, select { | |
| width: 100%; | |
| padding: 10px 12px; | |
| border: 1px solid var(--input-border); | |
| border-radius: 8px; | |
| background-color: var(--input-bg); | |
| color: var(--text-color); | |
| font-size: 1rem; | |
| transition: border-color 0.3s, box-shadow 0.3s; | |
| } | |
| input:focus, textarea:focus, select:focus { | |
| outline: none; | |
| border-color: var(--input-focus-border); | |
| box-shadow: 0 0 0 3px var(--input-focus-shadow); | |
| } | |
| /* File input styling */ | |
| input[type="file"] { | |
| padding: 8px; | |
| background-color: var(--input-bg); | |
| border: 1px dashed var(--input-border); | |
| border-radius: 8px; | |
| cursor: pointer; | |
| } | |
| input[type="file"]:hover { | |
| border-color: var(--primary-color); | |
| } | |
| button { | |
| background-color: var(--primary-color); | |
| color: white; | |
| border: none; | |
| border-radius: 8px; | |
| padding: 12px 20px; | |
| cursor: pointer; | |
| font-weight: 600; | |
| font-size: 1rem; | |
| transition: background-color 0.3s, transform 0.1s; | |
| width: 100%; | |
| } | |
| button:hover { | |
| background-color: var(--primary-hover); | |
| } | |
| button:active { | |
| transform: translateY(1px); | |
| } | |
| button:disabled { | |
| opacity: 0.7; | |
| cursor: not-allowed; | |
| } | |
| .btn-row { | |
| display: flex; | |
| gap: 10px; | |
| } | |
| .btn-row button { | |
| flex: 1; | |
| } | |
| .voice-list { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); | |
| gap: 16px; | |
| } | |
| .voice-card { | |
| border: 1px solid var(--voice-card-border); | |
| border-radius: 10px; | |
| padding: 16px; | |
| background-color: var(--voice-card-bg); | |
| box-shadow: 0 2px 6px var(--voice-card-shadow); | |
| transition: transform 0.2s ease, box-shadow 0.2s ease; | |
| } | |
| .voice-card:hover { | |
| transform: translateY(-3px); | |
| box-shadow: 0 6px 12px var(--voice-card-shadow); | |
| } | |
| .controls { | |
| display: flex; | |
| gap: 8px; | |
| margin-top: 12px; | |
| } | |
| .voice-name { | |
| font-weight: 600; | |
| font-size: 18px; | |
| margin: 0 0 8px 0; | |
| color: var(--primary-color); | |
| } | |
| .btn-danger { | |
| background-color: var(--danger-color); | |
| } | |
| .btn-danger:hover { | |
| background-color: var(--danger-hover); | |
| } | |
| .btn-secondary { | |
| background-color: var(--secondary-color); | |
| } | |
| .btn-secondary:hover { | |
| background-color: var(--secondary-hover); | |
| } | |
| #audio-preview { | |
| margin-top: 20px; | |
| width: 100%; | |
| border-radius: 8px; | |
| background-color: var(--card-bg); | |
| } | |
| /* Status indicators */ | |
| .status-indicator { | |
| padding: 12px 16px; | |
| margin: 16px 0; | |
| border-radius: 8px; | |
| font-weight: 500; | |
| display: flex; | |
| align-items: center; | |
| opacity: 0; | |
| transition: opacity 0.3s ease; | |
| max-height: 0; | |
| overflow: hidden; | |
| } | |
| .status-indicator.show { | |
| opacity: 1; | |
| max-height: 100px; | |
| } | |
| .status-indicator.success { | |
| background-color: var(--status-success-bg); | |
| color: var(--status-success-text); | |
| } | |
| .status-indicator.error { | |
| background-color: var(--status-error-bg); | |
| color: var(--status-error-text); | |
| } | |
| .status-indicator.warning { | |
| background-color: var(--status-warning-bg); | |
| color: var(--status-warning-text); | |
| } | |
| .status-indicator.info { | |
| background-color: var(--status-info-bg); | |
| color: var(--status-info-text); | |
| } | |
| .status-indicator svg { | |
| margin-right: 8px; | |
| flex-shrink: 0; | |
| } | |
| /* Tabs */ | |
| .tabs { | |
| display: flex; | |
| margin-bottom: 20px; | |
| border-bottom: 1px solid var(--tab-border); | |
| overflow-x: auto; | |
| -webkit-overflow-scrolling: touch; | |
| scrollbar-width: none; /* Hide scrollbar for Firefox */ | |
| } | |
| .tabs::-webkit-scrollbar { | |
| display: none; /* Hide scrollbar for Chrome/Safari */ | |
| } | |
| .tabs button { | |
| background-color: transparent; | |
| color: var(--tab-text); | |
| border: none; | |
| padding: 12px 16px; | |
| margin-right: 8px; | |
| cursor: pointer; | |
| position: relative; | |
| font-weight: 600; | |
| border-radius: 8px 8px 0 0; | |
| white-space: nowrap; | |
| width: auto; | |
| flex-shrink: 0; | |
| } | |
| .tabs button.active { | |
| color: var(--tab-active); | |
| background-color: var(--tab-active-bg); | |
| } | |
| .tabs button.active::after { | |
| content: ''; | |
| position: absolute; | |
| bottom: -1px; | |
| left: 0; | |
| right: 0; | |
| height: 2px; | |
| background-color: var(--tab-active); | |
| } | |
| .tab-content { | |
| display: none; | |
| } | |
| .tab-content.active { | |
| display: block; | |
| } | |
| /* Theme toggle switch */ | |
| .theme-switch-wrapper { | |
| display: flex; | |
| align-items: center; | |
| justify-content: flex-end; | |
| margin-bottom: 16px; | |
| } | |
| .theme-switch { | |
| display: inline-block; | |
| height: 28px; | |
| position: relative; | |
| width: 54px; | |
| } | |
| .theme-switch input { | |
| display: none; | |
| } | |
| .slider { | |
| background-color: var(--toggle-bg); | |
| bottom: 0; | |
| cursor: pointer; | |
| left: 0; | |
| position: absolute; | |
| right: 0; | |
| top: 0; | |
| transition: .4s; | |
| border-radius: 28px; | |
| } | |
| .slider:before { | |
| background-color: var(--toggle-circle); | |
| bottom: 4px; | |
| content: ""; | |
| height: 20px; | |
| left: 4px; | |
| position: absolute; | |
| transition: .4s; | |
| width: 20px; | |
| border-radius: 50%; | |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); | |
| } | |
| input:checked + .slider { | |
| background-color: var(--toggle-active); | |
| } | |
| input:checked + .slider:before { | |
| transform: translateX(26px); | |
| } | |
| .theme-icon { | |
| width: 16px; | |
| height: 16px; | |
| display: inline-block; | |
| margin: 0 8px; | |
| font-size: 16px; | |
| line-height: 1; | |
| } | |
| /* Progress bar */ | |
| .progress-bar { | |
| width: 100%; | |
| height: 12px; | |
| background-color: var(--input-bg); | |
| border-radius: 6px; | |
| margin-top: 12px; | |
| overflow: hidden; | |
| box-shadow: inset 0 1px 3px var(--card-shadow); | |
| } | |
| .progress-fill { | |
| height: 100%; | |
| background: linear-gradient(to right, var(--primary-color), var(--primary-hover)); | |
| width: 0%; | |
| transition: width 0.5s ease-in-out; | |
| border-radius: 6px; | |
| position: relative; | |
| } | |
| .progress-fill::after { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background: linear-gradient( | |
| -45deg, | |
| rgba(255, 255, 255, 0.2) 25%, | |
| transparent 25%, | |
| transparent 50%, | |
| rgba(255, 255, 255, 0.2) 50%, | |
| rgba(255, 255, 255, 0.2) 75%, | |
| transparent 75% | |
| ); | |
| background-size: 16px 16px; | |
| animation: progress-animation 1s linear infinite; | |
| border-radius: 6px; | |
| } | |
| @keyframes progress-animation { | |
| 0% { | |
| background-position: 0 0; | |
| } | |
| 100% { | |
| background-position: 16px 0; | |
| } | |
| } | |
| /* Divider */ | |
| .divider { | |
| margin: 24px 0; | |
| border-top: 1px solid var(--card-border); | |
| position: relative; | |
| } | |
| .divider-text { | |
| position: absolute; | |
| top: -10px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background-color: var(--bg-color); | |
| padding: 0 12px; | |
| color: var(--secondary-color); | |
| font-size: 0.9rem; | |
| } | |
| /* Small text */ | |
| small { | |
| color: var(--secondary-color); | |
| display: block; | |
| margin-top: 6px; | |
| font-size: 0.85rem; | |
| } | |
| /* Range slider styling */ | |
| input[type="range"] { | |
| -webkit-appearance: none; | |
| height: 8px; | |
| border-radius: 4px; | |
| background: var(--input-bg); | |
| outline: none; | |
| padding: 0; | |
| margin: 10px 0; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 20px; | |
| height: 20px; | |
| border-radius: 50%; | |
| background: var(--primary-color); | |
| cursor: pointer; | |
| border: 2px solid white; | |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); | |
| } | |
| input[type="range"]::-moz-range-thumb { | |
| width: 20px; | |
| height: 20px; | |
| border-radius: 50%; | |
| background: var(--primary-color); | |
| cursor: pointer; | |
| border: 2px solid white; | |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); | |
| } | |
| input[type="range"]::-ms-thumb { | |
| width: 20px; | |
| height: 20px; | |
| border-radius: 50%; | |
| background: var(--primary-color); | |
| cursor: pointer; | |
| border: 2px solid white; | |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); | |
| } | |
| #temperature-value { | |
| display: inline-block; | |
| width: 40px; | |
| text-align: center; | |
| font-weight: 600; | |
| color: var(--primary-color); | |
| } | |
| /* Toast notifications */ | |
| .toast-container { | |
| position: fixed; | |
| bottom: 20px; | |
| right: 20px; | |
| z-index: 1000; | |
| max-width: 100%; | |
| width: 300px; | |
| } | |
| .toast { | |
| padding: 12px 16px; | |
| margin-bottom: 12px; | |
| border-radius: 8px; | |
| color: white; | |
| font-weight: 500; | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| animation: toast-in 0.3s ease forwards; | |
| max-width: 100%; | |
| } | |
| .toast.success { | |
| background-color: var(--success-color); | |
| } | |
| .toast.error { | |
| background-color: var(--danger-color); | |
| } | |
| .toast.warning { | |
| background-color: var(--warning-color); | |
| } | |
| .toast.info { | |
| background-color: var(--primary-color); | |
| } | |
| .toast-close { | |
| background: none; | |
| border: none; | |
| color: white; | |
| font-size: 18px; | |
| cursor: pointer; | |
| opacity: 0.8; | |
| width: auto; | |
| padding: 0 0 0 12px; | |
| } | |
| .toast-close:hover { | |
| opacity: 1; | |
| background: none; | |
| } | |
| @keyframes toast-in { | |
| from { | |
| transform: translateX(100%); | |
| opacity: 0; | |
| } | |
| to { | |
| transform: translateX(0); | |
| opacity: 1; | |
| } | |
| } | |
| /* Loading spinner */ | |
| .spinner { | |
| display: inline-block; | |
| width: 20px; | |
| height: 20px; | |
| margin-right: 8px; | |
| border: 3px solid rgba(255, 255, 255, 0.3); | |
| border-radius: 50%; | |
| border-top-color: white; | |
| animation: spin 1s ease-in-out infinite; | |
| } | |
| @keyframes spin { | |
| to { transform: rotate(360deg); } | |
| } | |
| /* Mobile responsiveness */ | |
| @media (max-width: 640px) { | |
| body { | |
| padding: 12px 8px; | |
| } | |
| h1 { | |
| font-size: 1.6rem; | |
| } | |
| .card { | |
| padding: 16px; | |
| } | |
| .voice-list { | |
| grid-template-columns: 1fr; | |
| } | |
| .controls { | |
| flex-direction: column; | |
| } | |
| .controls button { | |
| width: 100%; | |
| } | |
| .tabs button { | |
| padding: 8px 12px; | |
| font-size: 0.9rem; | |
| } | |
| } | |
| /* Checkbox styling */ | |
| .checkbox-group { | |
| margin-bottom: 20px; | |
| } | |
| .checkbox-label { | |
| display: flex; | |
| align-items: center; | |
| cursor: pointer; | |
| font-weight: 500; | |
| font-size: 0.95rem; | |
| } | |
| input[type="checkbox"] { | |
| width: auto; | |
| margin-right: 10px; | |
| height: 18px; | |
| width: 18px; | |
| cursor: pointer; | |
| accent-color: var(--primary-color); | |
| } | |
| .checkbox-text { | |
| position: relative; | |
| top: 1px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <div class="theme-switch-wrapper"> | |
| <span class="theme-icon">☀️</span> | |
| <label class="theme-switch" for="checkbox"> | |
| <input type="checkbox" id="checkbox" /> | |
| <div class="slider"></div> | |
| </label> | |
| <span class="theme-icon">🌙</span> | |
| </div> | |
| <h1>Voice Cloning</h1> | |
| <div class="tabs"> | |
| <button id="tab-clone" class="active">Clone Voice</button> | |
| <button id="tab-voices">My Voices</button> | |
| <button id="tab-generate">Generate Speech</button> | |
| </div> | |
| <!-- Status indicator --> | |
| <div id="status-message" class="status-indicator"> | |
| <!-- Content will be dynamically inserted --> | |
| </div> | |
| <div id="clone-tab" class="tab-content active"> | |
| <div class="card"> | |
| <h2>Clone a New Voice</h2> | |
| <form id="clone-form"> | |
| <div class="form-group"> | |
| <label for="voice-name">Voice Name</label> | |
| <input type="text" id="voice-name" name="name" required placeholder="e.g. My Voice"> | |
| </div> | |
| <div class="form-group"> | |
| <label for="audio-file">Voice Sample (2-3 minute audio recording)</label> | |
| <input type="file" id="audio-file" name="audio_file" required accept="audio/*"> | |
| <small>For best results, provide a clear recording with minimal background noise.</small> | |
| </div> | |
| <div class="form-group"> | |
| <label for="transcript">Transcript (Optional)</label> | |
| <textarea id="transcript" name="transcript" rows="4" placeholder="Exact transcript of your audio sample..."></textarea> | |
| <small>Adding a transcript helps improve voice accuracy.</small> | |
| </div> | |
| <div class="form-group"> | |
| <label for="description">Description (Optional)</label> | |
| <input type="text" id="description" name="description" placeholder="A description of this voice"> | |
| </div> | |
| <button type="submit">Clone Voice</button> | |
| </form> | |
| </div> | |
| <div class="divider"> | |
| <span class="divider-text">OR</span> | |
| </div> | |
| <div class="card"> | |
| <h2>Clone Voice from YouTube</h2> | |
| <form id="youtube-clone-form"> | |
| <div class="form-group"> | |
| <label for="youtube-url">YouTube URL</label> | |
| <input type="url" id="youtube-url" name="youtube_url" required placeholder="https://www.youtube.com/watch?v=..."> | |
| </div> | |
| <div class="form-group"> | |
| <label for="youtube-voice-name">Voice Name</label> | |
| <input type="text" id="youtube-voice-name" name="voice_name" required placeholder="e.g. YouTube Voice"> | |
| </div> | |
| <div class="form-group"> | |
| <label for="start-time">Start Time (seconds)</label> | |
| <input type="number" id="start-time" name="start_time" min="0" value="0"> | |
| </div> | |
| <div class="form-group"> | |
| <label for="duration">Duration (seconds)</label> | |
| <input type="number" id="duration" name="duration" min="10" max="600" value="180"> | |
| <small>Recommended: 2-3 minutes of clear speech</small> | |
| </div> | |
| <div class="form-group"> | |
| <label for="youtube-description">Description (Optional)</label> | |
| <input type="text" id="youtube-description" name="description" placeholder="A description of this voice"> | |
| </div> | |
| <button type="submit">Clone from YouTube</button> | |
| </form> | |
| <div id="youtube-progress" style="display: none; margin-top: 16px;"> | |
| <p>Processing YouTube video... <span id="progress-status">Downloading</span></p> | |
| <div class="progress-bar"> | |
| <div class="progress-fill"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="voices-tab" class="tab-content"> | |
| <h2>My Cloned Voices</h2> | |
| <div id="voice-list" class="voice-list"> | |
| <!-- Voice cards will be added here --> | |
| </div> | |
| </div> | |
| <div id="generate-tab" class="tab-content"> | |
| <div class="card"> | |
| <h2>Generate Speech with Cloned Voice</h2> | |
| <form id="generate-form"> | |
| <div class="form-group"> | |
| <label for="voice-select">Select Voice</label> | |
| <select id="voice-select" name="voice" required> | |
| <option value="">Select a voice</option> | |
| <!-- Voice options will be added here --> | |
| </select> | |
| </div> | |
| <div class="form-group"> | |
| <label for="generate-text">Text to Speak</label> | |
| <textarea id="generate-text" name="text" rows="4" required placeholder="Enter text to synthesize with the selected voice..."></textarea> | |
| </div> | |
| <div class="form-group"> | |
| <label>Temperature: <span id="temperature-value">0.7</span></label> | |
| <input type="range" id="temperature" name="temperature" min="0.5" max="1.0" step="0.05" value="0.7"> | |
| <small>Lower values (0.5-0.7) produce more consistent speech, higher values (0.8-1.0) produce more varied speech.</small> | |
| </div> | |
| <div class="form-group checkbox-group"> | |
| <label class="checkbox-label"> | |
| <input type="checkbox" id="use-streaming" name="use_streaming"> | |
| <span class="checkbox-text">Use streaming mode</span> | |
| </label> | |
| <small>Stream audio as it's generated for faster start and lower latency.</small> | |
| </div> | |
| <button type="submit">Generate Speech</button> | |
| </form> | |
| <audio id="audio-preview" controls style="display: none;"></audio> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Toast notifications container --> | |
| <div class="toast-container" id="toast-container"></div> | |
| <script> | |
| // Theme toggle functionality | |
| const toggleSwitch = document.querySelector('#checkbox'); | |
| const html = document.querySelector('html'); | |
| // Check for saved theme preference or use system preference | |
| function getThemePreference() { | |
| const savedTheme = localStorage.getItem('theme'); | |
| if (savedTheme) { | |
| return savedTheme; | |
| } | |
| // Check if system prefers dark mode | |
| return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; | |
| } | |
| // Apply the theme | |
| function setTheme(theme) { | |
| html.setAttribute('data-theme', theme); | |
| localStorage.setItem('theme', theme); | |
| toggleSwitch.checked = theme === 'dark'; | |
| } | |
| // Initialize theme | |
| setTheme(getThemePreference()); | |
| // Listen for toggle changes | |
| toggleSwitch.addEventListener('change', function(e) { | |
| if (e.target.checked) { | |
| setTheme('dark'); | |
| } else { | |
| setTheme('light'); | |
| } | |
| }); | |
| // Toast notification system | |
| function showToast(message, type = 'info', duration = 5000) { | |
| const container = document.getElementById('toast-container'); | |
| const toast = document.createElement('div'); | |
| toast.className = `toast ${type}`; | |
| toast.innerHTML = ` | |
| <span>${message}</span> | |
| <button class="toast-close">×</button> | |
| `; | |
| container.appendChild(toast); | |
| // Auto remove after duration | |
| const timeout = setTimeout(() => { | |
| toast.style.opacity = '0'; | |
| setTimeout(() => { | |
| container.removeChild(toast); | |
| }, 300); | |
| }, duration); | |
| // Manual close | |
| toast.querySelector('.toast-close').addEventListener('click', () => { | |
| clearTimeout(timeout); | |
| toast.style.opacity = '0'; | |
| setTimeout(() => { | |
| container.removeChild(toast); | |
| }, 300); | |
| }); | |
| } | |
| // Status indicator functions | |
| function showStatus(message, type) { | |
| const statusElem = document.getElementById('status-message'); | |
| statusElem.className = `status-indicator ${type} show`; | |
| let icon = ''; | |
| switch(type) { | |
| case 'success': | |
| icon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9 12l2 2 4-4M21 12a9 9 0 11-18 0 9 9 0 0118 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>'; | |
| break; | |
| case 'error': | |
| icon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>'; | |
| break; | |
| case 'warning': | |
| icon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>'; | |
| break; | |
| case 'info': | |
| icon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>'; | |
| break; | |
| } | |
| statusElem.innerHTML = icon + message; | |
| // Auto-hide after 5 seconds | |
| setTimeout(() => { | |
| statusElem.className = 'status-indicator'; | |
| }, 5000); | |
| } | |
| function hideStatus() { | |
| const statusElem = document.getElementById('status-message'); | |
| statusElem.className = 'status-indicator'; | |
| } | |
| // Tab functionality | |
| const tabs = document.querySelectorAll('.tabs button'); | |
| const tabContents = document.querySelectorAll('.tab-content'); | |
| tabs.forEach(tab => { | |
| tab.addEventListener('click', () => { | |
| // Remove active class from all tabs | |
| tabs.forEach(t => t.classList.remove('active')); | |
| tabContents.forEach(tc => tc.classList.remove('active')); | |
| // Add active class to clicked tab | |
| tab.classList.add('active'); | |
| // Show corresponding tab content | |
| const tabId = tab.id.replace('tab-', ''); | |
| document.getElementById(`${tabId}-tab`).classList.add('active'); | |
| // Hide any status messages when changing tabs | |
| hideStatus(); | |
| }); | |
| }); | |
| // Temperature slider | |
| const temperatureSlider = document.getElementById('temperature'); | |
| const temperatureValue = document.getElementById('temperature-value'); | |
| temperatureSlider.addEventListener('input', () => { | |
| temperatureValue.textContent = temperatureSlider.value; | |
| }); | |
| // Load voices | |
| async function loadVoices() { | |
| try { | |
| const response = await fetch('/v1/voice-cloning/voices'); | |
| const data = await response.json(); | |
| const voiceList = document.getElementById('voice-list'); | |
| const voiceSelect = document.getElementById('voice-select'); | |
| // Clear existing content | |
| voiceList.innerHTML = ''; | |
| // Clear voice select options but keep the first one | |
| while (voiceSelect.options.length > 1) { | |
| voiceSelect.remove(1); | |
| } | |
| if (data.voices && data.voices.length > 0) { | |
| data.voices.forEach(voice => { | |
| // Add to voice list | |
| const voiceCard = document.createElement('div'); | |
| voiceCard.className = 'voice-card'; | |
| voiceCard.innerHTML = ` | |
| <h3 class="voice-name">${voice.name}</h3> | |
| <p>${voice.description || 'No description'}</p> | |
| <p>Created: ${new Date(voice.created_at * 1000).toLocaleString()}</p> | |
| <div class="controls"> | |
| <button class="btn-secondary preview-voice" data-id="${voice.id}">Preview</button> | |
| <button class="btn-danger delete-voice" data-id="${voice.id}">Delete</button> | |
| </div> | |
| `; | |
| voiceList.appendChild(voiceCard); | |
| // Add to voice select | |
| const option = document.createElement('option'); | |
| option.value = voice.id; | |
| option.textContent = voice.name; | |
| voiceSelect.appendChild(option); | |
| }); | |
| // Add event listeners for preview and delete buttons | |
| document.querySelectorAll('.preview-voice').forEach(button => { | |
| button.addEventListener('click', previewVoice); | |
| }); | |
| document.querySelectorAll('.delete-voice').forEach(button => { | |
| button.addEventListener('click', deleteVoice); | |
| }); | |
| showStatus(`Loaded ${data.voices.length} voices successfully`, 'success'); | |
| } else { | |
| voiceList.innerHTML = '<p>No cloned voices yet. Create one in the "Clone Voice" tab.</p>'; | |
| } | |
| } catch (error) { | |
| console.error('Error loading voices:', error); | |
| showStatus('Failed to load voices', 'error'); | |
| } | |
| } | |
| // Preview voice | |
| async function previewVoice(event) { | |
| const button = event.target; | |
| const originalText = button.textContent; | |
| button.disabled = true; | |
| button.innerHTML = '<div class="spinner"></div> Loading...'; | |
| const voiceId = button.dataset.id; | |
| const audioPreview = document.getElementById('audio-preview'); | |
| try { | |
| const response = await fetch(`/v1/voice-cloning/voices/${voiceId}/preview`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| text: "This is a preview of my cloned voice. I hope you like how it sounds!" | |
| }) | |
| }); | |
| if (response.ok) { | |
| const blob = await response.blob(); | |
| const url = URL.createObjectURL(blob); | |
| audioPreview.src = url; | |
| audioPreview.style.display = 'block'; | |
| // Switch to the generate tab | |
| document.getElementById('tab-generate').click(); | |
| // Set the voice in the select | |
| document.getElementById('voice-select').value = voiceId; | |
| audioPreview.play(); | |
| showToast('Voice preview loaded', 'success'); | |
| } else { | |
| showToast('Failed to preview voice', 'error'); | |
| } | |
| } catch (error) { | |
| console.error('Error previewing voice:', error); | |
| showToast('Error previewing voice', 'error'); | |
| } finally { | |
| button.disabled = false; | |
| button.textContent = originalText; | |
| } | |
| } | |
| // Delete voice | |
| async function deleteVoice(event) { | |
| if (!confirm('Are you sure you want to delete this voice? This cannot be undone.')) { | |
| return; | |
| } | |
| const button = event.target; | |
| const originalText = button.textContent; | |
| button.disabled = true; | |
| button.innerHTML = '<div class="spinner"></div> Deleting...'; | |
| const voiceId = button.dataset.id; | |
| try { | |
| const response = await fetch(`/v1/voice-cloning/voices/${voiceId}`, { | |
| method: 'DELETE' | |
| }); | |
| if (response.ok) { | |
| showToast('Voice deleted successfully', 'success'); | |
| loadVoices(); | |
| } else { | |
| showToast('Failed to delete voice', 'error'); | |
| } | |
| } catch (error) { | |
| console.error('Error deleting voice:', error); | |
| showToast('Error deleting voice', 'error'); | |
| } finally { | |
| button.disabled = false; | |
| button.textContent = originalText; | |
| } | |
| } | |
| // Clone voice form submission | |
| document.getElementById('clone-form').addEventListener('submit', async (event) => { | |
| event.preventDefault(); | |
| const formData = new FormData(event.target); | |
| const submitButton = event.target.querySelector('button[type="submit"]'); | |
| const originalText = submitButton.textContent; | |
| submitButton.disabled = true; | |
| submitButton.innerHTML = '<div class="spinner"></div> Cloning Voice...'; | |
| showStatus('Processing your audio sample...', 'info'); | |
| try { | |
| const response = await fetch('/v1/voice-cloning/clone', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| if (response.ok) { | |
| const result = await response.json(); | |
| showStatus('Voice cloned successfully!', 'success'); | |
| showToast('Voice cloned successfully!', 'success'); | |
| event.target.reset(); | |
| // Switch to the voices tab | |
| document.getElementById('tab-voices').click(); | |
| loadVoices(); | |
| } else { | |
| const error = await response.json(); | |
| showStatus(`Failed to clone voice: ${error.detail}`, 'error'); | |
| showToast('Failed to clone voice', 'error'); | |
| } | |
| } catch (error) { | |
| console.error('Error cloning voice:', error); | |
| showStatus('Error processing your request', 'error'); | |
| showToast('Error cloning voice', 'error'); | |
| } finally { | |
| submitButton.disabled = false; | |
| submitButton.textContent = originalText; | |
| } | |
| }); | |
| // YouTube voice cloning form submission | |
| document.getElementById('youtube-clone-form').addEventListener('submit', async (event) => { | |
| event.preventDefault(); | |
| const formData = new FormData(event.target); | |
| const youtubeUrl = formData.get('youtube_url'); | |
| const voiceName = formData.get('voice_name'); | |
| const startTime = parseInt(formData.get('start_time')); | |
| const duration = parseInt(formData.get('duration')); | |
| const description = formData.get('description'); | |
| const progressDiv = document.getElementById('youtube-progress'); | |
| const progressStatus = document.getElementById('progress-status'); | |
| const progressFill = document.querySelector('.progress-fill'); | |
| const submitButton = event.target.querySelector('button[type="submit"]'); | |
| const originalText = submitButton.textContent; | |
| submitButton.disabled = true; | |
| submitButton.innerHTML = '<div class="spinner"></div> Processing...'; | |
| showStatus('Starting YouTube download...', 'info'); | |
| // Show progress bar | |
| progressDiv.style.display = 'block'; | |
| progressFill.style.width = '10%'; | |
| progressStatus.textContent = 'Downloading audio...'; | |
| // Simulate progress updates (since we can't get real-time updates easily) | |
| let progress = 10; | |
| const progressInterval = setInterval(() => { | |
| if (progress < 90) { | |
| progress += 5; | |
| progressFill.style.width = `${progress}%`; | |
| if (progress > 30 && progress < 60) { | |
| progressStatus.textContent = 'Generating transcript...'; | |
| } else if (progress >= 60) { | |
| progressStatus.textContent = 'Cloning voice...'; | |
| } | |
| } | |
| }, 1000); | |
| try { | |
| const response = await fetch('/v1/voice-cloning/clone-from-youtube', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| youtube_url: youtubeUrl, | |
| voice_name: voiceName, | |
| start_time: startTime, | |
| duration: duration, | |
| description: description | |
| }) | |
| }); | |
| clearInterval(progressInterval); | |
| if (response.ok) { | |
| progressFill.style.width = '100%'; | |
| progressStatus.textContent = 'Complete!'; | |
| const result = await response.json(); | |
| showStatus('Voice cloned successfully from YouTube!', 'success'); | |
| showToast('Voice cloned from YouTube!', 'success'); | |
| event.target.reset(); | |
| // Switch to the voices tab | |
| document.getElementById('tab-voices').click(); | |
| loadVoices(); | |
| } else { | |
| const error = await response.json(); | |
| showStatus(`Failed to clone voice from YouTube: ${error.detail}`, 'error'); | |
| showToast('Failed to clone voice from YouTube', 'error'); | |
| progressDiv.style.display = 'none'; | |
| } | |
| } catch (error) { | |
| console.error('Error cloning voice from YouTube:', error); | |
| showStatus('Error processing YouTube video', 'error'); | |
| showToast('Error cloning voice from YouTube', 'error'); | |
| progressDiv.style.display = 'none'; | |
| } finally { | |
| clearInterval(progressInterval); | |
| submitButton.disabled = false; | |
| submitButton.textContent = originalText; | |
| } | |
| }); | |
| // Generate speech form submission | |
| document.getElementById('generate-form').addEventListener('submit', async (event) => { | |
| event.preventDefault(); | |
| const formData = new FormData(event.target); | |
| const voiceId = formData.get('voice'); | |
| const text = formData.get('text'); | |
| const temperature = formData.get('temperature'); | |
| const useStreaming = formData.get('use_streaming') === 'on'; | |
| if (!voiceId) { | |
| showToast('Please select a voice', 'warning'); | |
| return; | |
| } | |
| const submitButton = event.target.querySelector('button[type="submit"]'); | |
| const originalText = submitButton.textContent; | |
| submitButton.disabled = true; | |
| submitButton.innerHTML = '<div class="spinner"></div> Generating...'; | |
| showStatus(useStreaming ? 'Streaming speech...' : 'Generating speech...', 'info'); | |
| try { | |
| const audioPreview = document.getElementById('audio-preview'); | |
| if (useStreaming) { | |
| // For streaming, we need to handle the response differently to play audio as it arrives | |
| try { | |
| // Reset audio element | |
| audioPreview.style.display = 'block'; | |
| // Prepare the request | |
| const requestOptions = { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| model: "csm-1b", | |
| input: text, | |
| voice: voiceId, | |
| response_format: "mp3", | |
| temperature: parseFloat(temperature), | |
| speed: 1.0 | |
| }) | |
| }; | |
| // Create a unique URL for this request to avoid caching issues | |
| const timestamp = new Date().getTime(); | |
| const streamingUrl = `/v1/audio/speech/streaming?t=${timestamp}`; | |
| // Fetch from streaming endpoint | |
| const response = await fetch(streamingUrl, requestOptions); | |
| if (response.ok) { | |
| // Create a blob URL for immediate playback | |
| const blob = await response.blob(); | |
| const url = URL.createObjectURL(blob); | |
| // Set the audio source and play immediately | |
| audioPreview.src = url; | |
| audioPreview.autoplay = true; | |
| // Event listeners for success/failure | |
| audioPreview.onplay = () => { | |
| showStatus('Speech streamed successfully', 'success'); | |
| showToast('Speech streaming playback started', 'success'); | |
| }; | |
| audioPreview.onerror = (e) => { | |
| console.error('Audio playback error:', e); | |
| showStatus('Error playing streamed audio', 'error'); | |
| showToast('Streaming playback error', 'error'); | |
| }; | |
| } else { | |
| const error = await response.json(); | |
| showStatus(`Failed to stream speech: ${error.detail || 'Unknown error'}`, 'error'); | |
| showToast('Failed to stream speech', 'error'); | |
| } | |
| } catch (error) { | |
| console.error('Streaming error:', error); | |
| showStatus(`Error streaming speech: ${error.message}`, 'error'); | |
| showToast('Error streaming speech', 'error'); | |
| } | |
| } else { | |
| // Non-streaming uses the original endpoint | |
| const response = await fetch('/v1/voice-cloning/generate', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| voice_id: voiceId, | |
| text: text, | |
| temperature: parseFloat(temperature) | |
| }) | |
| }); | |
| if (response.ok) { | |
| const blob = await response.blob(); | |
| const url = URL.createObjectURL(blob); | |
| audioPreview.src = url; | |
| audioPreview.style.display = 'block'; | |
| audioPreview.play(); | |
| showStatus('Speech generated successfully', 'success'); | |
| showToast('Speech generated successfully', 'success'); | |
| } else { | |
| const error = await response.json(); | |
| showStatus(`Failed to generate speech: ${error.detail}`, 'error'); | |
| showToast('Failed to generate speech', 'error'); | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Error generating speech:', error); | |
| showStatus('Error generating speech', 'error'); | |
| showToast('Error generating speech', 'error'); | |
| } finally { | |
| submitButton.disabled = false; | |
| submitButton.textContent = originalText; | |
| } | |
| }); | |
| // Load voices on page load | |
| loadVoices(); | |
| </script> | |
| </body> | |
| </html> |