aliSaac510's picture
feat: integrate Supertonic-3 TTS support
081af21
Raw
History Blame Contribute Delete
13.5 kB
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")
@app.on_event("startup")
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)
@app.get("/", response_class=HTMLResponse)
async def index() -> HTMLResponse:
return HTMLResponse(INDEX_FILE.read_text(encoding="utf-8"))
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/defaults")
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,
}
@app.get("/api/bootstrap")
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)),
}
@app.get("/api/providers")
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.",
},
]
}
@app.get("/api/network")
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"},
}
@app.get("/api/voices")
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
]
}
@app.post("/api/synthesize")
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),
}
@app.get("/audio/{file_name}", name="get_audio_file")
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