| |
| """ |
| Voice AI Demo β Fully Configurable ASR / LLM / TTS |
| =================================================== |
| A real-time voice AI demo where ALL three services (ASR, LLM, TTS) are |
| freely configurable via OpenAI-compatible endpoints. |
| |
| Compatible with: |
| - Alibaba Cloud Model Studio (Bailian / DashScope) |
| - OpenAI |
| - Any OpenAI-compatible API |
| |
| Architecture: |
| User Speech β ASR (/audio/transcriptions) β LLM (chat) β TTS (/audio/speech) β Playback |
| |
| Languages are defined in config.json β add, remove, or edit freely. |
| """ |
|
|
| import json |
| import os |
| import sys |
| import tempfile |
| from pathlib import Path |
| from typing import Optional |
|
|
| import httpx |
| import uvicorn |
| from fastapi import FastAPI, HTTPException, UploadFile, File, Form |
| from fastapi.middleware.cors import CORSMiddleware |
| from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse |
| from pydantic import BaseModel |
|
|
| |
| |
| |
|
|
| CONFIG_PATH = Path(__file__).parent / "config.json" |
|
|
|
|
| def _env(*names): |
| """Return the first non-empty environment variable among `names`.""" |
| for n in names: |
| v = os.environ.get(n) |
| if v: |
| return v.strip() |
| return None |
|
|
|
|
| def _strip_placeholder(value): |
| """Treat template placeholders like <your-api-key> as empty/unset.""" |
| if isinstance(value, str) and "<" in value and ">" in value: |
| return "" |
| return value |
|
|
|
|
| def apply_env_overrides(cfg): |
| """Let environment variables (e.g. Hugging Face Space Secrets) override |
| config.json. Env vars always win, so real keys never need to be committed. |
| |
| Recognised vars: |
| ASR_BASE_URL / ASR_API_KEY / ASR_MODEL / ASR_MODE |
| LLM_BASE_URL / LLM_API_KEY / LLM_MODEL |
| TTS_BASE_URL / TTS_API_KEY / TTS_MODEL / TTS_VOICE |
| DASHSCOPE_API_KEY (or MAAS_API_KEY) β shared fallback key for all three |
| """ |
| shared_key = _env("DASHSCOPE_API_KEY", "MAAS_API_KEY") |
| shared_url = _env("DASHSCOPE_BASE_URL", "MAAS_BASE_URL") |
|
|
| |
| for svc in ("asr", "llm", "tts"): |
| s = cfg.setdefault(svc, {}) |
| s["base_url"] = _strip_placeholder(s.get("base_url", "")) |
| s["api_key"] = _strip_placeholder(s.get("api_key", "")) |
|
|
| asr = cfg["asr"] |
| asr["base_url"] = _env("ASR_BASE_URL") or shared_url or asr.get("base_url", "") |
| asr["api_key"] = _env("ASR_API_KEY") or shared_key or asr.get("api_key", "") |
| asr["model"] = _env("ASR_MODEL") or asr.get("model", "") |
| asr["mode"] = _env("ASR_MODE") or asr.get("mode", "api") |
|
|
| llm = cfg["llm"] |
| llm["base_url"] = _env("LLM_BASE_URL") or shared_url or llm.get("base_url", "") |
| llm["api_key"] = _env("LLM_API_KEY") or shared_key or llm.get("api_key", "") |
| llm["model"] = _env("LLM_MODEL") or llm.get("model", "") |
|
|
| tts = cfg["tts"] |
| tts["base_url"] = _env("TTS_BASE_URL") or shared_url or tts.get("base_url", "") |
| tts["api_key"] = _env("TTS_API_KEY") or shared_key or tts.get("api_key", "") |
| tts["model"] = _env("TTS_MODEL") or tts.get("model", "") |
| tts["voice"] = _env("TTS_VOICE") or tts.get("voice", "default") |
|
|
| |
| |
| for lang in cfg.get("languages", []): |
| lid = (lang.get("id") or "").upper() |
| if not lid: |
| continue |
| if _env(f"TTS_{lid}_BASE_URL"): |
| lang["tts_base_url"] = _env(f"TTS_{lid}_BASE_URL") |
| if _env(f"TTS_{lid}_API_KEY"): |
| lang["tts_api_key"] = _env(f"TTS_{lid}_API_KEY") |
| if _env(f"TTS_{lid}_FORMAT"): |
| lang["tts_format"] = _env(f"TTS_{lid}_FORMAT") |
| if _env(f"TTS_{lid}_MODEL"): |
| lang["tts_model"] = _env(f"TTS_{lid}_MODEL") |
| if _env(f"TTS_{lid}_VOICE"): |
| lang["tts_voice"] = _env(f"TTS_{lid}_VOICE") |
| if _env(f"TTS_{lid}_SOURCE_LANG"): |
| lang["tts_source_lang"] = _env(f"TTS_{lid}_SOURCE_LANG") |
| if _env(f"TTS_{lid}_VOICE_SEED"): |
| lang["tts_voice_seed"] = _env(f"TTS_{lid}_VOICE_SEED") |
| if _env(f"TTS_{lid}_POLL_INTERVAL"): |
| lang["tts_poll_interval"] = _env(f"TTS_{lid}_POLL_INTERVAL") |
| if _env(f"TTS_{lid}_TIMEOUT"): |
| lang["tts_timeout"] = _env(f"TTS_{lid}_TIMEOUT") |
| if _env(f"ASR_{lid}_BASE_URL"): |
| lang["asr_base_url"] = _env(f"ASR_{lid}_BASE_URL") |
| if _env(f"ASR_{lid}_API_KEY"): |
| lang["asr_api_key"] = _env(f"ASR_{lid}_API_KEY") |
| if _env(f"ASR_{lid}_MODEL"): |
| lang["asr_model"] = _env(f"ASR_{lid}_MODEL") |
|
|
| return cfg |
|
|
|
|
| def load_config(): |
| with open(CONFIG_PATH, "r") as f: |
| cfg = json.load(f) |
| return apply_env_overrides(cfg) |
|
|
|
|
| def save_config(cfg): |
| """Persist config to disk. On read-only / ephemeral filesystems (some Space |
| setups), persistence is skipped β the in-memory config still applies for the |
| session, and secrets supplied via env vars are re-applied on every reload.""" |
| try: |
| with open(CONFIG_PATH, "w") as f: |
| json.dump(cfg, f, indent=2, ensure_ascii=False) |
| except OSError as e: |
| print(f"[config] Could not persist config.json (continuing in-memory): {e}") |
|
|
|
|
| def _is_dashscope(url: str) -> bool: |
| """True for any DashScope / Model Studio host that uses the native |
| multimodal-generation format for ASR & TTS. Covers the public endpoints |
| (dashscope.aliyuncs.com, dashscope-intl.aliyuncs.com, dashscope-us.aliyuncs.com) |
| as well as dedicated workspace domains ({id}.{region}.maas.aliyuncs.com).""" |
| u = (url or "").lower() |
| return "aliyuncs.com" in u and ("dashscope" in u or "maas" in u) |
|
|
|
|
| CONFIG = load_config() |
|
|
| |
| |
| |
|
|
| app = FastAPI(title="Voice AI Demo") |
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
|
|
| |
| |
| |
|
|
| class ChatRequest(BaseModel): |
| messages: list[dict] |
| language: str = "" |
| stream: bool = True |
|
|
|
|
| class TTSRequest(BaseModel): |
| text: str |
| language: str = "" |
| voice: Optional[str] = None |
|
|
|
|
| class ConfigUpdate(BaseModel): |
| asr_mode: Optional[str] = None |
| asr_base_url: Optional[str] = None |
| asr_api_key: Optional[str] = None |
| asr_model: Optional[str] = None |
| llm_base_url: Optional[str] = None |
| llm_api_key: Optional[str] = None |
| llm_model: Optional[str] = None |
| tts_base_url: Optional[str] = None |
| tts_api_key: Optional[str] = None |
| tts_model: Optional[str] = None |
| tts_voice: Optional[str] = None |
|
|
|
|
| |
| |
| |
|
|
| def get_lang_config(lang_id: str) -> dict: |
| """Find language config by id. Falls back to first language.""" |
| for lc in CONFIG.get("languages", []): |
| if lc["id"] == lang_id: |
| return lc |
| langs = CONFIG.get("languages", []) |
| return langs[0] if langs else {} |
|
|
|
|
| def get_asr_config(lang_id: str) -> dict: |
| """Resolve the ASR endpoint for a language. A language may carry its own |
| asr_base_url / asr_api_key / asr_model (e.g. Yoruba -> a Whisper endpoint). |
| If a language sets its own asr_base_url it fully overrides the global block, |
| so it never inherits the Qwen URL/key for a different engine.""" |
| lang = get_lang_config(lang_id) |
| g = CONFIG.get("asr", {}) |
| if lang.get("asr_base_url"): |
| base_url = lang.get("asr_base_url") |
| api_key = lang.get("asr_api_key") or "" |
| model = lang.get("asr_model") or "whisper-large-v3" |
| else: |
| base_url = g.get("base_url", "") |
| api_key = g.get("api_key", "") |
| model = g.get("model", "") |
| return { |
| "mode": lang.get("asr_mode") or g.get("mode", "api"), |
| "base_url": base_url or "", |
| "api_key": api_key or "", |
| "model": model or "", |
| "lang_hint": lang.get("asr_lang") or "auto", |
| } |
|
|
|
|
| def get_tts_config(lang_id: str) -> dict: |
| """Resolve the TTS endpoint for a language. A language may carry its own |
| tts_base_url / tts_api_key / tts_format / tts_model / tts_voice; anything not |
| set falls back to the global `tts` block β EXCEPT for `custom` format, which |
| never inherits the global (Qwen) URL/key, so a misconfig can't accidentally |
| POST Yoruba text at the DashScope endpoint.""" |
| lang = get_lang_config(lang_id) |
| g = CONFIG.get("tts", {}) |
| fmt = (lang.get("tts_format") or "").strip().lower() |
|
|
| if fmt in ("custom", "async_job"): |
| base_url = lang.get("tts_base_url") or "" |
| api_key = lang.get("tts_api_key") or "" |
| else: |
| base_url = lang.get("tts_base_url") or g.get("base_url", "") |
| api_key = lang.get("tts_api_key") or g.get("api_key", "") |
|
|
| try: |
| voice_seed = int(lang.get("tts_voice_seed", 42)) |
| except (TypeError, ValueError): |
| voice_seed = 42 |
|
|
| return { |
| "base_url": base_url or "", |
| "api_key": api_key or "", |
| "model": (lang.get("tts_model") or g.get("model", "") or ""), |
| "voice": (lang.get("tts_voice") or g.get("voice", "default") or "default"), |
| "format": fmt, |
| "speed": g.get("speed", 1.0), |
| |
| "voice_seed": voice_seed, |
| "source_lang": lang.get("tts_source_lang") or "Yoruba", |
| "poll_interval": float(lang.get("tts_poll_interval", 1.5) or 1.5), |
| "timeout": float(lang.get("tts_timeout", 90) or 90), |
| } |
|
|
|
|
| |
| |
| |
|
|
| _local_whisper = None |
|
|
|
|
| @app.post("/api/asr") |
| async def transcribe_audio( |
| audio: UploadFile = File(...), |
| language: str = Form(default="auto"), |
| lang_id: str = Form(default=""), |
| ): |
| """ |
| Transcribe audio. Resolves a per-language ASR endpoint when lang_id is given |
| (e.g. Yoruda -> Whisper), otherwise uses the global ASR config. |
| - mode=api β POST to OpenAI-compatible /audio/transcriptions (or DashScope) |
| - mode=local β use local Whisper model |
| """ |
| if lang_id: |
| acfg = get_asr_config(lang_id) |
| lang_hint = acfg["lang_hint"] |
| lc = get_lang_config(lang_id) |
|
|
| |
| |
| |
| |
| if (lang_hint and lang_hint != "auto" |
| and not lc.get("asr_base_url") |
| and _is_dashscope(acfg.get("base_url", ""))): |
| raise HTTPException( |
| status_code=400, |
| detail=(f"No dedicated ASR endpoint for '{lang_id}'. Qwen ASR can't " |
| f"transcribe '{lang_hint}'. Set ASR_{lang_id.upper()}_BASE_URL " |
| f"and ASR_{lang_id.upper()}_API_KEY to a Whisper endpoint."), |
| ) |
| else: |
| acfg = CONFIG.get("asr", {}) |
| lang_hint = language |
|
|
| mode = acfg.get("mode", "api") |
| audio_bytes = await audio.read() |
|
|
| if mode == "api": |
| return await _asr_via_api(audio_bytes, audio.filename, lang_hint, acfg) |
| else: |
| return await _asr_via_local(audio_bytes, audio.filename, lang_hint, acfg) |
|
|
|
|
| async def _asr_via_api(audio_bytes: bytes, filename: str, language: str, asr_cfg: dict): |
| """Call ASR API. Supports DashScope MaaS (qwen3-asr-flash) and OpenAI-compatible.""" |
| import base64 as b64 |
|
|
| base_url = asr_cfg.get("base_url", "").rstrip("/") |
| api_key = asr_cfg.get("api_key", "") |
| model = asr_cfg.get("model", "whisper-large-v3") |
|
|
| if not base_url: |
| raise HTTPException(status_code=400, detail="ASR API not configured. Set the ASR Base URL in Settings (API key only needed for hosted providers).") |
|
|
| headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} |
|
|
| try: |
| async with httpx.AsyncClient(timeout=30.0) as client: |
| if _is_dashscope(base_url): |
| |
| if not model or model == "whisper-large-v3": |
| model = "qwen3-asr-flash" |
|
|
| |
| suffix = os.path.splitext(filename or "audio.webm")[1] or ".webm" |
| mime_map = {".webm": "audio/webm", ".wav": "audio/wav", ".mp3": "audio/mpeg", |
| ".ogg": "audio/ogg", ".m4a": "audio/mp4", ".flac": "audio/flac"} |
| mime = mime_map.get(suffix, "audio/webm") |
|
|
| |
| audio_b64 = b64.b64encode(audio_bytes).decode() |
| data_uri = f"data:{mime};base64,{audio_b64}" |
|
|
| payload = { |
| "model": model, |
| "input": { |
| "messages": [ |
| { |
| "role": "user", |
| "content": [{"audio": data_uri}] |
| } |
| ] |
| } |
| } |
| if language and language != "auto": |
| payload["parameters"] = {"asr_options": {"language": language}} |
|
|
| from urllib.parse import urlparse |
| parsed = urlparse(base_url) |
| asr_endpoint = f"{parsed.scheme}://{parsed.netloc}/api/v1/services/aigc/multimodal-generation/generation" |
|
|
| resp = await client.post(asr_endpoint, headers={**headers, "Content-Type": "application/json"}, json=payload) |
| if resp.status_code != 200: |
| raise HTTPException(status_code=resp.status_code, detail=f"ASR API error: {resp.text[:500]}") |
| result = resp.json() |
| |
| choices = result.get("output", {}).get("choices", []) |
| if choices: |
| content = choices[0].get("message", {}).get("content", []) |
| if isinstance(content, list): |
| text = " ".join(c.get("text", "") for c in content if "text" in c) |
| elif isinstance(content, str): |
| text = content |
| else: |
| text = "" |
| else: |
| text = result.get("output", {}).get("text", "") |
|
|
| return { |
| "text": text.strip(), |
| "language": language if language != "auto" else "auto", |
| "confidence": 0.0, |
| } |
| else: |
| |
| suffix = os.path.splitext(filename or "audio.webm")[1] or ".webm" |
| fname = f"audio{suffix}" |
| files = {"file": (fname, audio_bytes, "audio/webm")} |
| data = {"model": model} |
| if language and language != "auto": |
| data["language"] = language |
|
|
| resp = await client.post( |
| f"{base_url}/audio/transcriptions", |
| headers=headers, |
| files=files, |
| data=data, |
| ) |
| if resp.status_code != 200: |
| raise HTTPException(status_code=resp.status_code, detail=f"ASR API error: {resp.text[:300]}") |
| result = resp.json() |
| return { |
| "text": result.get("text", "").strip(), |
| "language": result.get("language", language if language != "auto" else "unknown"), |
| "confidence": 0.0, |
| } |
| except httpx.ConnectError: |
| raise HTTPException(status_code=502, detail=f"Cannot connect to ASR endpoint: {base_url}") |
| except Exception as e: |
| if isinstance(e, HTTPException): |
| raise |
| raise HTTPException(status_code=500, detail=f"ASR failed: {str(e)}") |
|
|
|
|
| async def _asr_via_local(audio_bytes: bytes, filename: str, language: str, asr_cfg: dict): |
| """Use a local Whisper model.""" |
| global _local_whisper |
| suffix = os.path.splitext(filename or "audio.webm")[1] or ".webm" |
| with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: |
| tmp.write(audio_bytes) |
| tmp_path = tmp.name |
|
|
| try: |
| if _local_whisper is None: |
| model_size = asr_cfg.get("model_size", "large-v3") |
| try: |
| from faster_whisper import WhisperModel |
| print(f"[ASR] Loading faster-whisper {model_size}...") |
| _local_whisper = WhisperModel(model_size, device="auto", compute_type="int8") |
| except ImportError: |
| import whisper |
| print(f"[ASR] Loading openai-whisper...") |
| _local_whisper = whisper.load_model("large") |
|
|
| lang_hint = None if language == "auto" else language |
|
|
| if hasattr(_local_whisper, 'transcribe'): |
| segments, info = _local_whisper.transcribe(tmp_path, language=lang_hint, beam_size=5) |
| text = " ".join(seg.text.strip() for seg in segments) |
| return {"text": text.strip(), "language": info.language, "confidence": getattr(info, 'language_probability', 0.0)} |
| else: |
| result = _local_whisper.transcribe(tmp_path, language=lang_hint) |
| return {"text": result["text"].strip(), "language": result.get("language", "unknown"), "confidence": 0.0} |
| finally: |
| os.unlink(tmp_path) |
|
|
|
|
| |
| |
| |
|
|
| @app.post("/api/chat") |
| async def chat(req: ChatRequest): |
| """Chat with any OpenAI-compatible LLM endpoint. Supports streaming.""" |
| llm_cfg = CONFIG.get("llm", {}) |
| base_url = llm_cfg.get("base_url", "").rstrip("/") |
| api_key = llm_cfg.get("api_key", "") |
| model = llm_cfg.get("model", "qwen-plus") |
|
|
| if not base_url or not api_key: |
| raise HTTPException(status_code=400, detail="LLM not configured. Set Base URL and API Key in Settings.") |
|
|
| |
| |
| lang_cfg = get_lang_config(req.language) |
| out_label = lang_cfg.get("label") or lang_cfg.get("id") or "the user's language" |
| system_prompt = ( |
| f"You are a helpful AI assistant. The user may write or speak in any language, " |
| f"but you must ALWAYS reply in {out_label}, regardless of the language the user used. " |
| f"Keep responses concise (2-4 sentences max) since they will be spoken aloud. " |
| f"Be warm, natural, and culturally appropriate." |
| ) |
| note = lang_cfg.get("reply_note", "") |
| if note: |
| system_prompt += " " + note |
| messages = [{"role": "system", "content": system_prompt}] + req.messages |
|
|
| payload = { |
| "model": model, |
| "messages": messages, |
| "stream": req.stream, |
| "max_tokens": llm_cfg.get("max_tokens", 512), |
| "temperature": llm_cfg.get("temperature", 0.7), |
| } |
| headers = { |
| "Authorization": f"Bearer {api_key}", |
| "Content-Type": "application/json", |
| } |
|
|
| if req.stream: |
| async def generate(): |
| async with httpx.AsyncClient(timeout=60.0) as client: |
| async with client.stream("POST", f"{base_url}/chat/completions", headers=headers, json=payload) as resp: |
| if resp.status_code != 200: |
| body = await resp.aread() |
| yield f"data: {json.dumps({'error': body.decode()[:500]})}\n\n" |
| return |
| async for line in resp.aiter_lines(): |
| if line.startswith("data: "): |
| yield line + "\n\n" |
| return StreamingResponse(generate(), media_type="text/event-stream") |
| else: |
| async with httpx.AsyncClient(timeout=60.0) as client: |
| resp = await client.post(f"{base_url}/chat/completions", headers=headers, json=payload) |
| if resp.status_code != 200: |
| raise HTTPException(status_code=resp.status_code, detail=resp.text[:500]) |
| return resp.json() |
|
|
|
|
| |
| |
| |
|
|
| def _is_dashscope_maas(url: str) -> bool: |
| """Check if the URL points to a DashScope instance (native TTS format).""" |
| return _is_dashscope(url) |
|
|
|
|
| def _dashscope_tts_url(base_url: str) -> str: |
| """Derive the DashScope native TTS path from the MaaS base URL.""" |
| from urllib.parse import urlparse |
| parsed = urlparse(base_url) |
| return f"{parsed.scheme}://{parsed.netloc}/api/v1/services/aigc/multimodal-generation/generation" |
|
|
|
|
| |
| _LANG_TYPE_MAP = { |
| "english": "English", "en": "English", |
| "chinese": "Chinese", "zh": "Chinese", |
| "japanese": "Japanese", "ja": "Japanese", |
| "spanish": "Spanish", "es": "Spanish", |
| "yoruba": "Auto", "yo": "Auto", |
| } |
|
|
|
|
| async def _tts_async_job(tcfg: dict, text: str): |
| """Async submit/poll/download TTS, mirroring the PlotWeaver Yoruba client: |
| 1. POST {base}/synthesize/async/submit {text, voice_seed, source_lang} -> job_id |
| 2. GET {base}/synthesize/async/status/{job_id} until status == 'completed' |
| 3. GET audio_url (or download_url) -> audio bytes |
| Tuned for short chat replies: fast polling, bounded wait.""" |
| import asyncio |
| import time |
|
|
| base_url = (tcfg.get("base_url") or "").strip().rstrip("/") |
| if not base_url: |
| return JSONResponse(status_code=200, content={ |
| "status": "tts_not_configured", |
| "message": "Async TTS endpoint not set for this language.", |
| "text": text, |
| }) |
|
|
| api_key = (tcfg.get("api_key") or "").strip() |
| submit_url = f"{base_url}/synthesize/async/submit" |
| status_base = f"{base_url}/synthesize/async/status" |
| headers = {"Content-Type": "application/json"} |
| if api_key: |
| headers["Authorization"] = f"Bearer {api_key}" |
| headers["x-api-key"] = api_key |
|
|
| payload = { |
| "text": text, |
| "voice_seed": tcfg.get("voice_seed", 42), |
| "source_lang": tcfg.get("source_lang", "Yoruba"), |
| } |
| poll_interval = tcfg.get("poll_interval", 1.5) |
| timeout = tcfg.get("timeout", 90) |
|
|
| def _err(msg): |
| return JSONResponse(status_code=200, content={ |
| "status": "tts_error", "message": msg, "text": text}) |
|
|
| async with httpx.AsyncClient(timeout=30.0) as client: |
| try: |
| r = await client.post(submit_url, headers=headers, json=payload) |
| except Exception as e: |
| return _err(f"TTS submit failed: {e}") |
| if r.status_code not in (200, 202): |
| return _err(f"TTS submit {r.status_code}: {r.text[:300]}") |
| try: |
| job_id = r.json().get("job_id") |
| except Exception: |
| return _err(f"TTS submit returned non-JSON: {r.text[:200]}") |
| if not job_id: |
| return _err("TTS submit did not return a job_id.") |
|
|
| start = time.monotonic() |
| audio_url = None |
| while time.monotonic() - start < timeout: |
| try: |
| s = await client.get(f"{status_base}/{job_id}") |
| except Exception as e: |
| return _err(f"TTS status poll failed: {e}") |
| if s.status_code == 200: |
| data = s.json() |
| status = data.get("status", "unknown") |
| if status == "completed": |
| audio_url = data.get("audio_url") or data.get("download_url") |
| break |
| if status == "failed": |
| return _err(f"TTS job failed: {data.get('error', 'unknown')}") |
| await asyncio.sleep(poll_interval) |
|
|
| if not audio_url: |
| return _err(f"TTS job timed out after {timeout}s.") |
|
|
| try: |
| a = await client.get(audio_url, timeout=120.0) |
| except Exception as e: |
| return _err(f"TTS audio download failed: {e}") |
| if a.status_code != 200: |
| return _err(f"TTS audio download {a.status_code}.") |
| ct = a.headers.get("content-type", "") |
| if not ct.startswith("audio/"): |
| ct = "audio/wav" |
| return Response(content=a.content, media_type=ct) |
|
|
|
|
| async def _tts_custom(base_url: str, api_key: str, text: str, speed: float): |
| """POST to a custom TTS service and return audio. Handles common shapes: |
| - URL is used verbatim (append /tts only if a bare host is given), so an |
| API Gateway invoke URL like .../prod/tts works as-is. |
| - Auth: sends both `Authorization: Bearer` and `x-api-key` when a key is |
| provided (harmless extras are ignored; covers API Gateway usage plans). |
| - Response: raw audio bytes (audio/*) OR JSON containing base64 audio under |
| a common key (audio / audio_base64 / data / wav / audio_content).""" |
| if not base_url: |
| return JSONResponse(status_code=200, content={ |
| "status": "tts_not_configured", |
| "message": "Custom TTS endpoint not set for this language.", |
| "text": text, |
| }) |
|
|
| from urllib.parse import urlparse |
| parsed = urlparse(base_url) |
| url = base_url if parsed.path.strip("/") else base_url.rstrip("/") + "/tts" |
|
|
| headers = {"Content-Type": "application/json"} |
| if api_key: |
| headers["Authorization"] = f"Bearer {api_key}" |
| headers["x-api-key"] = api_key |
|
|
| |
| async with httpx.AsyncClient(timeout=120.0) as client: |
| resp = await client.post(url, headers=headers, json={"text": text, "speed": speed}) |
| if resp.status_code != 200: |
| return JSONResponse(status_code=200, content={ |
| "status": "tts_error", |
| "message": f"Custom TTS {resp.status_code}: {resp.text[:300]}", |
| "text": text, |
| }) |
|
|
| ct = resp.headers.get("content-type", "") |
| if ct.startswith("audio/"): |
| return Response(content=resp.content, media_type=ct) |
|
|
| |
| try: |
| data = resp.json() |
| except Exception: |
| return JSONResponse(status_code=200, content={ |
| "status": "tts_error", |
| "message": f"Unexpected TTS response (content-type: {ct or 'unknown'})", |
| "text": text, |
| }) |
|
|
| import base64 as b64 |
| b64str = None |
| if isinstance(data, dict): |
| for k in ("audio_base64", "audio", "data", "wav", "audio_content", "b64_audio"): |
| v = data.get(k) |
| if isinstance(v, str) and len(v) > 100: |
| b64str = v |
| break |
| if not b64str: |
| return JSONResponse(status_code=200, content={ |
| "status": "tts_error", |
| "message": "No base64 audio found in JSON TTS response.", |
| "text": text, |
| }) |
| if b64str.startswith("data:") and "," in b64str: |
| b64str = b64str.split(",", 1)[1] |
| try: |
| audio = b64.b64decode(b64str) |
| except Exception as e: |
| return JSONResponse(status_code=200, content={ |
| "status": "tts_error", |
| "message": f"Could not decode base64 audio: {e}", |
| "text": text, |
| }) |
| return Response(content=audio, media_type="audio/wav") |
|
|
|
|
| @app.post("/api/tts") |
| async def text_to_speech(req: TTSRequest): |
| """ |
| Synthesize speech. Supports two formats: |
| - DashScope MaaS (auto-detected): native /multimodal-generation/generation |
| - Other endpoints: OpenAI-compatible POST /audio/speech |
| |
| Model resolution order: |
| 1. Language-specific tts_model from languages config |
| 2. Global tts.model |
| 3. Default: qwen3-tts-flash (DashScope) or tts-1 (OpenAI) |
| """ |
| tcfg = get_tts_config(req.language) |
| base_url = tcfg["base_url"].strip().rstrip("/") |
| api_key = tcfg["api_key"].strip() |
| model = tcfg["model"].strip() |
| fmt = tcfg["format"] |
|
|
| if fmt not in ("custom", "async_job") and (not base_url or not api_key): |
| return JSONResponse(status_code=200, content={ |
| "status": "tts_not_configured", |
| "message": "TTS not configured. Set TTS Base URL and API Key in Settings.", |
| "text": req.text, |
| }) |
|
|
| voice = req.voice or tcfg["voice"] |
| speed = tcfg["speed"] |
|
|
| try: |
| if fmt == "custom": |
| |
| return await _tts_custom(base_url, api_key, req.text, speed) |
| if fmt == "async_job": |
| |
| return await _tts_async_job(tcfg, req.text) |
| if _is_dashscope_maas(base_url): |
| |
| tts_endpoint = _dashscope_tts_url(base_url) |
| if not model: |
| model = "qwen3-tts-flash" |
| lang_type = _LANG_TYPE_MAP.get(req.language, "Auto") |
|
|
| payload = { |
| "model": model, |
| "input": { |
| "text": req.text, |
| "voice": voice if voice != "default" else "Cherry", |
| "language_type": lang_type, |
| } |
| } |
| async with httpx.AsyncClient(timeout=30.0) as client: |
| resp = await client.post( |
| tts_endpoint, |
| headers={ |
| "Authorization": f"Bearer {api_key}", |
| "Content-Type": "application/json", |
| }, |
| json=payload, |
| ) |
| if resp.status_code != 200: |
| return JSONResponse(status_code=200, content={ |
| "status": "tts_error", |
| "message": f"TTS {resp.status_code}: {resp.text[:500]}", |
| "text": req.text, |
| }) |
|
|
| data = resp.json() |
| audio_url = data.get("output", {}).get("audio", {}).get("url") |
| if not audio_url: |
| |
| audio_data = data.get("output", {}).get("audio", {}).get("data") |
| if audio_data: |
| import base64 as b64 |
| return Response(content=b64.b64decode(audio_data), media_type="audio/wav") |
| return JSONResponse(status_code=200, content={ |
| "status": "tts_error", |
| "message": "No audio in TTS response", |
| "text": req.text, |
| }) |
|
|
| |
| audio_resp = await client.get(audio_url) |
| if audio_resp.status_code != 200: |
| return JSONResponse(status_code=200, content={ |
| "status": "tts_error", |
| "message": f"Failed to download audio: {audio_resp.status_code}", |
| "text": req.text, |
| }) |
| ct = audio_resp.headers.get("content-type", "audio/wav") |
| return Response(content=audio_resp.content, media_type=ct) |
| else: |
| |
| if not model: |
| model = "tts-1" |
| async with httpx.AsyncClient(timeout=30.0) as client: |
| resp = await client.post( |
| f"{base_url}/audio/speech", |
| headers={ |
| "Authorization": f"Bearer {api_key}", |
| "Content-Type": "application/json", |
| }, |
| json={ |
| "model": model, |
| "input": req.text, |
| "voice": voice, |
| "speed": speed, |
| "response_format": "mp3", |
| }, |
| ) |
| if resp.status_code != 200: |
| return JSONResponse(status_code=200, content={ |
| "status": "tts_error", |
| "message": f"TTS {resp.status_code}: {resp.text[:500]}", |
| "text": req.text, |
| }) |
| return Response( |
| content=resp.content, |
| media_type=resp.headers.get("content-type", "audio/mpeg"), |
| ) |
|
|
| except httpx.ConnectError: |
| return JSONResponse(status_code=200, content={ |
| "status": "tts_connection_error", |
| "message": f"Cannot connect to TTS: {base_url}", |
| "text": req.text, |
| }) |
| except Exception as e: |
| return JSONResponse(status_code=200, content={ |
| "status": "tts_error", |
| "message": f"TTS error: {str(e)}", |
| "text": req.text, |
| }) |
|
|
|
|
| |
| |
| |
|
|
| @app.get("/api/languages") |
| async def get_languages(): |
| """Return configured languages for the UI switcher.""" |
| return CONFIG.get("languages", []) |
|
|
|
|
| @app.get("/api/config") |
| async def get_config(): |
| """Get current config (with keys masked).""" |
| cfg = load_config() |
|
|
| def mask(key_val): |
| if not key_val: |
| return "(not set)" |
| return "***" + key_val[-4:] if len(key_val) > 4 else "***" |
|
|
| return { |
| "asr": { |
| "mode": cfg.get("asr", {}).get("mode", "api"), |
| "base_url": cfg.get("asr", {}).get("base_url", ""), |
| "api_key_masked": mask(cfg.get("asr", {}).get("api_key", "")), |
| "model": cfg.get("asr", {}).get("model", ""), |
| }, |
| "llm": { |
| "base_url": cfg.get("llm", {}).get("base_url", ""), |
| "api_key_masked": mask(cfg.get("llm", {}).get("api_key", "")), |
| "model": cfg.get("llm", {}).get("model", ""), |
| }, |
| "tts": { |
| "base_url": cfg.get("tts", {}).get("base_url", ""), |
| "api_key_masked": mask(cfg.get("tts", {}).get("api_key", "")), |
| "model": cfg.get("tts", {}).get("model", ""), |
| "voice": cfg.get("tts", {}).get("voice", "default"), |
| }, |
| "tts_configured": bool(cfg.get("tts", {}).get("base_url") and cfg.get("tts", {}).get("api_key")), |
| "asr_configured": bool(cfg.get("asr", {}).get("mode") == "local" or (cfg.get("asr", {}).get("base_url") and cfg.get("asr", {}).get("api_key"))), |
| } |
|
|
|
|
| @app.post("/api/config") |
| async def update_config(req: ConfigUpdate): |
| """Update config. Only updates provided fields.""" |
| cfg = load_config() |
|
|
| |
| asr = cfg.setdefault("asr", {}) |
| if req.asr_mode is not None: |
| asr["mode"] = req.asr_mode |
| if req.asr_base_url is not None: |
| asr["base_url"] = req.asr_base_url |
| if req.asr_api_key is not None: |
| asr["api_key"] = req.asr_api_key |
| if req.asr_model is not None: |
| asr["model"] = req.asr_model |
|
|
| |
| llm = cfg.setdefault("llm", {}) |
| if req.llm_base_url is not None: |
| llm["base_url"] = req.llm_base_url |
| if req.llm_api_key is not None: |
| llm["api_key"] = req.llm_api_key |
| if req.llm_model is not None: |
| llm["model"] = req.llm_model |
|
|
| |
| tts = cfg.setdefault("tts", {}) |
| if req.tts_base_url is not None: |
| tts["base_url"] = req.tts_base_url |
| if req.tts_api_key is not None: |
| tts["api_key"] = req.tts_api_key |
| if req.tts_model is not None: |
| tts["model"] = req.tts_model |
| if req.tts_voice is not None: |
| tts["voice"] = req.tts_voice |
|
|
| save_config(cfg) |
| global CONFIG |
| CONFIG = cfg |
| return {"status": "ok"} |
|
|
|
|
| |
| |
| |
|
|
| HTML_PAGE = r"""<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Voice AI Demo</title> |
| <style> |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap'); |
| *{margin:0;padding:0;box-sizing:border-box} |
| :root{--accent:#6C5CE7;--accent2:#00CEC9;--bg:#0a0a14;--surface:rgba(255,255,255,0.06);--text:#e8e8f0;--text2:#8888a8;--danger:#ff6b6b} |
| body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);height:100vh;overflow:hidden;user-select:none} |
| |
| .app{display:flex;flex-direction:column;height:100vh;position:relative} |
| |
| .bg-gradient{position:fixed;inset:0;z-index:0; |
| background:radial-gradient(ellipse at 50% 30%,rgba(108,92,231,0.12) 0%,transparent 60%), |
| radial-gradient(ellipse at 80% 80%,rgba(0,206,201,0.06) 0%,transparent 40%)} |
| |
| /* === Top Bar === */ |
| .topbar{position:relative;z-index:10;display:flex;align-items:center;padding:16px 24px;gap:12px;flex-wrap:wrap} |
| .lang-switch{margin-left:auto;display:flex;gap:4px;background:var(--surface);border-radius:20px;padding:3px} |
| .lang-btn{padding:6px 16px;border-radius:17px;border:none;font-size:13px;font-weight:500;cursor:pointer;color:var(--text2);background:transparent;transition:all .25s} |
| .lang-btn.active{background:var(--accent);color:#fff} |
| .status-badges{display:flex;gap:6px;align-items:center} |
| .badge{font-size:10px;padding:3px 10px;border-radius:12px;font-weight:600;letter-spacing:.3px} |
| .badge.off{background:rgba(255,107,107,0.15);color:var(--danger)} |
| .badge.on{background:rgba(0,206,201,0.15);color:var(--accent2)} |
| .gear{width:36px;height:36px;border-radius:50%;border:none;background:var(--surface);color:var(--text2);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s} |
| .gear:hover{background:rgba(255,255,255,0.12);color:var(--text)} |
| .gear svg{width:18px;height:18px} |
| |
| /* === Center Stage === */ |
| .stage{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;z-index:5} |
| .orb-wrap{position:relative;width:220px;height:220px;display:flex;align-items:center;justify-content:center} |
| .orb{width:120px;height:120px;border-radius:50%;position:relative;transition:all .5s cubic-bezier(.4,0,.2,1)} |
| .orb::before,.orb::after{content:'';position:absolute;inset:0;border-radius:50%;animation:orbPulse 3s ease-in-out infinite} |
| .orb::before{inset:-20px;border:1.5px solid rgba(108,92,231,0.2);animation-delay:.5s} |
| .orb::after{inset:-40px;border:1px solid rgba(108,92,231,0.1);animation-delay:1s} |
| .orb-inner{position:absolute;inset:0;border-radius:50%; |
| background:radial-gradient(circle at 35% 35%,rgba(108,92,231,0.9),rgba(0,206,201,0.6)); |
| box-shadow:0 0 60px rgba(108,92,231,0.4),0 0 120px rgba(108,92,231,0.15); |
| animation:orbFloat 4s ease-in-out infinite} |
| .orb-wrap.listening .orb-inner{background:radial-gradient(circle at 35% 35%,#ff6b6b,#ee5a24);box-shadow:0 0 60px rgba(255,107,107,0.5),0 0 120px rgba(255,107,107,0.2)} |
| .orb-wrap.listening .orb::before{border-color:rgba(255,107,107,0.3);animation:orbRipple 1.2s ease-out infinite} |
| .orb-wrap.listening .orb::after{border-color:rgba(255,107,107,0.15);animation:orbRipple 1.2s ease-out infinite .4s} |
| .orb-wrap.thinking .orb-inner{background:radial-gradient(circle at 35% 35%,#f9ca24,#f0932b);box-shadow:0 0 60px rgba(249,202,36,0.4);animation:orbSpin 2s linear infinite} |
| .orb-wrap.thinking .orb::before{border-color:rgba(249,202,36,0.3);animation:orbSpin 3s linear infinite reverse} |
| .orb-wrap.speaking .orb-inner{background:radial-gradient(circle at 35% 35%,var(--accent2),#00b894);box-shadow:0 0 60px rgba(0,206,201,0.5),0 0 120px rgba(0,206,201,0.2)} |
| .orb-wrap.speaking .orb::before{border-color:rgba(0,206,201,0.3);animation:orbRipple 1.5s ease-out infinite} |
| .state-label{margin-top:28px;font-size:13px;font-weight:500;color:var(--text2);letter-spacing:1px;text-transform:uppercase;transition:all .3s;min-height:20px;text-align:center} |
| |
| /* === Transcript === */ |
| .transcript{position:relative;z-index:5;width:100%;max-width:560px;margin:0 auto;padding:0 24px;flex-shrink:0} |
| .msg-scroll{max-height:35vh;overflow-y:auto;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,0.1) transparent;padding-bottom:8px} |
| .msg{padding:10px 16px;margin-bottom:8px;border-radius:12px;font-size:14px;line-height:1.6;animation:msgIn .4s ease} |
| .msg.user{background:rgba(108,92,231,0.15);border-left:3px solid var(--accent);color:#c8c0f8} |
| .msg.assistant{background:var(--surface);border-left:3px solid var(--accent2);color:#c0f0ee} |
| .msg .role{font-size:11px;font-weight:600;opacity:.5;margin-bottom:2px;text-transform:uppercase;letter-spacing:.5px} |
| |
| /* === Bottom Bar === */ |
| .bottombar{position:relative;z-index:10;padding:16px 24px 24px;display:flex;align-items:center;gap:12px;max-width:560px;margin:0 auto;width:100%} |
| .text-input{flex:1;background:var(--surface);border:1px solid rgba(255,255,255,0.08);border-radius:24px;padding:12px 20px;font-size:14px;color:var(--text);outline:none;font-family:inherit;transition:border-color .2s} |
| .text-input::placeholder{color:var(--text2)} |
| .text-input:focus{border-color:var(--accent)} |
| .mic-btn{width:56px;height:56px;border-radius:50%;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s;flex-shrink:0; |
| background:linear-gradient(135deg,var(--accent),var(--accent2));box-shadow:0 4px 20px rgba(108,92,231,0.3)} |
| .mic-btn:hover{transform:scale(1.06);box-shadow:0 6px 28px rgba(108,92,231,0.4)} |
| .mic-btn:active{transform:scale(0.95)} |
| .mic-btn.recording{background:linear-gradient(135deg,#ff6b6b,#ee5a24);box-shadow:0 4px 20px rgba(255,107,107,0.4);animation:micPulse 1.5s infinite} |
| .mic-btn svg{width:24px;height:24px;fill:#fff} |
| .mic-btn:disabled{opacity:.4;cursor:not-allowed;transform:none} |
| .send-btn{width:44px;height:44px;border-radius:50%;border:none;background:var(--surface);color:var(--text2);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s;flex-shrink:0} |
| .send-btn:hover{background:rgba(255,255,255,0.1);color:var(--text)} |
| .send-btn svg{width:20px;height:20px;fill:currentColor} |
| |
| /* === Settings Modal === */ |
| .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,0.6);backdrop-filter:blur(8px);z-index:100;display:none;align-items:center;justify-content:center} |
| .modal-bg.open{display:flex} |
| .modal{background:#1a1a2e;border:1px solid rgba(255,255,255,0.08);border-radius:16px;width:92%;max-width:480px;max-height:85vh;overflow-y:auto;padding:28px;box-shadow:0 20px 60px rgba(0,0,0,0.5)} |
| .modal h2{font-size:18px;font-weight:600;margin-bottom:20px} |
| .modal h3{font-size:12px;font-weight:600;margin:22px 0 10px;text-transform:uppercase;letter-spacing:.5px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,0.06)} |
| .modal h3.asr{color:#fd79a8} |
| .modal h3.llm{color:var(--accent)} |
| .modal h3.tts{color:var(--accent2)} |
| .field{margin-bottom:12px} |
| .field label{display:block;font-size:12px;font-weight:500;color:var(--text2);margin-bottom:4px} |
| .field input,.field select{width:100%;padding:9px 14px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:8px;font-size:13px;color:var(--text);outline:none;font-family:inherit} |
| .field input:focus,.field select:focus{border-color:var(--accent)} |
| .field input::placeholder{color:rgba(255,255,255,0.2)} |
| .field select{appearance:none;cursor:pointer} |
| .field select option{background:#1a1a2e;color:var(--text)} |
| .field .hint{font-size:11px;color:var(--text2);margin-top:3px} |
| .modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:24px} |
| .btn{padding:10px 22px;border-radius:8px;border:none;font-size:13px;font-weight:500;cursor:pointer;transition:all .2s;font-family:inherit} |
| .btn-p{background:var(--accent);color:#fff} |
| .btn-p:hover{background:#5a4bd6} |
| .btn-s{background:rgba(255,255,255,0.06);color:var(--text);border:1px solid rgba(255,255,255,0.1)} |
| |
| /* === Toast === */ |
| .toast{position:fixed;bottom:100px;left:50%;transform:translateX(-50%);background:rgba(30,30,50,0.95);border:1px solid rgba(255,255,255,0.1);color:var(--text);padding:10px 24px;border-radius:10px;font-size:13px;z-index:200;opacity:0;transition:opacity .3s;pointer-events:none} |
| .toast.show{opacity:1} |
| |
| @keyframes orbPulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.08);opacity:.7}} |
| @keyframes orbFloat{0%,100%{transform:translateY(0)}50%{transform:translateY(-6px)}} |
| @keyframes orbRipple{0%{transform:scale(1);opacity:.6}100%{transform:scale(1.6);opacity:0}} |
| @keyframes orbSpin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}} |
| @keyframes micPulse{0%,100%{box-shadow:0 4px 20px rgba(255,107,107,0.4)}50%{box-shadow:0 4px 32px rgba(255,107,107,0.6)}} |
| @keyframes msgIn{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}} |
| </style> |
| </head> |
| <body> |
| <div class="bg-gradient"></div> |
| <div class="app"> |
| |
| <!-- Top Bar --> |
| <div class="topbar"> |
| <div class="lang-switch" id="langSwitch"><!-- filled dynamically --></div> |
| <div class="status-badges"> |
| <span class="badge off" id="asrBadge">ASR</span> |
| <span class="badge off" id="llmBadge">LLM</span> |
| <span class="badge off" id="ttsBadge">TTS</span> |
| </div> |
| <button class="gear" onclick="openSettings()" title="Settings"> |
| <svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14,12.94c.04-.3.06-.61.06-.94s-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94L14.4,2.81a.47.47 0 00-.48-.41h-3.84a.47.47 0 00-.47.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33a.49.49 0 00-.59.22L2.74,8.87a.49.49 0 00.12.61l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s.02.64.07.94l-2.03,1.58a.49.49 0 00-.12.61l1.92,3.32c.12.22.37.29.59.22l2.39-.96c.5.38,1.03.7,1.62.94l.36,2.54c.05.24.24.41.48.41h3.84c.24,0,.44-.17.47-.41l.36-2.54c.59-.24,1.13-.56,1.62-.94l2.39.96c.22.08.47,0 .59-.22l1.92-3.32a.49.49 0 00-.12-.61L19.14,12.94zM12,15.6A3.6,3.6 0 1115.6,12,3.6,3.6 0 0112,15.6z"/></svg> |
| </button> |
| </div> |
| |
| <!-- Center Stage --> |
| <div class="stage"> |
| <div class="orb-wrap" id="orbWrap"> |
| <div class="orb"><div class="orb-inner"></div></div> |
| </div> |
| <div class="state-label" id="stateLabel">Tap mic or type to begin</div> |
| </div> |
| |
| <!-- Transcript --> |
| <div class="transcript"> |
| <div class="msg-scroll" id="chatArea"></div> |
| </div> |
| |
| <!-- Bottom Input --> |
| <div class="bottombar"> |
| <input class="text-input" id="textInput" placeholder="Type a message..." onkeydown="if(event.key==='Enter')sendText()"> |
| <button class="mic-btn" id="micBtn" onclick="toggleRecord()"> |
| <svg viewBox="0 0 24 24"><path d="M12,14c1.66,0,3-1.34,3-3V5c0-1.66-1.34-3-3-3S9,3.34,9,5v6C9,12.66,10.34,14,12,14zM17.3,11c0,3-2.54,5.1-5.3,5.1S6.7,14,6.7,11H5c0,3.41,2.72,6.23,6,6.72V21h2v-3.28c3.28-.49,6-3.31,6-6.72H17.3z"/></svg> |
| </button> |
| <button class="send-btn" onclick="sendText()"> |
| <svg viewBox="0 0 24 24"><path d="M2.01,21L23,12L2.01,3L2,10l15,2l-15,2z"/></svg> |
| </button> |
| </div> |
| </div> |
| |
| <!-- Settings Modal --> |
| <div class="modal-bg" id="modalBg"> |
| <div class="modal"> |
| <h2>Settings</h2> |
| |
| <!-- ASR --> |
| <h3 class="asr">ASR β Speech Recognition</h3> |
| <div class="field"> |
| <label>Mode</label> |
| <select id="cfgAsrMode"> |
| <option value="api">Remote API (OpenAI-compatible)</option> |
| <option value="local">Local Whisper</option> |
| </select> |
| <div class="hint">API mode: DashScope MaaS auto-detected, or any OpenAI-compatible /audio/transcriptions. Local: Whisper on this machine.</div> |
| </div> |
| <div class="field"><label>Base URL</label><input id="cfgAsrUrl" placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1"><div class="hint">OpenAI-compatible endpoint. Model Studio, OpenAI, Groq, etc.</div></div> |
| <div class="field"><label>API Key</label><input type="password" id="cfgAsrKey" placeholder="sk-..."></div> |
| <div class="field"><label>Model</label><input id="cfgAsrModel" placeholder="qwen3-asr-flash"></div> |
| |
| <!-- LLM --> |
| <h3 class="llm">LLM β Language Model</h3> |
| <div class="field"><label>Base URL</label><input id="cfgLlmUrl" placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1"><div class="hint">Any OpenAI-compatible chat endpoint.</div></div> |
| <div class="field"><label>API Key</label><input type="password" id="cfgLlmKey" placeholder="sk-..."></div> |
| <div class="field"><label>Model</label><input id="cfgLlmModel" placeholder="qwen-plus"></div> |
| |
| <!-- TTS --> |
| <h3 class="tts">TTS β Text to Speech</h3> |
| <div class="field"><label>Base URL</label><input id="cfgTtsUrl" placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1"><div class="hint">DashScope MaaS auto-detected (native format). Also supports OpenAI /audio/speech.</div></div> |
| <div class="field"><label>API Key</label><input type="password" id="cfgTtsKey" placeholder="sk-..."></div> |
| <div class="field"><label>Model</label><input id="cfgTtsModel" placeholder="qwen3-tts-flash"><div class="hint">Per-language override available in config.json β languages[].tts_model</div></div> |
| <div class="field"><label>Voice</label><input id="cfgTtsVoice" placeholder="default"></div> |
| |
| <div class="modal-actions"> |
| <button class="btn btn-s" onclick="closeSettings()">Cancel</button> |
| <button class="btn btn-p" onclick="saveSettings()">Save</button> |
| </div> |
| </div> |
| </div> |
| |
| <div class="toast" id="toast"></div> |
| |
| <script> |
| let inputLang='',outputLang='',isRecording=false,mediaRecorder=null,audioChunks=[],chatHistory=[]; |
| let languages=[]; |
| const $=s=>document.querySelector(s),$$=s=>document.querySelectorAll(s); |
| |
| function setState(s,label){ |
| const o=$('#orbWrap');o.className='orb-wrap'+(s?' '+s:''); |
| $('#stateLabel').textContent=label||''; |
| } |
| |
| // === Dual Language Selectors (input + output) === |
| async function initLanguages(){ |
| try{ |
| const r=await fetch('/api/languages');languages=await r.json(); |
| }catch(e){languages=[{id:'default',label:'Default'}]} |
| if(!languages.length)languages=[{id:'default',label:'Default'}]; |
| inputLang=languages[0].id; |
| outputLang=languages[0].id; |
| renderLangSwitch(); |
| } |
| |
| function renderLangSwitch(){ |
| const wrap=$('#langSwitch');wrap.innerHTML=''; |
| wrap.style.cssText='display:flex;flex-wrap:wrap;gap:6px 12px;align-items:center;'; |
| wrap.appendChild(buildLangGroup('I speak','input')); |
| wrap.appendChild(buildLangGroup('Reply in','output')); |
| } |
| |
| function buildLangGroup(label,role){ |
| const g=document.createElement('div'); |
| g.style.cssText='display:inline-flex;align-items:center;gap:5px;'; |
| const lab=document.createElement('span'); |
| lab.textContent=label; |
| lab.style.cssText='font-size:11px;opacity:.55;white-space:nowrap;'; |
| g.appendChild(lab); |
| const sel=document.createElement('select'); |
| sel.className='lang-select'; |
| sel.dataset.role=role; |
| sel.style.cssText='background:rgba(255,255,255,.08);color:#fff;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:4px 8px;font-size:13px;cursor:pointer;outline:none;'; |
| const current=role==='input'?inputLang:outputLang; |
| languages.forEach(l=>{ |
| const opt=document.createElement('option'); |
| opt.value=l.id;opt.textContent=l.label; |
| opt.style.cssText='background:#1a1a2e;color:#fff;'; |
| if(l.id===current)opt.selected=true; |
| sel.appendChild(opt); |
| }); |
| sel.onchange=()=>setLang(role,sel.value); |
| g.appendChild(sel); |
| return g; |
| } |
| |
| function setLang(role,id){ |
| if(role==='input')inputLang=id;else outputLang=id; |
| if(role==='output'){chatHistory=[];$('#chatArea').innerHTML='';setState('','Tap mic or type to begin');} |
| } |
| |
| // === Recording === |
| async function toggleRecord(){isRecording?stopRec():startRec()} |
| async function startRec(){ |
| try{ |
| const s=await navigator.mediaDevices.getUserMedia({audio:true}); |
| mediaRecorder=new MediaRecorder(s,{mimeType:'audio/webm;codecs=opus'}); |
| audioChunks=[]; |
| mediaRecorder.ondataavailable=e=>{if(e.data.size>0)audioChunks.push(e.data)}; |
| mediaRecorder.onstop=async()=>{const b=new Blob(audioChunks,{type:'audio/webm'});s.getTracks().forEach(t=>t.stop());await processAudio(b)}; |
| mediaRecorder.start();isRecording=true; |
| $('#micBtn').classList.add('recording');setState('listening','Listening...'); |
| }catch(e){showToast('Microphone denied')} |
| } |
| function stopRec(){ |
| if(mediaRecorder&&mediaRecorder.state!=='inactive')mediaRecorder.stop(); |
| isRecording=false;$('#micBtn').classList.remove('recording'); |
| } |
| |
| // === ASR β Chat β TTS === |
| async function processAudio(blob){ |
| $('#micBtn').disabled=true; |
| try{ |
| setState('thinking','Transcribing...'); |
| // ASR uses the INPUT language (what the user speaks) |
| const langCfg=languages.find(l=>l.id===inputLang)||{}; |
| const asrLang=langCfg.asr_lang||'auto'; |
| const fd=new FormData();fd.append('audio',blob,'rec.webm');fd.append('language',asrLang||'auto');fd.append('lang_id',inputLang); |
| const r=await fetch('/api/asr',{method:'POST',body:fd}); |
| if(!r.ok){const e=await r.json();throw new Error(e.detail||'ASR error')} |
| const d=await r.json(); |
| if(!d.text){setState('','Could not detect speech');return} |
| addMsg('user',d.text);chatHistory.push({role:'user',content:d.text}); |
| await streamChat(); |
| }catch(e){setState('','');showToast(e.message)} |
| finally{$('#micBtn').disabled=false} |
| } |
| |
| async function sendText(){ |
| const t=$('#textInput').value.trim();if(!t)return; |
| $('#textInput').value='';addMsg('user',t);chatHistory.push({role:'user',content:t}); |
| $('#micBtn').disabled=true; |
| try{await streamChat()}catch(e){showToast(e.message)} |
| finally{$('#micBtn').disabled=false} |
| } |
| |
| async function streamChat(){ |
| setState('thinking','Thinking...'); |
| try{ |
| const r=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'}, |
| body:JSON.stringify({messages:chatHistory,language:outputLang,stream:true})}); |
| if(!r.ok){const e=await r.json();throw new Error(e.detail||'Chat failed')} |
| setState('speaking',''); |
| const el=addMsg('assistant','');const span=el.querySelector('.mt');let full=''; |
| const reader=r.body.getReader(),dec=new TextDecoder();let buf=''; |
| while(true){ |
| const{done,value}=await reader.read();if(done)break; |
| buf+=dec.decode(value,{stream:true});const lines=buf.split('\n');buf=lines.pop()||''; |
| for(const line of lines){ |
| if(!line.startsWith('data: '))continue;const d=line.slice(6).trim();if(d==='[DONE]')continue; |
| try{const p=JSON.parse(d);if(p.error){showToast(p.error.slice(0,80));return} |
| full+=p.choices?.[0]?.delta?.content||'';span.textContent=full;scroll()}catch(e){} |
| } |
| } |
| chatHistory.push({role:'assistant',content:full});setState('',''); |
| await autoTTS(full); |
| }catch(e){setState('','');throw e} |
| } |
| |
| // === Auto TTS === |
| async function autoTTS(text){ |
| setState('speaking','Synthesizing...'); |
| try{ |
| const r=await fetch('/api/tts',{method:'POST',headers:{'Content-Type':'application/json'}, |
| body:JSON.stringify({text,language:outputLang})}); |
| const ct=r.headers.get('content-type')||''; |
| if(ct.startsWith('audio/')){ |
| const blob=await r.blob();const url=URL.createObjectURL(blob);const a=new Audio(url); |
| setState('speaking','Speaking...'); |
| a.onended=()=>{setState('','');URL.revokeObjectURL(url)}; |
| a.onerror=()=>{setState('','');URL.revokeObjectURL(url)}; |
| await a.play(); |
| }else{ |
| const d=await r.json(); |
| if(d.status==='tts_not_configured'){setState('','TTS not configured β text only')} |
| else{setState('','');showToast(d.message?.slice(0,80)||'TTS error')} |
| } |
| }catch(e){setState('','');showToast('TTS: '+e.message)} |
| } |
| |
| // === UI helpers === |
| function addMsg(role,text){ |
| const c=$('#chatArea'),d=document.createElement('div');d.className='msg '+role; |
| d.innerHTML=`<div class="role">${role==='user'?'You':'AI'}</div><span class="mt">${esc(text)}</span>`; |
| c.appendChild(d);scroll();return d; |
| } |
| function scroll(){$('#chatArea').scrollTop=$('#chatArea').scrollHeight} |
| function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML} |
| function showToast(m){const t=$('#toast');t.textContent=m;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),3500)} |
| |
| // === Settings === |
| async function openSettings(){ |
| $('#modalBg').classList.add('open'); |
| try{ |
| const r=await fetch('/api/config');const c=await r.json(); |
| $('#cfgAsrMode').value=c.asr.mode||'api'; |
| $('#cfgAsrUrl').value=c.asr.base_url||''; |
| $('#cfgAsrKey').value='';$('#cfgAsrKey').placeholder=c.asr.api_key_masked||''; |
| $('#cfgAsrModel').value=c.asr.model||''; |
| $('#cfgLlmUrl').value=c.llm.base_url||''; |
| $('#cfgLlmKey').value='';$('#cfgLlmKey').placeholder=c.llm.api_key_masked||''; |
| $('#cfgLlmModel').value=c.llm.model||''; |
| $('#cfgTtsUrl').value=c.tts.base_url||''; |
| $('#cfgTtsKey').value='';$('#cfgTtsKey').placeholder=c.tts.api_key_masked||''; |
| $('#cfgTtsModel').value=c.tts.model||''; |
| $('#cfgTtsVoice').value=c.tts.voice||'default'; |
| updateBadges(c); |
| }catch(e){} |
| } |
| function closeSettings(){$('#modalBg').classList.remove('open')} |
| |
| async function saveSettings(){ |
| const b={},v=id=>$('#'+id).value.trim(); |
| b.asr_mode=$('#cfgAsrMode').value; |
| b.asr_base_url=v('cfgAsrUrl');if(v('cfgAsrKey'))b.asr_api_key=v('cfgAsrKey'); |
| b.asr_model=v('cfgAsrModel'); |
| if(v('cfgLlmUrl'))b.llm_base_url=v('cfgLlmUrl');if(v('cfgLlmKey'))b.llm_api_key=v('cfgLlmKey'); |
| if(v('cfgLlmModel'))b.llm_model=v('cfgLlmModel'); |
| b.tts_base_url=v('cfgTtsUrl')||'';if(v('cfgTtsKey'))b.tts_api_key=v('cfgTtsKey'); |
| b.tts_model=v('cfgTtsModel')||'';b.tts_voice=v('cfgTtsVoice')||'default'; |
| try{ |
| await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)}); |
| showToast('Saved');closeSettings(); |
| const r=await fetch('/api/config');updateBadges(await r.json()); |
| }catch(e){showToast('Save failed')} |
| } |
| |
| function updateBadges(c){ |
| setBadge('#asrBadge',c.asr_configured,'ASR'); |
| setBadge('#llmBadge',!!(c.llm&&c.llm.api_key_masked!=='(not set)'),'LLM'); |
| setBadge('#ttsBadge',c.tts_configured,'TTS'); |
| } |
| function setBadge(sel,on,label){const b=$(sel);b.className='badge '+(on?'on':'off');b.textContent=label} |
| |
| // === Init === |
| (async()=>{ |
| await initLanguages(); |
| try{const r=await fetch('/api/config');updateBadges(await r.json())}catch(e){} |
| })(); |
| </script> |
| </body> |
| </html>""" |
|
|
|
|
| @app.get("/", response_class=HTMLResponse) |
| async def serve_frontend(): |
| return HTML_PAGE |
|
|
|
|
| |
| |
| |
|
|
| if __name__ == "__main__": |
| cfg = CONFIG.get("app", {}) |
| host = os.environ.get("HOST", cfg.get("host", "0.0.0.0")) |
| port = int(os.environ.get("PORT", cfg.get("port", 8765))) |
|
|
| print(f""" |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| β Voice AI Demo β Fully Configurable β |
| β ββββββββββββββββββββββββββββββββββββββββββββββββββ β |
| β Server: http://{host}:{port:<5} β |
| β β |
| β All services (ASR/LLM/TTS) are freely configurable β |
| β via OpenAI-compatible endpoints. β |
| β β |
| β Works with: β |
| β * Alibaba Cloud Model Studio (Bailian / DashScope) β |
| β β’ OpenAI β |
| β β’ Any OpenAI-compatible API β |
| β β |
| β Press Ctrl+C to stop β |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| """) |
|
|
| uvicorn.run(app, host=host, port=port, log_level="info") |
|
|