"""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")