import os, io, asyncio, tempfile, threading, re, subprocess, shutil, logging, secrets from fastapi import FastAPI, Form, Request, HTTPException from fastapi.responses import StreamingResponse, JSONResponse from fastapi.middleware.gzip import GZipMiddleware app = FastAPI() app.add_middleware(GZipMiddleware, minimum_size=1000) # ══════════════════════════════════════════════════════════════════ # SECURITY — Token check # Set API_SECRET environment variable in HuggingFace Space settings # ══════════════════════════════════════════════════════════════════ API_SECRET = os.environ.get("API_SECRET", "") def verify_token(request: Request): if not API_SECRET: logging.warning("⚠️ API_SECRET not set — rejecting request") raise HTTPException(status_code=503, detail="Server not configured") token = request.headers.get("X-API-Token", "") if not secrets.compare_digest(token, API_SECRET): raise HTTPException(status_code=403, detail="Unauthorized") # ══════════════════════════════════════════════════════════════════ # PIPER TTS SETUP — Auto download on first run # ══════════════════════════════════════════════════════════════════ PIPER_DIR = "/tmp/piper" PIPER_BIN = os.path.join(PIPER_DIR, "piper") PIPER_MODELS_DIR = "/tmp/piper_models" PIPER_READY = False PIPER_VOICES = { # English "piper:en_US-amy-medium": ("en_US-amy-medium.onnx", "en_US-amy-medium.onnx.json"), "piper:en_US-joe-medium": ("en_US-joe-medium.onnx", "en_US-joe-medium.onnx.json"), "piper:en_US-lessac-medium": ("en_US-lessac-medium.onnx", "en_US-lessac-medium.onnx.json"), "piper:en_US-ryan-high": ("en_US-ryan-high.onnx", "en_US-ryan-high.onnx.json"), "piper:en_GB-alan-medium": ("en_GB-alan-medium.onnx", "en_GB-alan-medium.onnx.json"), "piper:en_GB-alba-medium": ("en_GB-alba-medium.onnx", "en_GB-alba-medium.onnx.json"), # Urdu / Hindi / Arabic "piper:ur_PK-fasih-medium": ("ur_PK-fasih-medium.onnx", "ur_PK-fasih-medium.onnx.json"), "piper:hi_IN-pratham-medium": ("hi_IN-pratham-medium.onnx", "hi_IN-pratham-medium.onnx.json"), "piper:ar_JO-kareem-medium": ("ar_JO-kareem-medium.onnx", "ar_JO-kareem-medium.onnx.json"), # Other languages "piper:de_DE-thorsten-medium": ("de_DE-thorsten-medium.onnx", "de_DE-thorsten-medium.onnx.json"), "piper:fr_FR-upmc-medium": ("fr_FR-upmc-medium.onnx", "fr_FR-upmc-medium.onnx.json"), "piper:ru_RU-irina-medium": ("ru_RU-irina-medium.onnx", "ru_RU-irina-medium.onnx.json"), "piper:tr_TR-dfki-medium": ("tr_TR-dfki-medium.onnx", "tr_TR-dfki-medium.onnx.json"), "piper:pt_BR-faber-medium": ("pt_BR-faber-medium.onnx", "pt_BR-faber-medium.onnx.json"), "piper:nl_NL-mls-medium": ("nl_NL-mls-medium.onnx", "nl_NL-mls-medium.onnx.json"), } PIPER_BASE_URL = "https://huggingface.co/rhasspy/piper-voices/resolve/main" def setup_piper(): global PIPER_READY try: import platform os.makedirs(PIPER_DIR, exist_ok=True) os.makedirs(PIPER_MODELS_DIR, exist_ok=True) system = platform.system().lower() arch = platform.machine().lower() if system == "linux" and "x86" in arch: piper_url = "https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz" elif system == "linux" and "aarch" in arch: piper_url = "https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_aarch64.tar.gz" else: print(f"⚠️ Piper: unsupported platform {system}/{arch}, Piper disabled") return if not os.path.exists(PIPER_BIN): print("📥 Piper binary indiriliyor...") import urllib.request tar_path = "/tmp/piper.tar.gz" urllib.request.urlretrieve(piper_url, tar_path) import tarfile with tarfile.open(tar_path, "r:gz") as tf: tf.extractall("/tmp/piper_extract") extracted = "/tmp/piper_extract/piper" if os.path.isdir(extracted): for item in os.listdir(extracted): shutil.move(os.path.join(extracted, item), os.path.join(PIPER_DIR, item)) else: shutil.move(extracted, PIPER_BIN) os.chmod(PIPER_BIN, 0o755) print("✅ Piper binary ready") PIPER_READY = True print("✅ Piper TTS ready") except Exception as e: print(f"⚠️ Piper setup failed (non-critical): {e}") PIPER_READY = False threading.Thread(target=setup_piper, daemon=True).start() def download_piper_model(voice_code: str) -> tuple: """Model yoksa indir, path tuple dondur (onnx, json)""" if voice_code not in PIPER_VOICES: raise ValueError(f"Unknown Piper voice: {voice_code}") onnx_file, json_file = PIPER_VOICES[voice_code] onnx_path = os.path.join(PIPER_MODELS_DIR, onnx_file) json_path = os.path.join(PIPER_MODELS_DIR, json_file) import urllib.request # Build correct HF path: en/en_US/amy/medium/en_US-amy-medium.onnx parts = onnx_file.rsplit("-", 2) lang_code = parts[0] # en_US voice = parts[1] # amy quality = parts[2].replace(".onnx", "") # medium lang_short = lang_code.split("_")[0] # en hf_dir = f"{lang_short}/{lang_code}/{voice}/{quality}" for fname, fpath in [(onnx_file, onnx_path), (json_file, json_path)]: if not os.path.exists(fpath): url = f"{PIPER_BASE_URL}/{hf_dir}/{fname}" print(f"📥 Downloading Piper model: {fname}") try: urllib.request.urlretrieve(url, fpath) except Exception: url2 = f"{PIPER_BASE_URL}/{lang_short}/{lang_code}/{fname}" urllib.request.urlretrieve(url2, fpath) return onnx_path, json_path def download_piper_dynamic(voice_code: str) -> tuple: """Dynamic Piper voice download — code format: piper:ar_JO-kareem-low""" model_name = voice_code.replace("piper:", "") # ar_JO-kareem-low # Prevent path traversal if ".." in model_name or "/" in model_name or "\\" in model_name: raise ValueError(f"Invalid voice code: {voice_code}") onnx_file = f"{model_name}.onnx" json_file = f"{model_name}.onnx.json" onnx_path = os.path.join(PIPER_MODELS_DIR, onnx_file) json_path = os.path.join(PIPER_MODELS_DIR, json_file) if os.path.exists(onnx_path) and os.path.exists(json_path): return onnx_path, json_path import urllib.request # Model format: lang_code-voice_name-quality (e.g., ar_JO-kareem-low) # HF path: ar/ar_JO/kareem/low/ar_JO-kareem-low.onnx dash_parts = model_name.rsplit("-", 2) # Split from right: ["ar_JO", "kareem", "low"] if len(dash_parts) >= 3: lang_code = dash_parts[0] # ar_JO voice = dash_parts[1] # kareem quality = dash_parts[2] # low lang = lang_code.split("_")[0] # ar hf_path = f"{lang}/{lang_code}/{voice}/{quality}" else: hf_path = f"{model_name}/{model_name}" for fname, fpath in [(onnx_file, onnx_path), (json_file, json_path)]: if not os.path.exists(fpath): url = f"{PIPER_BASE_URL}/{hf_path}/{fname}" print(f"📥 Downloading dynamic Piper model: {fname}") try: urllib.request.urlretrieve(url, fpath) except Exception as e: print(f"⚠️ Dynamic Piper download failed: {e}") raise return onnx_path, json_path def _piper_synth_chunk(text: str, onnx_path: str, json_path: str, length_scale: float) -> bytes: with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as out_f: out_path = out_f.name try: cmd = [ PIPER_BIN, "--model", onnx_path, "--config", json_path, "--output_file", out_path, "--length_scale", str(round(length_scale, 2)), ] result = subprocess.run( cmd, input=text.encode("utf-8"), capture_output=True, timeout=120, ) if result.returncode != 0: raise Exception(f"Piper error: {result.stderr.decode()[:200]}") with open(out_path, "rb") as f: return f.read() finally: if os.path.exists(out_path): os.unlink(out_path) def _ffmpeg_concat_wav(parts: list) -> bytes: """Merge multiple WAV byte chunks using ffmpeg concat (same codec).""" if len(parts) == 1: return parts[0] tmp_files, concat_list, out_path = [], None, None try: for data in parts: fd, path = tempfile.mkstemp(suffix=".wav") os.close(fd) with open(path, "wb") as f: f.write(data) tmp_files.append(path) fd, concat_list = tempfile.mkstemp(suffix=".txt") os.close(fd) with open(concat_list, "w") as f: for p in tmp_files: f.write(f"file '{p}'\n") fd, out_path = tempfile.mkstemp(suffix=".wav") os.close(fd) subprocess.run(["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", concat_list, "-c", "copy", out_path], check=True, timeout=60) with open(out_path, "rb") as f: return f.read() except Exception: return b"".join(parts) finally: for p in tmp_files: try: os.unlink(p) except Exception: pass for x in (concat_list, out_path): if x: try: os.unlink(x) except Exception: pass def synthesize_piper(text: str, voice_code: str, speed: float = 1.0) -> bytes: if not PIPER_READY: raise Exception("Piper is not available on this system") # Pehle PIPER_VOICES dict mein check karo, nahi mila to dynamic download if voice_code in PIPER_VOICES: onnx_path, json_path = download_piper_model(voice_code) else: # Dynamic voice — direct HuggingFace se download onnx_path, json_path = download_piper_dynamic(voice_code) length_scale = 1.0 / max(0.25, min(4.0, speed)) # Lambi text ko chunk karo (Piper stdin limit + timeout avoid karne ke liye) if len(text) > 1400: chunks = split_text(text, max_chars=1400) else: chunks = [text] if len(chunks) == 1: return _piper_synth_chunk(chunks[0], onnx_path, json_path, length_scale) parts = [] for ch in chunks: if ch.strip(): parts.append(_piper_synth_chunk(ch, onnx_path, json_path, length_scale)) if not parts: raise Exception("Piper: no audio generated") return _ffmpeg_concat_wav(parts) def split_text(text: str, max_chars: int = 1400) -> list: """Text ko chunklara bol""" text = text.strip() if not text: return [] sentence_re = re.compile( r'(?:(?<=[.!?\u0964\u06D4\u061F\u2026])\s+)|(?<=[\u3002\uff01\uff1f])' ) chunks, current = [], "" for para in re.split(r'\n+', text): para = para.strip() if not para: if current: chunks.append(current) current = "" continue for sentence in sentence_re.split(para): sentence = sentence.strip() if not sentence: continue if len(sentence) > max_chars: words, buf = sentence.split(), "" for word in words: add = (" " if buf else "") + word if len(buf) + len(add) <= max_chars: buf += add else: if buf: chunks.append(buf) buf = word if buf: chunks.append(buf) elif len(current) + len(sentence) + 1 <= max_chars: current = (current + " " + sentence).strip() else: if current: chunks.append(current) current = sentence if current: chunks.append(current) current = "" if current: chunks.append(current) return [c for c in chunks if c.strip()] # ══════════════════════════════════════════════════════════════════ # SILERO TTS — v4 model (48kHz, 12 Russian speakers) # Loaded via torch.package.PackageImporter (requires torch < 2.3) # ══════════════════════════════════════════════════════════════════ SILERO_READY = False SILERO_MODELS_DIR = "/tmp/silero_models" SILERO_SAMPLE_RATE = 48000 SILERO_MODELS = {} SILERO_LOAD_LOCK = threading.Lock() SILERO_MODEL_URLS = [ "https://models.silero.ai/models/tts/ru/v4_ru.pt", "https://huggingface.co/Derur/silero-models/resolve/main/tts/ru/ru_v4/v4_ru.pt", ] SILERO_SPEAKERS_RU = [ "xenia", "eugene", "baya", "kseniya", "aidar", "kolya", "mikhai", "nikita", "pavel", "tatiana", "elena", "irina", ] def download_silero_model() -> str: """Download Silero v4 Russian model. Returns path on success.""" import urllib.request os.makedirs(SILERO_MODELS_DIR, exist_ok=True) model_path = os.path.join(SILERO_MODELS_DIR, "v4_ru.pt") if os.path.exists(model_path) and os.path.getsize(model_path) > 100000: return model_path for url in SILERO_MODEL_URLS: try: print(f"📥 Downloading Silero v4: {url[:80]}...") urllib.request.urlretrieve(url, model_path) if os.path.getsize(model_path) > 100000: print(f"✅ Silero v4 downloaded ({os.path.getsize(model_path)//1024}KB)") return model_path os.remove(model_path) except Exception as e: print(f"⚠️ Download failed: {e}") try: os.remove(model_path) except Exception: pass return "" def setup_silero(): global SILERO_READY try: import torch model_path = download_silero_model() if model_path: model = torch.package.PackageImporter(model_path).load_pickle("tts_models", "model") SILERO_MODELS["ru"] = model SILERO_READY = True print("✅ Silero TTS ready (v4 Russian — 12 speakers)") else: print("❌ Silero TTS: model download failed") except Exception as e: print(f"❌ Silero setup failed: {e}") threading.Thread(target=setup_silero, daemon=True).start() def synthesize_silero(text: str, voice_code: str) -> bytes: """Silero TTS — code: silero:ru_xenia. v4 model via torch.package. Model lazily load hota hai (self-heal) agar startup thread fail hua ho.""" import numpy as np, scipy.io.wavfile as wav try: import torch except ImportError: raise Exception("Silero TTS requires torch. Install: pip install torch==2.1.2") lang_speaker = voice_code.replace("silero:", "") lang = lang_speaker.split("_")[0] speaker = lang_speaker.split("_", 1)[1] if "_" in lang_speaker else lang_speaker # Validate speaker — only real v4 speakers allowed valid_speakers = set(SILERO_SPEAKERS_RU) if speaker not in valid_speakers: raise Exception(f"Invalid Silero speaker '{speaker}'. Valid: {', '.join(valid_speakers)}") # Model on-demand load (self-heals if startup thread failed / was slow) if lang not in SILERO_MODELS: with SILERO_LOAD_LOCK: if lang not in SILERO_MODELS: model_path = download_silero_model() if not model_path: raise Exception("Silero v4 model could not be downloaded (check network / model URL).") model = torch.package.PackageImporter(model_path).load_pickle("tts_models", "model") SILERO_MODELS[lang] = model global SILERO_READY SILERO_READY = True model = SILERO_MODELS[lang] audio = model.apply_tts(text=text, speaker=speaker, sample_rate=SILERO_SAMPLE_RATE) audio_np = audio.numpy() if hasattr(audio, "numpy") else audio.cpu().detach().numpy() buf = io.BytesIO() wav.write(buf, SILERO_SAMPLE_RATE, (audio_np * 32767).astype(np.int16)) buf.seek(0) return buf.read() def _ffmpeg_concat_mp3(parts: list) -> bytes: """Merge multiple MP3 byte chunks using ffmpeg concat.""" if len(parts) == 1: return parts[0] tmp_files = [] concat_list = None out_path = None try: for data in parts: fd, path = tempfile.mkstemp(suffix=".mp3") os.close(fd) with open(path, "wb") as f: f.write(data) tmp_files.append(path) fd, concat_list = tempfile.mkstemp(suffix=".txt") os.close(fd) with open(concat_list, "w") as f: for p in tmp_files: f.write(f"file '{p}'\n") fd, out_path = tempfile.mkstemp(suffix=".mp3") os.close(fd) subprocess.run(["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", concat_list, "-c", "copy", out_path], check=True, timeout=60) with open(out_path, "rb") as f: return f.read() except Exception: return b"".join(parts) finally: for p in tmp_files: try: os.unlink(p) except Exception: pass if concat_list: try: os.unlink(concat_list) except Exception: pass if out_path: try: os.unlink(out_path) except Exception: pass async def synthesize_edge( text: str, voice: str, rate: str = "+0%", volume: str = "+0%", pitch: str = "+0Hz", style: str = None, styledegree: str = None, ) -> bytes: import edge_tts chunks = split_text(text) audio_parts = [] kwargs = {"rate": rate, "volume": volume, "pitch": pitch} if style and style != "Default" and style != "General": kwargs["style"] = style if styledegree is not None: try: sd = float(styledegree) if 0.0 <= sd <= 2.0: kwargs["styledegree"] = styledegree except Exception: pass for chunk in chunks: final_data = None for attempt in range(3): data = bytearray() try: comm = edge_tts.Communicate(chunk, voice, **kwargs) async for packet in comm.stream(): if packet["type"] == "audio" and packet.get("data"): data.extend(packet["data"]) if data: final_data = bytes(data) break except Exception as e: if attempt == 2: raise await asyncio.sleep(1 + attempt) audio_parts.append(final_data if final_data else b"") if len(audio_parts) == 1: return audio_parts[0] return _ffmpeg_concat_mp3(audio_parts) @app.get("/") def root(): return {"status": "VoiceCraft TTS Server OK", "engines": ["edge", "piper", "silero"]} @app.get("/health") @app.head("/health") def health(): return { "status": "ok", "piper_ready": PIPER_READY, "silero_ready": SILERO_READY, "silero_speakers": SILERO_SPEAKERS_RU, "engines": ["edge", "piper", "silero"], } @app.get("/all_voices") async def all_voices_list(): """All voices — Edge (400+) + Piper (900+) + Silero (12 RU)""" result = {"edge": {}, "piper": {}, "silero": {}} # Edge TTS — 400+ voices (complete list, clean naming) try: import edge_tts voices = await edge_tts.list_voices() for v in voices: short = v.get("ShortName", "") friendly = v.get("FriendlyName", "") or short name = friendly for remove in ["Microsoft Server Speech Text to Speech Voice", "Microsoft", "Online", "(Natural)", "(Neural)", "(Standard)", "(Multilingual)", "(Expressive)"]: name = name.replace(remove, "") if "," in name: name = name.split(",")[-1] # Clean dash pattern: " - " or " - " → single " - " name = re.sub(r'\s*-\s*', ' - ', name) # Collapse all whitespace to single space name = re.sub(r'\s+', ' ', name) name = name.strip(" -").strip() region = short.split("-")[0] + "-" + short.split("-")[1] if "-" in short else "" result["edge"][f"{name} [{region}]"] = short except Exception as e: print(f"Edge voices error: {e}") # Piper TTS — all voices (clean naming, no engine hints) try: import urllib.request, json as _json api_url = "https://huggingface.co/api/models/rhasspy/piper-voices" req = urllib.request.Request(api_url, headers={"User-Agent": "VoiceCraft/2.0"}) with urllib.request.urlopen(req, timeout=60) as resp: data = _json.loads(resp.read()) siblings = data.get("siblings", []) for s in siblings: rfn = s.get("rfilename", s.get("rfn", "")) if not rfn.endswith(".onnx") or ".json" in rfn or "samples" in rfn: continue parts = rfn.split("/") if len(parts) < 2: continue model_name = parts[-1].replace(".onnx", "") dash_parts = model_name.rsplit("-", 2) if len(dash_parts) >= 3: lang_code = dash_parts[0].replace("_", "-") voice = dash_parts[1].replace("_", " ").title() quality = dash_parts[2] if quality in ("low", "x_low"): continue quality_map = {"high": " +", "medium": "", "low": " -", "x_low": " --"} qs = quality_map.get(quality, " -") display = f"{voice} [{lang_code}]{qs}" else: display = model_name.replace("_", " ").title() full_code = f"piper:{model_name}" result["piper"][display] = full_code except Exception as e: print(f"Piper dynamic fetch error: {e}") for key in PIPER_VOICES: result["piper"][key] = key # voice code as string, not tuple # Silero v4 — Russian (12 speakers) silero_ru_speakers = { "xenia": "Xenia", "eugene": "Eugene", "baya": "Baya", "kseniya": "Kseniya", "aidar": "Aidar", "kolya": "Kolya", "mikhai": "Mikhai", "nikita": "Nikita", "pavel": "Pavel", "tatiana": "Tatiana", "elena": "Elena", "irina": "Irina", } for speaker_code, display_name in silero_ru_speakers.items(): result["silero"][f"{display_name} \u2022 Russian [RU]"] = f"silero:ru_{speaker_code}" total = len(result["edge"]) + len(result["piper"]) + len(result.get("silero", {})) return {"voices": result, "total": total} @app.post("/tts") async def tts_endpoint( request: Request, engine: str = Form(...), # "edge" | "piper" | "silero" text: str = Form(...), voice: str = Form("en-US-AvaNeural"), # edge voice code OR piper/silero code rate: str = Form("+0%"), # edge only volume: str = Form("+0%"), # edge only pitch: str = Form("+0Hz"), # edge only speed: float = Form(1.0), # piper only style: str = Form(None), # edge style (emotion) styledegree: str = Form(None), # edge style degree 0-2 ): verify_token(request) if not text or not text.strip(): return JSONResponse(status_code=400, content={"error": "Text is empty"}) text = text.strip() try: if engine == "edge": audio = await synthesize_edge(text, voice, rate=rate, volume=volume, pitch=pitch, style=style, styledegree=styledegree) media = "audio/mpeg" fname = "tts_edge.mp3" elif engine == "piper": audio = synthesize_piper(text, voice, speed=speed) media = "audio/wav" fname = "tts_piper.wav" elif engine == "silero": audio = synthesize_silero(text, voice) media = "audio/wav" fname = "tts_silero.wav" else: return JSONResponse(status_code=400, content={"error": f"Unknown engine: {engine}"}) return StreamingResponse( io.BytesIO(audio), media_type=media, headers={"Content-Disposition": f"attachment; filename={fname}"}, ) except Exception as e: logging.error(f"TTS error: {e}", exc_info=True) return JSONResponse( status_code=500, content={"error": f"Synthesis failed: {str(e)[:240]}"}, )