andevs commited on
Commit
ae1f4f3
·
verified ·
1 Parent(s): f5d5730

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +234 -437
app.py CHANGED
@@ -1,7 +1,5 @@
1
  # ============================================================
2
  # CRITICAL: Auto-accept Coqui license - MUST BE FIRST
3
- # This patches the input() function to automatically answer 'y'
4
- # to any license prompts from Coqui TTS
5
  # ============================================================
6
  import builtins
7
 
@@ -24,6 +22,8 @@ import tempfile
24
  import os
25
  import time
26
  import numpy as np
 
 
27
  from pathlib import Path
28
 
29
  # ============================================================
@@ -42,16 +42,67 @@ _loading_error = None
42
  _model_downloading = False
43
 
44
  # ============================================================
45
- # CHECK IF MODEL IS ALREADY DOWNLOADED
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  # ============================================================
47
  def is_model_downloaded():
48
  """Check if the XTTS-v2 model is already downloaded."""
49
  model_path = os.path.expanduser("~/.local/share/tts/tts_models--multilingual--multi-dataset--xtts_v2")
50
  return os.path.exists(model_path)
51
 
52
- # ============================================================
53
- # MODEL LOADING WITH PROGRESS & ERROR HANDLING
54
- # ============================================================
55
  def get_tts(progress_callback=None):
56
  """Lazy-load the XTTS-v2 model with proper error handling."""
57
  global _tts, _model_loading, _loading_error, _model_downloading
@@ -60,42 +111,36 @@ def get_tts(progress_callback=None):
60
  return _tts
61
 
62
  if _loading_error is not None:
63
- raise gr.Error(f"Failed to load TTS model: {_loading_error}")
64
 
65
  if _model_loading:
66
- raise gr.Error("Model is still loading. Please wait a few seconds and try again.")
67
 
68
  try:
69
  _model_loading = True
70
 
71
- # Check if model needs to be downloaded
72
  if not is_model_downloaded():
73
  _model_downloading = True
74
  if progress_callback:
75
- progress_callback(0.1, desc="📥 Model not found. Downloading 4GB file... (2-5 minutes)")
76
  print("=" * 60)
77
- print("XTTS-v2 model not found. Downloading 4GB model from Hugging Face...")
78
- print("This will take 2-5 minutes on first run. Please wait...")
79
  print("=" * 60)
80
 
81
  from TTS.api import TTS
82
 
83
  if progress_callback:
84
- progress_callback(0.3, desc="🔄 Loading XTTS-v2 model into memory...")
85
 
86
- # Load the multilingual XTTS-v2 model
87
  _tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2", gpu=torch.cuda.is_available())
88
 
89
- # Move to GPU if available
90
  if torch.cuda.is_available():
91
  _tts.to("cuda")
92
  if progress_callback:
93
  progress_callback(0.8, desc="✅ Model loaded on GPU")
94
- print("Model loaded on GPU")
95
  else:
96
  if progress_callback:
97
  progress_callback(0.8, desc="⚠️ Model loaded on CPU (slower)")
98
- print("Model loaded on CPU (synthesis will be slower)")
99
 
100
  _model_loading = False
101
  _model_downloading = False
@@ -105,46 +150,7 @@ def get_tts(progress_callback=None):
105
  _loading_error = str(e)
106
  _model_loading = False
107
  _model_downloading = False
108
- raise gr.Error(f"Failed to load TTS model: {str(e)}")
109
-
110
- # ============================================================
111
- # AUDIO VALIDATION
112
- # ============================================================
113
- def validate_audio_file(audio_path):
114
- """Check if the uploaded audio file is valid for voice cloning."""
115
- if audio_path is None:
116
- return False, "No audio file provided."
117
-
118
- if not os.path.exists(audio_path):
119
- return False, "Audio file not found."
120
-
121
- # Check file size (max 50MB)
122
- file_size = os.path.getsize(audio_path)
123
- if file_size > 50 * 1024 * 1024:
124
- return False, f"Audio file too large ({file_size / 1024 / 1024:.1f}MB). Maximum size: 50MB"
125
-
126
- # Check file extension
127
- if not audio_path.lower().endswith(('.wav', '.mp3', '.m4a', '.flac')):
128
- return False, "Audio format not supported. Please upload WAV, MP3, M4A, or FLAC."
129
-
130
- return True, "Valid audio file."
131
-
132
- # ============================================================
133
- # TEXT VALIDATION
134
- # ============================================================
135
- def validate_text(text):
136
- """Validate the input text before synthesis."""
137
- if not text or not text.strip():
138
- return False, "Please enter some text to synthesize."
139
-
140
- if len(text) > MAX_TEXT_LENGTH:
141
- return False, f"Text exceeds {MAX_TEXT_LENGTH} characters. Current length: {len(text)}"
142
-
143
- # Check for common issues
144
- if len(text.strip()) < 2:
145
- return False, "Text is too short. Please enter at least 2 characters."
146
-
147
- return True, "Valid text."
148
 
149
  # ============================================================
150
  # MAIN SYNTHESIS FUNCTION
@@ -156,50 +162,51 @@ def synthesize(
156
  speed: float = 1.0,
157
  progress=gr.Progress()
158
  ):
