PlotweaverModel commited on
Commit
67c4dbc
Β·
verified Β·
1 Parent(s): 52aa48a

Upload 5 files

Browse files
Files changed (5) hide show
  1. .gitignore +7 -0
  2. Dockerfile +19 -0
  3. app.py +1042 -0
  4. config.json +42 -0
  5. requirements.txt +5 -0
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ .DS_Store
6
+ .env
7
+ server.log
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Hugging Face Spaces best practice: run as a non-root user (uid 1000)
4
+ RUN useradd -m -u 1000 user
5
+ USER user
6
+ ENV PATH="/home/user/.local/bin:$PATH"
7
+
8
+ WORKDIR /home/user/app
9
+
10
+ COPY --chown=user requirements.txt .
11
+ RUN pip install --no-cache-dir --user -r requirements.txt
12
+
13
+ COPY --chown=user . .
14
+
15
+ # The app reads PORT from the environment; keep it aligned with app_port in README.
16
+ ENV PORT=8765
17
+ EXPOSE 8765
18
+
19
+ CMD ["python3", "app.py"]
app.py ADDED
@@ -0,0 +1,1042 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Voice AI Demo β€” Fully Configurable ASR / LLM / TTS
4
+ ===================================================
5
+ A real-time voice AI demo where ALL three services (ASR, LLM, TTS) are
6
+ freely configurable via OpenAI-compatible endpoints.
7
+
8
+ Compatible with:
9
+ - Alibaba Cloud Model Studio (Bailian / DashScope)
10
+ - OpenAI
11
+ - Any OpenAI-compatible API
12
+
13
+ Architecture:
14
+ User Speech β†’ ASR (/audio/transcriptions) β†’ LLM (chat) β†’ TTS (/audio/speech) β†’ Playback
15
+
16
+ Languages are defined in config.json β€” add, remove, or edit freely.
17
+ """
18
+
19
+ import json
20
+ import os
21
+ import sys
22
+ import tempfile
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ import httpx
27
+ import uvicorn
28
+ from fastapi import FastAPI, HTTPException, UploadFile, File, Form
29
+ from fastapi.middleware.cors import CORSMiddleware
30
+ from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
31
+ from pydantic import BaseModel
32
+
33
+ # ============================================================
34
+ # Configuration
35
+ # ============================================================
36
+
37
+ CONFIG_PATH = Path(__file__).parent / "config.json"
38
+
39
+
40
+ def _env(*names):
41
+ """Return the first non-empty environment variable among `names`."""
42
+ for n in names:
43
+ v = os.environ.get(n)
44
+ if v:
45
+ return v.strip()
46
+ return None
47
+
48
+
49
+ def _strip_placeholder(value):
50
+ """Treat template placeholders like <your-api-key> as empty/unset."""
51
+ if isinstance(value, str) and "<" in value and ">" in value:
52
+ return ""
53
+ return value
54
+
55
+
56
+ def apply_env_overrides(cfg):
57
+ """Let environment variables (e.g. Hugging Face Space Secrets) override
58
+ config.json. Env vars always win, so real keys never need to be committed.
59
+
60
+ Recognised vars:
61
+ ASR_BASE_URL / ASR_API_KEY / ASR_MODEL / ASR_MODE
62
+ LLM_BASE_URL / LLM_API_KEY / LLM_MODEL
63
+ TTS_BASE_URL / TTS_API_KEY / TTS_MODEL / TTS_VOICE
64
+ DASHSCOPE_API_KEY (or MAAS_API_KEY) β€” shared fallback key for all three
65
+ """
66
+ shared_key = _env("DASHSCOPE_API_KEY", "MAAS_API_KEY")
67
+
68
+ # First, scrub committed placeholders so the app doesn't treat them as real.
69
+ for svc in ("asr", "llm", "tts"):
70
+ s = cfg.setdefault(svc, {})
71
+ s["base_url"] = _strip_placeholder(s.get("base_url", ""))
72
+ s["api_key"] = _strip_placeholder(s.get("api_key", ""))
73
+
74
+ asr = cfg["asr"]
75
+ asr["base_url"] = _env("ASR_BASE_URL") or asr.get("base_url", "")
76
+ asr["api_key"] = _env("ASR_API_KEY") or shared_key or asr.get("api_key", "")
77
+ asr["model"] = _env("ASR_MODEL") or asr.get("model", "")
78
+ asr["mode"] = _env("ASR_MODE") or asr.get("mode", "api")
79
+
80
+ llm = cfg["llm"]
81
+ llm["base_url"] = _env("LLM_BASE_URL") or llm.get("base_url", "")
82
+ llm["api_key"] = _env("LLM_API_KEY") or shared_key or llm.get("api_key", "")
83
+ llm["model"] = _env("LLM_MODEL") or llm.get("model", "")
84
+
85
+ tts = cfg["tts"]
86
+ tts["base_url"] = _env("TTS_BASE_URL") or tts.get("base_url", "")
87
+ tts["api_key"] = _env("TTS_API_KEY") or shared_key or tts.get("api_key", "")
88
+ tts["model"] = _env("TTS_MODEL") or tts.get("model", "")
89
+ tts["voice"] = _env("TTS_VOICE") or tts.get("voice", "default")
90
+
91
+ return cfg
92
+
93
+
94
+ def load_config():
95
+ with open(CONFIG_PATH, "r") as f:
96
+ cfg = json.load(f)
97
+ return apply_env_overrides(cfg)
98
+
99
+
100
+ def save_config(cfg):
101
+ """Persist config to disk. On read-only / ephemeral filesystems (some Space
102
+ setups), persistence is skipped β€” the in-memory config still applies for the
103
+ session, and secrets supplied via env vars are re-applied on every reload."""
104
+ try:
105
+ with open(CONFIG_PATH, "w") as f:
106
+ json.dump(cfg, f, indent=2, ensure_ascii=False)
107
+ except OSError as e:
108
+ print(f"[config] Could not persist config.json (continuing in-memory): {e}")
109
+
110
+
111
+ CONFIG = load_config()
112
+
113
+ # ============================================================
114
+ # App Setup
115
+ # ============================================================
116
+
117
+ app = FastAPI(title="Voice AI Demo")
118
+ app.add_middleware(
119
+ CORSMiddleware,
120
+ allow_origins=["*"],
121
+ allow_methods=["*"],
122
+ allow_headers=["*"],
123
+ )
124
+
125
+
126
+ # ============================================================
127
+ # API Models
128
+ # ============================================================
129
+
130
+ class ChatRequest(BaseModel):
131
+ messages: list[dict]
132
+ language: str = ""
133
+ stream: bool = True
134
+
135
+
136
+ class TTSRequest(BaseModel):
137
+ text: str
138
+ language: str = ""
139
+ voice: Optional[str] = None
140
+
141
+
142
+ class ConfigUpdate(BaseModel):
143
+ asr_mode: Optional[str] = None # "api" or "local"
144
+ asr_base_url: Optional[str] = None
145
+ asr_api_key: Optional[str] = None
146
+ asr_model: Optional[str] = None
147
+ llm_base_url: Optional[str] = None
148
+ llm_api_key: Optional[str] = None
149
+ llm_model: Optional[str] = None
150
+ tts_base_url: Optional[str] = None
151
+ tts_api_key: Optional[str] = None
152
+ tts_model: Optional[str] = None
153
+ tts_voice: Optional[str] = None
154
+
155
+
156
+ # ============================================================
157
+ # Helper: resolve current language config
158
+ # ============================================================
159
+
160
+ def get_lang_config(lang_id: str) -> dict:
161
+ """Find language config by id. Falls back to first language."""
162
+ for lc in CONFIG.get("languages", []):
163
+ if lc["id"] == lang_id:
164
+ return lc
165
+ langs = CONFIG.get("languages", [])
166
+ return langs[0] if langs else {}
167
+
168
+
169
+ # ============================================================
170
+ # API Routes β€” ASR
171
+ # ============================================================
172
+
173
+ _local_whisper = None
174
+
175
+
176
+ @app.post("/api/asr")
177
+ async def transcribe_audio(audio: UploadFile = File(...), language: str = Form(default="auto")):
178
+ """
179
+ Transcribe audio. Two modes:
180
+ - mode=api β†’ POST to OpenAI-compatible /audio/transcriptions
181
+ - mode=local β†’ use local Whisper model (requires faster-whisper or openai-whisper)
182
+ """
183
+ asr_cfg = CONFIG.get("asr", {})
184
+ mode = asr_cfg.get("mode", "api")
185
+
186
+ audio_bytes = await audio.read()
187
+
188
+ if mode == "api":
189
+ return await _asr_via_api(audio_bytes, audio.filename, language, asr_cfg)
190
+ else:
191
+ return await _asr_via_local(audio_bytes, audio.filename, language, asr_cfg)
192
+
193
+
194
+ async def _asr_via_api(audio_bytes: bytes, filename: str, language: str, asr_cfg: dict):
195
+ """Call ASR API. Supports DashScope MaaS (qwen3-asr-flash) and OpenAI-compatible."""
196
+ import base64 as b64
197
+
198
+ base_url = asr_cfg.get("base_url", "").rstrip("/")
199
+ api_key = asr_cfg.get("api_key", "")
200
+ model = asr_cfg.get("model", "whisper-large-v3")
201
+
202
+ if not base_url or not api_key:
203
+ raise HTTPException(status_code=400, detail="ASR API not configured. Set ASR Base URL and API Key in Settings.")
204
+
205
+ headers = {"Authorization": f"Bearer {api_key}"}
206
+
207
+ try:
208
+ async with httpx.AsyncClient(timeout=30.0) as client:
209
+ if "maas.aliyuncs.com" in base_url:
210
+ # === DashScope MaaS: qwen3-asr-flash via multimodal endpoint ===
211
+ if not model or model == "whisper-large-v3":
212
+ model = "qwen3-asr-flash"
213
+
214
+ # Determine audio MIME type
215
+ suffix = os.path.splitext(filename or "audio.webm")[1] or ".webm"
216
+ mime_map = {".webm": "audio/webm", ".wav": "audio/wav", ".mp3": "audio/mpeg",
217
+ ".ogg": "audio/ogg", ".m4a": "audio/mp4", ".flac": "audio/flac"}
218
+ mime = mime_map.get(suffix, "audio/webm")
219
+
220
+ # Encode audio as base64 data URI
221
+ audio_b64 = b64.b64encode(audio_bytes).decode()
222
+ data_uri = f"data:{mime};base64,{audio_b64}"
223
+
224
+ payload = {
225
+ "model": model,
226
+ "input": {
227
+ "messages": [
228
+ {
229
+ "role": "user",
230
+ "content": [{"audio": data_uri}]
231
+ }
232
+ ]
233
+ }
234
+ }
235
+ if language and language != "auto":
236
+ payload["parameters"] = {"asr_options": {"language": language}}
237
+
238
+ from urllib.parse import urlparse
239
+ parsed = urlparse(base_url)
240
+ asr_endpoint = f"{parsed.scheme}://{parsed.netloc}/api/v1/services/aigc/multimodal-generation/generation"
241
+
242
+ resp = await client.post(asr_endpoint, headers={**headers, "Content-Type": "application/json"}, json=payload)
243
+ if resp.status_code != 200:
244
+ raise HTTPException(status_code=resp.status_code, detail=f"ASR API error: {resp.text[:500]}")
245
+ result = resp.json()
246
+ # Extract text from multimodal response
247
+ choices = result.get("output", {}).get("choices", [])
248
+ if choices:
249
+ content = choices[0].get("message", {}).get("content", [])
250
+ if isinstance(content, list):
251
+ text = " ".join(c.get("text", "") for c in content if "text" in c)
252
+ elif isinstance(content, str):
253
+ text = content
254
+ else:
255
+ text = ""
256
+ else:
257
+ text = result.get("output", {}).get("text", "")
258
+
259
+ return {
260
+ "text": text.strip(),
261
+ "language": language if language != "auto" else "auto",
262
+ "confidence": 0.0,
263
+ }
264
+ else:
265
+ # === Standard OpenAI-compatible /audio/transcriptions ===
266
+ suffix = os.path.splitext(filename or "audio.webm")[1] or ".webm"
267
+ fname = f"audio{suffix}"
268
+ files = {"file": (fname, audio_bytes, "audio/webm")}
269
+ data = {"model": model}
270
+ if language and language != "auto":
271
+ data["language"] = language
272
+
273
+ resp = await client.post(
274
+ f"{base_url}/audio/transcriptions",
275
+ headers=headers,
276
+ files=files,
277
+ data=data,
278
+ )
279
+ if resp.status_code != 200:
280
+ raise HTTPException(status_code=resp.status_code, detail=f"ASR API error: {resp.text[:300]}")
281
+ result = resp.json()
282
+ return {
283
+ "text": result.get("text", "").strip(),
284
+ "language": result.get("language", language if language != "auto" else "unknown"),
285
+ "confidence": 0.0,
286
+ }
287
+ except httpx.ConnectError:
288
+ raise HTTPException(status_code=502, detail=f"Cannot connect to ASR endpoint: {base_url}")
289
+ except Exception as e:
290
+ if isinstance(e, HTTPException):
291
+ raise
292
+ raise HTTPException(status_code=500, detail=f"ASR failed: {str(e)}")
293
+
294
+
295
+ async def _asr_via_local(audio_bytes: bytes, filename: str, language: str, asr_cfg: dict):
296
+ """Use a local Whisper model."""
297
+ global _local_whisper
298
+ suffix = os.path.splitext(filename or "audio.webm")[1] or ".webm"
299
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
300
+ tmp.write(audio_bytes)
301
+ tmp_path = tmp.name
302
+
303
+ try:
304
+ if _local_whisper is None:
305
+ model_size = asr_cfg.get("model_size", "large-v3")
306
+ try:
307
+ from faster_whisper import WhisperModel
308
+ print(f"[ASR] Loading faster-whisper {model_size}...")
309
+ _local_whisper = WhisperModel(model_size, device="auto", compute_type="int8")
310
+ except ImportError:
311
+ import whisper
312
+ print(f"[ASR] Loading openai-whisper...")
313
+ _local_whisper = whisper.load_model("large")
314
+
315
+ lang_hint = None if language == "auto" else language
316
+
317
+ if hasattr(_local_whisper, 'transcribe'):
318
+ segments, info = _local_whisper.transcribe(tmp_path, language=lang_hint, beam_size=5)
319
+ text = " ".join(seg.text.strip() for seg in segments)
320
+ return {"text": text.strip(), "language": info.language, "confidence": getattr(info, 'language_probability', 0.0)}
321
+ else:
322
+ result = _local_whisper.transcribe(tmp_path, language=lang_hint)
323
+ return {"text": result["text"].strip(), "language": result.get("language", "unknown"), "confidence": 0.0}
324
+ finally:
325
+ os.unlink(tmp_path)
326
+
327
+
328
+ # ============================================================
329
+ # API Routes β€” LLM Chat (OpenAI-compatible)
330
+ # ============================================================
331
+
332
+ @app.post("/api/chat")
333
+ async def chat(req: ChatRequest):
334
+ """Chat with any OpenAI-compatible LLM endpoint. Supports streaming."""
335
+ llm_cfg = CONFIG.get("llm", {})
336
+ base_url = llm_cfg.get("base_url", "").rstrip("/")
337
+ api_key = llm_cfg.get("api_key", "")
338
+ model = llm_cfg.get("model", "qwen-plus")
339
+
340
+ if not base_url or not api_key:
341
+ raise HTTPException(status_code=400, detail="LLM not configured. Set Base URL and API Key in Settings.")
342
+
343
+ # Build messages with system prompt from language config
344
+ lang_cfg = get_lang_config(req.language)
345
+ system_prompt = lang_cfg.get("system_prompt", "You are a helpful assistant. Keep responses concise (2-4 sentences) as they will be spoken aloud.")
346
+ messages = [{"role": "system", "content": system_prompt}] + req.messages
347
+
348
+ payload = {
349
+ "model": model,
350
+ "messages": messages,
351
+ "stream": req.stream,
352
+ "max_tokens": llm_cfg.get("max_tokens", 512),
353
+ "temperature": llm_cfg.get("temperature", 0.7),
354
+ }
355
+ headers = {
356
+ "Authorization": f"Bearer {api_key}",
357
+ "Content-Type": "application/json",
358
+ }
359
+
360
+ if req.stream:
361
+ async def generate():
362
+ async with httpx.AsyncClient(timeout=60.0) as client:
363
+ async with client.stream("POST", f"{base_url}/chat/completions", headers=headers, json=payload) as resp:
364
+ if resp.status_code != 200:
365
+ body = await resp.aread()
366
+ yield f"data: {json.dumps({'error': body.decode()[:500]})}\n\n"
367
+ return
368
+ async for line in resp.aiter_lines():
369
+ if line.startswith("data: "):
370
+ yield line + "\n\n"
371
+ return StreamingResponse(generate(), media_type="text/event-stream")
372
+ else:
373
+ async with httpx.AsyncClient(timeout=60.0) as client:
374
+ resp = await client.post(f"{base_url}/chat/completions", headers=headers, json=payload)
375
+ if resp.status_code != 200:
376
+ raise HTTPException(status_code=resp.status_code, detail=resp.text[:500])
377
+ return resp.json()
378
+
379
+
380
+ # ============================================================
381
+ # API Routes β€” TTS (DashScope native + OpenAI-compatible)
382
+ # ============================================================
383
+
384
+ def _is_dashscope_maas(url: str) -> bool:
385
+ """Check if the URL points to a DashScope MaaS instance (native TTS format)."""
386
+ return "maas.aliyuncs.com" in url
387
+
388
+
389
+ def _dashscope_tts_url(base_url: str) -> str:
390
+ """Derive the DashScope native TTS path from the MaaS base URL."""
391
+ from urllib.parse import urlparse
392
+ parsed = urlparse(base_url)
393
+ return f"{parsed.scheme}://{parsed.netloc}/api/v1/services/aigc/multimodal-generation/generation"
394
+
395
+
396
+ # Map language id β†’ language_type for DashScope TTS
397
+ _LANG_TYPE_MAP = {
398
+ "english": "English", "en": "English",
399
+ "chinese": "Chinese", "zh": "Chinese",
400
+ "japanese": "Japanese", "ja": "Japanese",
401
+ "spanish": "Spanish", "es": "Spanish",
402
+ "yoruba": "Auto", "yo": "Auto",
403
+ }
404
+
405
+
406
+ @app.post("/api/tts")
407
+ async def text_to_speech(req: TTSRequest):
408
+ """
409
+ Synthesize speech. Supports two formats:
410
+ - DashScope MaaS (auto-detected): native /multimodal-generation/generation
411
+ - Other endpoints: OpenAI-compatible POST /audio/speech
412
+
413
+ Model resolution order:
414
+ 1. Language-specific tts_model from languages config
415
+ 2. Global tts.model
416
+ 3. Default: qwen3-tts-flash (DashScope) or tts-1 (OpenAI)
417
+ """
418
+ tts_cfg = CONFIG.get("tts", {})
419
+ base_url = tts_cfg.get("base_url", "").strip().rstrip("/")
420
+ api_key = tts_cfg.get("api_key", "").strip()
421
+
422
+ # Resolve model: per-language override or global
423
+ lang_cfg = get_lang_config(req.language)
424
+ model = lang_cfg.get("tts_model", "").strip() or tts_cfg.get("model", "").strip()
425
+
426
+ if not base_url or not api_key:
427
+ return JSONResponse(status_code=200, content={
428
+ "status": "tts_not_configured",
429
+ "message": "TTS not configured. Set TTS Base URL and API Key in Settings.",
430
+ "text": req.text,
431
+ })
432
+
433
+ voice = req.voice or tts_cfg.get("voice", "default")
434
+ speed = tts_cfg.get("speed", 1.0)
435
+
436
+ try:
437
+ if _is_dashscope_maas(base_url):
438
+ # === DashScope Native TTS Format ===
439
+ tts_endpoint = _dashscope_tts_url(base_url)
440
+ if not model:
441
+ model = "qwen3-tts-flash"
442
+ lang_type = _LANG_TYPE_MAP.get(req.language, "Auto")
443
+
444
+ payload = {
445
+ "model": model,
446
+ "input": {
447
+ "text": req.text,
448
+ "voice": voice if voice != "default" else "Cherry",
449
+ "language_type": lang_type,
450
+ }
451
+ }
452
+ async with httpx.AsyncClient(timeout=30.0) as client:
453
+ resp = await client.post(
454
+ tts_endpoint,
455
+ headers={
456
+ "Authorization": f"Bearer {api_key}",
457
+ "Content-Type": "application/json",
458
+ },
459
+ json=payload,
460
+ )
461
+ if resp.status_code != 200:
462
+ return JSONResponse(status_code=200, content={
463
+ "status": "tts_error",
464
+ "message": f"TTS {resp.status_code}: {resp.text[:500]}",
465
+ "text": req.text,
466
+ })
467
+
468
+ data = resp.json()
469
+ audio_url = data.get("output", {}).get("audio", {}).get("url")
470
+ if not audio_url:
471
+ # Check for base64 data (SSE streaming mode)
472
+ audio_data = data.get("output", {}).get("audio", {}).get("data")
473
+ if audio_data:
474
+ import base64 as b64
475
+ return Response(content=b64.b64decode(audio_data), media_type="audio/wav")
476
+ return JSONResponse(status_code=200, content={
477
+ "status": "tts_error",
478
+ "message": "No audio in TTS response",
479
+ "text": req.text,
480
+ })
481
+
482
+ # Download audio from the temporary URL
483
+ audio_resp = await client.get(audio_url)
484
+ if audio_resp.status_code != 200:
485
+ return JSONResponse(status_code=200, content={
486
+ "status": "tts_error",
487
+ "message": f"Failed to download audio: {audio_resp.status_code}",
488
+ "text": req.text,
489
+ })
490
+ ct = audio_resp.headers.get("content-type", "audio/wav")
491
+ return Response(content=audio_resp.content, media_type=ct)
492
+ else:
493
+ # === OpenAI-compatible TTS Format ===
494
+ if not model:
495
+ model = "tts-1"
496
+ async with httpx.AsyncClient(timeout=30.0) as client:
497
+ resp = await client.post(
498
+ f"{base_url}/audio/speech",
499
+ headers={
500
+ "Authorization": f"Bearer {api_key}",
501
+ "Content-Type": "application/json",
502
+ },
503
+ json={
504
+ "model": model,
505
+ "input": req.text,
506
+ "voice": voice,
507
+ "speed": speed,
508
+ "response_format": "mp3",
509
+ },
510
+ )
511
+ if resp.status_code != 200:
512
+ return JSONResponse(status_code=200, content={
513
+ "status": "tts_error",
514
+ "message": f"TTS {resp.status_code}: {resp.text[:500]}",
515
+ "text": req.text,
516
+ })
517
+ return Response(
518
+ content=resp.content,
519
+ media_type=resp.headers.get("content-type", "audio/mpeg"),
520
+ )
521
+
522
+ except httpx.ConnectError:
523
+ return JSONResponse(status_code=200, content={
524
+ "status": "tts_connection_error",
525
+ "message": f"Cannot connect to TTS: {base_url}",
526
+ "text": req.text,
527
+ })
528
+ except Exception as e:
529
+ return JSONResponse(status_code=200, content={
530
+ "status": "tts_error",
531
+ "message": f"TTS error: {str(e)}",
532
+ "text": req.text,
533
+ })
534
+
535
+
536
+ # ============================================================
537
+ # API Routes β€” Config & Languages
538
+ # ============================================================
539
+
540
+ @app.get("/api/languages")
541
+ async def get_languages():
542
+ """Return configured languages for the UI switcher."""
543
+ return CONFIG.get("languages", [])
544
+
545
+
546
+ @app.get("/api/config")
547
+ async def get_config():
548
+ """Get current config (with keys masked)."""
549
+ cfg = load_config()
550
+
551
+ def mask(key_val):
552
+ if not key_val:
553
+ return "(not set)"
554
+ return "***" + key_val[-4:] if len(key_val) > 4 else "***"
555
+
556
+ return {
557
+ "asr": {
558
+ "mode": cfg.get("asr", {}).get("mode", "api"),
559
+ "base_url": cfg.get("asr", {}).get("base_url", ""),
560
+ "api_key_masked": mask(cfg.get("asr", {}).get("api_key", "")),
561
+ "model": cfg.get("asr", {}).get("model", ""),
562
+ },
563
+ "llm": {
564
+ "base_url": cfg.get("llm", {}).get("base_url", ""),
565
+ "api_key_masked": mask(cfg.get("llm", {}).get("api_key", "")),
566
+ "model": cfg.get("llm", {}).get("model", ""),
567
+ },
568
+ "tts": {
569
+ "base_url": cfg.get("tts", {}).get("base_url", ""),
570
+ "api_key_masked": mask(cfg.get("tts", {}).get("api_key", "")),
571
+ "model": cfg.get("tts", {}).get("model", ""),
572
+ "voice": cfg.get("tts", {}).get("voice", "default"),
573
+ },
574
+ "tts_configured": bool(cfg.get("tts", {}).get("base_url") and cfg.get("tts", {}).get("api_key")),
575
+ "asr_configured": bool(cfg.get("asr", {}).get("mode") == "local" or (cfg.get("asr", {}).get("base_url") and cfg.get("asr", {}).get("api_key"))),
576
+ }
577
+
578
+
579
+ @app.post("/api/config")
580
+ async def update_config(req: ConfigUpdate):
581
+ """Update config. Only updates provided fields."""
582
+ cfg = load_config()
583
+
584
+ # ASR
585
+ asr = cfg.setdefault("asr", {})
586
+ if req.asr_mode is not None:
587
+ asr["mode"] = req.asr_mode
588
+ if req.asr_base_url is not None:
589
+ asr["base_url"] = req.asr_base_url
590
+ if req.asr_api_key is not None:
591
+ asr["api_key"] = req.asr_api_key
592
+ if req.asr_model is not None:
593
+ asr["model"] = req.asr_model
594
+
595
+ # LLM
596
+ llm = cfg.setdefault("llm", {})
597
+ if req.llm_base_url is not None:
598
+ llm["base_url"] = req.llm_base_url
599
+ if req.llm_api_key is not None:
600
+ llm["api_key"] = req.llm_api_key
601
+ if req.llm_model is not None:
602
+ llm["model"] = req.llm_model
603
+
604
+ # TTS
605
+ tts = cfg.setdefault("tts", {})
606
+ if req.tts_base_url is not None:
607
+ tts["base_url"] = req.tts_base_url
608
+ if req.tts_api_key is not None:
609
+ tts["api_key"] = req.tts_api_key
610
+ if req.tts_model is not None:
611
+ tts["model"] = req.tts_model
612
+ if req.tts_voice is not None:
613
+ tts["voice"] = req.tts_voice
614
+
615
+ save_config(cfg)
616
+ global CONFIG
617
+ CONFIG = cfg
618
+ return {"status": "ok"}
619
+
620
+
621
+ # ============================================================
622
+ # Frontend β€” HTML
623
+ # ============================================================
624
+
625
+ HTML_PAGE = r"""<!DOCTYPE html>
626
+ <html lang="en">
627
+ <head>
628
+ <meta charset="UTF-8">
629
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
630
+ <title>Voice AI Demo</title>
631
+ <style>
632
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap');
633
+ *{margin:0;padding:0;box-sizing:border-box}
634
+ :root{--accent:#6C5CE7;--accent2:#00CEC9;--bg:#0a0a14;--surface:rgba(255,255,255,0.06);--text:#e8e8f0;--text2:#8888a8;--danger:#ff6b6b}
635
+ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);height:100vh;overflow:hidden;user-select:none}
636
+
637
+ .app{display:flex;flex-direction:column;height:100vh;position:relative}
638
+
639
+ .bg-gradient{position:fixed;inset:0;z-index:0;
640
+ background:radial-gradient(ellipse at 50% 30%,rgba(108,92,231,0.12) 0%,transparent 60%),
641
+ radial-gradient(ellipse at 80% 80%,rgba(0,206,201,0.06) 0%,transparent 40%)}
642
+
643
+ /* === Top Bar === */
644
+ .topbar{position:relative;z-index:10;display:flex;align-items:center;padding:16px 24px;gap:12px;flex-wrap:wrap}
645
+ .lang-switch{margin-left:auto;display:flex;gap:4px;background:var(--surface);border-radius:20px;padding:3px}
646
+ .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}
647
+ .lang-btn.active{background:var(--accent);color:#fff}
648
+ .status-badges{display:flex;gap:6px;align-items:center}
649
+ .badge{font-size:10px;padding:3px 10px;border-radius:12px;font-weight:600;letter-spacing:.3px}
650
+ .badge.off{background:rgba(255,107,107,0.15);color:var(--danger)}
651
+ .badge.on{background:rgba(0,206,201,0.15);color:var(--accent2)}
652
+ .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}
653
+ .gear:hover{background:rgba(255,255,255,0.12);color:var(--text)}
654
+ .gear svg{width:18px;height:18px}
655
+
656
+ /* === Center Stage === */
657
+ .stage{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;z-index:5}
658
+ .orb-wrap{position:relative;width:220px;height:220px;display:flex;align-items:center;justify-content:center}
659
+ .orb{width:120px;height:120px;border-radius:50%;position:relative;transition:all .5s cubic-bezier(.4,0,.2,1)}
660
+ .orb::before,.orb::after{content:'';position:absolute;inset:0;border-radius:50%;animation:orbPulse 3s ease-in-out infinite}
661
+ .orb::before{inset:-20px;border:1.5px solid rgba(108,92,231,0.2);animation-delay:.5s}
662
+ .orb::after{inset:-40px;border:1px solid rgba(108,92,231,0.1);animation-delay:1s}
663
+ .orb-inner{position:absolute;inset:0;border-radius:50%;
664
+ background:radial-gradient(circle at 35% 35%,rgba(108,92,231,0.9),rgba(0,206,201,0.6));
665
+ box-shadow:0 0 60px rgba(108,92,231,0.4),0 0 120px rgba(108,92,231,0.15);
666
+ animation:orbFloat 4s ease-in-out infinite}
667
+ .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)}
668
+ .orb-wrap.listening .orb::before{border-color:rgba(255,107,107,0.3);animation:orbRipple 1.2s ease-out infinite}
669
+ .orb-wrap.listening .orb::after{border-color:rgba(255,107,107,0.15);animation:orbRipple 1.2s ease-out infinite .4s}
670
+ .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}
671
+ .orb-wrap.thinking .orb::before{border-color:rgba(249,202,36,0.3);animation:orbSpin 3s linear infinite reverse}
672
+ .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)}
673
+ .orb-wrap.speaking .orb::before{border-color:rgba(0,206,201,0.3);animation:orbRipple 1.5s ease-out infinite}
674
+ .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}
675
+
676
+ /* === Transcript === */
677
+ .transcript{position:relative;z-index:5;width:100%;max-width:560px;margin:0 auto;padding:0 24px;flex-shrink:0}
678
+ .msg-scroll{max-height:35vh;overflow-y:auto;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,0.1) transparent;padding-bottom:8px}
679
+ .msg{padding:10px 16px;margin-bottom:8px;border-radius:12px;font-size:14px;line-height:1.6;animation:msgIn .4s ease}
680
+ .msg.user{background:rgba(108,92,231,0.15);border-left:3px solid var(--accent);color:#c8c0f8}
681
+ .msg.assistant{background:var(--surface);border-left:3px solid var(--accent2);color:#c0f0ee}
682
+ .msg .role{font-size:11px;font-weight:600;opacity:.5;margin-bottom:2px;text-transform:uppercase;letter-spacing:.5px}
683
+
684
+ /* === Bottom Bar === */
685
+ .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%}
686
+ .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}
687
+ .text-input::placeholder{color:var(--text2)}
688
+ .text-input:focus{border-color:var(--accent)}
689
+ .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;
690
+ background:linear-gradient(135deg,var(--accent),var(--accent2));box-shadow:0 4px 20px rgba(108,92,231,0.3)}
691
+ .mic-btn:hover{transform:scale(1.06);box-shadow:0 6px 28px rgba(108,92,231,0.4)}
692
+ .mic-btn:active{transform:scale(0.95)}
693
+ .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}
694
+ .mic-btn svg{width:24px;height:24px;fill:#fff}
695
+ .mic-btn:disabled{opacity:.4;cursor:not-allowed;transform:none}
696
+ .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}
697
+ .send-btn:hover{background:rgba(255,255,255,0.1);color:var(--text)}
698
+ .send-btn svg{width:20px;height:20px;fill:currentColor}
699
+
700
+ /* === Settings Modal === */
701
+ .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}
702
+ .modal-bg.open{display:flex}
703
+ .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)}
704
+ .modal h2{font-size:18px;font-weight:600;margin-bottom:20px}
705
+ .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)}
706
+ .modal h3.asr{color:#fd79a8}
707
+ .modal h3.llm{color:var(--accent)}
708
+ .modal h3.tts{color:var(--accent2)}
709
+ .field{margin-bottom:12px}
710
+ .field label{display:block;font-size:12px;font-weight:500;color:var(--text2);margin-bottom:4px}
711
+ .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}
712
+ .field input:focus,.field select:focus{border-color:var(--accent)}
713
+ .field input::placeholder{color:rgba(255,255,255,0.2)}
714
+ .field select{appearance:none;cursor:pointer}
715
+ .field select option{background:#1a1a2e;color:var(--text)}
716
+ .field .hint{font-size:11px;color:var(--text2);margin-top:3px}
717
+ .modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:24px}
718
+ .btn{padding:10px 22px;border-radius:8px;border:none;font-size:13px;font-weight:500;cursor:pointer;transition:all .2s;font-family:inherit}
719
+ .btn-p{background:var(--accent);color:#fff}
720
+ .btn-p:hover{background:#5a4bd6}
721
+ .btn-s{background:rgba(255,255,255,0.06);color:var(--text);border:1px solid rgba(255,255,255,0.1)}
722
+
723
+ /* === Toast === */
724
+ .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}
725
+ .toast.show{opacity:1}
726
+
727
+ @keyframes orbPulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.08);opacity:.7}}
728
+ @keyframes orbFloat{0%,100%{transform:translateY(0)}50%{transform:translateY(-6px)}}
729
+ @keyframes orbRipple{0%{transform:scale(1);opacity:.6}100%{transform:scale(1.6);opacity:0}}
730
+ @keyframes orbSpin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
731
+ @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)}}
732
+ @keyframes msgIn{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
733
+ </style>
734
+ </head>
735
+ <body>
736
+ <div class="bg-gradient"></div>
737
+ <div class="app">
738
+
739
+ <!-- Top Bar -->
740
+ <div class="topbar">
741
+ <div class="lang-switch" id="langSwitch"><!-- filled dynamically --></div>
742
+ <div class="status-badges">
743
+ <span class="badge off" id="asrBadge">ASR</span>
744
+ <span class="badge off" id="llmBadge">LLM</span>
745
+ <span class="badge off" id="ttsBadge">TTS</span>
746
+ </div>
747
+ <button class="gear" onclick="openSettings()" title="Settings">
748
+ <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>
749
+ </button>
750
+ </div>
751
+
752
+ <!-- Center Stage -->
753
+ <div class="stage">
754
+ <div class="orb-wrap" id="orbWrap">
755
+ <div class="orb"><div class="orb-inner"></div></div>
756
+ </div>
757
+ <div class="state-label" id="stateLabel">Tap mic or type to begin</div>
758
+ </div>
759
+
760
+ <!-- Transcript -->
761
+ <div class="transcript">
762
+ <div class="msg-scroll" id="chatArea"></div>
763
+ </div>
764
+
765
+ <!-- Bottom Input -->
766
+ <div class="bottombar">
767
+ <input class="text-input" id="textInput" placeholder="Type a message..." onkeydown="if(event.key==='Enter')sendText()">
768
+ <button class="mic-btn" id="micBtn" onclick="toggleRecord()">
769
+ <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>
770
+ </button>
771
+ <button class="send-btn" onclick="sendText()">
772
+ <svg viewBox="0 0 24 24"><path d="M2.01,21L23,12L2.01,3L2,10l15,2l-15,2z"/></svg>
773
+ </button>
774
+ </div>
775
+ </div>
776
+
777
+ <!-- Settings Modal -->
778
+ <div class="modal-bg" id="modalBg">
779
+ <div class="modal">
780
+ <h2>Settings</h2>
781
+
782
+ <!-- ASR -->
783
+ <h3 class="asr">ASR β€” Speech Recognition</h3>
784
+ <div class="field">
785
+ <label>Mode</label>
786
+ <select id="cfgAsrMode">
787
+ <option value="api">Remote API (OpenAI-compatible)</option>
788
+ <option value="local">Local Whisper</option>
789
+ </select>
790
+ <div class="hint">API mode: DashScope MaaS auto-detected, or any OpenAI-compatible /audio/transcriptions. Local: Whisper on this machine.</div>
791
+ </div>
792
+ <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>
793
+ <div class="field"><label>API Key</label><input type="password" id="cfgAsrKey" placeholder="sk-..."></div>
794
+ <div class="field"><label>Model</label><input id="cfgAsrModel" placeholder="qwen3-asr-flash"></div>
795
+
796
+ <!-- LLM -->
797
+ <h3 class="llm">LLM β€” Language Model</h3>
798
+ <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>
799
+ <div class="field"><label>API Key</label><input type="password" id="cfgLlmKey" placeholder="sk-..."></div>
800
+ <div class="field"><label>Model</label><input id="cfgLlmModel" placeholder="qwen-plus"></div>
801
+
802
+ <!-- TTS -->
803
+ <h3 class="tts">TTS β€” Text to Speech</h3>
804
+ <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>
805
+ <div class="field"><label>API Key</label><input type="password" id="cfgTtsKey" placeholder="sk-..."></div>
806
+ <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>
807
+ <div class="field"><label>Voice</label><input id="cfgTtsVoice" placeholder="default"></div>
808
+
809
+ <div class="modal-actions">
810
+ <button class="btn btn-s" onclick="closeSettings()">Cancel</button>
811
+ <button class="btn btn-p" onclick="saveSettings()">Save</button>
812
+ </div>
813
+ </div>
814
+ </div>
815
+
816
+ <div class="toast" id="toast"></div>
817
+
818
+ <script>
819
+ let currentLang='',isRecording=false,mediaRecorder=null,audioChunks=[],chatHistory=[];
820
+ let languages=[];
821
+ const $=s=>document.querySelector(s),$$=s=>document.querySelectorAll(s);
822
+
823
+ function setState(s,label){
824
+ const o=$('#orbWrap');o.className='orb-wrap'+(s?' '+s:'');
825
+ $('#stateLabel').textContent=label||'';
826
+ }
827
+
828
+ // === Dynamic Language Switcher ===
829
+ async function initLanguages(){
830
+ try{
831
+ const r=await fetch('/api/languages');languages=await r.json();
832
+ }catch(e){languages=[{id:'default',label:'Default'}]}
833
+ if(!languages.length)languages=[{id:'default',label:'Default'}];
834
+ currentLang=languages[0].id;
835
+ renderLangSwitch();
836
+ }
837
+
838
+ function renderLangSwitch(){
839
+ const wrap=$('#langSwitch');wrap.innerHTML='';
840
+ languages.forEach(l=>{
841
+ const btn=document.createElement('button');
842
+ btn.className='lang-btn'+(l.id===currentLang?' active':'');
843
+ btn.dataset.lang=l.id;
844
+ btn.textContent=l.label;
845
+ btn.onclick=()=>switchLang(l.id);
846
+ wrap.appendChild(btn);
847
+ });
848
+ }
849
+
850
+ function switchLang(id){
851
+ currentLang=id;
852
+ $$('.lang-btn').forEach(b=>b.classList.toggle('active',b.dataset.lang===id));
853
+ chatHistory=[];$('#chatArea').innerHTML='';
854
+ setState('','Tap mic or type to begin');
855
+ }
856
+
857
+ // === Recording ===
858
+ async function toggleRecord(){isRecording?stopRec():startRec()}
859
+ async function startRec(){
860
+ try{
861
+ const s=await navigator.mediaDevices.getUserMedia({audio:true});
862
+ mediaRecorder=new MediaRecorder(s,{mimeType:'audio/webm;codecs=opus'});
863
+ audioChunks=[];
864
+ mediaRecorder.ondataavailable=e=>{if(e.data.size>0)audioChunks.push(e.data)};
865
+ mediaRecorder.onstop=async()=>{const b=new Blob(audioChunks,{type:'audio/webm'});s.getTracks().forEach(t=>t.stop());await processAudio(b)};
866
+ mediaRecorder.start();isRecording=true;
867
+ $('#micBtn').classList.add('recording');setState('listening','Listening...');
868
+ }catch(e){showToast('Microphone denied')}
869
+ }
870
+ function stopRec(){
871
+ if(mediaRecorder&&mediaRecorder.state!=='inactive')mediaRecorder.stop();
872
+ isRecording=false;$('#micBtn').classList.remove('recording');
873
+ }
874
+
875
+ // === ASR β†’ Chat β†’ TTS ===
876
+ async function processAudio(blob){
877
+ $('#micBtn').disabled=true;
878
+ try{
879
+ setState('thinking','Transcribing...');
880
+ // Get asr_lang hint from current language config
881
+ const langCfg=languages.find(l=>l.id===currentLang)||{};
882
+ const asrLang=langCfg.asr_lang||'auto';
883
+ const fd=new FormData();fd.append('audio',blob,'rec.webm');fd.append('language',asrLang||'auto');
884
+ const r=await fetch('/api/asr',{method:'POST',body:fd});
885
+ if(!r.ok){const e=await r.json();throw new Error(e.detail||'ASR error')}
886
+ const d=await r.json();
887
+ if(!d.text){setState('','Could not detect speech');return}
888
+ addMsg('user',d.text);chatHistory.push({role:'user',content:d.text});
889
+ await streamChat();
890
+ }catch(e){setState('','');showToast(e.message)}
891
+ finally{$('#micBtn').disabled=false}
892
+ }
893
+
894
+ async function sendText(){
895
+ const t=$('#textInput').value.trim();if(!t)return;
896
+ $('#textInput').value='';addMsg('user',t);chatHistory.push({role:'user',content:t});
897
+ $('#micBtn').disabled=true;
898
+ try{await streamChat()}catch(e){showToast(e.message)}
899
+ finally{$('#micBtn').disabled=false}
900
+ }
901
+
902
+ async function streamChat(){
903
+ setState('thinking','Thinking...');
904
+ try{
905
+ const r=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},
906
+ body:JSON.stringify({messages:chatHistory,language:currentLang,stream:true})});
907
+ if(!r.ok){const e=await r.json();throw new Error(e.detail||'Chat failed')}
908
+ setState('speaking','');
909
+ const el=addMsg('assistant','');const span=el.querySelector('.mt');let full='';
910
+ const reader=r.body.getReader(),dec=new TextDecoder();let buf='';
911
+ while(true){
912
+ const{done,value}=await reader.read();if(done)break;
913
+ buf+=dec.decode(value,{stream:true});const lines=buf.split('\n');buf=lines.pop()||'';
914
+ for(const line of lines){
915
+ if(!line.startsWith('data: '))continue;const d=line.slice(6).trim();if(d==='[DONE]')continue;
916
+ try{const p=JSON.parse(d);if(p.error){showToast(p.error.slice(0,80));return}
917
+ full+=p.choices?.[0]?.delta?.content||'';span.textContent=full;scroll()}catch(e){}
918
+ }
919
+ }
920
+ chatHistory.push({role:'assistant',content:full});setState('','');
921
+ await autoTTS(full);
922
+ }catch(e){setState('','');throw e}
923
+ }
924
+
925
+ // === Auto TTS ===
926
+ async function autoTTS(text){
927
+ setState('speaking','Synthesizing...');
928
+ try{
929
+ const r=await fetch('/api/tts',{method:'POST',headers:{'Content-Type':'application/json'},
930
+ body:JSON.stringify({text,language:currentLang})});
931
+ const ct=r.headers.get('content-type')||'';
932
+ if(ct.startsWith('audio/')){
933
+ const blob=await r.blob();const url=URL.createObjectURL(blob);const a=new Audio(url);
934
+ setState('speaking','Speaking...');
935
+ a.onended=()=>{setState('','');URL.revokeObjectURL(url)};
936
+ a.onerror=()=>{setState('','');URL.revokeObjectURL(url)};
937
+ await a.play();
938
+ }else{
939
+ const d=await r.json();
940
+ if(d.status==='tts_not_configured'){setState('','TTS not configured β€” text only')}
941
+ else{setState('','');showToast(d.message?.slice(0,80)||'TTS error')}
942
+ }
943
+ }catch(e){setState('','');showToast('TTS: '+e.message)}
944
+ }
945
+
946
+ // === UI helpers ===
947
+ function addMsg(role,text){
948
+ const c=$('#chatArea'),d=document.createElement('div');d.className='msg '+role;
949
+ d.innerHTML=`<div class="role">${role==='user'?'You':'AI'}</div><span class="mt">${esc(text)}</span>`;
950
+ c.appendChild(d);scroll();return d;
951
+ }
952
+ function scroll(){$('#chatArea').scrollTop=$('#chatArea').scrollHeight}
953
+ function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
954
+ function showToast(m){const t=$('#toast');t.textContent=m;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),3500)}
955
+
956
+ // === Settings ===
957
+ async function openSettings(){
958
+ $('#modalBg').classList.add('open');
959
+ try{
960
+ const r=await fetch('/api/config');const c=await r.json();
961
+ $('#cfgAsrMode').value=c.asr.mode||'api';
962
+ $('#cfgAsrUrl').value=c.asr.base_url||'';
963
+ $('#cfgAsrKey').value='';$('#cfgAsrKey').placeholder=c.asr.api_key_masked||'';
964
+ $('#cfgAsrModel').value=c.asr.model||'';
965
+ $('#cfgLlmUrl').value=c.llm.base_url||'';
966
+ $('#cfgLlmKey').value='';$('#cfgLlmKey').placeholder=c.llm.api_key_masked||'';
967
+ $('#cfgLlmModel').value=c.llm.model||'';
968
+ $('#cfgTtsUrl').value=c.tts.base_url||'';
969
+ $('#cfgTtsKey').value='';$('#cfgTtsKey').placeholder=c.tts.api_key_masked||'';
970
+ $('#cfgTtsModel').value=c.tts.model||'';
971
+ $('#cfgTtsVoice').value=c.tts.voice||'default';
972
+ updateBadges(c);
973
+ }catch(e){}
974
+ }
975
+ function closeSettings(){$('#modalBg').classList.remove('open')}
976
+
977
+ async function saveSettings(){
978
+ const b={},v=id=>$('#'+id).value.trim();
979
+ b.asr_mode=$('#cfgAsrMode').value;
980
+ b.asr_base_url=v('cfgAsrUrl');if(v('cfgAsrKey'))b.asr_api_key=v('cfgAsrKey');
981
+ b.asr_model=v('cfgAsrModel');
982
+ if(v('cfgLlmUrl'))b.llm_base_url=v('cfgLlmUrl');if(v('cfgLlmKey'))b.llm_api_key=v('cfgLlmKey');
983
+ if(v('cfgLlmModel'))b.llm_model=v('cfgLlmModel');
984
+ b.tts_base_url=v('cfgTtsUrl')||'';if(v('cfgTtsKey'))b.tts_api_key=v('cfgTtsKey');
985
+ b.tts_model=v('cfgTtsModel')||'';b.tts_voice=v('cfgTtsVoice')||'default';
986
+ try{
987
+ await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)});
988
+ showToast('Saved');closeSettings();
989
+ const r=await fetch('/api/config');updateBadges(await r.json());
990
+ }catch(e){showToast('Save failed')}
991
+ }
992
+
993
+ function updateBadges(c){
994
+ setBadge('#asrBadge',c.asr_configured,'ASR');
995
+ setBadge('#llmBadge',!!(c.llm&&c.llm.api_key_masked!=='(not set)'),'LLM');
996
+ setBadge('#ttsBadge',c.tts_configured,'TTS');
997
+ }
998
+ function setBadge(sel,on,label){const b=$(sel);b.className='badge '+(on?'on':'off');b.textContent=label}
999
+
1000
+ // === Init ===
1001
+ (async()=>{
1002
+ await initLanguages();
1003
+ try{const r=await fetch('/api/config');updateBadges(await r.json())}catch(e){}
1004
+ })();
1005
+ </script>
1006
+ </body>
1007
+ </html>"""
1008
+
1009
+
1010
+ @app.get("/", response_class=HTMLResponse)
1011
+ async def serve_frontend():
1012
+ return HTML_PAGE
1013
+
1014
+
1015
+ # ============================================================
1016
+ # Entry Point
1017
+ # ============================================================
1018
+
1019
+ if __name__ == "__main__":
1020
+ cfg = CONFIG.get("app", {})
1021
+ host = os.environ.get("HOST", cfg.get("host", "0.0.0.0"))
1022
+ port = int(os.environ.get("PORT", cfg.get("port", 8765)))
1023
+
1024
+ print(f"""
1025
+ ╔══════════════════════════════════════════════════════╗
1026
+ β•‘ Voice AI Demo β€” Fully Configurable β•‘
1027
+ β•‘ ────────────────────────────────────────────────── β•‘
1028
+ β•‘ Server: http://{host}:{port:<5} β•‘
1029
+ β•‘ β•‘
1030
+ β•‘ All services (ASR/LLM/TTS) are freely configurable β•‘
1031
+ β•‘ via OpenAI-compatible endpoints. β•‘
1032
+ β•‘ β•‘
1033
+ β•‘ Works with: β•‘
1034
+ β•‘ * Alibaba Cloud Model Studio (Bailian / DashScope) β•‘
1035
+ β•‘ β€’ OpenAI β•‘
1036
+ β•‘ β€’ Any OpenAI-compatible API β•‘
1037
+ β•‘ β•‘
1038
+ β•‘ Press Ctrl+C to stop β•‘
1039
+ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
1040
+ """)
1041
+
1042
+ uvicorn.run(app, host=host, port=port, log_level="info")
config.json ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "languages": [
3
+ {
4
+ "id": "english",
5
+ "label": "English",
6
+ "asr_lang": null,
7
+ "system_prompt": "You are a helpful and friendly AI assistant. Be warm, natural, and keep responses concise (2-4 sentences max) since they will be spoken aloud. Speak naturally as if having a real conversation.",
8
+ "tts_model": ""
9
+ },
10
+ {
11
+ "id": "yoruba",
12
+ "label": "Yoruba",
13
+ "asr_lang": "yo",
14
+ "system_prompt": "You are a helpful AI assistant that converses in Yoruba. Understand the user's Yoruba input, respond in Yoruba naturally. Keep responses concise (2-4 sentences max) since they will be spoken aloud. Be warm and culturally appropriate.",
15
+ "tts_model": ""
16
+ }
17
+ ],
18
+ "asr": {
19
+ "mode": "api",
20
+ "base_url": "",
21
+ "api_key": "sk-secret9999",
22
+ "model": "qwen3-asr-flash"
23
+ },
24
+ "llm": {
25
+ "base_url": "",
26
+ "api_key": "sk-secret9999",
27
+ "model": "qwen-plus",
28
+ "max_tokens": 512,
29
+ "temperature": 0.7
30
+ },
31
+ "tts": {
32
+ "base_url": "",
33
+ "api_key": "sk-secret9999",
34
+ "model": "qwen3-tts-flash",
35
+ "voice": "Serena",
36
+ "speed": 1.0
37
+ },
38
+ "app": {
39
+ "host": "0.0.0.0",
40
+ "port": 8765
41
+ }
42
+ }
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn==0.30.6
3
+ httpx==0.27.2
4
+ python-multipart==0.0.12
5
+ pydantic>=2.0