Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| import socket | |
| import time | |
| from uuid import uuid4 | |
| from fastapi import FastAPI, HTTPException, Request as FastApiRequest | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import FileResponse, HTMLResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from pydantic import BaseModel, Field | |
| from tts_engine import ( | |
| DEFAULT_KOKORO_LANG, | |
| DEFAULT_KOKORO_VOICE, | |
| DEFAULT_TEXT, | |
| DEFAULT_VOICE, | |
| get_kokoro_voices, | |
| get_supertonic_voices, | |
| get_voices, | |
| prewarm_kokoro, | |
| synthesize_kokoro_to_file, | |
| synthesize_supertonic_to_file, | |
| synthesize_to_file, | |
| ) | |
| APP_DIR = Path(__file__).resolve().parent | |
| GENERATED_DIR = APP_DIR / "generated" | |
| STATIC_DIR = APP_DIR / "static" | |
| TEMPLATE_DIR = APP_DIR / "templates" | |
| INDEX_FILE = TEMPLATE_DIR / "index.html" | |
| GENERATED_DIR.mkdir(parents=True, exist_ok=True) | |
| MAX_TEXT_LENGTH = 3000 | |
| DEFAULT_LANG = "en" | |
| DEFAULT_SUPERTONIC_VOICE = "M1" | |
| VOICE_CACHE_TTL_SECONDS = 900 | |
| TRANSLATE_LANGUAGES = [ | |
| {"code": "en", "label": "English"}, | |
| {"code": "ar", "label": "Arabic"}, | |
| {"code": "af", "label": "Afrikaans"}, | |
| {"code": "sq", "label": "Albanian"}, | |
| {"code": "am", "label": "Amharic"}, | |
| {"code": "hy", "label": "Armenian"}, | |
| {"code": "az", "label": "Azerbaijani"}, | |
| {"code": "bn", "label": "Bengali"}, | |
| {"code": "bs", "label": "Bosnian"}, | |
| {"code": "bg", "label": "Bulgarian"}, | |
| {"code": "ca", "label": "Catalan"}, | |
| {"code": "zh-TW", "label": "Chinese (Traditional)"}, | |
| {"code": "zh-CN", "label": "Chinese (Simplified)"}, | |
| {"code": "hr", "label": "Croatian"}, | |
| {"code": "cs", "label": "Czech"}, | |
| {"code": "da", "label": "Danish"}, | |
| {"code": "nl", "label": "Dutch"}, | |
| {"code": "et", "label": "Estonian"}, | |
| {"code": "fil", "label": "Filipino"}, | |
| {"code": "fi", "label": "Finnish"}, | |
| {"code": "fr", "label": "French"}, | |
| {"code": "de", "label": "German"}, | |
| {"code": "el", "label": "Greek"}, | |
| {"code": "gu", "label": "Gujarati"}, | |
| {"code": "he", "label": "Hebrew"}, | |
| {"code": "hi", "label": "Hindi"}, | |
| {"code": "hu", "label": "Hungarian"}, | |
| {"code": "id", "label": "Indonesian"}, | |
| {"code": "it", "label": "Italian"}, | |
| {"code": "ja", "label": "Japanese"}, | |
| {"code": "kn", "label": "Kannada"}, | |
| {"code": "kk", "label": "Kazakh"}, | |
| {"code": "ko", "label": "Korean"}, | |
| {"code": "lv", "label": "Latvian"}, | |
| {"code": "lt", "label": "Lithuanian"}, | |
| {"code": "mk", "label": "Macedonian"}, | |
| {"code": "ms", "label": "Malay"}, | |
| {"code": "ml", "label": "Malayalam"}, | |
| {"code": "mr", "label": "Marathi"}, | |
| {"code": "ne", "label": "Nepali"}, | |
| {"code": "no", "label": "Norwegian"}, | |
| {"code": "fa", "label": "Persian"}, | |
| {"code": "pl", "label": "Polish"}, | |
| {"code": "pt", "label": "Portuguese"}, | |
| {"code": "pa", "label": "Punjabi"}, | |
| {"code": "ro", "label": "Romanian"}, | |
| {"code": "ru", "label": "Russian"}, | |
| {"code": "sr", "label": "Serbian"}, | |
| {"code": "sk", "label": "Slovak"}, | |
| {"code": "sl", "label": "Slovenian"}, | |
| {"code": "es", "label": "Spanish"}, | |
| {"code": "sw", "label": "Swahili"}, | |
| {"code": "sv", "label": "Swedish"}, | |
| {"code": "ta", "label": "Tamil"}, | |
| {"code": "te", "label": "Telugu"}, | |
| {"code": "th", "label": "Thai"}, | |
| {"code": "tr", "label": "Turkish"}, | |
| {"code": "uk", "label": "Ukrainian"}, | |
| {"code": "ur", "label": "Urdu"}, | |
| {"code": "uz", "label": "Uzbek"}, | |
| {"code": "vi", "label": "Vietnamese"}, | |
| {"code": "cy", "label": "Welsh"}, | |
| ] | |
| VOICE_CACHE: dict[str, dict[str, object]] = {} | |
| app = FastAPI(title="EG Autonomous TTS Studio", version="3.0.0") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=False, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") | |
| async def prewarm_models_on_startup() -> None: | |
| # Preload all runtime-critical assets so the UI is fully ready on first open. | |
| try: | |
| prewarm_kokoro() | |
| except Exception as error: | |
| print(f"[startup] Kokoro prewarm failed: {error}") | |
| for provider in ("edge", "kokoro"): | |
| try: | |
| await get_cached_voices_(provider, None) | |
| except Exception as error: | |
| print(f"[startup] voices prewarm failed for {provider}: {error}") | |
| class SynthesizeRequest(BaseModel): | |
| provider: str = Field(default="edge", pattern="^(edge|kokoro|supertonic)$") | |
| text: str = Field(..., min_length=1, max_length=MAX_TEXT_LENGTH) | |
| voice: str = Field(default=DEFAULT_VOICE, min_length=1) | |
| rate: str = Field(default="+0%") | |
| volume: str = Field(default="+0%") | |
| pitch: str = Field(default="+0Hz") | |
| lang: str = Field(default=DEFAULT_LANG, min_length=2, max_length=12) | |
| slow: bool = Field(default=False) | |
| speed: float = Field(default=1.0, ge=0.5, le=2.0) | |
| async def index() -> HTMLResponse: | |
| return HTMLResponse(INDEX_FILE.read_text(encoding="utf-8")) | |
| async def health() -> dict[str, str]: | |
| return {"status": "ok"} | |
| async def get_defaults() -> dict[str, object]: | |
| return { | |
| "text": DEFAULT_TEXT, | |
| "provider": "edge", | |
| "voice": DEFAULT_VOICE, | |
| "kokoroVoice": DEFAULT_KOKORO_VOICE, | |
| "rate": "+0%", | |
| "volume": "+0%", | |
| "pitch": "+0Hz", | |
| "lang": DEFAULT_LANG, | |
| "kokoroLang": DEFAULT_KOKORO_LANG, | |
| "supertonicVoice": DEFAULT_SUPERTONIC_VOICE, | |
| "slow": False, | |
| "translateLanguages": TRANSLATE_LANGUAGES, | |
| } | |
| async def get_bootstrap_data(request: FastApiRequest) -> dict[str, object]: | |
| return { | |
| "defaults": await get_defaults(), | |
| "providers": (await get_providers())["items"], | |
| "network": (await get_network_info(request)), | |
| } | |
| async def get_providers() -> dict[str, object]: | |
| return { | |
| "items": [ | |
| { | |
| "id": "edge", | |
| "displayName": "Edge TTS Engine", | |
| "description": "Generate MP3 audio with edge-tts through the internal synthesis engine.", | |
| }, | |
| { | |
| "id": "kokoro", | |
| "displayName": "Kokoro ONNX", | |
| "description": "Higher-quality ONNX speech model with multiple bundled voices.", | |
| }, | |
| { | |
| "id": "supertonic", | |
| "displayName": "Supertonic 3", | |
| "description": "State-of-the-art on-device TTS with emotional and natural voice styles.", | |
| }, | |
| ] | |
| } | |
| async def get_network_info(request: FastApiRequest) -> dict[str, object]: | |
| host_header = request.headers.get("host", "") | |
| current_host = host_header.split(":", 1)[0] if host_header else request.url.hostname or "127.0.0.1" | |
| current_port = request.url.port or 7860 | |
| lan_urls = build_lan_urls_(current_port) | |
| return { | |
| "ok": True, | |
| "host": current_host, | |
| "port": current_port, | |
| "localUrl": f"http://127.0.0.1:{current_port}", | |
| "currentUrl": str(request.base_url).rstrip("/"), | |
| "lanUrls": lan_urls, | |
| "lanEnabledHint": f"python -m uvicorn app:app --host 0.0.0.0 --port {current_port}", | |
| "isLocalRequest": current_host in {"127.0.0.1", "localhost", "0.0.0.0"}, | |
| } | |
| async def list_voice_catalog(provider: str = "edge", filter: str | None = None) -> dict[str, object]: | |
| voices = await get_cached_voices_(provider, filter) | |
| return { | |
| "items": [ | |
| { | |
| "name": voice["ShortName"], | |
| "locale": voice["Locale"], | |
| "gender": voice["Gender"], | |
| "friendlyName": voice.get("FriendlyName", voice["ShortName"]), | |
| } | |
| for voice in voices | |
| ] | |
| } | |
| async def synthesize_audio(payload: SynthesizeRequest, request: FastApiRequest) -> dict[str, object]: | |
| text = normalize_text_(payload.text) | |
| if not text: | |
| raise HTTPException(status_code=400, detail="Text is required.") | |
| selected_voice = payload.voice.strip() | |
| extension = ".mp3" if payload.provider == "edge" else ".wav" | |
| file_name = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + "-" + uuid4().hex + extension | |
| output_path = GENERATED_DIR / file_name | |
| if payload.provider == "edge": | |
| await synthesize_to_file( | |
| text=text, | |
| output=output_path, | |
| voice=selected_voice, | |
| rate=payload.rate.strip(), | |
| volume=payload.volume.strip(), | |
| pitch=payload.pitch.strip(), | |
| ) | |
| elif payload.provider == "kokoro": | |
| # Guard against stale UI values (e.g., an Edge voice sent while Kokoro is selected). | |
| if "_" not in selected_voice: | |
| selected_voice = DEFAULT_KOKORO_VOICE | |
| kokoro_lang = normalize_kokoro_lang_(payload.lang) | |
| await synthesize_kokoro_to_file( | |
| text=text, | |
| output=output_path, | |
| voice=(selected_voice or DEFAULT_KOKORO_VOICE).strip(), | |
| speed=payload.speed, | |
| lang=kokoro_lang, | |
| ) | |
| elif payload.provider == "supertonic": | |
| # Ensure we have a valid supertonic voice style | |
| if not selected_voice or selected_voice not in {"F1", "F2", "F3", "F4", "F5", "M1", "M2", "M3", "M4", "M5"}: | |
| selected_voice = DEFAULT_SUPERTONIC_VOICE | |
| await synthesize_supertonic_to_file( | |
| text=text, | |
| output=output_path, | |
| voice=selected_voice, | |
| lang=payload.lang[:2], # Supertonic expects 2-letter lang usually | |
| ) | |
| else: | |
| raise HTTPException(status_code=400, detail="Unsupported provider.") | |
| audio_url = str(request.url_for("get_audio_file", file_name=file_name)) | |
| return { | |
| "ok": True, | |
| "provider": payload.provider, | |
| "audioUrl": audio_url, | |
| "downloadUrl": audio_url, | |
| "fileName": file_name, | |
| "contentType": "audio/mpeg" if payload.provider == "edge" else "audio/wav", | |
| "voice": selected_voice, | |
| "rate": payload.rate.strip(), | |
| "volume": payload.volume.strip(), | |
| "pitch": payload.pitch.strip(), | |
| "speed": payload.speed, | |
| "lang": normalize_kokoro_lang_(payload.lang) if payload.provider == "kokoro" else payload.lang, | |
| "textLength": len(text), | |
| } | |
| async def get_audio_file(file_name: str) -> FileResponse: | |
| if "/" in file_name or "\\" in file_name: | |
| raise HTTPException(status_code=400, detail="Invalid file name.") | |
| file_path = GENERATED_DIR / file_name | |
| if not file_path.is_file(): | |
| raise HTTPException(status_code=404, detail="Audio file not found.") | |
| media_type = "audio/mpeg" if file_path.suffix.lower() == ".mp3" else "audio/wav" | |
| return FileResponse(file_path, media_type=media_type, content_disposition_type="inline") | |
| def normalize_text_(value: str) -> str: | |
| text = str(value or "").replace("\x00", "").replace("\r\n", "\n") | |
| text = "\n".join(line.strip() for line in text.split("\n")) | |
| text = text.strip() | |
| return text[:MAX_TEXT_LENGTH] | |
| def normalize_lang_(value: str) -> str: | |
| lang = str(value or DEFAULT_LANG).strip() | |
| if not lang: | |
| return DEFAULT_LANG | |
| return lang[:12] | |
| def normalize_kokoro_lang_(value: str) -> str: | |
| raw = (value or "").strip().lower() | |
| if not raw: | |
| return DEFAULT_KOKORO_LANG | |
| mapping = { | |
| "en": "en-us", | |
| "en-us": "en-us", | |
| "en_us": "en-us", | |
| "en-gb": "en-gb", | |
| "en_gb": "en-gb", | |
| "fr": "fr-fr", | |
| "fr-fr": "fr-fr", | |
| "fr_fr": "fr-fr", | |
| "ja": "ja", | |
| "zh": "zh", | |
| } | |
| return mapping.get(raw, DEFAULT_KOKORO_LANG) | |
| async def get_cached_voices_(provider: str, keyword: str | None) -> list[dict[str, object]]: | |
| cache_key = provider + "::" + (keyword or "").strip().lower() | |
| cached = VOICE_CACHE.get(cache_key) | |
| now = time.time() | |
| if cached and now - float(cached["timestamp"]) < VOICE_CACHE_TTL_SECONDS: | |
| return cached["voices"] # type: ignore[return-value] | |
| if provider == "edge": | |
| voices = await get_voices(keyword) | |
| elif provider == "kokoro": | |
| voices = get_kokoro_voices(keyword) | |
| elif provider == "supertonic": | |
| voices = get_supertonic_voices(keyword) | |
| else: | |
| raise HTTPException(status_code=400, detail="Unsupported provider for voices.") | |
| VOICE_CACHE[cache_key] = { | |
| "timestamp": now, | |
| "voices": voices, | |
| } | |
| return voices | |
| def build_lan_urls_(port: int) -> list[str]: | |
| urls: list[str] = [] | |
| candidates = {"127.0.0.1"} | |
| try: | |
| hostname = socket.gethostname() | |
| for result in socket.getaddrinfo(hostname, None, family=socket.AF_INET): | |
| ip = result[4][0] | |
| if ip: | |
| candidates.add(ip) | |
| except Exception: | |
| pass | |
| try: | |
| udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) | |
| udp_socket.connect(("8.8.8.8", 80)) | |
| candidates.add(udp_socket.getsockname()[0]) | |
| udp_socket.close() | |
| except Exception: | |
| pass | |
| for ip in sorted(candidates): | |
| if ip.startswith("127."): | |
| continue | |
| urls.append(f"http://{ip}:{port}") | |
| return urls | |