372 lines
12 KiB
Python
372 lines
12 KiB
Python
"""OpenAI-compatible TTS/ASR adapter bound to the shared LLM API."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import logging
|
|
import os
|
|
import time
|
|
from typing import Any, Optional
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _int_env(name: str, default: int) -> int:
|
|
try:
|
|
return max(1, int(os.getenv(name, str(default))))
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
meta_router = APIRouter()
|
|
|
|
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()
|
|
|
|
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 _read_uint16(data: bytes, offset: int) -> Optional[int]:
|
|
if len(data) < offset + 2:
|
|
return None
|
|
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 _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
|
|
|
|
data_size = 0
|
|
byte_rate = 0
|
|
offset = 12
|
|
|
|
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))
|
|
|
|
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)
|
|
|
|
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 _duration_from_audio_bytes(audio_bytes: bytes) -> int:
|
|
return _parse_wav_duration_ms(audio_bytes)
|
|
|
|
|
|
def _audio_bytes_to_base64(audio_bytes: bytes) -> str:
|
|
return base64.b64encode(audio_bytes).decode("utf-8")
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
def _normalize_asr_language(language: Optional[str]) -> Optional[str]:
|
|
if not language:
|
|
return None
|
|
value = str(language).strip().lower()
|
|
if value in {"", "auto"}:
|
|
return None
|
|
mapping = {
|
|
"zh-cn": "zh",
|
|
"zh-hans": "zh",
|
|
"en-us": "en",
|
|
"ja-jp": "ja",
|
|
"ko-kr": "ko",
|
|
}
|
|
return mapping.get(value, value.split("-")[0])
|
|
|
|
|
|
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
|
|
|
|
|
|
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 {
|
|
"audio_bytes": audio_bytes,
|
|
"request_ms": elapsed_ms,
|
|
"upstream_request_id": _extract_upstream_request_id(response),
|
|
}
|
|
|
|
|
|
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 {
|
|
"text": text,
|
|
"language": str(detected_language),
|
|
"request_ms": elapsed_ms,
|
|
"upstream_request_id": _extract_upstream_request_id(response),
|
|
}
|
|
|
|
|
|
async def generate_tts_response(
|
|
text: str,
|
|
instruct: str = "",
|
|
speaker: str = "Vivian",
|
|
output_format: str = "wav",
|
|
) -> 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") -> 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 ""),
|
|
}
|
|
|
|
|
|
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 = ""
|
|
|
|
|
|
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 = ""
|
|
|
|
|
|
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")
|