Upload 2 files
Browse files
README.md
CHANGED
|
@@ -125,6 +125,8 @@ The app auto-detects these DashScope endpoints and uses the native multimodal fo
|
|
| 125 |
|
| 126 |
**Per-service override (only if services differ):** set any of `ASR_BASE_URL` / `LLM_BASE_URL` / `TTS_BASE_URL` and `ASR_API_KEY` / `LLM_API_KEY` / `TTS_API_KEY` to point a single service somewhere else. A per-service value wins over the shared `DASHSCOPE_*` value. Optional model/voice overrides: `ASR_MODEL`, `LLM_MODEL`, `TTS_MODEL`, `TTS_VOICE`.
|
| 127 |
|
|
|
|
|
|
|
| 128 |
Leave the placeholder values in `config.json` as they are — they're scrubbed automatically at load, and the Secrets fill in the real values.
|
| 129 |
|
| 130 |
**Step 3: Open the Space**
|
|
|
|
| 125 |
|
| 126 |
**Per-service override (only if services differ):** set any of `ASR_BASE_URL` / `LLM_BASE_URL` / `TTS_BASE_URL` and `ASR_API_KEY` / `LLM_API_KEY` / `TTS_API_KEY` to point a single service somewhere else. A per-service value wins over the shared `DASHSCOPE_*` value. Optional model/voice overrides: `ASR_MODEL`, `LLM_MODEL`, `TTS_MODEL`, `TTS_VOICE`.
|
| 127 |
|
| 128 |
+
**Per-language TTS backend:** each language can use its own TTS engine, so e.g. English speaks via Qwen while Yoruba speaks via a self-hosted model. A language uses its own endpoint when it sets `tts_format` / `tts_base_url` / `tts_api_key` (in `config.json` or via env). Supported `tts_format` values: `dashscope`, `openai`, or `custom`. The `custom` format POSTs `{text, speed}` to the URL **verbatim** (so an API Gateway invoke URL like `.../prod/tts` is used exactly as given), sends both `Authorization: Bearer` and `x-api-key` when a key is set, and accepts either raw audio (`audio/*`) or JSON carrying base64 audio (key `audio` / `audio_base64` / `data` / `wav` / `audio_content`). Per-language env vars follow the pattern `TTS_<LANGID>_BASE_URL`, `TTS_<LANGID>_API_KEY`, `TTS_<LANGID>_FORMAT`, `TTS_<LANGID>_MODEL`, `TTS_<LANGID>_VOICE` — e.g. `TTS_YORUBA_BASE_URL`. A `custom`-format language never inherits the global Qwen URL/key, so a missing value fails safely instead of sending text to the wrong engine.
|
| 129 |
+
|
| 130 |
Leave the placeholder values in `config.json` as they are — they're scrubbed automatically at load, and the Secrets fill in the real values.
|
| 131 |
|
| 132 |
**Step 3: Open the Space**
|
app.py
CHANGED
|
@@ -89,6 +89,23 @@ def apply_env_overrides(cfg):
|
|
| 89 |
tts["model"] = _env("TTS_MODEL") or tts.get("model", "")
|
| 90 |
tts["voice"] = _env("TTS_VOICE") or tts.get("voice", "default")
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
return cfg
|
| 93 |
|
| 94 |
|
|
@@ -176,6 +193,33 @@ def get_lang_config(lang_id: str) -> dict:
|
|
| 176 |
return langs[0] if langs else {}
|
| 177 |
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
# ============================================================
|
| 180 |
# API Routes — ASR
|
| 181 |
# ============================================================
|
|
@@ -413,6 +457,81 @@ _LANG_TYPE_MAP = {
|
|
| 413 |
}
|
| 414 |
|
| 415 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
@app.post("/api/tts")
|
| 417 |
async def text_to_speech(req: TTSRequest):
|
| 418 |
"""
|
|
@@ -425,25 +544,26 @@ async def text_to_speech(req: TTSRequest):
|
|
| 425 |
2. Global tts.model
|
| 426 |
3. Default: qwen3-tts-flash (DashScope) or tts-1 (OpenAI)
|
| 427 |
"""
|
| 428 |
-
|
| 429 |
-
base_url =
|
| 430 |
-
api_key =
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
lang_cfg = get_lang_config(req.language)
|
| 434 |
-
model = lang_cfg.get("tts_model", "").strip() or tts_cfg.get("model", "").strip()
|
| 435 |
|
| 436 |
-
if not base_url or not api_key:
|
| 437 |
return JSONResponse(status_code=200, content={
|
| 438 |
"status": "tts_not_configured",
|
| 439 |
"message": "TTS not configured. Set TTS Base URL and API Key in Settings.",
|
| 440 |
"text": req.text,
|
| 441 |
})
|
| 442 |
|
| 443 |
-
voice = req.voice or
|
| 444 |
-
speed =
|
| 445 |
|
| 446 |
try:
|
|
|
|
|
|
|
|
|
|
| 447 |
if _is_dashscope_maas(base_url):
|
| 448 |
# === DashScope Native TTS Format ===
|
| 449 |
tts_endpoint = _dashscope_tts_url(base_url)
|
|
|
|
| 89 |
tts["model"] = _env("TTS_MODEL") or tts.get("model", "")
|
| 90 |
tts["voice"] = _env("TTS_VOICE") or tts.get("voice", "default")
|
| 91 |
|
| 92 |
+
# Per-language TTS endpoint overrides, e.g. TTS_YORUBA_BASE_URL / _API_KEY /
|
| 93 |
+
# _FORMAT / _MODEL / _VOICE. Lets each language point at its own TTS backend.
|
| 94 |
+
for lang in cfg.get("languages", []):
|
| 95 |
+
lid = (lang.get("id") or "").upper()
|
| 96 |
+
if not lid:
|
| 97 |
+
continue
|
| 98 |
+
if _env(f"TTS_{lid}_BASE_URL"):
|
| 99 |
+
lang["tts_base_url"] = _env(f"TTS_{lid}_BASE_URL")
|
| 100 |
+
if _env(f"TTS_{lid}_API_KEY"):
|
| 101 |
+
lang["tts_api_key"] = _env(f"TTS_{lid}_API_KEY")
|
| 102 |
+
if _env(f"TTS_{lid}_FORMAT"):
|
| 103 |
+
lang["tts_format"] = _env(f"TTS_{lid}_FORMAT")
|
| 104 |
+
if _env(f"TTS_{lid}_MODEL"):
|
| 105 |
+
lang["tts_model"] = _env(f"TTS_{lid}_MODEL")
|
| 106 |
+
if _env(f"TTS_{lid}_VOICE"):
|
| 107 |
+
lang["tts_voice"] = _env(f"TTS_{lid}_VOICE")
|
| 108 |
+
|
| 109 |
return cfg
|
| 110 |
|
| 111 |
|
|
|
|
| 193 |
return langs[0] if langs else {}
|
| 194 |
|
| 195 |
|
| 196 |
+
def get_tts_config(lang_id: str) -> dict:
|
| 197 |
+
"""Resolve the TTS endpoint for a language. A language may carry its own
|
| 198 |
+
tts_base_url / tts_api_key / tts_format / tts_model / tts_voice; anything not
|
| 199 |
+
set falls back to the global `tts` block — EXCEPT for `custom` format, which
|
| 200 |
+
never inherits the global (Qwen) URL/key, so a misconfig can't accidentally
|
| 201 |
+
POST Yoruba text at the DashScope endpoint."""
|
| 202 |
+
lang = get_lang_config(lang_id)
|
| 203 |
+
g = CONFIG.get("tts", {})
|
| 204 |
+
fmt = (lang.get("tts_format") or "").strip().lower()
|
| 205 |
+
|
| 206 |
+
if fmt == "custom":
|
| 207 |
+
base_url = lang.get("tts_base_url") or ""
|
| 208 |
+
api_key = lang.get("tts_api_key") or ""
|
| 209 |
+
else:
|
| 210 |
+
base_url = lang.get("tts_base_url") or g.get("base_url", "")
|
| 211 |
+
api_key = lang.get("tts_api_key") or g.get("api_key", "")
|
| 212 |
+
|
| 213 |
+
return {
|
| 214 |
+
"base_url": base_url or "",
|
| 215 |
+
"api_key": api_key or "",
|
| 216 |
+
"model": (lang.get("tts_model") or g.get("model", "") or ""),
|
| 217 |
+
"voice": (lang.get("tts_voice") or g.get("voice", "default") or "default"),
|
| 218 |
+
"format": fmt,
|
| 219 |
+
"speed": g.get("speed", 1.0),
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
|
| 223 |
# ============================================================
|
| 224 |
# API Routes — ASR
|
| 225 |
# ============================================================
|
|
|
|
| 457 |
}
|
| 458 |
|
| 459 |
|
| 460 |
+
async def _tts_custom(base_url: str, api_key: str, text: str, speed: float):
|
| 461 |
+
"""POST to a custom TTS service and return audio. Handles common shapes:
|
| 462 |
+
- URL is used verbatim (append /tts only if a bare host is given), so an
|
| 463 |
+
API Gateway invoke URL like .../prod/tts works as-is.
|
| 464 |
+
- Auth: sends both `Authorization: Bearer` and `x-api-key` when a key is
|
| 465 |
+
provided (harmless extras are ignored; covers API Gateway usage plans).
|
| 466 |
+
- Response: raw audio bytes (audio/*) OR JSON containing base64 audio under
|
| 467 |
+
a common key (audio / audio_base64 / data / wav / audio_content)."""
|
| 468 |
+
if not base_url:
|
| 469 |
+
return JSONResponse(status_code=200, content={
|
| 470 |
+
"status": "tts_not_configured",
|
| 471 |
+
"message": "Custom TTS endpoint not set for this language.",
|
| 472 |
+
"text": text,
|
| 473 |
+
})
|
| 474 |
+
|
| 475 |
+
from urllib.parse import urlparse
|
| 476 |
+
parsed = urlparse(base_url)
|
| 477 |
+
url = base_url if parsed.path.strip("/") else base_url.rstrip("/") + "/tts"
|
| 478 |
+
|
| 479 |
+
headers = {"Content-Type": "application/json"}
|
| 480 |
+
if api_key:
|
| 481 |
+
headers["Authorization"] = f"Bearer {api_key}"
|
| 482 |
+
headers["x-api-key"] = api_key
|
| 483 |
+
|
| 484 |
+
# F5-TTS can be slow on the first (cold) call — allow a generous timeout.
|
| 485 |
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
| 486 |
+
resp = await client.post(url, headers=headers, json={"text": text, "speed": speed})
|
| 487 |
+
if resp.status_code != 200:
|
| 488 |
+
return JSONResponse(status_code=200, content={
|
| 489 |
+
"status": "tts_error",
|
| 490 |
+
"message": f"Custom TTS {resp.status_code}: {resp.text[:300]}",
|
| 491 |
+
"text": text,
|
| 492 |
+
})
|
| 493 |
+
|
| 494 |
+
ct = resp.headers.get("content-type", "")
|
| 495 |
+
if ct.startswith("audio/"):
|
| 496 |
+
return Response(content=resp.content, media_type=ct)
|
| 497 |
+
|
| 498 |
+
# Otherwise expect JSON carrying base64 audio.
|
| 499 |
+
try:
|
| 500 |
+
data = resp.json()
|
| 501 |
+
except Exception:
|
| 502 |
+
return JSONResponse(status_code=200, content={
|
| 503 |
+
"status": "tts_error",
|
| 504 |
+
"message": f"Unexpected TTS response (content-type: {ct or 'unknown'})",
|
| 505 |
+
"text": text,
|
| 506 |
+
})
|
| 507 |
+
|
| 508 |
+
import base64 as b64
|
| 509 |
+
b64str = None
|
| 510 |
+
if isinstance(data, dict):
|
| 511 |
+
for k in ("audio_base64", "audio", "data", "wav", "audio_content", "b64_audio"):
|
| 512 |
+
v = data.get(k)
|
| 513 |
+
if isinstance(v, str) and len(v) > 100:
|
| 514 |
+
b64str = v
|
| 515 |
+
break
|
| 516 |
+
if not b64str:
|
| 517 |
+
return JSONResponse(status_code=200, content={
|
| 518 |
+
"status": "tts_error",
|
| 519 |
+
"message": "No base64 audio found in JSON TTS response.",
|
| 520 |
+
"text": text,
|
| 521 |
+
})
|
| 522 |
+
if b64str.startswith("data:") and "," in b64str:
|
| 523 |
+
b64str = b64str.split(",", 1)[1]
|
| 524 |
+
try:
|
| 525 |
+
audio = b64.b64decode(b64str)
|
| 526 |
+
except Exception as e:
|
| 527 |
+
return JSONResponse(status_code=200, content={
|
| 528 |
+
"status": "tts_error",
|
| 529 |
+
"message": f"Could not decode base64 audio: {e}",
|
| 530 |
+
"text": text,
|
| 531 |
+
})
|
| 532 |
+
return Response(content=audio, media_type="audio/wav")
|
| 533 |
+
|
| 534 |
+
|
| 535 |
@app.post("/api/tts")
|
| 536 |
async def text_to_speech(req: TTSRequest):
|
| 537 |
"""
|
|
|
|
| 544 |
2. Global tts.model
|
| 545 |
3. Default: qwen3-tts-flash (DashScope) or tts-1 (OpenAI)
|
| 546 |
"""
|
| 547 |
+
tcfg = get_tts_config(req.language)
|
| 548 |
+
base_url = tcfg["base_url"].strip().rstrip("/")
|
| 549 |
+
api_key = tcfg["api_key"].strip()
|
| 550 |
+
model = tcfg["model"].strip()
|
| 551 |
+
fmt = tcfg["format"]
|
|
|
|
|
|
|
| 552 |
|
| 553 |
+
if fmt != "custom" and (not base_url or not api_key):
|
| 554 |
return JSONResponse(status_code=200, content={
|
| 555 |
"status": "tts_not_configured",
|
| 556 |
"message": "TTS not configured. Set TTS Base URL and API Key in Settings.",
|
| 557 |
"text": req.text,
|
| 558 |
})
|
| 559 |
|
| 560 |
+
voice = req.voice or tcfg["voice"]
|
| 561 |
+
speed = tcfg["speed"]
|
| 562 |
|
| 563 |
try:
|
| 564 |
+
if fmt == "custom":
|
| 565 |
+
# === Custom HTTP TTS service (e.g. self-hosted F5-TTS Yoruba) ===
|
| 566 |
+
return await _tts_custom(base_url, api_key, req.text, speed)
|
| 567 |
if _is_dashscope_maas(base_url):
|
| 568 |
# === DashScope Native TTS Format ===
|
| 569 |
tts_endpoint = _dashscope_tts_url(base_url)
|