Spaces:
Sleeping
Sleeping
| 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) | |
| def root(): | |
| return {"status": "VoiceCraft TTS Server OK", "engines": ["edge", "piper", "silero"]} | |
| def health(): | |
| return { | |
| "status": "ok", | |
| "piper_ready": PIPER_READY, | |
| "silero_ready": SILERO_READY, | |
| "silero_speakers": SILERO_SPEAKERS_RU, | |
| "engines": ["edge", "piper", "silero"], | |
| } | |
| 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} | |
| 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]}"}, | |
| ) | |