159
- """
160
- Synthesize speech using zero-shot voice cloning.
161
-
162
- Args:
163
- text: Text to synthesize (max 2000 chars)
164
- reference_audio: Path to reference WAV file (6+ seconds)
165
- language: Language code (en, es, fr, de, etc.)
166
- speed: Speaking speed (0.5 to 2.0)
167
- progress: Gradio progress bar
168
-
169
- Returns:
170
- Path to generated audio file
171
- """
172
- # Validate inputs
173
  progress(0, desc="🔍 Validating inputs...")
174
 
175
  # Validate text
176
- is_valid, error_msg = validate_text(text)
177
- if not is_valid:
178
- raise gr.Error(error_msg)
 
 
179
 
180
  # Validate audio
181
- is_valid, error_msg = validate_audio_file(reference_audio)
 
 
 
 
 
 
 
 
182
  if not is_valid:
183
- raise gr.Error(f"Invalid reference audio: {error_msg}")
184
 
185
- # Validate language
 
 
 
186
  if language not in SUPPORTED_LANGUAGES:
187
- raise gr.Error(f"Unsupported language: {language}. Supported: {', '.join(SUPPORTED_LANGUAGES)}")
188
 
189
- # Validate speed
190
  if speed < 0.5 or speed > 2.0:
191
  raise gr.Error(f"Speed must be between 0.5 and 2.0. Got: {speed}")
192
 
193
  try:
194
- # Show download warning if this is first time
195
  if not is_model_downloaded():
196
- progress(0.05, desc="⚠️ FIRST RUN: Downloading 4GB model (2-5 minutes). Please wait...")
197
 
198
- # Load TTS model with progress callback
199
- progress(0.1, desc="🔄 Loading TTS model...")
200
  tts = get_tts(progress_callback=progress)
201
 
202
- # Create temporary output file
203
  progress(0.4, desc="📝 Preparing synthesis...")
204
  timestamp = int(time.time())
