import inspect import os import random import subprocess import sys import tempfile import threading import time from pathlib import Path from typing import Optional import gradio as gr import spaces MODEL_ID = "IndexTeam/IndexTTS-2" MODEL_DIR = Path("/tmp/index-tts-2") OUTPUT_DIR = Path(tempfile.gettempdir()) / "index-tts-2-outputs" os.environ.setdefault("HF_HOME", "/tmp/huggingface") os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1") _runtime_lock = threading.Lock() _runtime_ready = False _tts = None _warmup_state = {"done": False, "started_at": time.time()} def _install_runtime_dependencies() -> None: """Install the official IndexTTS runtime without changing Space SDK pins.""" global _runtime_ready if _runtime_ready: return with _runtime_lock: if _runtime_ready: return commands = [ [ sys.executable, "-m", "pip", "install", "--no-cache-dir", "--no-deps", "tokenizers==0.21.0", ], [ sys.executable, "-m", "pip", "install", "--no-cache-dir", "--no-deps", "transformers==4.52.1", ], [ sys.executable, "-m", "pip", "install", "--no-cache-dir", "--no-deps", "git+https://github.com/index-tts/index-tts.git", ], ] for command in commands: subprocess.check_call(command) _runtime_ready = True def _download_model() -> Path: """Download the model snapshot to local disk (CPU only, costs no ZeroGPU quota).""" from huggingface_hub import snapshot_download MODEL_DIR.mkdir(parents=True, exist_ok=True) marker = MODEL_DIR / ".snapshot-complete" if not marker.exists(): snapshot_download( repo_id=MODEL_ID, local_dir=str(MODEL_DIR), ) marker.touch() return MODEL_DIR def _download_aux_models() -> None: """Pre-download auxiliary models (w2v-bert-2.0, MaskGCT codec, CAMPPlus, BigVGAN) into the model cache on the CPU. IndexTTS2 downloads these itself when given aux_paths=None, which would happen inside the ZeroGPU allocation and waste quota on ~5 GB of downloads. """ from indextts.utils.model_download import ensure_models_available ensure_models_available(str(MODEL_DIR)) def _patch_numpy_compat() -> None: """Restore NumPy aliases removed in NumPy 2.x for legacy pinned libraries. Old tensorboard/keras code (imported via audiotools) references np.bool8, which no longer exists in NumPy 2.x, crashing synthesis at import time. """ import numpy as np if not hasattr(np, "bool8"): np.bool8 = np.bool_ ASSETS_DIR = Path(__file__).resolve().parent / "assets" MY_VOICES = [ ("๐๏ธ My Voice 1", ASSETS_DIR / "voice_1.wav"), ("๐๏ธ My Voice 2", ASSETS_DIR / "voice_2.wav"), ("๐๏ธ My Voice 3", ASSETS_DIR / "voice_3.wav"), ] MY_VOICES = [(label, str(path)) for label, path in MY_VOICES if path.is_file()] DEFAULT_VOICE = MY_VOICES[0][1] if MY_VOICES else None def _warm_up() -> None: """Prepare dependencies and model files at Space startup, on the CPU. This keeps the paid ZeroGPU allocation focused on actual synthesis instead of pip installs and multi-GB downloads, so the daily GPU quota lasts much longer. """ try: _patch_numpy_compat() _install_runtime_dependencies() _download_model() _download_aux_models() _warmup_state["done"] = True print("[warmup] runtime dependencies, model snapshot and aux models are ready", flush=True) except Exception as exc: print( f"[warmup] deferred, will retry on first request: {type(exc).__name__}: {exc}", flush=True, ) _warmup_thread = threading.Thread(target=_warm_up, daemon=True) _warmup_thread.start() def _find_config(model_dir: Path) -> str: candidates = [ model_dir / "config.yaml", model_dir / "config.yml", model_dir / "configs" / "inference.yaml", model_dir / "configs" / "config.yaml", ] for candidate in candidates: if candidate.is_file(): return str(candidate) discovered = sorted(model_dir.rglob("*.yaml")) + sorted(model_dir.rglob("*.yml")) if discovered: return str(discovered[0]) raise FileNotFoundError(f"No IndexTTS configuration file was found under {model_dir}.") def _get_tts(): global _tts if _tts is not None: return _tts with _runtime_lock: if _tts is not None: return _tts try: import torch if not torch.cuda.is_available(): raise RuntimeError("CUDA GPU is not available in this Space.") _patch_numpy_compat() _install_runtime_dependencies() model_dir = _download_model() from indextts.infer_v2 import IndexTTS2 _tts = IndexTTS2( cfg_path=_find_config(model_dir), model_dir=str(model_dir), use_fp16=True, device="cuda", use_cuda_kernel=False, use_deepspeed=False, use_accel=False, use_torch_compile=False, use_qwen_emo=True, ) return _tts except Exception: _tts = None raise def _validate_audio(path: Optional[str], label: str) -> str: if not path: raise gr.Error(f"{label} is required.") audio = Path(path) if not audio.is_file(): raise gr.Error(f"{label} could not be found.") if audio.stat().st_size == 0: raise gr.Error(f"{label} is empty.") return str(audio) def _clean_reference(path: str) -> str: """Trim silence, cap length and normalise the reference clip for a closer clone.""" try: import librosa import numpy as np import soundfile as sf audio, sample_rate = librosa.load(path, sr=None, mono=True) trimmed, _ = librosa.effects.trim(audio, top_db=30) if len(trimmed) < sample_rate // 2: trimmed = audio trimmed = trimmed[: int(sample_rate * 20.0)] peak = float(np.max(np.abs(trimmed))) if trimmed.size else 0.0 if peak > 0: trimmed = trimmed * (0.9 / peak) cleaned = f"{path}.cleaned.wav" sf.write(cleaned, trimmed, sample_rate) return cleaned except Exception as exc: print(f"[reference-clean] skipped: {type(exc).__name__}: {exc}", flush=True) return path def _set_seed(seed: int) -> None: random.seed(seed) try: import numpy as np np.random.seed(seed) except ImportError: pass try: import torch torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) except ImportError: pass def _supported_kwargs(function, values: dict) -> dict: """Keep compatibility with small upstream inference API changes.""" parameters = inspect.signature(function).parameters if any(parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters.values()): return values return {key: value for key, value in values.items() if key in parameters} def _estimate_duration(*args, **kwargs) -> int: """Request only as much ZeroGPU time as this request realistically needs. The platform pre-checks the declared duration against the visitor's remaining quota, so smaller realistic durations let people fit far more generations into their daily allowance. Accepts *args/**kwargs because Gradio may inject extra parameters (e.g. gr.Progress). """ if _tts is None: return 120 # cold start: one-time model load plus synthesis text = kwargs.get("text") if not text and len(args) > 1: text = args[1] characters = len((text or "").strip()) return int(min(120, 30 + characters * 0.05)) @spaces.GPU(duration=_estimate_duration) def synthesize( reference_audio: Optional[str], text: str, permission: bool, emotion_audio: Optional[str], emotion_strength: float, use_text_emotion: bool, seed, progress=gr.Progress(), ): progress(0.05, desc="Validating input") if not permission: raise gr.Error("Please confirm that you have permission to use the reference voice.") progress(0.1, desc="Preparing reference audio") reference_audio = _clean_reference(_validate_audio(reference_audio, "Reference audio")) text = (text or "").strip() if not text: raise gr.Error("English text is required.") if len(text) > 2000: raise gr.Error("Please keep the English text to 2,000 characters or fewer.") try: numeric_seed = float(seed) if not numeric_seed.is_integer() or numeric_seed < 0: raise ValueError numeric_seed = int(numeric_seed) except (TypeError, ValueError, OverflowError) as exc: raise gr.Error("Seed must be a non-negative integer.") from exc try: emotion_strength = float(emotion_strength) except (TypeError, ValueError) as exc: raise gr.Error("Emotion strength must be a number between 0 and 1.") from exc if not 0 <= emotion_strength <= 1: raise gr.Error("Emotion strength must be between 0 and 1.") if not isinstance(use_text_emotion, bool): raise gr.Error("Use text emotion must be a boolean.") emotion_audio = ( _clean_reference(_validate_audio(emotion_audio, "Emotion audio")) if emotion_audio else None ) output_dir = OUTPUT_DIR output_dir.mkdir(parents=True, exist_ok=True) output_path = output_dir / "index-tts-2-output.wav" if output_path.exists(): output_path.unlink() started = time.perf_counter() try: _set_seed(numeric_seed) if _tts is None: progress(0.18, desc="Loading AI model - first run takes 1-3 min") else: progress(0.45, desc="Model warm - starting synthesis") tts = _get_tts() progress(0.6, desc="Synthesizing voice") values = { "spk_audio_prompt": reference_audio, "text": text, "output_path": str(output_path), "use_random": False, } if emotion_audio: values.update( { "emo_audio_prompt": emotion_audio, "emo_alpha": emotion_strength, } ) if use_text_emotion: values.update( { "use_emo_text": True, "emo_alpha": min(emotion_strength, 0.6), } ) _ticker_stop = threading.Event() def _tick_progress() -> None: value = 0.62 while not _ticker_stop.wait(2.0) and value < 0.92: value += 0.01 try: progress(value, desc="Synthesizing voice") except Exception: break _ticker = threading.Thread(target=_tick_progress, daemon=True) _ticker.start() try: tts.infer(**_supported_kwargs(tts.infer, values)) finally: _ticker_stop.set() if not output_path.is_file() or output_path.stat().st_size == 0: raise RuntimeError("IndexTTS-2 returned without creating an audio file.") progress(0.97, desc="Saving audio file") elapsed = round(time.perf_counter() - started, 1) return str(output_path), elapsed except gr.Error: raise except Exception as exc: raise gr.Error(f"Synthesis failed: {type(exc).__name__}: {exc}") from exc # -------------------------------------------------------------------------- # Quota panel helpers (CPU only, never touches the GPU) # -------------------------------------------------------------------------- DAILY_HINT = "Free accounts get ~5 min/day, PRO ~40 min/day. The window resets 24h after your first use." def backend_status(): """Lightweight, non-GPU status probe for external frontends.""" return { "status": "ready" if _warmup_state["done"] else "preparing", "model_loaded": _tts is not None, "uptime_minutes": round((time.time() - _warmup_state["started_at"]) / 60, 1), } def _read_ip_token(request) -> dict: """Decode the ZeroGPU identity token HF attaches to every request.""" import base64 import json headers = getattr(request, "headers", None) or {} token = headers.get("x-ip-token") or headers.get("X-IP-Token") if not token: return {} try: payload = json.loads(base64.urlsafe_b64decode(f"{token.split('.')[1]}==")) return payload if isinstance(payload, dict) else {} except Exception: return {} def _quota_html(request, used_seconds: float) -> str: claims = _read_ip_token(request) identity = claims.get("user") or ("signed-in visitor" if claims.get("sub") else "guest (not signed in)") used = max(0.0, float(used_seconds or 0)) bar = min(100, round(used / 300 * 100)) # free-tier daily allowance estimate color = "#22c55e" if bar < 50 else "#eab308" if bar < 80 else "#ef4444" return f"""
Clone any voice from a short clip โ high fidelity, powered by IndexTTS-2.