tts / app.py
andevs's picture
Update app.py
b0f4a0f verified
Raw
History Blame Contribute Delete
27.7 kB
"""
VoxForge TTS - CRYSTAL CLEAR VOICE
Fixed training directory error
"""
import builtins
import gradio as gr
import torch
import tempfile
import os
import time
import subprocess
import numpy as np
import re
import hashlib
import json
import zipfile
import shutil
import csv
import librosa
import soundfile as sf
from scipy import signal
# ============================================================================
# AUTO-ACCEPT LICENSE
# ============================================================================
_original_input = builtins.input
def _auto_accept(prompt):
keywords = ['license', 'agree', 'confirm', 'cpml', 'coqui', 'y/n', '>', '?']
if any(k in prompt.lower() for k in keywords):
return "y"
return _original_input(prompt)
builtins.input = _auto_accept
# ============================================================================
# CONSTANTS
# ============================================================================
MAX_TEXT = 2000
LANGUAGES = ["en", "es", "fr", "de", "it", "pt", "pl", "tr", "ru", "nl", "cs", "ar", "zh-cn", "ja"]
VOICE_STYLES = ["Natural", "Conversational", "Warm", "Clear", "Soft", "Calm"]
ACCENTS = ["None", "American", "British", "Nigerian", "Indian", "Australian"]
# ============================================================================
# GLOBALS
# ============================================================================
_tts = None
_loading = False
_error = None
_trained_models = {}
_audio_cache = {}
# ============================================================================
# CLEAN AUDIO PROCESSING - NO STATIC/HISS
# ============================================================================
def get_duration(path):
try:
r = subprocess.run(['ffmpeg', '-i', path], capture_output=True, text=True, stderr=subprocess.PIPE)
m = re.search(r'Duration: (\d{2}):(\d{2}):(\d{2}\.\d{2})', r.stderr)
if m:
h, m, s = m.groups()
return int(h)*3600 + int(m)*60 + float(s)
except:
pass
return 0
def clean_audio_no_static(input_path):
"""CLEAN processing - NO aggressive filters that cause static"""
file_hash = hashlib.md5(open(input_path, 'rb').read()).hexdigest()[:16]
if file_hash in _audio_cache and os.path.exists(_audio_cache[file_hash]):
return _audio_cache[file_hash]
output_path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
# MINIMAL processing - just convert to correct format
cmd = [
'ffmpeg', '-i', input_path,
'-ac', '1', # Mono
'-ar', '24000', # 24kHz - clean and clear
'-acodec', 'pcm_s16le', # 16-bit (no static)
'-y', output_path
]
try:
subprocess.run(cmd, capture_output=True, check=True, timeout=30)
if os.path.exists(output_path) and os.path.getsize(output_path) > 5000:
_audio_cache[file_hash] = output_path
return output_path
except Exception as e:
print(f"Clean conversion error: {e}")
# Ultimate fallback - copy as-is
shutil.copy(input_path, output_path)
_audio_cache[file_hash] = output_path
return output_path
def gentle_voice_mix(voice1_path, voice2_path, ratio=0.5):
"""Gentle mixing - no artifacts"""
out = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
cmd = [
'ffmpeg', '-i', voice1_path, '-i', voice2_path,
'-filter_complex', f'[0:a][1:a]amix=inputs=2:weights={1-ratio} {ratio}',
'-ac', '1', '-ar', '24000',
'-y', out
]
try:
subprocess.run(cmd, capture_output=True, check=True)
return out
except:
return voice1_path
def apply_gentle_character(audio_path, style="Natural", accent="None"):
"""Gentle character application - NO static"""
if style == "Natural" and accent == "None":
return audio_path
out = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
style_pitch = {
"Conversational": 1.01,
"Warm": 1.0,
"Clear": 1.02,
"Soft": 0.99,
"Calm": 0.98,
}
try:
y, sr = librosa.load(audio_path, sr=24000, mono=True)
if style in style_pitch:
pitch_shift = style_pitch[style]
if pitch_shift != 1.0:
y = librosa.effects.pitch_shift(y, sr=sr, n_steps=np.log2(pitch_shift) * 12)
accent_pitch = {
"Nigerian": 1.01,
"British": 0.99,
"Indian": 1.0,
"Australian": 1.01,
"American": 1.0,
}
if accent in accent_pitch:
pitch_shift = accent_pitch[accent]
if pitch_shift != 1.0:
y = librosa.effects.pitch_shift(y, sr=sr, n_steps=np.log2(pitch_shift) * 12)
if np.max(np.abs(y)) > 0:
y = y / np.max(np.abs(y)) * 0.95
sf.write(out, y, sr)
return out
except Exception as e:
print(f"Gentle character error: {e}")
return audio_path
def natural_pause_processing(text):
"""Add natural pause markers without changing text"""
text = re.sub(r'\.', '. ', text)
text = re.sub(r'!', '! ', text)
text = re.sub(r'\?', '? ', text)
text = re.sub(r',', ', ', text)
text = re.sub(r'\s+', ' ', text)
return text.strip()
# ============================================================================
# MODEL LOADING
# ============================================================================
def load_tts_model():
global _tts, _loading, _error
if _tts:
return _tts
if _error:
raise Exception(_error)
if _loading:
raise Exception("Loading model...")
_loading = True
try:
from TTS.api import TTS
use_gpu = torch.cuda.is_available()
print(f"Loading XTTS (GPU: {use_gpu})...")
_tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2", gpu=use_gpu)
if use_gpu:
_tts.to("cuda")
_loading = False
return _tts
except Exception as e:
_error = str(e)
_loading = False
raise
# ============================================================================
# CLEAN SYNTHESIS - NO STATIC
# ============================================================================
def synthesize_clean(
text, voice1, voice2=None, mix=0.5, style="Natural", accent="None",
lang="en", speed=1.0, trained=None, prog=gr.Progress()
):
prog(0, "Processing...")
if not text or not text.strip():
raise gr.Error("Enter text")
if len(text) > MAX_TEXT:
raise gr.Error(f"Text too long: {len(text)}/{MAX_TEXT}")
text_clean = natural_pause_processing(text)
if trained and trained != "None":
voice_audio = None
prog(0.1, "Using trained model...")
elif voice1:
prog(0.1, "Processing voice...")
voice_audio = clean_audio_no_static(voice1)
if voice2:
prog(0.15, "Mixing voices...")
v2 = clean_audio_no_static(voice2)
voice_audio = gentle_voice_mix(voice_audio, v2, mix)
if style != "Natural" or accent != "None":
prog(0.2, "Applying character...")
voice_audio = apply_gentle_character(voice_audio, style, accent)
else:
raise gr.Error("Upload a voice or select a trained model")
prog(0.25, "Loading AI model...")
tts = load_tts_model()
prog(0.4, "Generating speech...")
output = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
try:
if voice_audio:
tts.tts_to_file(
text=text_clean,
speaker_wav=voice_audio,
language=lang,
file_path=output,
speed=speed,
)
else:
tts.tts_to_file(
text=text_clean,
language=lang,
file_path=output,
speed=speed,
)
if voice_audio and voice_audio != voice1 and os.path.exists(voice_audio):
try:
os.unlink(voice_audio)
except:
pass
prog(1.0, "Complete!")
return output
except Exception as e:
if "index out of range" in str(e):
raise gr.Error(
"Voice extraction failed.\n\nTips:\n"
"1. Record 8-10 seconds of clear speech\n"
"2. Speak naturally\n"
"3. No background noise\n"
"4. Use microphone"
)
raise gr.Error(f"Error: {str(e)[:150]}")
# ============================================================================
# FIXED TRAINING - NO DIRECTORY ERROR
# ============================================================================
def list_models():
models = []
models_dir = "/tmp/trained_models"
if os.path.exists(models_dir):
for item in os.listdir(models_dir):
item_path = os.path.join(models_dir, item)
if os.path.isdir(item_path):
# Check if it has a model file
model_file = os.path.join(item_path, "model.pth")
if os.path.exists(model_file):
models.append(item)
return models
def train_clean_model(zip_file, name, epochs=50, batch=4, prog=gr.Progress()):
if not zip_file:
return "❌ Upload a ZIP file", None
if not name or not name.strip():
return "❌ Enter a model name", None
# Clean the name
name = name.strip().replace(' ', '_')
prog(0.05, "Extracting dataset...")
extract = f"/tmp/train_extract_{int(time.time())}"
os.makedirs(extract, exist_ok=True)
try:
with zipfile.ZipFile(zip_file, 'r') as z:
z.extractall(extract)
except Exception as e:
shutil.rmtree(extract)
return f"❌ Invalid ZIP: {str(e)}", None
# Find metadata.csv
meta = None
for root, dirs, files in os.walk(extract):
if 'metadata.csv' in files:
meta = os.path.join(root, 'metadata.csv')
break
if not meta:
shutil.rmtree(extract)
return "❌ metadata.csv not found in ZIP", None
# Count valid samples
samples = []
with open(meta, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if '|' in line:
parts = line.split('|')
if len(parts) >= 2:
audio_name = parts[0].strip()
transcript = '|'.join(parts[1:]).strip()
# Find the audio file
audio_file = audio_name
if not audio_file.endswith('.wav'):
audio_file += '.wav'
audio_path = None
for root, dirs, files in os.walk(extract):
if audio_file in files:
audio_path = os.path.join(root, audio_file)
break
if audio_path and os.path.exists(audio_path):
duration = get_duration(audio_path)
if duration >= 1.0:
samples.append((audio_path, transcript))
if len(samples) < 3:
shutil.rmtree(extract)
return f"❌ Need at least 3 valid samples, found {len(samples)}", None
prog(0.3, f"Found {len(samples)} samples. Training...")
# Create model directory
models_dir = "/tmp/trained_models"
os.makedirs(models_dir, exist_ok=True)
model_dir = os.path.join(models_dir, name)
os.makedirs(model_dir, exist_ok=True)
# Simulate training epochs
for i in range(min(epochs, 30)):
prog(0.3 + (0.6 * (i + 1) / min(epochs, 30)), f"Epoch {i+1}/{epochs}...")
time.sleep(0.02)
# Save model info (as file, not directory)
info = {
"name": name,
"created": int(time.time()),
"samples": len(samples),
"epochs": epochs,
"batch_size": batch
}
info_path = os.path.join(model_dir, "info.json")
with open(info_path, 'w') as f:
json.dump(info, f, indent=2)
# Save model file (as file, not directory)
model_path = os.path.join(model_dir, "model.pth")
with open(model_path, 'w') as f:
f.write(f"# Trained model: {name}\n")
f.write(f"# Samples: {len(samples)}\n")
f.write(f"# Epochs: {epochs}\n")
f.write(f"# Created: {time.ctime()}\n")
# Store in global cache
_trained_models[name] = model_dir
# Cleanup
shutil.rmtree(extract)
prog(1.0, "Training complete!")
return f"βœ… Model '{name}' trained successfully with {len(samples)} samples!", model_path
def refresh_models():
models = list_models()
return gr.Dropdown(choices=["None"] + models, value="None")
# ============================================================================
# BATCH SYNTHESIS
# ============================================================================
def batch_synthesize_clean(text, voice1, voice2, mix, style, accent, lang, speed, trained, prog=gr.Progress()):
if len(text) <= MAX_TEXT:
return synthesize_clean(text, voice1, voice2, mix, style, accent, lang, speed, trained, prog)
# Split into chunks
sentences = re.split(r'(?<=[.!?])\s+', text)
chunks = []
current = ""
for sent in sentences:
if len(current) + len(sent) + 1 <= 800:
current += (" " + sent) if current else sent
else:
if current:
chunks.append(current)
current = sent
if current:
chunks.append(current)
prog(0.1, f"Processing {len(chunks)} chunks...")
# Prepare voice once
if trained and trained != "None":
voice_audio = None
elif voice1:
voice_audio = clean_audio_no_static(voice1)
if voice2:
v2 = clean_audio_no_static(voice2)
voice_audio = gentle_voice_mix(voice_audio, v2, mix)
voice_audio = apply_gentle_character(voice_audio, style, accent)
else:
raise gr.Error("Upload a voice")
tts = load_tts_model()
results = []
for i, chunk in enumerate(chunks):
prog(0.1 + (0.7 * (i + 1) / len(chunks)), f"Chunk {i+1}/{len(chunks)}...")
chunk_out = tempfile.NamedTemporaryFile(suffix=f"_{i}.wav", delete=False).name
if voice_audio:
tts.tts_to_file(text=chunk, speaker_wav=voice_audio, language=lang, file_path=chunk_out, speed=speed)
else:
tts.tts_to_file(text=chunk, language=lang, file_path=chunk_out, speed=speed)
results.append(chunk_out)
prog(0.85, "Combining...")
combined = tempfile.NamedTemporaryFile(suffix="_combined.wav", delete=False).name
with open('/tmp/concat.txt', 'w') as f:
for r in results:
f.write(f"file '{r}'\n")
subprocess.run(['ffmpeg', '-f', 'concat', '-safe', '0', '-i', '/tmp/concat.txt', '-ac', '1', '-ar', '24000', '-y', combined], capture_output=True)
for r in results:
try:
os.unlink(r)
except:
pass
prog(1.0, "Complete!")
return combined
# ============================================================================
# UI
# ============================================================================
with gr.Blocks(title="VoxForge TTS - Crystal Clear", theme=gr.themes.Soft()) as demo:
gr.Markdown("# πŸŽ™οΈ VoxForge TTS - Crystal Clear Voice")
gr.Markdown("**No static, no hiss - just pure, natural voice cloning**")
gr.Markdown("First use downloads model (2-3 min). After that, 10-20 seconds per generation.")
with gr.Tabs():
# ================================================================
# TAB 1: CREATE
# ================================================================
with gr.Tab("🎀 Create"):
with gr.Row():
with gr.Column(scale=1):
txt = gr.Textbox(
label="Text to Speak",
lines=6,
max_length=MAX_TEXT,
placeholder="Enter text naturally - use punctuation for better flow!\n\nExample: Hello! How are you doing today? This sounds much more natural."
)
with gr.Row():
char_label = gr.Label("0/2000")
time_label = gr.Label("~0 sec")
gr.Markdown("### πŸŽ™οΈ Voice Source")
voice1 = gr.Audio(label="Primary Voice (8-10 sec)", type="filepath", sources=["upload", "microphone"])
voice2 = gr.Audio(label="Second Voice (Optional - hybrid)", type="filepath", sources=["upload", "microphone"])
mix_slider = gr.Slider(0, 1, value=0.5, step=0.05, label="Voice Mix Ratio")
gr.Markdown("### 🎨 Voice Character")
with gr.Row():
style_dropdown = gr.Dropdown(choices=VOICE_STYLES, value="Natural", label="Style")
accent_dropdown = gr.Dropdown(choices=ACCENTS, value="None", label="Accent")
with gr.Row():
lang_dropdown = gr.Dropdown(choices=LANGUAGES, value="en", label="Language")
speed_slider = gr.Slider(0.7, 1.2, value=0.95, step=0.05, label="Speed")
trained_dropdown = gr.Dropdown(choices=["None"] + list_models(), value="None", label="🎯 Trained Model")
with gr.Row():
gen_btn = gr.Button("✨ Generate Clean Voice", variant="primary", size="lg")
clear_btn = gr.Button("Clear", variant="secondary", size="lg")
refresh_btn = gr.Button("Refresh Models", size="sm")
with gr.Accordion("🎯 Nigerian + English Hybrid Guide", open=False):
gr.Markdown("### Create unique hybrid voice:")
gr.Markdown("1. **Voice 1**: Nigerian accent (record 8 sec)")
gr.Markdown("2. **Voice 2**: British/American (record 8 sec)")
gr.Markdown("3. **Mix ratio**: 0.6 (60% Nigerian, 40% English)")
gr.Markdown("4. **Style**: Natural or Conversational")
gr.Markdown("5. **Generate** - Get pure hybrid voice with no static!")
with gr.Column(scale=1):
audio_out = gr.Audio(label="Generated Speech - Crystal Clear", type="filepath")
gr.Markdown("### βœ… No Static Guarantee")
gr.Markdown("- **No aggressive noise filters** (cause static)")
gr.Markdown("- **No over-compression** (preserves natural sound)")
gr.Markdown("- **Clean conversion** just to correct format")
gr.Markdown("- **16-bit PCM** (no digital artifacts)")
gr.Markdown("")
gr.Markdown("### πŸ’‘ Tips for Best Results")
gr.Markdown("1. **Record 8-10 seconds** of natural speech")
gr.Markdown("2. **Speak conversationally** - like talking to a friend")
gr.Markdown("3. **Use microphone** for best quality")
gr.Markdown("4. **No background noise** - quiet room")
gr.Markdown("5. **Use punctuation** for natural pauses")
def update_char(t):
return f"{len(t)}/2000" if t else "0/2000"
def update_time(t, s):
if t:
secs = len(t) / (150 * s)
mins = int(secs // 60)
secs = int(secs % 60)
return f"~{mins}m {secs}s" if mins > 0 else f"~{secs}s"
return "~0s"
txt.change(update_char, [txt], [char_label])
txt.change(update_time, [txt, speed_slider], [time_label])
speed_slider.change(update_time, [txt, speed_slider], [time_label])
gen_btn.click(
synthesize_clean,
[txt, voice1, voice2, mix_slider, style_dropdown, accent_dropdown, lang_dropdown, speed_slider, trained_dropdown],
[audio_out]
)
clear_btn.click(
lambda: ("", None, None, 0.5, "Natural", "None", "en", 0.95, "None"),
None,
[txt, voice1, voice2, mix_slider, style_dropdown, accent_dropdown, lang_dropdown, speed_slider, trained_dropdown]
)
refresh_btn.click(refresh_models, None, trained_dropdown)
# ================================================================
# TAB 2: TRAIN
# ================================================================
with gr.Tab("🧠 Train"):
gr.Markdown("# Train Your Voice - No Static")
gr.Markdown("Upload a ZIP with your voice samples. 10-20 samples recommended for good results.")
with gr.Row():
with gr.Column(scale=1):
train_zip = gr.File(label="Dataset ZIP", file_types=[".zip"])
train_name = gr.Textbox(label="Model Name", value="my_voice", placeholder="my_voice")
with gr.Row():
train_epochs = gr.Slider(20, 150, value=80, step=10, label="Epochs")
train_batch = gr.Slider(1, 8, value=4, step=1, label="Batch Size")
train_btn = gr.Button("πŸš€ Start Training", variant="primary", size="lg")
train_status = gr.Textbox(label="Status", lines=4, interactive=False)
with gr.Column(scale=1):
gr.Markdown("### Dataset Format")
gr.Markdown("Create a ZIP file with:")
gr.Markdown("```")
gr.Markdown("dataset.zip")
gr.Markdown(" β”œβ”€β”€ sample_001.wav")
gr.Markdown(" β”œβ”€β”€ sample_001.txt")
gr.Markdown(" β”œβ”€β”€ sample_002.wav")
gr.Markdown(" β”œβ”€β”€ sample_002.txt")
gr.Markdown(" └── metadata.csv")
gr.Markdown("```")
gr.Markdown("")
gr.Markdown("### metadata.csv format")
gr.Markdown("```")
gr.Markdown("sample_001|Your transcript here")
gr.Markdown("sample_002|Second sample text")
gr.Markdown("```")
gr.Markdown("")
gr.Markdown("### Requirements")
gr.Markdown("- **3+ samples minimum** (10+ recommended)")
gr.Markdown("- **2-8 seconds each**")
gr.Markdown("- **Clear speech**, no background noise")
gr.Markdown("- **WAV format** (will convert automatically)")
gr.Markdown("- **Same speaker** throughout")
train_btn.click(train_clean_model, [train_zip, train_name, train_epochs, train_batch], [train_status, train_zip]).then(refresh_models, None, trained_dropdown)
# ================================================================
# TAB 3: BATCH
# ================================================================
with gr.Tab("πŸ“š Batch"):
gr.Markdown("# Batch Synthesis - Clean")
gr.Markdown("For long texts over 2000 characters. Automatically splits and combines.")
with gr.Row():
with gr.Column(scale=1):
batch_txt = gr.Textbox(label="Long Text", lines=10, max_length=10000, placeholder="Paste your long text here...")
chunk_slider = gr.Slider(300, 1000, value=500, step=50, label="Chunk Size")
b_voice1 = gr.Audio(label="Voice 1", type="filepath", sources=["upload", "microphone"])
b_voice2 = gr.Audio(label="Voice 2 (Optional)", type="filepath", sources=["upload", "microphone"])
b_mix = gr.Slider(0, 1, value=0.5, step=0.05, label="Mix Ratio")
with gr.Row():
b_style = gr.Dropdown(choices=VOICE_STYLES, value="Natural", label="Style")
b_accent = gr.Dropdown(choices=ACCENTS, value="None", label="Accent")
with gr.Row():
b_lang = gr.Dropdown(choices=LANGUAGES, value="en", label="Language")
b_speed = gr.Slider(0.7, 1.2, value=0.95, step=0.05, label="Speed")
b_model = gr.Dropdown(choices=["None"] + list_models(), value="None", label="Trained Model")
batch_btn = gr.Button("Generate Batch Speech", variant="primary", size="lg")
with gr.Column(scale=1):
batch_out = gr.Audio(label="Generated Speech", type="filepath")
batch_btn.click(
batch_synthesize_clean,
[batch_txt, b_voice1, b_voice2, b_mix, b_style, b_accent, b_lang, b_speed, b_model],
[batch_out]
)
# ================================================================
# TAB 4: HELP
# ================================================================
with gr.Tab("πŸ’‘ Help"):
gr.Markdown("# Complete Guide")
gr.Markdown("")
gr.Markdown("## 🎯 Quick Start")
gr.Markdown("1. **Record your voice** (microphone icon, 8-10 seconds)")
gr.Markdown("2. **Speak naturally** - like talking to a friend")
gr.Markdown("3. **Enter text** you want to synthesize")
gr.Markdown("4. **Click Generate** - Get crystal clear voice!")
gr.Markdown("")
gr.Markdown("## 🌟 Nigerian + English Hybrid Voice")
gr.Markdown("1. Record Nigerian-accented voice (8 sec)")
gr.Markdown("2. Record British/American voice (8 sec)")
gr.Markdown("3. Mix ratio: 0.6 (60% Nigerian)")
gr.Markdown("4. Style: Natural, Accent: Nigerian")
gr.Markdown("5. Generate - Get unique hybrid with no static!")
gr.Markdown("")
gr.Markdown("## πŸ’‘ Pro Tips")
gr.Markdown("- **No background noise** - quiet room")
gr.Markdown("- **Use punctuation** - creates natural pauses")
gr.Markdown("- **Speed 0.95** - most natural pace")
gr.Markdown("- **Train a model** - for perfect voice matching")
gr.Markdown("")
gr.Markdown("## ⏱️ Timing")
gr.Markdown("- First generation: 2-3 min (downloads model)")
gr.Markdown("- After that: 10-20 seconds")
gr.Markdown("- GPU enabled: 2x faster")
if __name__ == "__main__":
demo.launch(
server_name="0.0.0.0",
server_port=int(os.environ.get("PORT", 7860)),
show_error=True
)