231 lines
8.3 KiB
Python
231 lines
8.3 KiB
Python
"""yuanqi 本地 ASR 适配器 — ``POST /v1/audio/transcriptions`` (multipart)。
|
||
|
||
与 MiMo 走 ``/chat/completions`` + ``input_audio`` 不同,元炁本地推理引擎
|
||
(yuanqi)对外暴露的是 OpenAI Whisper 兼容的 multipart 端点::
|
||
|
||
POST {base_url}/v1/audio/transcriptions
|
||
Content-Type: multipart/form-data
|
||
Authorization: Bearer {api_key}
|
||
|
||
file: <audio bytes>
|
||
language: zh (可选)
|
||
|
||
响应::
|
||
|
||
{"text": "转写后的文本"}
|
||
|
||
模型实际为 FunASR SenseVoiceSmall,转写文本已在 yuanqi 侧剥离了
|
||
``<|zh|><|NEUTRAL|><|Speech|><|withitn|>`` 等内部标签,此处无需二次清洗。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import time
|
||
|
||
import httpx
|
||
|
||
from app.ai_provider.audio.base import (
|
||
ASRProtocol,
|
||
ASRRequest,
|
||
ASRResponse,
|
||
AudioAdapterBase,
|
||
AudioServiceError,
|
||
)
|
||
from app.core.logging import logger
|
||
|
||
# multipart 使用的 MIME 映射(与 yuanqi _SUPPORTED_FORMATS 白名单对齐)
|
||
_MIME_MAP = {
|
||
"wav": "audio/wav",
|
||
"mp3": "audio/mpeg",
|
||
"m4a": "audio/mp4",
|
||
"flac": "audio/flac",
|
||
"ogg": "audio/ogg",
|
||
"opus": "audio/opus",
|
||
"aac": "audio/aac",
|
||
"webm": "audio/webm",
|
||
}
|
||
|
||
# yuanqi ASR 路径前缀(与 src/endpoints/asr.py 的 router 前缀一致)
|
||
_ASR_PATH = "/v1/audio/transcriptions"
|
||
|
||
# ASR 缓存:默认最多 128 条,TTL 1 小时(与 MiMo ASR 适配器一致)
|
||
_ASR_CACHE_MAX_SIZE = 128
|
||
_ASR_CACHE_TTL_SECONDS = 3600
|
||
|
||
|
||
class _ASRCacheEntry:
|
||
__slots__ = ("text", "stored_at")
|
||
|
||
def __init__(self, text: str) -> None:
|
||
self.text = text
|
||
self.stored_at = time.monotonic()
|
||
|
||
|
||
_asr_cache: dict[str, _ASRCacheEntry] = {}
|
||
|
||
|
||
def _asr_cache_key(model: str, audio_data: bytes, language: str, fmt: str) -> str:
|
||
"""生成 ASR 缓存键:SHA256(model + audio_bytes + language + format)。"""
|
||
raw = f"{model}\0{language}\0{fmt}\0"
|
||
raw_bytes = raw.encode() + audio_data
|
||
return hashlib.sha256(raw_bytes).hexdigest()
|
||
|
||
|
||
def _asr_cache_get(key: str) -> str | None:
|
||
entry = _asr_cache.get(key)
|
||
if entry is None:
|
||
return None
|
||
if time.monotonic() - entry.stored_at > _ASR_CACHE_TTL_SECONDS:
|
||
del _asr_cache[key]
|
||
return None
|
||
return entry.text
|
||
|
||
|
||
def _asr_cache_set(key: str, text: str) -> None:
|
||
if len(_asr_cache) >= _ASR_CACHE_MAX_SIZE:
|
||
sorted_keys = sorted(_asr_cache.keys(), key=lambda k: _asr_cache[k].stored_at)
|
||
for old_key in sorted_keys[: _ASR_CACHE_MAX_SIZE // 2]:
|
||
del _asr_cache[old_key]
|
||
_asr_cache[key] = _ASRCacheEntry(text)
|
||
|
||
|
||
class YuanqiASRWhisper(AudioAdapterBase, ASRProtocol):
|
||
"""yuanqi 本地 ASR 适配器,走 OpenAI Whisper 兼容的 multipart 端点。
|
||
|
||
将音频字节以 ``multipart/form-data`` 的 ``file`` 字段上传到 yuanqi 的
|
||
``/v1/audio/transcriptions``,从 JSON 响应中提取转写文本。
|
||
"""
|
||
|
||
_adapter_type = "asr"
|
||
_default_timeout = 300
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
base_url: str,
|
||
api_key: str,
|
||
model: str = "SenseVoiceSmall",
|
||
request_timeout_seconds: int = 300,
|
||
) -> None:
|
||
self._base_url = base_url.rstrip("/")
|
||
self._api_key = api_key
|
||
self._model = model
|
||
self._timeout = request_timeout_seconds
|
||
self._client: httpx.AsyncClient | None = None
|
||
|
||
# ── 客户端 ───────────────────────────────────────────────────────────
|
||
|
||
@property
|
||
def _http(self) -> httpx.AsyncClient:
|
||
if self._client is None or self._client.is_closed:
|
||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
|
||
return self._client
|
||
|
||
async def close(self) -> None:
|
||
if self._client is not None and not self._client.is_closed:
|
||
await self._client.aclose()
|
||
|
||
# ── ASRProtocol ──────────────────────────────────────────────────────
|
||
|
||
async def transcribe(self, request: ASRRequest) -> ASRResponse:
|
||
"""通过 ``POST /v1/audio/transcriptions`` 转写音频。"""
|
||
# ── 缓存查找 ──────────────────────────────────────────────────
|
||
cache_key = _asr_cache_key(
|
||
self._model, request.audio_data, request.language or "auto", request.format
|
||
)
|
||
cached = _asr_cache_get(cache_key)
|
||
if cached is not None:
|
||
logger.info("audio_asr.cache_hit", model=self._model, text_length=len(cached))
|
||
return ASRResponse(text=cached)
|
||
|
||
# ── 构造 multipart 请求 ───────────────────────────────────────
|
||
mime = _MIME_MAP.get(request.format, "audio/wav")
|
||
filename = f"audio.{request.format}"
|
||
files = {"file": (filename, request.audio_data, mime)}
|
||
data: dict[str, str] = {}
|
||
if request.language:
|
||
data["language"] = request.language
|
||
|
||
# 本地 yuanqi 引擎通常无需鉴权;仅当配置了 api_key 才带 Authorization 头
|
||
headers: dict[str, str] = {}
|
||
if self._api_key:
|
||
headers["Authorization"] = f"Bearer {self._api_key}"
|
||
|
||
url = f"{self._base_url}{_ASR_PATH}"
|
||
|
||
logger.info(
|
||
"audio_asr.request",
|
||
model=self._model,
|
||
format=request.format,
|
||
language=request.language,
|
||
url=url,
|
||
size_bytes=len(request.audio_data),
|
||
)
|
||
|
||
# ── API 调用 ────────────────────────────────────────────────
|
||
try:
|
||
response = await self._http.post(url, files=files, data=data, headers=headers)
|
||
except httpx.TimeoutException as exc:
|
||
raise AudioServiceError(
|
||
status_code=504,
|
||
code="audio_asr_timeout",
|
||
message="ASR request timed out calling yuanqi transcriptions.",
|
||
) from exc
|
||
except httpx.RequestError as exc:
|
||
raise AudioServiceError(
|
||
status_code=503,
|
||
code="audio_asr_connection_error",
|
||
message=f"ASR connection error: {exc}",
|
||
) from exc
|
||
|
||
if response.status_code != 200:
|
||
body = response.text[:500]
|
||
raise AudioServiceError(
|
||
status_code=response.status_code,
|
||
code="audio_asr_api_error",
|
||
message=f"ASR API returned {response.status_code}: {body}",
|
||
)
|
||
|
||
# 解析响应,提取转写文本
|
||
text = self._extract_text(response.json())
|
||
if not text:
|
||
raise AudioServiceError(
|
||
status_code=500,
|
||
code="audio_asr_empty_response",
|
||
message="ASR API returned empty transcription.",
|
||
)
|
||
|
||
# ── 写入缓存 ────────────────────────────────────────────────
|
||
_asr_cache_set(cache_key, text)
|
||
|
||
logger.info(
|
||
"audio_asr.success",
|
||
model=self._model,
|
||
text_length=len(text),
|
||
cache_size=len(_asr_cache),
|
||
)
|
||
return ASRResponse(text=text)
|
||
|
||
# ── 辅助方法 ──────────────────────────────────────────────────────────
|
||
|
||
@staticmethod
|
||
def _extract_text(body: dict) -> str:
|
||
"""从 Whisper 兼容响应中提取转写文本。"""
|
||
try:
|
||
text = body.get("text")
|
||
if text is None:
|
||
raise AudioServiceError(
|
||
status_code=500,
|
||
code="audio_asr_no_text",
|
||
message="ASR response has no 'text' field.",
|
||
)
|
||
return text or ""
|
||
except AudioServiceError:
|
||
raise
|
||
except Exception as exc:
|
||
raise AudioServiceError(
|
||
status_code=500,
|
||
code="audio_asr_response_parse_error",
|
||
message=f"Failed to parse ASR response: {exc}",
|
||
) from exc |