PlotweaverModel commited on
Commit
75a410e
·
verified ·
1 Parent(s): 6376d90

Upload 2 files

Browse files
Files changed (2) hide show
  1. README.md +2 -0
  2. app.py +130 -10
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
- tts_cfg = CONFIG.get("tts", {})
429
- base_url = tts_cfg.get("base_url", "").strip().rstrip("/")
430
- api_key = tts_cfg.get("api_key", "").strip()
431
-
432
- # Resolve model: per-language override or global
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 tts_cfg.get("voice", "default")
444
- speed = tts_cfg.get("speed", 1.0)
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)