andevs commited on
Commit
2e786f4
·
verified ·
1 Parent(s): 39bb05f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +988 -177
app.py CHANGED
@@ -1,23 +1,26 @@
1
- # app.py - Voice Cloning with FastAPI
2
- import gradio as gr
3
- import torch
4
- import tempfile
5
  import os
 
6
  import subprocess
7
  import re
8
  import hashlib
9
  import gc
10
- import time
11
  import json
 
 
 
 
 
12
  from fastapi import FastAPI, Request, File, UploadFile, Form, HTTPException
13
- from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
14
- from fastapi.templating import Jinja2Templates
15
  import uvicorn
16
 
17
  # ============================================================
18
  # CONFIGURATION
19
  # ============================================================
20
- MAX_TEXT_LENGTH = 3000
 
21
  SUPPORTED_LANGUAGES = ["en", "es", "fr", "de", "it", "pt", "pl", "tr", "ru", "nl", "cs", "ar", "zh-cn", "ja"]
22
 
23
  # ============================================================
@@ -30,7 +33,9 @@ class TTSengine:
30
  self._loading = False
31
  self._error = None
32
  self._cache = {}
 
33
  self._model_loaded = False
 
34
 
35
  def get_model(self):
36
  if self._tts is not None:
@@ -58,25 +63,52 @@ class TTSengine:
58
  self._loading = False
59
  raise Exception(f"Model loading failed: {str(e)}")
60
 
61
- def optimize_audio(self, input_path):
62
- """Convert audio to the right format"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  output_path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
64
 
65
  cmd = [
66
  'ffmpeg', '-i', input_path,
67
  '-ac', '1',
68
- '-ar', '22050',
 
69
  '-acodec', 'pcm_s16le',
70
  '-y', output_path
71
  ]
72
 
73
  try:
74
- subprocess.run(cmd, capture_output=True, check=True, timeout=10)
75
- return output_path
 
 
76
  except:
77
- cmd2 = ['ffmpeg', '-i', input_path, '-ac', '1', '-ar', '22050', '-y', output_path]
78
- subprocess.run(cmd2, capture_output=True, check=True)
79
- return output_path
 
 
 
 
 
 
 
 
80
 
81
  def synthesize(self, text, audio_path, language="en", speed=1.0):
82
  """Generate speech"""
@@ -86,11 +118,18 @@ class TTSengine:
86
  if not os.path.exists(audio_path):
87
  raise Exception(f"Audio file not found: {audio_path}")
88
 
 
 
89
  # Check cache
90
- cache_key = hashlib.md5(f"{text[:100]}_{language}_{speed}".encode()).hexdigest()[:12]
91
  if cache_key in self._cache and os.path.exists(self._cache[cache_key]):
92
  return self._cache[cache_key]
93
 
 
 
 
 
 
94
  # Optimize audio
95
  processed_audio = self.optimize_audio(audio_path)
96
 
@@ -101,7 +140,6 @@ class TTSengine:
101
  output_path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
102
 
103
  try:
104
- print(f"Generating speech for: {text[:50]}...")
105
  tts.tts_to_file(
106
  text=text.strip(),
107
  speaker_wav=processed_audio,
@@ -111,7 +149,7 @@ class TTSengine:
111
  )
112
 
113
  # Cache result
114
- if len(self._cache) > 2:
115
  oldest_key = next(iter(self._cache))
116
  try:
117
  if os.path.exists(self._cache[oldest_key]):
@@ -129,181 +167,394 @@ class TTSengine:
129
  except:
130
  pass
131
 
132
- print("✅ Generation complete!")
133
  return output_path
134
 
135
  except Exception as e:
136
- raise Exception(f"Synthesis failed: {str(e)}")
 
 
 
 
137
  finally:
138
  gc.collect()
 
 
 
 
 
 
 
 
139
 
140
  # Initialize TTS
141
  tts_engine = TTSengine()
142
 
143
  # ============================================================
144
- # FASTAPI APP
145
  # ============================================================
146
 
147
- # Create FastAPI app
148
- app = FastAPI(title="VoxForge TTS API")
149
-
150
- # Simple HTML page
151
- HTML_PAGE = """
152
  <!DOCTYPE html>
153
- <html>
154
  <head>
155
- <title>VoxForge TTS</title>
156
- <meta charset="utf-8">
157
- <meta name="viewport" content="width=device-width, initial-scale=1">
158
  <style>
 
 
 
 
 
159
  body {
160
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
161
- max-width: 800px;
162
- margin: 0 auto;
163
  padding: 20px;
164
- background: #f5f5f5;
165
  }
166
  .container {
 
 
167
  background: white;
 
168
  padding: 30px;
169
- border-radius: 10px;
170
- box-shadow: 0 2px 10px rgba(0,0,0,0.1);
171
  }
172
  h1 {
173
- color: #6c5ce7;
174
- margin-top: 0;
 
 
 
175
  }
