feat: sync full-stack Docker runtime and UI

This commit is contained in:
“ydy0615”
2026-06-27 22:22:42 +08:00
parent 356108e792
commit 23bfca51e4
50 changed files with 2750 additions and 2120 deletions
+297 -254
View File
@@ -1,174 +1,133 @@
"""OpenAI-compatible TTS/ASR adapter bound to the shared LLM API."""
from __future__ import annotations
import asyncio
import base64
import logging
import os
import tempfile
from typing import Optional
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
import time
from typing import Any, Optional
import httpx
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
logger = logging.getLogger(__name__)
try:
import numpy as np # type: ignore
except Exception as exc: # pragma: no cover
logger.debug("numpy import failed: %s", exc)
np = None # type: ignore
try:
import torch # type: ignore
except Exception as exc: # pragma: no cover
logger.debug("torch import failed: %s", exc)
torch = None # type: ignore
def _int_env(name: str, default: int) -> int:
try:
return max(1, int(os.getenv(name, str(default))))
except (TypeError, ValueError):
return default
try:
from qwen_tts import Qwen3TTSModel # type: ignore
except Exception as exc: # pragma: no cover
logger.debug("qwen_tts import failed: %s", exc)
Qwen3TTSModel = None # type: ignore
try:
from faster_whisper import WhisperModel # type: ignore
except Exception as exc: # pragma: no cover
logger.debug("faster_whisper import failed: %s", exc)
WhisperModel = None # type: ignore
try:
from modelscope import snapshot_download # type: ignore
except Exception as exc: # pragma: no cover
logger.debug("modelscope import failed: %s", exc)
snapshot_download = None # type: ignore
meta_router = APIRouter()
generation_router = APIRouter()
MODEL_ID_HF = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
MODEL_ID_MS = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
ASR_MODEL_ID = os.getenv("ASR_MODEL_ID", "small")
ASR_COMPUTE_TYPE = os.getenv("ASR_COMPUTE_TYPE", "int8")
LLM_BASE_URL = (os.getenv("LLM_BASE_URL", "https://api.openai.com/v1/") or "").strip().rstrip("/")
LLM_API_KEY = (os.getenv("LLM_API_KEY", "") or "").strip()
_tts_model: Optional["Qwen3TTSModel"] = None
_asr_model: Optional["WhisperModel"] = None
DEFAULT_TTS_MODEL_ID = "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit"
DEFAULT_ASR_MODEL_ID = "Qwen3-ASR-0.6B-8bit"
DEFAULT_TTS_INSTRUCTIONS = (
os.getenv("TTS_DEFAULT_INSTRUCTIONS", "A clear, natural voice speaking Mandarin Chinese.")
or "A clear, natural voice speaking Mandarin Chinese."
).strip()
TTS_MODEL_ID = (os.getenv("TTS_MODEL_ID", DEFAULT_TTS_MODEL_ID) or DEFAULT_TTS_MODEL_ID).strip()
ASR_MODEL_ID = (os.getenv("ASR_MODEL_ID", DEFAULT_ASR_MODEL_ID) or DEFAULT_ASR_MODEL_ID).strip()
TTS_MAX_TEXT_CHARS = _int_env("TTS_ASR_MAX_TEXT_CHARS", 4096)
ASR_MAX_AUDIO_BYTES = _int_env("ASR_MAX_AUDIO_BYTES", 100 * 1024 * 1024)
TTS_TIMEOUT_SECONDS = _int_env("TTS_ASR_TTS_TIMEOUT_SECONDS", 180)
ASR_TIMEOUT_SECONDS = _int_env("TTS_ASR_ASR_TIMEOUT_SECONDS", 300)
HEALTHCHECK_TIMEOUT_SECONDS = _int_env("TTS_ASR_HEALTHCHECK_TIMEOUT_SECONDS", 5)
SPEECH_MAX_CONNECTIONS = _int_env("TTS_ASR_MAX_CONNECTIONS", 16)
SPEECH_MAX_KEEPALIVE_CONNECTIONS = _int_env("TTS_ASR_MAX_KEEPALIVE_CONNECTIONS", 8)
_httpx_client: Optional[httpx.AsyncClient] = None
_httpx_client_lock = asyncio.Lock()
def _get_device_map() -> str:
if torch is None:
return "cpu"
if torch.cuda.is_available():
return "cuda"
try:
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps"
except Exception as exc: # pragma: no cover
logger.debug("MPS check failed: %s", exc)
return "cpu"
def _download_tts_model_from_modelscope() -> Optional[str]:
if snapshot_download is None:
def _read_uint16(data: bytes, offset: int) -> Optional[int]:
if len(data) < offset + 2:
return None
cache_dir = os.path.join(os.path.dirname(__file__), "models")
os.makedirs(cache_dir, exist_ok=True)
try:
return snapshot_download(MODEL_ID_MS, cache_dir=cache_dir, revision="master")
except Exception as exc: # pragma: no cover
logger.warning("ModelScope TTS download failed: %s", exc)
return int.from_bytes(data[offset : offset + 2], "little", signed=False)
def _read_uint32(data: bytes, offset: int) -> Optional[int]:
if len(data) < offset + 4:
return None
return int.from_bytes(data[offset : offset + 4], "little", signed=False)
def _ensure_tts_model() -> "Qwen3TTSModel":
global _tts_model
if _tts_model is not None:
return _tts_model
if np is None or torch is None or Qwen3TTSModel is None:
raise RuntimeError("TTS 依赖未安装完整")
def _parse_wav_duration_ms(audio_bytes: bytes) -> int:
if len(audio_bytes) < 44 or audio_bytes[:4] != b"RIFF" or audio_bytes[8:12] != b"WAVE":
return 0
device_map = _get_device_map()
dtype = torch.float16 if device_map != "cpu" else torch.float32
data_size = 0
byte_rate = 0
offset = 12
model_path = _download_tts_model_from_modelscope()
last_error = None
while offset + 8 <= len(audio_bytes):
chunk_id = audio_bytes[offset : offset + 4]
chunk_size = _read_uint32(audio_bytes, offset + 4)
if chunk_size is None:
break
chunk_start = offset + 8
chunk_end = min(chunk_start + chunk_size, len(audio_bytes))
for candidate in [model_path, MODEL_ID_HF]:
if not candidate:
continue
try:
_tts_model = Qwen3TTSModel.from_pretrained( # type: ignore
candidate,
device_map=device_map,
dtype=dtype,
)
return _tts_model
except Exception as exc:
last_error = exc
logger.warning("TTS model load failed from %s: %s", candidate, exc)
if chunk_id == b"fmt ":
audio_format = _read_uint16(audio_bytes, chunk_start)
channels = _read_uint16(audio_bytes, chunk_start + 2)
sample_rate = _read_uint32(audio_bytes, chunk_start + 4)
bits_per_sample = _read_uint16(audio_bytes, chunk_start + 14)
if audio_format == 1 and channels and sample_rate and bits_per_sample:
byte_rate = int(sample_rate * channels * bits_per_sample // 8)
raise RuntimeError(f"TTS 模型加载失败: {last_error}") from last_error
if chunk_id == b"data":
data_size = chunk_size
offset = chunk_end + (chunk_end - chunk_start) % 2
if data_size and byte_rate:
return max(0, int(data_size * 1000 / byte_rate))
return 0
def _ensure_asr_model() -> "WhisperModel":
global _asr_model
if _asr_model is not None:
return _asr_model
if WhisperModel is None:
raise RuntimeError("faster-whisper 未安装")
device = "cuda" if _get_device_map() == "cuda" else "cpu"
compute_type = ASR_COMPUTE_TYPE if device == "cpu" else "float16"
_asr_model = WhisperModel(ASR_MODEL_ID, device=device, compute_type=compute_type)
return _asr_model
def _duration_from_audio_bytes(audio_bytes: bytes) -> int:
return _parse_wav_duration_ms(audio_bytes)
async def _warmup_tts():
await asyncio.to_thread(_ensure_tts_model)
def _audio_bytes_to_base64(audio_bytes: bytes) -> str:
return base64.b64encode(audio_bytes).decode("utf-8")
async def _warmup_asr():
await asyncio.to_thread(_ensure_asr_model)
def _normalize_tts_text(text: str) -> str:
value = (text or "").strip()
if not value:
raise HTTPException(status_code=400, detail="TTS 文本为空")
if len(value) > TTS_MAX_TEXT_CHARS:
raise HTTPException(status_code=400, detail=f"TTS 文本过长,超过限制 {TTS_MAX_TEXT_CHARS} 个字符")
return value
class TTSRequest(BaseModel):
text: str
instruct: str = ""
speaker: str = "Vivian"
format: str = "wav"
def _normalize_output_format(output_format: str) -> str:
value = (output_format or "wav").strip().lower()
if value not in {"wav", "mp3"}:
raise HTTPException(status_code=400, detail="不支持的 TTS 输出格式")
return value
class TTSResponse(BaseModel):
audio_base64: str
format: str
duration_ms: int
class ASRRequest(BaseModel):
audio_base64: str
language: Optional[str] = "zh-CN"
class ASRResponse(BaseModel):
text: str
language: Optional[str] = None
class ModelStatus(BaseModel):
tts_loaded: bool
asr_loaded: bool = False
device: str
def _normalize_language(language: Optional[str]) -> Optional[str]:
def _normalize_asr_language(language: Optional[str]) -> Optional[str]:
if not language:
return None
value = language.strip().lower()
if value in {"auto", ""}:
value = str(language).strip().lower()
if value in {"", "auto"}:
return None
mapping = {
"zh-cn": "zh",
"zh-hans": "zh",
"zh-tw": "zh",
"en-us": "en",
"ja-jp": "ja",
"ko-kr": "ko",
@@ -176,38 +135,138 @@ def _normalize_language(language: Optional[str]) -> Optional[str]:
return mapping.get(value, value.split("-")[0])
@meta_router.get("/status", response_model=ModelStatus)
async def get_status():
return ModelStatus(
tts_loaded=_tts_model is not None,
asr_loaded=_asr_model is not None,
device=_get_device_map(),
)
def _speech_headers() -> dict[str, str]:
headers = {"Accept": "*/*"}
if LLM_API_KEY:
headers["Authorization"] = f"Bearer {LLM_API_KEY}"
headers["X-API-Key"] = LLM_API_KEY
return headers
@meta_router.get("/config")
async def get_config():
def _raise_http_error(response: httpx.Response, operation: str) -> None:
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
body = (exc.response.text or "").strip()[:1000]
detail = f"{operation} 请求失败 HTTP {exc.response.status_code}"
if body:
detail = f"{detail}: {body}"
raise HTTPException(status_code=exc.response.status_code, detail=detail) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=f"{operation} 请求失败: {exc}") from exc
def _tts_timeout() -> httpx.Timeout:
return httpx.Timeout(TTS_TIMEOUT_SECONDS, connect=5.0)
def _asr_timeout() -> httpx.Timeout:
return httpx.Timeout(ASR_TIMEOUT_SECONDS, connect=5.0)
def _extract_upstream_request_id(response: httpx.Response) -> str:
for header_name in ("x-request-id", "request-id", "openai-request-id"):
value = (response.headers.get(header_name) or "").strip()
if value:
return value
return ""
async def _get_speech_client() -> httpx.AsyncClient:
global _httpx_client
if _httpx_client is None or getattr(_httpx_client, "is_closed", False):
limits = httpx.Limits(
max_connections=SPEECH_MAX_CONNECTIONS,
max_keepalive_connections=max(1, SPEECH_MAX_KEEPALIVE_CONNECTIONS),
)
async with _httpx_client_lock:
if _httpx_client is None or getattr(_httpx_client, "is_closed", False):
_httpx_client = httpx.AsyncClient(
base_url=LLM_BASE_URL,
timeout=_tts_timeout(),
headers=_speech_headers(),
follow_redirects=True,
limits=limits,
)
return _httpx_client
async def close_speech_client() -> None:
global _httpx_client
if _httpx_client is not None and not getattr(_httpx_client, "is_closed", False):
await _httpx_client.aclose()
_httpx_client = None
async def _call_tts_api(text: str, instruct: str = "", speaker: str = "Vivian", output_format: str = "wav") -> dict[str, Any]:
normalized_text = _normalize_tts_text(text)
normalized_format = _normalize_output_format(output_format)
client = await _get_speech_client()
payload: dict[str, Any] = {
"model": TTS_MODEL_ID,
"input": normalized_text,
"response_format": normalized_format,
"voice": speaker or "Vivian",
}
payload["instructions"] = (instruct or "").strip() or DEFAULT_TTS_INSTRUCTIONS
started_at = time.perf_counter()
response = await client.post("audio/speech", json=payload, timeout=_tts_timeout(), headers=_speech_headers())
elapsed_ms = int((time.perf_counter() - started_at) * 1000)
_raise_http_error(response, "TTS")
audio_bytes = response.content
if not audio_bytes:
raise HTTPException(status_code=502, detail="TTS API 返回音频为空")
return {
"model": {
"tts": MODEL_ID_MS,
"asr": ASR_MODEL_ID,
},
"device": _get_device_map(),
"status": {
"tts_loaded": _tts_model is not None,
"asr_loaded": _asr_model is not None,
}
"audio_bytes": audio_bytes,
"request_ms": elapsed_ms,
"upstream_request_id": _extract_upstream_request_id(response),
}
@meta_router.post("/warmup")
async def warmup_models():
await _warmup_tts()
await _warmup_asr()
async def _call_asr_api(audio_bytes: bytes, language: Optional[str] = "zh-CN") -> dict[str, Any]:
if not audio_bytes:
raise HTTPException(status_code=400, detail="ASR 音频内容为空")
if len(audio_bytes) > ASR_MAX_AUDIO_BYTES:
raise HTTPException(status_code=400, detail=f"ASR 音频过大,超过限制 {ASR_MAX_AUDIO_BYTES} 字节")
normalized_language = _normalize_asr_language(language)
client = await _get_speech_client()
files = {"file": ("audio.wav", audio_bytes, "audio/wav")}
data = {"model": ASR_MODEL_ID}
if normalized_language:
data["language"] = normalized_language
started_at = time.perf_counter()
response = await client.post(
"audio/transcriptions",
files=files,
data=data,
timeout=_asr_timeout(),
headers=_speech_headers(),
)
elapsed_ms = int((time.perf_counter() - started_at) * 1000)
_raise_http_error(response, "ASR")
try:
result = response.json()
except ValueError as exc:
raise HTTPException(status_code=502, detail="ASR API 返回非 JSON 数据") from exc
if not isinstance(result, dict):
raise HTTPException(status_code=502, detail="ASR API 返回结构异常")
text = str(result.get("text", "") or "").strip()
if not text:
raise HTTPException(status_code=422, detail="ASR API 返回结果为空")
detected_language = result.get("language") or normalized_language or "auto"
return {
"tts_warmup": _tts_model is not None,
"asr_warmup": _asr_model is not None,
"device": _get_device_map(),
"text": text,
"language": str(detected_language),
"request_ms": elapsed_ms,
"upstream_request_id": _extract_upstream_request_id(response),
}
@@ -216,113 +275,97 @@ async def generate_tts_response(
instruct: str = "",
speaker: str = "Vivian",
output_format: str = "wav",
) -> TTSResponse:
del speaker
del output_format
if np is None:
raise HTTPException(status_code=501, detail="numpy 未安装,TTS 功能不可用")
try:
model = _ensure_tts_model()
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
try:
wavs, sample_rate = await asyncio.to_thread(
model.generate_voice_design, # type: ignore
text=text,
language="Chinese",
instruct=instruct or "",
)
except Exception as exc:
logger.exception("TTS inference failed")
raise HTTPException(status_code=500, detail=f"TTS 推理失败: {exc}")
wav_data = wavs[0] if isinstance(wavs, (list, tuple)) else wavs
if hasattr(wav_data, "cpu"):
wav_data = wav_data.cpu().numpy()
wav_data = np.asarray(wav_data, dtype=np.float32)
tmp_path = None
try:
import soundfile as sf # type: ignore
fd, tmp_path = tempfile.mkstemp(suffix=".wav")
os.close(fd)
sf.write(tmp_path, wav_data, sample_rate)
with open(tmp_path, "rb") as handle:
audio_bytes = handle.read()
except Exception as exc:
logger.exception("TTS audio encode failed")
raise HTTPException(status_code=500, detail=f"音频编码失败: {exc}")
finally:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
duration_ms = int(len(wav_data) / sample_rate * 1000) if sample_rate > 0 else 0
return TTSResponse(
audio_base64=base64.b64encode(audio_bytes).decode("utf-8"),
format="wav",
duration_ms=duration_ms,
) -> dict[str, Any]:
result = await _call_tts_api(
text=text,
instruct=instruct or "",
speaker=speaker or "Vivian",
output_format=output_format or "wav",
)
audio_bytes = bytes(result["audio_bytes"])
return {
"audio_base64": _audio_bytes_to_base64(audio_bytes),
"format": _normalize_output_format(output_format or "wav"),
"duration_ms": _duration_from_audio_bytes(audio_bytes),
"audio_bytes": len(audio_bytes),
"text_chars": len(_normalize_tts_text(text)),
"speaker": speaker or "Vivian",
"model": TTS_MODEL_ID,
"request_ms": int(result.get("request_ms", 0) or 0),
"upstream_request_id": str(result.get("upstream_request_id", "") or ""),
}
async def generate_asr_response(audio_bytes: bytes, language: Optional[str] = "zh-CN") -> ASRResponse:
if not audio_bytes:
raise HTTPException(status_code=400, detail="音频内容为空")
try:
model = _ensure_asr_model()
except Exception as exc:
raise HTTPException(status_code=500, detail=f"ASR 模型加载失败: {exc}")
normalized_language = _normalize_language(language)
tmp_path = None
try:
fd, tmp_path = tempfile.mkstemp(suffix=".wav")
os.close(fd)
with open(tmp_path, "wb") as handle:
handle.write(audio_bytes)
segments, info = await asyncio.to_thread(
model.transcribe,
tmp_path,
language=normalized_language,
vad_filter=True,
beam_size=5,
)
text = "".join(segment.text for segment in segments).strip()
if not text:
raise RuntimeError("ASR 返回结果为空")
detected_language = getattr(info, "language", normalized_language or "unknown")
return ASRResponse(text=text, language=str(detected_language))
except HTTPException:
raise
except Exception as exc:
logger.exception("ASR inference failed")
raise HTTPException(status_code=500, detail=f"ASR 推理失败: {exc}")
finally:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
async def generate_asr_response(audio_bytes: bytes, language: Optional[str] = "zh-CN") -> dict[str, Any]:
result = await _call_asr_api(bytes(audio_bytes or b""), language or "zh-CN")
return {
"text": str(result["text"]),
"language": str(result["language"]),
"audio_bytes": len(audio_bytes or b""),
"model": ASR_MODEL_ID,
"request_ms": int(result.get("request_ms", 0) or 0),
"upstream_request_id": str(result.get("upstream_request_id", "") or ""),
}
@generation_router.post("/tts", response_model=TTSResponse)
async def tts_endpoint(req: TTSRequest):
return await generate_tts_response(
text=req.text,
instruct=req.instruct or "",
speaker=req.speaker,
output_format=req.format,
)
class TTSResponse(BaseModel):
audio_base64: str = ""
format: str = "wav"
duration_ms: int = 0
audio_bytes: int = 0
text_chars: int = 0
speaker: str = "Vivian"
model: str = TTS_MODEL_ID
request_ms: int = 0
upstream_request_id: str = ""
@generation_router.post("/asr", response_model=ASRResponse)
async def asr_endpoint(req: ASRRequest):
audio_bytes = base64.b64decode(req.audio_base64)
return await generate_asr_response(audio_bytes, req.language if req.language else None)
class ASRResponse(BaseModel):
text: str = ""
language: Optional[str] = None
audio_bytes: int = 0
model: str = ASR_MODEL_ID
request_ms: int = 0
upstream_request_id: str = ""
def register_tts_asr_routes(app, include_generation_routes: bool = True):
class ModelStatus(BaseModel):
llm_url: str
tts_model: str
asr_model: str
status: dict[str, Any]
def _status_payload() -> dict[str, Any]:
return {
"llm_url": LLM_BASE_URL or "",
"tts_model": TTS_MODEL_ID,
"asr_model": ASR_MODEL_ID,
"status": {
"api_configured": bool(LLM_BASE_URL),
"api_key_configured": bool(LLM_API_KEY),
"tts_model": TTS_MODEL_ID,
"asr_model": ASR_MODEL_ID,
"tts_timeout_seconds": TTS_TIMEOUT_SECONDS,
"asr_timeout_seconds": ASR_TIMEOUT_SECONDS,
"healthcheck_timeout_seconds": HEALTHCHECK_TIMEOUT_SECONDS,
"max_connections": SPEECH_MAX_CONNECTIONS,
"keepalive_connections": max(1, SPEECH_MAX_KEEPALIVE_CONNECTIONS),
"max_tts_text_chars": TTS_MAX_TEXT_CHARS,
"max_asr_audio_bytes": ASR_MAX_AUDIO_BYTES,
},
}
@meta_router.get("/status", response_model=ModelStatus)
async def get_status():
return _status_payload()
@meta_router.get("/config")
async def get_config():
return _status_payload()
def register_tts_asr_routes(app) -> None:
app.include_router(meta_router, prefix="/v1/tts-asr")
if include_generation_routes:
app.include_router(generation_router, prefix="/v1/tts-asr")