初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+231
View File
@@ -0,0 +1,231 @@
"""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