205
  output_path = tempfile.NamedTemporaryFile(
@@ -210,39 +217,60 @@ def synthesize(
210
  # Generate speech
211
  progress(0.6, desc="🎤 Synthesizing speech (10-30 seconds)...")
212
 
213
- tts.tts_to_file(
214
- text=text.strip(),
215
- speaker_wav=reference_audio,
216
- language=language,
217
- file_path=output_path,
218
- speed=speed,
219
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
 
221
- # Verify output file was created
222
  progress(0.9, desc="✅ Finalizing...")
223
  if not os.path.exists(output_path) or os.path.getsize(output_path) == 0:
224
  raise Exception("Output file was not created or is empty")
225
 
226
  progress(1.0, desc="✨ Complete!")
227
-
228
  return output_path
229
 
 
 
 
230
  except Exception as e:
231
  error_msg = str(e)
232
- # Provide more user-friendly error messages
233
  if "CUDA out of memory" in error_msg:
234
- raise gr.Error("❌ GPU out of memory. Try shorter text or use CPU mode.")
235
- elif "timeout" in error_msg.lower():
236
- raise gr.Error("❌ Synthesis timed out. Try with shorter text.")
237
- elif "EOF" in error_msg:
238
- raise gr.Error("❌ License acceptance issue. The auto-accept patch should handle this. Please try again.")
239
- elif "No such file or directory" in error_msg and "reference" in error_msg.lower():
240
- raise gr.Error("❌ Reference audio file issue. Please re-upload your audio file.")
 
 
241
  else:
242
  raise gr.Error(f"❌ Synthesis failed: {error_msg}")
243
 
244
  # ============================================================
245
- # BATCH SYNTHESIS (for long texts)
246
  # ============================================================
247
  def synthesize_batch(
248
  text: str,
@@ -252,20 +280,16 @@ def synthesize_batch(
252
  chunk_size: int = 500,
253
  progress=gr.Progress()
254
  ):
255
- """
256
- Synthesize long text by splitting into chunks and concatenating.
257
- Useful for texts > 1000 characters.
258
- """
259
  if not text or not text.strip():
260
  raise gr.Error("Please enter some text to synthesize.")
261
 
262
  if len(text) <= MAX_TEXT_LENGTH:
263
- # Use regular synthesis for shorter texts
264
  return synthesize(text, reference_audio, language, speed, progress)
265
 
266
  progress(0.05, desc="📝 Splitting text into chunks...")
267
 
268
- # Split text into sentences or chunks
269
  sentences = text.replace('\n', ' ').split('. ')
270
  chunks = []
271
  current_chunk = ""
@@ -289,29 +313,35 @@ def synthesize_batch(
289
 
290
  progress(0.1, desc=f"📊 Split into {len(chunks)} chunks")
291
 
292
- # Synthesize each chunk
293
  audio_files = []
294
  total_chunks = len(chunks)
295
 
296
  for i, chunk in enumerate(chunks):
297
  chunk_progress = 0.1 + (0.7 * (i / total_chunks))
298
- progress(chunk_progress, desc=f"🎤 Synthesizing chunk {i+1}/{total_chunks}...")
299
 
300
  with tempfile.NamedTemporaryFile(suffix=f"_chunk_{i}.wav", delete=False) as f:
301
  chunk_path = f.name
302
 
303
- tts = get_tts()
304
- tts.tts_to_file(
305
- text=chunk.strip(),
306
- speaker_wav=reference_audio,
307
- language=language,
308
- file_path=chunk_path,
309
- speed=speed,
310
- )
311
- audio_files.append(chunk_path)
312
-
313
- # Concatenate audio files
314
- progress(0.85, desc="🔊 Concatenating audio chunks...")
 
 
 
 
 
 
315
 
316
  try:
317
  from pydub import AudioSegment
@@ -324,7 +354,7 @@ def synthesize_batch(
324
  output_path = tempfile.NamedTemporaryFile(suffix="_combined.wav", delete=False).name
325
  combined.export(output_path, format="wav")
326
 
327
- # Clean up chunk files
328
  for audio_file in audio_files:
329
  try:
330
  os.unlink(audio_file)
@@ -335,40 +365,34 @@ def synthesize_batch(
335
  return output_path
336
 
337
  except Exception as e:
338
- raise gr.Error(f"Failed to concatenate audio chunks: {str(e)}")
339
 
340
  # ============================================================
341
  # UTILITY FUNCTIONS
342
  # ============================================================
343
  def clear_cache():
344
- """Clear the TTS model cache to free memory."""
345
  global _tts
346
  _tts = None
347
- torch.cuda.empty_cache() if torch.cuda.is_available() else None
348
- return "🗑️ Model cache cleared. Next synthesis will reload the model."
 
349
 
350
  def get_model_status():
351
- """Get current model loading status."""
352
  if _tts is not None:
353
- device = "GPU" if torch.cuda.is_available() and next(_tts.model.parameters()).is_cuda else "CPU"
354
  return f"✅ Model loaded on {device}"
355
  elif _model_loading:
356
  if _model_downloading:
357
- return "📥 Downloading model (4GB)... Please wait 2-5 minutes on first run"
358
  return "⏳ Model is loading..."
359
  elif _loading_error:
360
- return f"❌ Model failed to load: {_loading_error}"
361
- elif not is_model_downloaded():
362
- return "📥 Model will download on first synthesis (4GB, 2-5 minutes)"
363
  else:
364
- return "🔄 Model not loaded yet"
365
 
366
  def estimate_duration(text: str, speed: float = 1.0):
367
- """Estimate the duration of synthesized speech."""
368
  if not text:
369
  return 0
370
-
371
- # Rough estimate: ~150 characters per second at normal speed
372
  chars_per_second = 150 * speed
373
  duration = len(text) / chars_per_second
374
  return round(duration, 1)
@@ -377,335 +401,108 @@ def estimate_duration(text: str, speed: float = 1.0):
377
  # GRADIO UI
378
  # ============================================================
379
  with gr.Blocks(
380
- title="VoxForge TTS — Voice Clone Backend",
381
- theme=gr.themes.Soft(
382
- primary_hue="purple",
383
- secondary_hue="teal",
384
- ),
385
- css="""
386
- .gradio-container {
387
- max-width: 1200px !important;
388
- margin: auto !important;
389
- }
390
- .status-text {
391
- font-family: monospace;
392
- font-size: 0.9em;
393
- }
394
- """
395
  ) as demo:
396
 
397
- # Header
398
  gr.Markdown(
399
  """
400
- # 🎙️ VoxForge TTS — Professional Voice Cloning
401
 
402
- **Zero-shot voice cloning** powered by Coqui XTTS-v2. Upload any 6-second WAV file and synthesize natural speech in 14 languages.
403
 
404
- ⚠️ **First time use**: The 4GB model will download on your first synthesis (2-5 minutes). Please be patient!
405
  """
406
  )
407
 
408
- with gr.Tabs():
409
- # ========================================================
410
- # TAB 1: STANDARD SYNTHESIS
411
- # ========================================================
412
- with gr.TabItem("🎤 Standard Synthesis"):
413
- with gr.Row():
414
- with gr.Column(scale=1):
415
- text_input = gr.Textbox(
416
- label="📝 Text to Synthesize",
417
- placeholder="Enter up to 2000 characters...\n\nExample: Hello, this is my cloned voice speaking naturally.",
418
- lines=8,
419
- max_length=MAX_TEXT_LENGTH
420
- )
421
-
422
- with gr.Row():
423
- char_count = gr.Label(value=f"0 / {MAX_TEXT_LENGTH} characters", label="Character Count")
424
- est_duration = gr.Label(value="Estimated duration: 0s", label="Estimated Duration")
425
-
426
- ref_audio = gr.Audio(
427
- label="🎵 Reference Voice (6+ seconds, clear WAV recommended)",
428
- type="filepath",
429
- sources=["upload", "microphone"]
430
- )
431
-
432
- with gr.Row():
433
- language = gr.Dropdown(
434
- choices=SUPPORTED_LANGUAGES,
435
- value="en",
436
- label="🌐 Language"
437
- )
438
- speed_slider = gr.Slider(
439
- 0.5, 2.0,
440
- value=1.0,
441
- step=0.05,
442
- label="⚡ Speech Speed"
443
- )
444
-
445
- with gr.Row():
446
- synthesize_btn = gr.Button(
447
- "🎵 Generate Speech",
448
- variant="primary",
449
- size="lg"
450
- )
451
- clear_btn = gr.Button("🗑️ Clear", variant="secondary", size="lg")
452
-
453
- with gr.Accordion("⚙️ Advanced Options", open=False):
454
- model_status = gr.Label(value=get_model_status(), label="Model Status")
455
- clear_cache_btn = gr.Button("Clear Model Cache", variant="secondary", size="sm")
456
- clear_cache_output = gr.Textbox(label="Cache Action", interactive=False, visible=False)
457
-
458
- with gr.Column(scale=1):
459
- audio_output = gr.Audio(
460
- label="🔊 Generated Speech",
461
- type="filepath",
462
- interactive=False
463
- )
464
-
465
- gr.Markdown(
466
- """
467
- ### 💡 Tips for Best Results
468
-
469
- 1. **Reference Audio**: Use a clean 6-10 second WAV file at 22050Hz mono
470
- 2. **Text Quality**: Write naturally with proper punctuation
471
- 3. **Language**: Match the language of your reference voice
472
- 4. **Speed**: Adjust between 0.8-1.2 for most natural results
473
-
474
- ---
475
-
476
- ### ⏱️ Performance Notes
477
- - **First synthesis**: 2-5 minutes (downloading 4GB model)
478
- - **Subsequent syntheses**: 10-30 seconds
479
- - **GPU recommended**: Enable T4 GPU in Space settings
480
-
481
- ---
482
-
483
- ### Supported Languages
484
- English, Spanish, French, German, Italian, Portuguese, Polish,
485
- Turkish, Russian, Dutch, Czech, Arabic, Chinese, Japanese
486
- """
487
- )
488
-
489
- # Event handlers for Tab 1
490
- def update_char_count(text):
491
- if text:
492
- return f"{len(text)} / {MAX_TEXT_LENGTH} characters"
493
- return f"0 / {MAX_TEXT_LENGTH} characters"
494
-
495
- def update_duration_estimate(text, speed):
496
- if text:
497
- duration = estimate_duration(text, speed)
498
- mins = int(duration // 60)
499
- secs = int(duration % 60)
500
- if mins > 0:
501
- return f"Estimated duration: {mins}m {secs}s"
502
- return f"Estimated duration: {secs}s"
503
- return "Estimated duration: 0s"
504
-
505
- text_input.change(
506
- update_char_count,
507
- inputs=[text_input],
508
- outputs=[char_count]
509
- )
510
- text_input.change(
511
- update_duration_estimate,
512
- inputs=[text_input, speed_slider],
513
- outputs=[est_duration]
514
- )
515
- speed_slider.change(
516
- update_duration_estimate,
517
- inputs=[text_input, speed_slider],
518
- outputs=[est_duration]
519
  )
520
 
521
- synthesize_btn.click(
522
- fn=synthesize,
523
- inputs=[text_input, ref_audio, language, speed_slider],
524
- outputs=[audio_output],
525
- api_name="synthesize"
526
- )
527
 
528
- clear_btn.click(
529
- fn=lambda: ("", None, "en", 1.0),
530
- inputs=[],
531
- outputs=[text_input, ref_audio, language, speed_slider]
532
  )
533
 
534
- def update_status():
535
- return get_model_status()
536
-
537
- clear_cache_btn.click(
538
- fn=clear_cache,
539
- inputs=[],
540
- outputs=[clear_cache_output]
541
- ).then(
542
- fn=update_status,
543
- inputs=[],
544
- outputs=[model_status]
545
- )
546
-
547
- # ========================================================
548
- # TAB 2: BATCH SYNTHESIS (for long texts)
549
- # ========================================================
550
- with gr.TabItem("📚 Batch Synthesis (Long Text)"):
551
- gr.Markdown(
552
- """
553
- ### 🔄 Long Text Synthesis
554
- This mode automatically splits long texts (>2000 characters) into smaller chunks and concatenates them.
555
-
556
- **Note**: Processing may take several minutes for very long texts.
557
- """
558
- )
559
 
560
  with gr.Row():
561
- with gr.Column(scale=1):
562
- long_text = gr.Textbox(
563
- label="📝 Long Text",
564
- placeholder="Paste your long text here (up to 10000 characters)...\n\nThe system will automatically split it into chunks and synthesize them sequentially.",
565
- lines=12,
566
- max_length=10000
567
- )
568
-
569
- with gr.Row():
570
- chunk_size = gr.Slider(
571
- 300, 800,
572
- value=500,
573
- step=50,
574
- label="📏 Chunk Size (characters)"
575
- )
576
-
577
- ref_audio_batch = gr.Audio(
578
- label="🎵 Reference Voice",
579
- type="filepath",
580
- sources=["upload", "microphone"]
581
- )
582
-
583
- with gr.Row():
584
- language_batch = gr.Dropdown(
585
- choices=SUPPORTED_LANGUAGES,
586
- value="en",
587
- label="🌐 Language"
588
- )
589
- speed_batch = gr.Slider(
590
- 0.5, 2.0,
591
- value=1.0,
592
- step=0.05,
593
- label="⚡ Speech Speed"
594
- )
595
-
596
- synthesize_batch_btn = gr.Button(
597
- "🎵 Generate Long Speech",
598
- variant="primary",
599
- size="lg"
600
- )
601
-
602
- with gr.Column(scale=1):
603
- batch_audio_output = gr.Audio(
604
- label="🔊 Generated Speech (Combined)",
605
- type="filepath",
606
- interactive=False
607
- )
608
-
609
- gr.Markdown(
610
- """
611
- ### ℹ️ About Batch Mode
612
-
613
- - Automatically splits text at sentence boundaries
614
- - Synthesizes each chunk with the same voice
615
- - Combines all chunks into a single audio file
616
- - Useful for audiobooks, presentations, or long narrations
617
-
618
- **Limitation**: Quality may vary slightly between chunks
619
- """
620
- )
621
 
622
- synthesize_batch_btn.click(
623
- fn=synthesize_batch,
624
- inputs=[long_text, ref_audio_batch, language_batch, speed_batch, chunk_size],
625
- outputs=[batch_audio_output],
626
- api_name="synthesize_batch"
627
- )
628
 
629
- # ========================================================
630
- # TAB 3: HELP & TROUBLESHOOTING
631
- # ========================================================
632
- with gr.TabItem("❓ Help & Troubleshooting"):
633
  gr.Markdown(
634
  """
635
- # 📖 Help Guide
636
 
637
- ## 🎯 Quick Start
 
 
638
 
639
- 1. **Prepare Reference Audio**
640
- - Record or upload a 6-10 second WAV file
641
- - Use 22050Hz sample rate, mono channel
642
- - Speak clearly without background noise
643
-
644
- 2. **Enter Text**
645
- - Type or paste up to 2000 characters
646
- - Use proper punctuation for natural pauses
647
- - Match the language of your reference voice
648
-
649
- 3. **Generate Speech**
650
- - Click "Generate Speech" button
651
- - **First time**: Wait 2-5 minutes for model download
652
- - **Subsequent times**: Wait 10-30 seconds for synthesis
653
- - Download or listen to the result
654
-
655
- ## ⏱️ Why is first synthesis so slow?
656
-
657
- The XTTS-v2 model is **4GB** and must be downloaded from Hugging Face.
658
- - First synthesis: Downloads model (2-5 minutes) + synthesis (10-30 seconds)
659
- - After that: Model is cached, synthesis only (10-30 seconds)
660
-
661
- **Pro tip**: Enable GPU in Space Settings → Hardware → T4 Small for faster performance!
662
-
663
- ## 🔧 Troubleshooting
664
-
665
- ### "Failed to load TTS model"
666
- - Wait a few minutes for the model to download (first time only)
667
- - Check if your Space has GPU enabled
668
- - Try clearing the model cache in Advanced Options
669
-
670
- ### "CUDA out of memory"
671
- - Reduce text length
672
- - Use CPU mode (not recommended for long texts)
673
- - Clear model cache and retry
674
-
675
- ### Poor voice quality
676
- - Ensure reference audio is clean (no background noise)
677
- - Use longer reference audio (8-10 seconds)
678
- - Match speaking style of reference voice
679
-
680
- ### Synthesis is still slow after first time
681
- - Enable GPU in Space settings
682
- - Reduce text length
683
- - Check your internet connection
684
-
685
- ## 📞 Support
686
-
687
- - Check container logs in Space settings
688
- - Ensure all files (Dockerfile, requirements.txt) are present
689
- - Restart the Space if issues persist
690
  """
691
  )
692
 
693
- # Footer
694
- gr.Markdown(
695
- """
696
- ---
697
- **Powered by Coqui XTTS-v2** | Non-commercial use only | [License](https://coqui.ai/cpml)
698
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
699
  )
700
 
701
- # ============================================================
702
- # LAUNCH THE APP
703
- # ============================================================
704
  if __name__ == "__main__":
705
  demo.launch(
706
  server_name="0.0.0.0",
707
  server_port=int(os.environ.get("PORT", 7860)),
708
- share=False,
709
- show_error=True,
710
- quiet=False
711
  )
 
1
  # ============================================================
2
  # CRITICAL: Auto-accept Coqui license - MUST BE FIRST
 
 
3
  # ============================================================
4
  import builtins
5
 
 
22
  import os
23
  import time
24
  import numpy as np
25
+ import wave
26
+ import contextlib
27
  from pathlib import Path
28
 
29
  # ============================================================
 
42
  _model_downloading = False
43
 
44
  # ============================================================
45
+ # AUDIO VALIDATION FUNCTIONS
46
+ # ============================================================
47
+ def get_audio_duration(file_path):
48
+ """Get duration of WAV file in seconds."""
49
+ try:
50
+ with contextlib.closing(wave.open(file_path, 'r')) as f:
51
+ frames = f.getnframes()
52
+ rate = f.getframerate()
53
+ duration = frames / float(rate)
54
+ return duration
55
+ except Exception:
56
+ return 0
57
+
58
+ def validate_audio_quality(file_path):
59
+ """Check if audio file has good quality for voice cloning."""
60
+ try:
61
+ duration = get_audio_duration(file_path)
62
+
63
+ if duration < 3.0:
64
+ return False, f"Audio is too short ({duration:.1f} seconds). Minimum 6 seconds recommended."
65
+ if duration < 6.0:
66
+ return False, f"Audio is short ({duration:.1f} seconds). For best results, use 6-10 seconds."
67
+
68
+ # Check file size
69
+ file_size = os.path.getsize(file_path)
70
+ if file_size < 5000: # Less than 5KB
71
+ return False, "Audio file appears to be empty or corrupted."
72
+
73
+ return True, f"Audio duration: {duration:.1f} seconds (good!)"
74
+
75
+ except Exception as e:
76
+ return False, f"Could not read audio file: {str(e)}"
77
+
78
+ def check_audio_encoded(file_path):
79
+ """Check if audio file is properly encoded."""
80
+ try:
81
+ # Try to read with soundfile
82
+ import soundfile as sf
83
+ data, samplerate = sf.read(file_path)
84
+ if len(data.shape) == 1: # Mono
85
+ pass
86
+ elif len(data.shape) == 2 and data.shape[1] == 1: # Mono
87
+ pass
88
+ else:
89
+ return False, "Audio should be mono (single channel). Please convert to mono."
90
+
91
+ if samplerate < 16000:
92
+ return False, f"Sample rate ({samplerate}Hz) is too low. Use 22050Hz or higher."
93
+
94
+ return True, f"Audio format OK: {samplerate}Hz, mono"
95
+ except Exception as e:
96
+ return True, "Could not verify audio format (will try anyway)"
97
+
98
+ # ============================================================
99
+ # MODEL LOADING
100
  # ============================================================
101
  def is_model_downloaded():
102
  """Check if the XTTS-v2 model is already downloaded."""
103
  model_path = os.path.expanduser("~/.local/share/tts/tts_models--multilingual--multi-dataset--xtts_v2")
104
  return os.path.exists(model_path)
105
 
 
 
 
106
  def get_tts(progress_callback=None):
107
  """Lazy-load the XTTS-v2 model with proper error handling."""
108
  global _tts, _model_loading, _loading_error, _model_downloading
 
111
  return _tts
112
 
113
  if _loading_error is not None:
114
+ raise Exception(_loading_error)
115
 
116
  if _model_loading:
117
+ raise Exception("Model is still loading")
118
 
119
  try:
120
  _model_loading = True
121
 
 
122
  if not is_model_downloaded():
123
  _model_downloading = True
124
  if progress_callback:
125
+ progress_callback(0.1, desc="📥 Downloading 4GB model... (2-5 minutes)")
126
  print("=" * 60)
127
+ print("Downloading XTTS-v2 model (4GB)...")
 
128
  print("=" * 60)
129
 
130
  from TTS.api import TTS
131
 
132
  if progress_callback:
133
+ progress_callback(0.3, desc="🔄 Loading model into memory...")
134
 
 
135
  _tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2", gpu=torch.cuda.is_available())
136
 
 
137
  if torch.cuda.is_available():
138
  _tts.to("cuda")
139
  if progress_callback:
140
  progress_callback(0.8, desc="✅ Model loaded on GPU")
 
141
  else:
142
  if progress_callback:
143
  progress_callback(0.8, desc="⚠️ Model loaded on CPU (slower)")
 
144
 
145
  _model_loading = False
146
  _model_downloading = False
 
150
  _loading_error = str(e)
151
  _model_loading = False
152
  _model_downloading = False
153
+ raise Exception(str(e))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
 
155
  # ============================================================
156
  # MAIN SYNTHESIS FUNCTION
 
162
  speed: float = 1.0,
163
  progress=gr.Progress()
164
  ):
165
+ """Synthesize speech using zero-shot voice cloning."""
166
+
 
 
 
 
 
 
 
 
 
 
 
 
167
  progress(0, desc="🔍 Validating inputs...")
168
 
169
  # Validate text
170
+ if not text or not text.strip():
171
+ raise gr.Error("Please enter some text to synthesize.")
172
+
173
+ if len(text) > MAX_TEXT_LENGTH:
174
+ raise gr.Error(f"Text exceeds {MAX_TEXT_LENGTH} characters. Current length: {len(text)}")
175
 
176
  # Validate audio
177
+ if reference_audio is None:
178
+ raise gr.Error("Please upload a reference audio clip of your voice.")
179
+
180
+ if not os.path.exists(reference_audio):
181
+ raise gr.Error("Reference audio file not found. Please upload again.")
182
+
183
+ # Check audio quality
184
+ progress(0.05, desc="🔊 Checking audio quality...")
185
+ is_valid, msg = validate_audio_quality(reference_audio)
186
  if not is_valid:
187
+ raise gr.Error(msg)
188
 
189
+ # Show audio info to user
190
+ print(f"Audio info: {msg}")
191
+
192
+ # Check language
193
  if language not in SUPPORTED_LANGUAGES:
194
+ raise gr.Error(f"Unsupported language: {language}")
195
 
196
+ # Check speed
197
  if speed < 0.5 or speed > 2.0:
198
  raise gr.Error(f"Speed must be between 0.5 and 2.0. Got: {speed}")
199
 
200
  try:
201
+ # Show download warning if first time
202
  if not is_model_downloaded():
203
+ progress(0.1, desc="⚠️ FIRST RUN: Downloading 4GB model (2-5 minutes)...")
204
 
205
+ # Load model
206
+ progress(0.2, desc="🔄 Loading TTS model...")
207
  tts = get_tts(progress_callback=progress)
208
 
209
+ # Create output file
210
  progress(0.4, desc="📝 Preparing synthesis...")
211
  timestamp = int(time.time())
212
  output_path = tempfile.NamedTemporaryFile(
 
217
  # Generate speech
218
  progress(0.6, desc="🎤 Synthesizing speech (10-30 seconds)...")
219
 
220
+ # Try different approaches if the first fails
221
+ try:
222
+ tts.tts_to_file(
223
+ text=text.strip(),
224
+ speaker_wav=reference_audio,
225
+ language=language,
226
+ file_path=output_path,
227
+ speed=speed,
228
+ )
229
+ except IndexError as e:
230
+ if "list index out of range" in str(e):
231
+ # This usually means the reference audio is problematic
232
+ raise gr.Error(
233
+ "❌ Could not extract voice from reference audio.\n\n"
234
+ "Please ensure:\n"
235
+ "1. Audio is at least 6 seconds long\n"
236
+ "2. Audio contains clear speech (no silence)\n"
237
+ "3. Audio is in WAV format with 22050Hz sample rate\n"
238
+ "4. There is only one speaker in the audio\n\n"
239
+ "Try uploading a different audio clip."
240
+ )
241
+ else:
242
+ raise e
243
+ except Exception as e:
244
+ raise e
245
 
246
+ # Verify output
247
  progress(0.9, desc="✅ Finalizing...")
248
  if not os.path.exists(output_path) or os.path.getsize(output_path) == 0:
249
  raise Exception("Output file was not created or is empty")
250
 
251
  progress(1.0, desc="✨ Complete!")
 
252
  return output_path
253
 
254
+ except gr.Error:
255
+ # Re-raise Gradio errors as-is
256
+ raise
257
  except Exception as e:
258
  error_msg = str(e)
 
259
  if "CUDA out of memory" in error_msg:
260
+ raise gr.Error("❌ GPU out of memory. Try shorter text or enable CPU mode.")
261
+ elif "list index out of range" in error_msg:
262
+ raise gr.Error(
263
+ "❌ Voice cloning failed. This usually means:\n"
264
+ "• Reference audio is too short (need 6+ seconds)\n"
265
+ "• Audio doesn't contain clear speech\n"
266
+ "• Audio format is not supported\n\n"
267
+ "Try uploading a clean 6-10 second WAV file."
268
+ )
269
  else:
270
  raise gr.Error(f"❌ Synthesis failed: {error_msg}")
271
 
272
  # ============================================================
273
+ # BATCH SYNTHESIS
274
  # ============================================================
275
  def synthesize_batch(
276
  text: str,
 
280
  chunk_size: int = 500,
281
  progress=gr.Progress()
282
  ):
283
+ """Synthesize long text by splitting into chunks."""
 
 
 
284
  if not text or not text.strip():
285
  raise gr.Error("Please enter some text to synthesize.")
286
 
287
  if len(text) <= MAX_TEXT_LENGTH:
 
288
  return synthesize(text, reference_audio, language, speed, progress)
289
 
290
  progress(0.05, desc="📝 Splitting text into chunks...")
291
 
292
+ # Split into sentences
293
  sentences = text.replace('\n', ' ').split('. ')
294
  chunks = []
295
  current_chunk = ""
 
313
 
314
  progress(0.1, desc=f"📊 Split into {len(chunks)} chunks")
315
 
316
+ # Synthesize chunks
317
  audio_files = []
318
  total_chunks = len(chunks)
319
 
320
  for i, chunk in enumerate(chunks):
321
  chunk_progress = 0.1 + (0.7 * (i / total_chunks))
322
+ progress(chunk_progress, desc=f"🎤 Chunk {i+1}/{total_chunks}...")
323
 
324
  with tempfile.NamedTemporaryFile(suffix=f"_chunk_{i}.wav", delete=False) as f:
325
  chunk_path = f.name
326
 
327
+ try:
328
+ tts = get_tts()
329
+ tts.tts_to_file(
330
+ text=chunk.strip(),
331
+ speaker_wav=reference_audio,
332
+ language=language,
333
+ file_path=chunk_path,
334
+ speed=speed,
335
+ )
336
+ audio_files.append(chunk_path)
337
+ except IndexError as e:
338
+ raise gr.Error(
339
+ "❌ Voice cloning failed on chunk {i+1}. This usually means the reference audio "
340
+ "doesn't contain clear speech. Please upload a cleaner audio clip."
341
+ )
342
+
343
+ # Combine audio
344
+ progress(0.85, desc="🔊 Combining audio...")
345
 
346
  try:
347
  from pydub import AudioSegment
 
354
  output_path = tempfile.NamedTemporaryFile(suffix="_combined.wav", delete=False).name
355
  combined.export(output_path, format="wav")
356
 
357
+ # Cleanup
358
  for audio_file in audio_files:
359
  try:
360
  os.unlink(audio_file)
 
365
  return output_path
366
 
367
  except Exception as e:
368
+ raise gr.Error(f"Failed to combine audio: {str(e)}")
369
 
370
  # ============================================================
371
  # UTILITY FUNCTIONS
372
  # ============================================================
373
  def clear_cache():
 
374
  global _tts
375
  _tts = None
376
+ if torch.cuda.is_available():
377
+ torch.cuda.empty_cache()
378
+ return "🗑️ Model cache cleared."
379
 
380
  def get_model_status():
 
381
  if _tts is not None:
382
+ device = "GPU" if torch.cuda.is_available() else "CPU"
383
  return f"✅ Model loaded on {device}"
384
  elif _model_loading:
385
  if _model_downloading:
386
+ return "📥 Downloading model (4GB)... 2-5 minutes"
387
  return "⏳ Model is loading..."
388
  elif _loading_error:
389
+ return f"❌ Error: {_loading_error[:50]}..."
 
 
390
  else:
391
+ return "🔄 Model not loaded (will load on first use)"
392
 
393
  def estimate_duration(text: str, speed: float = 1.0):
 
394
  if not text:
395
  return 0
 
 
396
  chars_per_second = 150 * speed
397
  duration = len(text) / chars_per_second
398
  return round(duration, 1)
 
401
  # GRADIO UI
402
  # ============================================================
403
  with gr.Blocks(
404
+ title="VoxForge TTS — Voice Clone",
405
+ theme=gr.themes.Soft(primary_hue="purple", secondary_hue="teal"),
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  ) as demo:
407
 
 
408
  gr.Markdown(
409
  """
410
+ # 🎙️ VoxForge TTS — Voice Cloning
411
 
412
+ **Upload a 6-10 second WAV file of any voice** and make it say anything in 14 languages.
413
 
414
+ ⚠️ **First use**: Downloads 4GB model (2-5 minutes). Please be patient!
415
  """
416
  )
417
 
418
+ with gr.Row():
419
+ with gr.Column(scale=1):
420
+ text_input = gr.Textbox(
421
+ label="Text to Synthesize",
422
+ placeholder="Enter up to 2000 characters...",
423
+ lines=6,
424
+ max_length=MAX_TEXT_LENGTH
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425
  )
426
 
427
+ with gr.Row():
428
+ char_count = gr.Label(value=f"0 / {MAX_TEXT_LENGTH}")
429
+ est_duration = gr.Label(value="Est. 0s")
 
 
 
430
 
431
+ ref_audio = gr.Audio(
432
+ label="Reference Voice (6-10 seconds, clear WAV)",
433
+ type="filepath",
434
+ sources=["upload", "microphone"]
435
  )
436
 
437
+ with gr.Row():
438
+ language = gr.Dropdown(
439
+ choices=SUPPORTED_LANGUAGES,
440
+ value="en",
441
+ label="Language"
442
+ )
443
+ speed_slider = gr.Slider(
444
+ 0.5, 2.0, value=1.0, step=0.05, label="Speed"
445
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
446
 
447
  with gr.Row():
448
+ synthesize_btn = gr.Button("Generate Speech", variant="primary", size="lg")
449
+ clear_btn = gr.Button("Clear", variant="secondary", size="lg")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
 
451
+ with gr.Accordion("Advanced", open=False):
452
+ model_status = gr.Label(value=get_model_status())
453
+ clear_cache_btn = gr.Button("Clear Model Cache", size="sm")
 
 
 
454
 
455
+ with gr.Column(scale=1):
456
+ audio_output = gr.Audio(label="Generated Speech", type="filepath")
457
+
 
458
  gr.Markdown(
459
  """
460
+ ### Tips
461
 
462
+ 1. **Audio**: 6-10 seconds, clear speech, no background noise
463
+ 2. **Format**: WAV, 22050Hz, mono (convert with ffmpeg if needed)
464
+ 3. **Language**: Match the language of the reference voice
465
 
466
+ ### First time?
467
+ - Downloads 4GB model (2-5 minutes)
468
+ - Subsequent uses: 10-30 seconds
469
+ - Enable GPU in Space settings for faster performance
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
  """
471
  )
472
 
473
+ # Event handlers
474
+ def update_char_count(text):
475
+ return f"{len(text)} / {MAX_TEXT_LENGTH}" if text else f"0 / {MAX_TEXT_LENGTH}"
476
+
477
+ def update_duration(text, speed):
478
+ if text:
479
+ dur = estimate_duration(text, speed)
480
+ return f"Est. {dur}s"
481
+ return "Est. 0s"
482
+
483
+ text_input.change(update_char_count, [text_input], [char_count])
484
+ text_input.change(update_duration, [text_input, speed_slider], [est_duration])
485
+ speed_slider.change(update_duration, [text_input, speed_slider], [est_duration])
486
+
487
+ synthesize_btn.click(
488
+ synthesize,
489
+ [text_input, ref_audio, language, speed_slider],
490
+ [audio_output]
491
+ )
492
+
493
+ clear_btn.click(
494
+ lambda: ("", None, "en", 1.0),
495
+ None,
496
+ [text_input, ref_audio, language, speed_slider]
497
+ )
498
+
499
+ clear_cache_btn.click(clear_cache, None, [model_status]).then(
500
+ lambda: get_model_status(), None, [model_status]
501
  )
502
 
 
 
 
503
  if __name__ == "__main__":
504
  demo.launch(
505
  server_name="0.0.0.0",
506
  server_port=int(os.environ.get("PORT", 7860)),
507
+ show_error=True
 
 
508
  )