Upload 5 files
Browse files- .gitignore +7 -0
- Dockerfile +19 -0
- app.py +1042 -0
- config.json +42 -0
- 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
|