feat: sync full-stack Docker runtime and UI
This commit is contained in:
+297
-254
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user