176
- .status {
177
- padding: 10px;
178
- border-radius: 5px;
179
- margin: 10px 0;
 
 
 
 
 
 
 
 
 
 
 
 
180
  background: #e8f5e9;
181
  color: #2e7d32;
182
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  .form-group {
184
- margin: 20px 0;
185
  }
186
  label {
187
  display: block;
188
  font-weight: 600;
189
- margin-bottom: 5px;
 
190
  }
191
- textarea, input, select {
192
  width: 100%;
193
- padding: 10px;
194
- border: 1px solid #ddd;
195
- border-radius: 5px;
196
  font-size: 14px;
 
 
 
 
 
 
197
  }
198
  textarea {
199
- min-height: 100px;
200
- font-family: inherit;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  }
202
  .btn {
203
- background: #6c5ce7;
204
- color: white;
205
- padding: 12px 30px;
206
  border: none;
207
- border-radius: 5px;
208
  font-size: 16px;
 
209
  cursor: pointer;
 
 
 
 
 
 
 
 
210
  }
211
- .btn:hover {
212
- background: #5f3dc4;
 
213
  }
214
- .btn:disabled {
215
- background: #bdbdbd;
216
  cursor: not-allowed;
 
217
  }
218
- .result {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  margin-top: 20px;
220
  padding: 20px;
221
  background: #f8f9fa;
222
- border-radius: 5px;
223
  display: none;
224
  }
225
- .result audio {
 
 
 
226
  width: 100%;
 
227
  }
228
- .error {
229
- color: #d32f2f;
230
- padding: 10px;
 
 
 
 
 
 
 
 
 
231
  background: #ffebee;
232
- border-radius: 5px;
 
233
  display: none;
 
234
  }
235
- .loading {
236
- display: none;
 
 
237
  text-align: center;
238
- padding: 20px;
 
 
 
 
239
  }
240
  .spinner {
241
  border: 4px solid #f3f3f3;
242
- border-top: 4px solid #6c5ce7;
243
  border-radius: 50%;
244
- width: 40px;
245
- height: 40px;
246
  animation: spin 1s linear infinite;
247
- margin: 0 auto;
248
  }
249
  @keyframes spin {
250
  0% { transform: rotate(0deg); }
251
  100% { transform: rotate(360deg); }
252
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  .tips {
254
  background: #e3f2fd;
255
- padding: 15px;
256
- border-radius: 5px;
257
  margin: 20px 0;
258
  }
259
  .tips ul {
260
- margin: 5px 0;
261
  padding-left: 20px;
 
262
  }
263
- .lang-select {
264
- width: 100%;
265
- padding: 10px;
266
  }
267
- .row {
268
- display: flex;
269
- gap: 20px;
 
 
 
270
  }
271
- .col {
272
- flex: 1;
 
273
  }
274
- @media (max-width: 600px) {
275
- .row {
276
- flex-direction: column;
277
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  .container {
279
  padding: 15px;
280
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  }
282
  </style>
283
  </head>
284
  <body>
285
  <div class="container">
286
  <h1>🎙️ VoxForge TTS</h1>
287
- <p>Clone any voice with just 3-10 seconds of audio</p>
288
 
289
- <div id="status" class="status">✅ Ready</div>
 
 
 
290
 
291
- <form id="ttsForm" enctype="multipart/form-data">
292
- <div class="form-group">
293
- <label for="text">📝 Text to Synthesize</label>
294
- <textarea id="text" name="text" placeholder="Enter text up to 3000 characters..." required>Hello, this is my cloned voice. It sounds just like me!</textarea>
295
- </div>
296
-
297
- <div class="form-group">
298
- <label for="audio">🎤 Reference Voice (Upload WAV/MP3)</label>
299
- <input type="file" id="audio" name="audio" accept=".wav,.mp3,.m4a,.flac" required>
300
- </div>
301
-
302
- <div class="row">
303
- <div class="col">
 
 
 
 
 
 
 
 
 
 
304
  <div class="form-group">
305
  <label for="language">🌐 Language</label>
306
- <select id="language" name="language" class="lang-select">
307
  <option value="en">English</option>
308
  <option value="es">Spanish</option>
309
  <option value="fr">French</option>
@@ -320,74 +571,315 @@ HTML_PAGE = """
320
  <option value="ja">Japanese</option>
321
  </select>
322
  </div>
323
- </div>
324
- <div class="col">
325
  <div class="form-group">
326
  <label for="speed">⚡ Speed</label>
327
- <input type="range" id="speed" name="speed" min="0.7" max="1.3" step="0.05" value="1.0">
328
- <span id="speedValue">1.0</span>
329
  </div>
330
  </div>
 
 
 
 
 
 
 
 
 
 
 
331
  </div>
332
 
333
- <button type="submit" class="btn" id="generateBtn">🎵 Generate Speech</button>
334
- </form>
335
-
336
- <div id="loading" class="loading">
337
- <div class="spinner"></div>
338
- <p>⏳ Generating speech... This may take 10-30 seconds</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  </div>
340
 
341
- <div id="error" class="error"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
 
343
- <div id="result" class="result">
344
- <h3>🎵 Generated Speech</h3>
345
- <audio id="audioResult" controls></audio>
346
- <br>
347
- <a id="downloadLink" href="#" download="speech.wav">⬇️ Download Audio</a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  </div>
349
 
350
- <div class="tips">
351
- <strong>💡 Tips for Best Results:</strong>
352
- <ul>
353
- <li>Record 3-10 seconds of clear speech</li>
354
- <li>No background noise</li>
355
- <li>One speaker only</li>
356
- <li>First use: Model downloads (2-5 min)</li>
357
- </ul>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
  </div>
359
  </div>
360
 
361
  <script>
362
- const form = document.getElementById('ttsForm');
363
- const statusDiv = document.getElementById('status');
364
- const errorDiv = document.getElementById('error');
365
- const resultDiv = document.getElementById('result');
366
- const loadingDiv = document.getElementById('loading');
367
- const audioResult = document.getElementById('audioResult');
368
- const downloadLink = document.getElementById('downloadLink');
369
- const generateBtn = document.getElementById('generateBtn');
370
- const speedSlider = document.getElementById('speed');
371
- const speedValue = document.getElementById('speedValue');
372
-
373
- speedSlider.addEventListener('input', function() {
374
- speedValue.textContent = this.value;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  });
 
376
 
377
- form.addEventListener('submit', async function(e) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  e.preventDefault();
379
 
 
 
 
 
 
 
 
 
380
  // Reset displays
381
- errorDiv.style.display = 'none';
382
- resultDiv.style.display = 'none';
383
- loadingDiv.style.display = 'block';
384
  generateBtn.disabled = true;
385
- statusDiv.textContent = '⏳ Generating...';
386
- statusDiv.className = 'status';
387
- statusDiv.style.background = '#fff3e0';
388
- statusDiv.style.color = '#e65100';
389
 
390
- const formData = new FormData(form);
 
 
391
 
392
  try {
393
  const response = await fetch('/synthesize', {
@@ -395,7 +887,7 @@ HTML_PAGE = """
395
  body: formData
396
  });
397
 
398
- loadingDiv.style.display = 'none';
399
 
400
  if (!response.ok) {
401
  const error = await response.json();
@@ -405,63 +897,244 @@ HTML_PAGE = """
405
  const blob = await response.blob();
406
  const url = URL.createObjectURL(blob);
407
 
408
- audioResult.src = url;
409
- downloadLink.href = url;
410
- downloadLink.download = 'speech.wav';
 
 
 
411
 
412
- resultDiv.style.display = 'block';
413
- statusDiv.textContent = '✅ Generation complete!';
414
- statusDiv.style.background = '#e8f5e9';
415
- statusDiv.style.color = '#2e7d32';
416
 
417
  } catch (error) {
418
- loadingDiv.style.display = 'none';
419
- errorDiv.textContent = '❌ ' + error.message;
420
- errorDiv.style.display = 'block';
421
- statusDiv.textContent = '❌ Error';
422
- statusDiv.style.background = '#ffebee';
423
- statusDiv.style.color = '#c62828';
424
  }
425
 
426
  generateBtn.disabled = false;
427
  });
428
 
429
- // Check status
430
- async function checkStatus() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
  try {
432
  const response = await fetch('/status');
433
  const data = await response.json();
 
 
 
 
434
  if (data.model_loaded) {
435
- document.getElementById('status').textContent = '✅ Model ready';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  }
437
- } catch (e) {
438
- console.log('Status check failed');
 
 
 
 
 
 
439
  }
440
  }
441
 
442
- checkStatus();
 
 
443
  </script>
444
  </body>
445
  </html>
446
  """
447
 
 
 
 
 
 
 
448
  # ============================================================
449
  # API ENDPOINTS
450
  # ============================================================
451
 
452
  @app.get("/", response_class=HTMLResponse)
453
  async def root():
454
- return HTML_PAGE
455
 
456
  @app.get("/status")
457
- async def status():
458
  return {
459
  "model_loaded": tts_engine._model_loaded,
460
- "model_loading": tts_engine._loading
 
 
461
  }
462
 
 
 
 
 
 
 
 
 
463
  @app.post("/synthesize")
464
- async def synthesize(
465
  text: str = Form(...),
466
  language: str = Form("en"),
467
  speed: float = Form(1.0),
@@ -480,10 +1153,7 @@ async def synthesize(
480
  temp_audio.close()
481
 
482
  try:
483
- # Generate speech
484
  output_path = tts_engine.synthesize(text, temp_audio.name, language, speed)
485
-
486
- # Return audio file
487
  return FileResponse(
488
  output_path,
489
  media_type="audio/wav",
@@ -492,7 +1162,148 @@ async def synthesize(
492
  except Exception as e:
493
  raise HTTPException(status_code=500, detail=str(e))
494
  finally:
495
- # Cleanup temp file
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
  try:
497
  if os.path.exists(temp_audio.name):
498
  os.unlink(temp_audio.name)
 
1
+ # app.py - Full Featured VoxForge TTS with FastAPI
 
 
 
2
  import os
3
+ import tempfile
4
  import subprocess
5
  import re
6
  import hashlib
7
  import gc
 
8
  import json
9
+ import shutil
10
+ from pathlib import Path
11
+ from datetime import datetime
12
+ from typing import Optional
13
+ import torch
14
  from fastapi import FastAPI, Request, File, UploadFile, Form, HTTPException
15
+ from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, StreamingResponse
16
+ from fastapi.staticfiles import StaticFiles
17
  import uvicorn
18
 
19
  # ============================================================
20
  # CONFIGURATION
21
  # ============================================================
22
+ MAX_TEXT_LENGTH = 5000
23
+ MAX_BATCH_SIZE = 10
24
  SUPPORTED_LANGUAGES = ["en", "es", "fr", "de", "it", "pt", "pl", "tr", "ru", "nl", "cs", "ar", "zh-cn", "ja"]
25
 
26
  # ============================================================
 
33
  self._loading = False
34
  self._error = None
35
  self._cache = {}
36
+ self._audio_cache = {}
37
  self._model_loaded = False
38
+ self._generation_count = 0
39
 
40
  def get_model(self):
41
  if self._tts is not None:
 
63
  self._loading = False
64
  raise Exception(f"Model loading failed: {str(e)}")
65
 
66
+ def check_audio_duration(self, file_path):
67
+ """Get audio duration using ffmpeg"""
68
+ try:
69
+ result = subprocess.run(['ffmpeg', '-i', file_path], capture_output=True, text=True, stderr=subprocess.PIPE)
70
+ match = re.search(r'Duration: (\d{2}):(\d{2}):(\d{2}\.\d{2})', result.stderr)
71
+ if match:
72
+ h, m, s = match.groups()
73
+ return int(h) * 3600 + int(m) * 60 + float(s)
74
+ return 0
75
+ except:
76
+ return 0
77
+
78
+ def optimize_audio(self, input_path, target_sample_rate=22050):
79
+ """Optimize audio for XTTS"""
80
+ file_hash = self._get_file_hash(input_path)
81
+ if file_hash in self._audio_cache and os.path.exists(self._audio_cache[file_hash]):
82
+ return self._audio_cache[file_hash]
83
+
84
  output_path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
85
 
86
  cmd = [
87
  'ffmpeg', '-i', input_path,
88
  '-ac', '1',
89
+ '-ar', str(target_sample_rate),
90
+ '-af', 'loudnorm=I=-16:LRA=11:TP=-1.5, silenceremove=1:0:-50dB',
91
  '-acodec', 'pcm_s16le',
92
  '-y', output_path
93
  ]
94
 
95
  try:
96
+ subprocess.run(cmd, capture_output=True, check=True, timeout=30)
97
+ if os.path.exists(output_path) and os.path.getsize(output_path) > 10000:
98
+ self._audio_cache[file_hash] = output_path
99
+ return output_path
100
  except:
101
+ pass
102
+
103
+ # Fallback
104
+ cmd2 = ['ffmpeg', '-i', input_path, '-ac', '1', '-ar', str(target_sample_rate), '-y', output_path]
105
+ subprocess.run(cmd2, capture_output=True, check=True)
106
+ self._audio_cache[file_hash] = output_path
107
+ return output_path
108
+
109
+ def _get_file_hash(self, file_path):
110
+ with open(file_path, 'rb') as f:
111
+ return hashlib.md5(f.read()).hexdigest()[:16]
112
 
113
  def synthesize(self, text, audio_path, language="en", speed=1.0):
114
  """Generate speech"""
 
118
  if not os.path.exists(audio_path):
119
  raise Exception(f"Audio file not found: {audio_path}")
120
 
121
+ self._generation_count += 1
122
+
123
  # Check cache
124
+ cache_key = hashlib.md5(f"{text[:200]}_{language}_{speed}".encode()).hexdigest()[:12]
125
  if cache_key in self._cache and os.path.exists(self._cache[cache_key]):
126
  return self._cache[cache_key]
127
 
128
+ # Check audio duration
129
+ duration = self.check_audio_duration(audio_path)
130
+ if duration > 0 and duration < 3:
131
+ raise Exception(f"Audio too short ({duration:.1f}s). Need 6-10 seconds")
132
+
133
  # Optimize audio
134
  processed_audio = self.optimize_audio(audio_path)
135
 
 
140
  output_path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
141
 
142
  try:
 
143
  tts.tts_to_file(
144
  text=text.strip(),
145
  speaker_wav=processed_audio,
 
149
  )
150
 
151
  # Cache result
152
+ if len(self._cache) > 5:
153
  oldest_key = next(iter(self._cache))
154
  try:
155
  if os.path.exists(self._cache[oldest_key]):
 
167
  except:
168
  pass
169
 
 
170
  return output_path
171
 
172
  except Exception as e:
173
+ if "out of memory" in str(e).lower():
174
+ self._cleanup_cache()
175
+ raise Exception("🔄 Memory limit reached. Please try again.")
176
+ raise
177
+
178
  finally:
179
  gc.collect()
180
+
181
+ def _cleanup_cache(self):
182
+ import gc
183
+ self._cache = {}
184
+ self._audio_cache = {}
185
+ gc.collect()
186
+ if torch.cuda.is_available():
187
+ torch.cuda.empty_cache()
188
 
189
  # Initialize TTS
190
  tts_engine = TTSengine()
191
 
192
  # ============================================================
193
+ # HTML TEMPLATE
194
  # ============================================================
195
 
196
+ HTML_TEMPLATE = """
 
 
 
 
197
  <!DOCTYPE html>
198
+ <html lang="en">
199
  <head>
200
+ <meta charset="UTF-8">
201
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
202
+ <title>VoxForge TTS - Voice Cloning</title>
203
  <style>
204
+ * {
205
+ margin: 0;
206
+ padding: 0;
207
+ box-sizing: border-box;
208
+ }
209
  body {
210
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
211
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
212
+ min-height: 100vh;
213
  padding: 20px;
 
214
  }
215
  .container {
216
+ max-width: 1200px;
217
+ margin: 0 auto;
218
  background: white;
219
+ border-radius: 20px;
220
  padding: 30px;
221
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3);
 
222
  }
223
  h1 {
224
+ font-size: 2.5em;
225
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
226
+ -webkit-background-clip: text;
227
+ -webkit-text-fill-color: transparent;
228
+ margin-bottom: 10px;
229
  }
230
+ .subtitle {
231
+ color: #666;
232
+ margin-bottom: 30px;
233
+ font-size: 1.1em;
234
+ }
235
+ .status-bar {
236
+ padding: 12px 20px;
237
+ border-radius: 10px;
238
+ margin-bottom: 20px;
239
+ display: flex;
240
+ justify-content: space-between;
241
+ align-items: center;
242
+ flex-wrap: wrap;
243
+ gap: 10px;
244
+ }
245
+ .status-ready {
246
  background: #e8f5e9;
247
  color: #2e7d32;
248
  }
249
+ .status-loading {
250
+ background: #fff3e0;
251
+ color: #e65100;
252
+ }
253
+ .status-error {
254
+ background: #ffebee;
255
+ color: #c62828;
256
+ }
257
+ .status-success {
258
+ background: #e8f5e9;
259
+ color: #2e7d32;
260
+ }
261
+ .tabs {
262
+ display: flex;
263
+ gap: 10px;
264
+ margin-bottom: 30px;
265
+ border-bottom: 2px solid #e0e0e0;
266
+ flex-wrap: wrap;
267
+ }
268
+ .tab {
269
+ padding: 12px 24px;
270
+ cursor: pointer;
271
+ border: none;
272
+ background: none;
273
+ font-size: 16px;
274
+ font-weight: 600;
275
+ color: #666;
276
+ border-bottom: 3px solid transparent;
277
+ transition: all 0.3s;
278
+ }
279
+ .tab:hover {
280
+ color: #667eea;
281
+ }
282
+ .tab.active {
283
+ color: #667eea;
284
+ border-bottom-color: #667eea;
285
+ }
286
+ .tab-content {
287
+ display: none;
288
+ animation: fadeIn 0.5s;
289
+ }
290
+ .tab-content.active {
291
+ display: block;
292
+ }
293
+ @keyframes fadeIn {
294
+ from { opacity: 0; transform: translateY(10px); }
295
+ to { opacity: 1; transform: translateY(0); }
296
+ }
297
  .form-group {
298
+ margin-bottom: 20px;
299
  }
300
  label {
301
  display: block;
302
  font-weight: 600;
303
+ margin-bottom: 8px;
304
+ color: #333;
305
  }
306
+ textarea, input[type="text"], select {
307
  width: 100%;
308
+ padding: 12px;
309
+ border: 2px solid #e0e0e0;
310
+ border-radius: 10px;
311
  font-size: 14px;
312
+ transition: border-color 0.3s;
313
+ font-family: inherit;
314
+ }
315
+ textarea:focus, input:focus, select:focus {
316
+ outline: none;
317
+ border-color: #667eea;
318
  }
319
  textarea {
320
+ min-height: 120px;
321
+ resize: vertical;
322
+ }
323
+ input[type="file"] {
324
+ padding: 12px;
325
+ border: 2px dashed #e0e0e0;
326
+ border-radius: 10px;
327
+ width: 100%;
328
+ cursor: pointer;
329
+ }
330
+ input[type="file"]:hover {
331
+ border-color: #667eea;
332
+ }
333
+ .row {
334
+ display: grid;
335
+ grid-template-columns: 1fr 1fr;
336
+ gap: 20px;
337
  }
338
  .btn {
339
+ padding: 14px 40px;
 
 
340
  border: none;
341
+ border-radius: 10px;
342
  font-size: 16px;
343
+ font-weight: 600;
344
  cursor: pointer;
345
+ transition: all 0.3s;
346
+ display: inline-flex;
347
+ align-items: center;
348
+ gap: 10px;
349
+ }
350
+ .btn-primary {
351
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
352
+ color: white;
353
  }
354
+ .btn-primary:hover {
355
+ transform: translateY(-2px);
356
+ box-shadow: 0 10px 20px rgba(102, 126, 234, 0.4);
357
  }
358
+ .btn-primary:disabled {
359
+ opacity: 0.6;
360
  cursor: not-allowed;
361
+ transform: none;
362
  }
363
+ .btn-secondary {
364
+ background: #f5f5f5;
365
+ color: #333;
366
+ }
367
+ .btn-secondary:hover {
368
+ background: #e0e0e0;
369
+ }
370
+ .btn-danger {
371
+ background: #ef5350;
372
+ color: white;
373
+ }
374
+ .btn-danger:hover {
375
+ background: #d32f2f;
376
+ }
377
+ .btn-group {
378
+ display: flex;
379
+ gap: 10px;
380
+ flex-wrap: wrap;
381
+ }
382
+ .result-box {
383
  margin-top: 20px;
384
  padding: 20px;
385
  background: #f8f9fa;
386
+ border-radius: 10px;
387
  display: none;
388
  }
389
+ .result-box.show {
390
+ display: block;
391
+ }
392
+ .result-box audio {
393
  width: 100%;
394
+ margin: 10px 0;
395
  }
396
+ .result-box .download-link {
397
+ display: inline-block;
398
+ margin-top: 10px;
399
+ color: #667eea;
400
+ text-decoration: none;
401
+ font-weight: 600;
402
+ }
403
+ .result-box .download-link:hover {
404
+ text-decoration: underline;
405
+ }
406
+ .error-box {
407
+ padding: 15px;
408
  background: #ffebee;
409
+ border-radius: 10px;
410
+ color: #c62828;
411
  display: none;
412
+ margin-top: 10px;
413
  }
414
+ .error-box.show {
415
+ display: block;
416
+ }
417
+ .loading-box {
418
  text-align: center;
419
+ padding: 30px;
420
+ display: none;
421
+ }
422
+ .loading-box.show {
423
+ display: block;
424
  }
425
  .spinner {
426
  border: 4px solid #f3f3f3;
427
+ border-top: 4px solid #667eea;
428
  border-radius: 50%;
429
+ width: 50px;
430
+ height: 50px;
431
  animation: spin 1s linear infinite;
432
+ margin: 0 auto 20px;
433
  }
434
  @keyframes spin {
435
  0% { transform: rotate(0deg); }
436
  100% { transform: rotate(360deg); }
437
  }
438
+ .stats {
439
+ display: grid;
440
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
441
+ gap: 15px;
442
+ margin: 20px 0;
443
+ }
444
+ .stat-card {
445
+ background: #f5f5f5;
446
+ padding: 15px;
447
+ border-radius: 10px;
448
+ text-align: center;
449
+ }
450
+ .stat-card .number {
451
+ font-size: 24px;
452
+ font-weight: bold;
453
+ color: #667eea;
454
+ }
455
+ .stat-card .label {
456
+ color: #666;
457
+ font-size: 14px;
458
+ }
459
  .tips {
460
  background: #e3f2fd;
461
+ padding: 20px;
462
+ border-radius: 10px;
463
  margin: 20px 0;
464
  }
465
  .tips ul {
 
466
  padding-left: 20px;
467
+ margin: 10px 0;
468
  }
469
+ .tips li {
470
+ margin: 5px 0;
 
471
  }
472
+ .badge {
473
+ display: inline-block;
474
+ padding: 4px 12px;
475
+ border-radius: 20px;
476
+ font-size: 12px;
477
+ font-weight: 600;
478
  }
479
+ .badge-success {
480
+ background: #e8f5e9;
481
+ color: #2e7d32;
482
  }
483
+ .badge-warning {
484
+ background: #fff3e0;
485
+ color: #e65100;
486
+ }
487
+ .char-counter {
488
+ text-align: right;
489
+ color: #999;
490
+ font-size: 14px;
491
+ margin-top: 5px;
492
+ }
493
+ .char-counter.warning {
494
+ color: #f57c00;
495
+ }
496
+ .char-counter.danger {
497
+ color: #d32f2f;
498
+ }
499
+ @media (max-width: 768px) {
500
  .container {
501
  padding: 15px;
502
  }
503
+ .row {
504
+ grid-template-columns: 1fr;
505
+ }
506
+ h1 {
507
+ font-size: 1.8em;
508
+ }
509
+ .tabs {
510
+ gap: 5px;
511
+ }
512
+ .tab {
513
+ padding: 10px 15px;
514
+ font-size: 14px;
515
+ }
516
+ .stats {
517
+ grid-template-columns: 1fr 1fr;
518
+ }
519
  }
520
  </style>
521
  </head>
522
  <body>
523
  <div class="container">
524
  <h1>🎙️ VoxForge TTS</h1>
525
+ <p class="subtitle">Advanced Voice Cloning with XTTS-v2</p>
526
 
527
+ <div id="statusBar" class="status-bar status-ready">
528
+ <span id="statusText">✅ Ready</span>
529
+ <span id="modelStatus" class="badge badge-warning">⏳ Model: Not Loaded</span>
530
+ </div>
531
 
532
+ <div class="tabs">
533
+ <button class="tab active" data-tab="synthesize">🎵 Synthesize</button>
534
+ <button class="tab" data-tab="batch">📦 Batch</button>
535
+ <button class="tab" data-tab="video">🎬 Video</button>
536
+ <button class="tab" data-tab="help">💡 Help</button>
537
+ </div>
538
+
539
+ <!-- Tab 1: Synthesize -->
540
+ <div id="tab-synthesize" class="tab-content active">
541
+ <form id="synthesizeForm" enctype="multipart/form-data">
542
+ <div class="form-group">
543
+ <label for="text">📝 Text to Synthesize</label>
544
+ <textarea id="text" name="text" placeholder="Enter text up to 5000 characters..." required>Hello, this is my cloned voice. It sounds just like me!</textarea>
545
+ <div id="charCounter" class="char-counter">0 / 5000</div>
546
+ </div>
547
+
548
+ <div class="form-group">
549
+ <label for="audio">🎤 Reference Voice (Upload WAV/MP3)</label>
550
+ <input type="file" id="audio" name="audio" accept=".wav,.mp3,.m4a,.flac" required>
551
+ <small style="color:#666;display:block;margin-top:5px;">Upload 3-10 seconds of clear speech</small>
552
+ </div>
553
+
554
+ <div class="row">
555
  <div class="form-group">
556
  <label for="language">🌐 Language</label>
557
+ <select id="language" name="language">
558
  <option value="en">English</option>
559
  <option value="es">Spanish</option>
560
  <option value="fr">French</option>
 
571
  <option value="ja">Japanese</option>
572
  </select>
573
  </div>
 
 
574
  <div class="form-group">
575
  <label for="speed">⚡ Speed</label>
576
+ <input type="range" id="speed" name="speed" min="0.5" max="2.0" step="0.05" value="1.0">
577
+ <span id="speedDisplay">1.0x</span>
578
  </div>
579
  </div>
580
+
581
+ <div class="btn-group">
582
+ <button type="submit" class="btn btn-primary" id="generateBtn">🎵 Generate Speech</button>
583
+ <button type="button" class="btn btn-secondary" id="loadModelBtn">📥 Load Model</button>
584
+ <button type="button" class="btn btn-secondary" id="clearBtn">🗑️ Clear All</button>
585
+ </div>
586
+ </form>
587
+
588
+ <div id="loadingBox" class="loading-box">
589
+ <div class="spinner"></div>
590
+ <p id="loadingText">⏳ Generating speech... This may take 10-30 seconds</p>
591
  </div>
592
 
593
+ <div id="errorBox" class="error-box"></div>
594
+
595
+ <div id="resultBox" class="result-box">
596
+ <h3>🎵 Generated Speech</h3>
597
+ <audio id="audioResult" controls></audio>
598
+ <br>
599
+ <a id="downloadLink" href="#" download="speech.wav" class="download-link">⬇️ Download Audio</a>
600
+ </div>
601
+
602
+ <div class="stats">
603
+ <div class="stat-card">
604
+ <div class="number" id="genCount">0</div>
605
+ <div class="label">Generations</div>
606
+ </div>
607
+ <div class="stat-card">
608
+ <div class="number" id="cacheSize">0</div>
609
+ <div class="label">Cache Items</div>
610
+ </div>
611
+ <div class="stat-card">
612
+ <div class="number" id="modelStatusText">Not Loaded</div>
613
+ <div class="label">Model Status</div>
614
+ </div>
615
+ </div>
616
  </div>
617
 
618
+ <!-- Tab 2: Batch -->
619
+ <div id="tab-batch" class="tab-content">
620
+ <div class="form-group">
621
+ <label for="batchText">📝 Multiple Paragraphs</label>
622
+ <textarea id="batchText" name="batchText" rows="8" placeholder="Paste multiple paragraphs separated by blank lines...">This is the first paragraph.
623
+
624
+ This is the second paragraph.
625
+
626
+ This is the third paragraph.</textarea>
627
+ </div>
628
+
629
+ <div class="form-group">
630
+ <label for="batchAudio">🎤 Reference Voice</label>
631
+ <input type="file" id="batchAudio" accept=".wav,.mp3,.m4a,.flac">
632
+ </div>
633
+
634
+ <div class="row">
635
+ <div class="form-group">
636
+ <label>🌐 Language</label>
637
+ <select id="batchLanguage">
638
+ <option value="en">English</option>
639
+ <option value="es">Spanish</option>
640
+ <option value="fr">French</option>
641
+ <option value="de">German</option>
642
+ <option value="it">Italian</option>
643
+ <option value="pt">Portuguese</option>
644
+ <option value="pl">Polish</option>
645
+ <option value="tr">Turkish</option>
646
+ <option value="ru">Russian</option>
647
+ <option value="nl">Dutch</option>
648
+ <option value="cs">Czech</option>
649
+ <option value="ar">Arabic</option>
650
+ <option value="zh-cn">Chinese</option>
651
+ <option value="ja">Japanese</option>
652
+ </select>
653
+ </div>
654
+ <div class="form-group">
655
+ <label>Chunk Size</label>
656
+ <input type="number" id="chunkSize" value="500" min="200" max="800">
657
+ <small style="color:#666;">Characters per chunk</small>
658
+ </div>
659
+ </div>
660
+
661
+ <button class="btn btn-primary" id="batchBtn">📦 Generate Batch</button>
662
+
663
+ <div id="batchResult" class="result-box">
664
+ <h3>📦 Batch Results</h3>
665
+ <div id="batchStatus"></div>
666
+ <audio id="batchAudioResult" controls></audio>
667
+ <br>
668
+ <a id="batchDownloadLink" href="#" download="batch_speech.wav" class="download-link">⬇️ Download Combined Audio</a>
669
+ </div>
670
+
671
+ <div id="batchError" class="error-box"></div>
672
+ <div id="batchLoading" class="loading-box">
673
+ <div class="spinner"></div>
674
+ <p>⏳ Generating batch...</p>
675
+ </div>
676
+ </div>
677
 
678
+ <!-- Tab 3: Video -->
679
+ <div id="tab-video" class="tab-content">
680
+ <div class="form-group">
681
+ <label for="videoText">🎬 Text for Video</label>
682
+ <textarea id="videoText" rows="3" placeholder="Text to display on video...">This is a video with voiceover</textarea>
683
+ </div>
684
+
685
+ <div class="form-group">
686
+ <label for="videoAudio">🎤 Reference Voice</label>
687
+ <input type="file" id="videoAudio" accept=".wav,.mp3,.m4a,.flac">
688
+ </div>
689
+
690
+ <div class="row">
691
+ <div class="form-group">
692
+ <label>Video Duration (seconds)</label>
693
+ <input type="number" id="videoDuration" value="10" min="5" max="120">
694
+ </div>
695
+ <div class="form-group">
696
+ <label>🌐 Language</label>
697
+ <select id="videoLanguage">
698
+ <option value="en">English</option>
699
+ <option value="es">Spanish</option>
700
+ <option value="fr">French</option>
701
+ <option value="de">German</option>
702
+ <option value="it">Italian</option>
703
+ <option value="pt">Portuguese</option>
704
+ <option value="pl">Polish</option>
705
+ <option value="tr">Turkish</option>
706
+ <option value="ru">Russian</option>
707
+ <option value="nl">Dutch</option>
708
+ <option value="cs">Czech</option>
709
+ <option value="ar">Arabic</option>
710
+ <option value="zh-cn">Chinese</option>
711
+ <option value="ja">Japanese</option>
712
+ </select>
713
+ </div>
714
+ </div>
715
+
716
+ <button class="btn btn-primary" id="videoBtn">🎬 Generate Video</button>
717
+
718
+ <div id="videoResult" class="result-box">
719
+ <h3>🎬 Generated Video</h3>
720
+ <video id="videoOutput" controls style="width:100%;max-height:500px;"></video>
721
+ <br>
722
+ <a id="videoDownloadLink" href="#" download="video.mp4" class="download-link">⬇️ Download Video</a>
723
+ </div>
724
+
725
+ <div id="videoError" class="error-box"></div>
726
+ <div id="videoLoading" class="loading-box">
727
+ <div class="spinner"></div>
728
+ <p>⏳ Generating video...</p>
729
+ </div>
730
  </div>
731
 
732
+ <!-- Tab 4: Help -->
733
+ <div id="tab-help" class="tab-content">
734
+ <h2>💡 Help Guide</h2>
735
+
736
+ <div class="tips">
737
+ <h3>Quick Start</h3>
738
+ <ol>
739
+ <li>Record or upload 3-10 seconds of clear speech</li>
740
+ <li>Enter text you want to synthesize</li>
741
+ <li>Click "Generate Speech"</li>
742
+ <li>Download your cloned voice audio!</li>
743
+ </ol>
744
+ </div>
745
+
746
+ <div class="tips">
747
+ <h3>🎯 For Best Results</h3>
748
+ <ul>
749
+ <li><strong>Record 3-10 seconds</strong> of clear, uninterrupted speech</li>
750
+ <li><strong>No background noise</strong> - find a quiet space</li>
751
+ <li><strong>One speaker only</strong> - avoid multiple voices</li>
752
+ <li><strong>Match language</strong> to the speaker's accent</li>
753
+ <li><strong>First use</strong>: Model downloads (2-5 min)</li>
754
+ </ul>
755
+ </div>
756
+
757
+ <div class="tips">
758
+ <h3>⚡ Performance Tips</h3>
759
+ <ul>
760
+ <li>Keep text under 3000 characters for best results</li>
761
+ <li>Use WAV format for fastest processing</li>
762
+ <li>Be patient with first generation (model download)</li>
763
+ <li>Subsequent generations: 10-30 seconds</li>
764
+ </ul>
765
+ </div>
766
+
767
+ <div class="tips">
768
+ <h3>🌐 Supported Languages</h3>
769
+ <p>English, Spanish, French, German, Italian, Portuguese, Polish, Turkish, Russian, Dutch, Czech, Arabic, Chinese, Japanese</p>
770
+ </div>
771
+
772
+ <div class="tips">
773
+ <h3>📦 Batch Mode</h3>
774
+ <ul>
775
+ <li>Splits text at sentence boundaries (., !, ?)</li>
776
+ <li>Synthesizes each chunk separately</li>
777
+ <li>Combines all chunks into one file</li>
778
+ <li>Perfect for audiobooks and long narrations</li>
779
+ </ul>
780
+ </div>
781
  </div>
782
  </div>
783
 
784
  <script>
785
+ // ============================================================
786
+ // Global State
787
+ // ============================================================
788
+ let generationCount = 0;
789
+
790
+ // ============================================================
791
+ // Tab Switching
792
+ // ============================================================
793
+ document.querySelectorAll('.tab').forEach(tab => {
794
+ tab.addEventListener('click', function() {
795
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
796
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
797
+ this.classList.add('active');
798
+ document.getElementById('tab-' + this.dataset.tab).classList.add('active');
799
+ });
800
+ });
801
+
802
+ // ============================================================
803
+ // Character Counter
804
+ // ============================================================
805
+ const textInput = document.getElementById('text');
806
+ const charCounter = document.getElementById('charCounter');
807
+ const MAX_CHARS = 5000;
808
+
809
+ textInput.addEventListener('input', function() {
810
+ const len = this.value.length;
811
+ charCounter.textContent = len + ' / ' + MAX_CHARS;
812
+ charCounter.className = 'char-counter';
813
+ if (len > MAX_CHARS * 0.8) charCounter.classList.add('warning');
814
+ if (len > MAX_CHARS) charCounter.classList.add('danger');
815
  });
816
+ textInput.dispatchEvent(new Event('input'));
817
 
818
+ // ============================================================
819
+ // Speed Display
820
+ // ============================================================
821
+ document.getElementById('speed').addEventListener('input', function() {
822
+ document.getElementById('speedDisplay').textContent = this.value + 'x';
823
+ });
824
+
825
+ // ============================================================
826
+ // Load Model
827
+ // ============================================================
828
+ document.getElementById('loadModelBtn').addEventListener('click', async function() {
829
+ const statusBar = document.getElementById('statusBar');
830
+ const statusText = document.getElementById('statusText');
831
+ const modelStatus = document.getElementById('modelStatus');
832
+
833
+ statusBar.className = 'status-bar status-loading';
834
+ statusText.textContent = '⏳ Loading model...';
835
+ modelStatus.textContent = '⏳ Loading...';
836
+ this.disabled = true;
837
+
838
+ try {
839
+ const response = await fetch('/load_model', { method: 'POST' });
840
+ const data = await response.json();
841
+
842
+ if (data.success) {
843
+ statusBar.className = 'status-bar status-success';
844
+ statusText.textContent = '✅ Model loaded!';
845
+ modelStatus.textContent = '✅ Model: Loaded';
846
+ modelStatus.className = 'badge badge-success';
847
+ document.getElementById('modelStatusText').textContent = 'Loaded';
848
+ } else {
849
+ throw new Error(data.error || 'Failed to load model');
850
+ }
851
+ } catch (error) {
852
+ statusBar.className = 'status-bar status-error';
853
+ statusText.textContent = '❌ Error: ' + error.message;
854
+ modelStatus.textContent = '❌ Error';
855
+ }
856
+
857
+ this.disabled = false;
858
+ });
859
+
860
+ // ============================================================
861
+ // Synthesize Form
862
+ // ============================================================
863
+ document.getElementById('synthesizeForm').addEventListener('submit', async function(e) {
864
  e.preventDefault();
865
 
866
+ const formData = new FormData(this);
867
+ const statusBar = document.getElementById('statusBar');
868
+ const statusText = document.getElementById('statusText');
869
+ const loadingBox = document.getElementById('loadingBox');
870
+ const errorBox = document.getElementById('errorBox');
871
+ const resultBox = document.getElementById('resultBox');
872
+ const generateBtn = document.getElementById('generateBtn');
873
+
874
  // Reset displays
875
+ errorBox.classList.remove('show');
876
+ resultBox.classList.remove('show');
877
+ loadingBox.classList.add('show');
878
  generateBtn.disabled = true;
 
 
 
 
879
 
880
+ statusBar.className = 'status-bar status-loading';
881
+ statusText.textContent = '⏳ Generating...';
882
+ document.getElementById('loadingText').textContent = '⏳ Generating speech... This may take 10-30 seconds';
883
 
884
  try {
885
  const response = await fetch('/synthesize', {
 
887
  body: formData
888
  });
889
 
890
+ loadingBox.classList.remove('show');
891
 
892
  if (!response.ok) {
893
  const error = await response.json();
 
897
  const blob = await response.blob();
898
  const url = URL.createObjectURL(blob);
899
 
900
+ document.getElementById('audioResult').src = url;
901
+ document.getElementById('downloadLink').href = url;
902
+
903
+ resultBox.classList.add('show');
904
+ generationCount++;
905
+ document.getElementById('genCount').textContent = generationCount;
906
 
907
+ statusBar.className = 'status-bar status-success';
908
+ statusText.textContent = '✅ Generation complete!';
 
 
909
 
910
  } catch (error) {
911
+ loadingBox.classList.remove('show');
912
+ errorBox.textContent = '❌ ' + error.message;
913
+ errorBox.classList.add('show');
914
+
915
+ statusBar.className = 'status-bar status-error';
916
+ statusText.textContent = '❌ Error';
917
  }
918
 
919
  generateBtn.disabled = false;
920
  });
921
 
922
+ // ============================================================
923
+ // Batch Generation
924
+ // ============================================================
925
+ document.getElementById('batchBtn').addEventListener('click', async function() {
926
+ const batchText = document.getElementById('batchText').value;
927
+ const batchAudio = document.getElementById('batchAudio').files[0];
928
+ const language = document.getElementById('batchLanguage').value;
929
+ const chunkSize = document.getElementById('chunkSize').value;
930
+
931
+ if (!batchText.trim()) {
932
+ document.getElementById('batchError').textContent = '❌ Please enter text';
933
+ document.getElementById('batchError').classList.add('show');
934
+ return;
935
+ }
936
+
937
+ if (!batchAudio) {
938
+ document.getElementById('batchError').textContent = '❌ Please upload reference audio';
939
+ document.getElementById('batchError').classList.add('show');
940
+ return;
941
+ }
942
+
943
+ const formData = new FormData();
944
+ formData.append('text', batchText);
945
+ formData.append('audio', batchAudio);
946
+ formData.append('language', language);
947
+ formData.append('chunk_size', chunkSize);
948
+
949
+ document.getElementById('batchError').classList.remove('show');
950
+ document.getElementById('batchResult').classList.remove('show');
951
+ document.getElementById('batchLoading').classList.add('show');
952
+ this.disabled = true;
953
+
954
+ try {
955
+ const response = await fetch('/batch_synthesize', {
956
+ method: 'POST',
957
+ body: formData
958
+ });
959
+
960
+ document.getElementById('batchLoading').classList.remove('show');
961
+
962
+ if (!response.ok) {
963
+ const error = await response.json();
964
+ throw new Error(error.detail || 'Batch generation failed');
965
+ }
966
+
967
+ const blob = await response.blob();
968
+ const url = URL.createObjectURL(blob);
969
+
970
+ document.getElementById('batchAudioResult').src = url;
971
+ document.getElementById('batchDownloadLink').href = url;
972
+ document.getElementById('batchStatus').textContent = '✅ Batch generation complete!';
973
+ document.getElementById('batchResult').classList.add('show');
974
+
975
+ } catch (error) {
976
+ document.getElementById('batchLoading').classList.remove('show');
977
+ document.getElementById('batchError').textContent = '❌ ' + error.message;
978
+ document.getElementById('batchError').classList.add('show');
979
+ }
980
+
981
+ this.disabled = false;
982
+ });
983
+
984
+ // ============================================================
985
+ // Video Generation
986
+ // ============================================================
987
+ document.getElementById('videoBtn').addEventListener('click', async function() {
988
+ const videoText = document.getElementById('videoText').value;
989
+ const videoAudio = document.getElementById('videoAudio').files[0];
990
+ const language = document.getElementById('videoLanguage').value;
991
+ const duration = document.getElementById('videoDuration').value;
992
+
993
+ if (!videoText.trim()) {
994
+ document.getElementById('videoError').textContent = '❌ Please enter text';
995
+ document.getElementById('videoError').classList.add('show');
996
+ return;
997
+ }
998
+
999
+ if (!videoAudio) {
1000
+ document.getElementById('videoError').textContent = '❌ Please upload reference audio';
1001
+ document.getElementById('videoError').classList.add('show');
1002
+ return;
1003
+ }
1004
+
1005
+ const formData = new FormData();
1006
+ formData.append('text', videoText);
1007
+ formData.append('audio', videoAudio);
1008
+ formData.append('language', language);
1009
+ formData.append('duration', duration);
1010
+
1011
+ document.getElementById('videoError').classList.remove('show');
1012
+ document.getElementById('videoResult').classList.remove('show');
1013
+ document.getElementById('videoLoading').classList.add('show');
1014
+ this.disabled = true;
1015
+
1016
+ try {
1017
+ const response = await fetch('/generate_video', {
1018
+ method: 'POST',
1019
+ body: formData
1020
+ });
1021
+
1022
+ document.getElementById('videoLoading').classList.remove('show');
1023
+
1024
+ if (!response.ok) {
1025
+ const error = await response.json();
1026
+ throw new Error(error.detail || 'Video generation failed');
1027
+ }
1028
+
1029
+ const blob = await response.blob();
1030
+ const url = URL.createObjectURL(blob);
1031
+
1032
+ document.getElementById('videoOutput').src = url;
1033
+ document.getElementById('videoDownloadLink').href = url;
1034
+ document.getElementById('videoResult').classList.add('show');
1035
+
1036
+ } catch (error) {
1037
+ document.getElementById('videoLoading').classList.remove('show');
1038
+ document.getElementById('videoError').textContent = '❌ ' + error.message;
1039
+ document.getElementById('videoError').classList.add('show');
1040
+ }
1041
+
1042
+ this.disabled = false;
1043
+ });
1044
+
1045
+ // ============================================================
1046
+ // Clear All
1047
+ // ============================================================
1048
+ document.getElementById('clearBtn').addEventListener('click', function() {
1049
+ document.getElementById('text').value = '';
1050
+ document.getElementById('audio').value = '';
1051
+ document.getElementById('resultBox').classList.remove('show');
1052
+ document.getElementById('errorBox').classList.remove('show');
1053
+ document.getElementById('charCounter').textContent = '0 / 5000';
1054
+ document.getElementById('charCounter').className = 'char-counter';
1055
+ });
1056
+
1057
+ // ============================================================
1058
+ // Auto-check model status
1059
+ // ============================================================
1060
+ async function checkModelStatus() {
1061
  try {
1062
  const response = await fetch('/status');
1063
  const data = await response.json();
1064
+
1065
+ const modelStatus = document.getElementById('modelStatus');
1066
+ const modelStatusText = document.getElementById('modelStatusText');
1067
+
1068
  if (data.model_loaded) {
1069
+ modelStatus.textContent = '✅ Model: Loaded';
1070
+ modelStatus.className = 'badge badge-success';
1071
+ modelStatusText.textContent = 'Loaded';
1072
+ } else if (data.model_loading) {
1073
+ modelStatus.textContent = '⏳ Model: Loading...';
1074
+ modelStatus.className = 'badge badge-warning';
1075
+ modelStatusText.textContent = 'Loading...';
1076
+ } else {
1077
+ modelStatus.textContent = '⏳ Model: Not Loaded';
1078
+ modelStatus.className = 'badge badge-warning';
1079
+ modelStatusText.textContent = 'Not Loaded';
1080
+ }
1081
+
1082
+ // Update generation count
1083
+ if (data.generation_count !== undefined) {
1084
+ document.getElementById('genCount').textContent = data.generation_count;
1085
  }
1086
+
1087
+ // Update cache size
1088
+ if (data.cache_size !== undefined) {
1089
+ document.getElementById('cacheSize').textContent = data.cache_size;
1090
+ }
1091
+
1092
+ } catch (error) {
1093
+ console.log('Status check failed:', error);
1094
  }
1095
  }
1096
 
1097
+ // Check status every 5 seconds
1098
+ checkModelStatus();
1099
+ setInterval(checkModelStatus, 5000);
1100
  </script>
1101
  </body>
1102
  </html>
1103
  """
1104
 
1105
+ # ============================================================
1106
+ # FASTAPI APP
1107
+ # ============================================================
1108
+
1109
+ app = FastAPI(title="VoxForge TTS API")
1110
+
1111
  # ============================================================
1112
  # API ENDPOINTS
1113
  # ============================================================
1114
 
1115
  @app.get("/", response_class=HTMLResponse)
1116
  async def root():
1117
+ return HTML_TEMPLATE
1118
 
1119
  @app.get("/status")
1120
+ async def get_status():
1121
  return {
1122
  "model_loaded": tts_engine._model_loaded,
1123
+ "model_loading": tts_engine._loading,
1124
+ "generation_count": tts_engine._generation_count,
1125
+ "cache_size": len(tts_engine._cache)
1126
  }
1127
 
1128
+ @app.post("/load_model")
1129
+ async def load_model():
1130
+ try:
1131
+ tts_engine.get_model()
1132
+ return {"success": True, "message": "Model loaded successfully"}
1133
+ except Exception as e:
1134
+ return {"success": False, "error": str(e)}
1135
+
1136
  @app.post("/synthesize")
1137
+ async def synthesize_endpoint(
1138
  text: str = Form(...),
1139
  language: str = Form("en"),
1140
  speed: float = Form(1.0),
 
1153
  temp_audio.close()
1154
 
1155
  try:
 
1156
  output_path = tts_engine.synthesize(text, temp_audio.name, language, speed)
 
 
1157
  return FileResponse(
1158
  output_path,
1159
  media_type="audio/wav",
 
1162
  except Exception as e:
1163
  raise HTTPException(status_code=500, detail=str(e))
1164
  finally:
1165
+ try:
1166
+ if os.path.exists(temp_audio.name):
1167
+ os.unlink(temp_audio.name)
1168
+ except:
1169
+ pass
1170
+
1171
+ @app.post("/batch_synthesize")
1172
+ async def batch_synthesize_endpoint(
1173
+ text: str = Form(...),
1174
+ language: str = Form("en"),
1175
+ chunk_size: int = Form(500),
1176
+ audio: UploadFile = File(...)
1177
+ ):
1178
+ if not text or not text.strip():
1179
+ raise HTTPException(status_code=400, detail="Text cannot be empty")
1180
+
1181
+ # Save uploaded audio
1182
+ temp_audio = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
1183
+ content = await audio.read()
1184
+ temp_audio.write(content)
1185
+ temp_audio.close()
1186
+
1187
+ try:
1188
+ # Split text into chunks
1189
+ sentences = re.split(r'(?<=[.!?])\s+', text)
1190
+ chunks = []
1191
+ current = ""
1192
+
1193
+ for sent in sentences:
1194
+ if len(current) + len(sent) + 1 <= chunk_size:
1195
+ current += (" " + sent) if current else sent
1196
+ else:
1197
+ if current:
1198
+ chunks.append(current)
1199
+ current = sent
1200
+ if current:
1201
+ chunks.append(current)
1202
+
1203
+ if len(chunks) > MAX_BATCH_SIZE:
1204
+ chunks = chunks[:MAX_BATCH_SIZE]
1205
+
1206
+ # Synthesize each chunk
1207
+ audio_files = []
1208
+ for chunk in chunks:
1209
+ output = tts_engine.synthesize(chunk, temp_audio.name, language, 1.0)
1210
+ audio_files.append(output)
1211
+
1212
+ # Combine audio files
1213
+ from pydub import AudioSegment
1214
+ combined = AudioSegment.empty()
1215
+ for f in audio_files:
1216
+ combined += AudioSegment.from_wav(f)
1217
+
1218
+ output_path = tempfile.NamedTemporaryFile(suffix="_combined.wav", delete=False).name
1219
+ combined.export(output_path, format="wav")
1220
+
1221
+ # Cleanup individual files
1222
+ for f in audio_files:
1223
+ try:
1224
+ os.unlink(f)
1225
+ except:
1226
+ pass
1227
+
1228
+ return FileResponse(
1229
+ output_path,
1230
+ media_type="audio/wav",
1231
+ filename="batch_speech.wav"
1232
+ )
1233
+
1234
+ except Exception as e:
1235
+ raise HTTPException(status_code=500, detail=str(e))
1236
+ finally:
1237
+ try:
1238
+ if os.path.exists(temp_audio.name):
1239
+ os.unlink(temp_audio.name)
1240
+ except:
1241
+ pass
1242
+
1243
+ @app.post("/generate_video")
1244
+ async def generate_video_endpoint(
1245
+ text: str = Form(...),
1246
+ language: str = Form("en"),
1247
+ duration: int = Form(10),
1248
+ audio: UploadFile = File(...)
1249
+ ):
1250
+ if not text or not text.strip():
1251
+ raise HTTPException(status_code=400, detail="Text cannot be empty")
1252
+
1253
+ # Save uploaded audio
1254
+ temp_audio = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
1255
+ content = await audio.read()
1256
+ temp_audio.write(content)
1257
+ temp_audio.close()
1258
+
1259
+ try:
1260
+ # Generate speech
1261
+ speech_path = tts_engine.synthesize(text, temp_audio.name, language, 1.0)
1262
+
1263
+ # Create video
1264
+ output_video = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
1265
+
1266
+ cmd = [
1267
+ 'ffmpeg',
1268
+ '-f', 'lavfi',
1269
+ '-i', f'color=c=black:s=1920x1080:d={duration}:r=30',
1270
+ '-i', speech_path,
1271
+ '-vf', f"drawtext=text='{text[:100]}...':fontcolor=white:fontsize=60:x=(w-text_w)/2:y=(h-text_h)/2",
1272
+ '-c:v', 'libx264',
1273
+ '-preset', 'ultrafast',
1274
+ '-c:a', 'aac',
1275
+ '-shortest',
1276
+ '-y',
1277
+ output_video
1278
+ ]
1279
+
1280
+ try:
1281
+ subprocess.run(cmd, capture_output=True, check=True, timeout=60)
1282
+ except:
1283
+ # Fallback: simpler video
1284
+ cmd2 = [
1285
+ 'ffmpeg',
1286
+ '-f', 'lavfi',
1287
+ '-i', f'color=c=black:s=1280x720:d={duration}:r=30',
1288
+ '-i', speech_path,
1289
+ '-c:v', 'libx264',
1290
+ '-preset', 'ultrafast',
1291
+ '-c:a', 'aac',
1292
+ '-shortest',
1293
+ '-y',
1294
+ output_video
1295
+ ]
1296
+ subprocess.run(cmd2, capture_output=True, check=True, timeout=60)
1297
+
1298
+ return FileResponse(
1299
+ output_video,
1300
+ media_type="video/mp4",
1301
+ filename="video.mp4"
1302
+ )
1303
+
1304
+ except Exception as e:
1305
+ raise HTTPException(status_code=500, detail=str(e))
1306
+ finally:
1307
  try:
1308
  if os.path.exists(temp_audio.name):
1309
  os.unlink(temp_audio.name)