初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,197 @@
|
||||
"""音频服务 — ASR / TTS 适配器外观。
|
||||
|
||||
用法::
|
||||
|
||||
from app.ai_provider.audio import AudioService
|
||||
|
||||
service = AudioService(factory)
|
||||
result = await service.transcribe(audio_bytes, format="wav")
|
||||
result = await service.synthesize("你好,魂镜", voice="default")
|
||||
|
||||
``AudioService`` 通过注册表中带有 ``asr`` / ``tts`` 能力标签的模型来解析
|
||||
使用哪个模型,然后将创建委托给 ``ModelFactory.create_audio()``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.ai_provider.audio.base import (
|
||||
ASRProtocol,
|
||||
ASRRequest,
|
||||
ASRResponse,
|
||||
AudioServiceError,
|
||||
TTSProtocol,
|
||||
TTSRequest,
|
||||
TTSResponse,
|
||||
)
|
||||
|
||||
|
||||
class AudioService:
|
||||
"""外观类,负责解析 ASR / TTS 模型并委托给 ProviderManager。
|
||||
|
||||
接受 ProviderManager 或实现了以下接口的适配器:
|
||||
- ``.registry`` → 具有 ``list_capability_models(capability: str)`` 方法
|
||||
- ``.create_audio(name: str)`` → 返回 ASRProtocol / TTSProtocol 实例
|
||||
"""
|
||||
|
||||
def __init__(self, manager) -> None:
|
||||
self._manager = manager
|
||||
self._asr_adapter: ASRProtocol | None = None
|
||||
self._tts_adapter: TTSProtocol | None = None
|
||||
|
||||
# ── Registry 兼容适配 ─────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def _registry(self):
|
||||
"""按需获取 registry,兼容 adapter 和裸 ProviderManager/ModelRegistry。"""
|
||||
mgr = self._manager
|
||||
if hasattr(mgr, "registry"):
|
||||
return mgr.registry
|
||||
if hasattr(mgr, "model_registry"):
|
||||
return mgr.model_registry
|
||||
return mgr
|
||||
|
||||
def _list_capability_models(self, tag: str):
|
||||
reg = self._registry
|
||||
# 适配器接口(list_capability_models)vs ModelRegistry 接口(list_by_capability)
|
||||
if hasattr(reg, "list_capability_models"):
|
||||
return reg.list_capability_models(tag)
|
||||
if hasattr(reg, "list_by_capability"):
|
||||
return reg.list_by_capability(tag)
|
||||
return []
|
||||
|
||||
@property
|
||||
def _factory(self):
|
||||
mgr = self._manager
|
||||
if hasattr(mgr, "create_audio"):
|
||||
return mgr
|
||||
return None
|
||||
|
||||
# ── ASR ──────────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def asr(self) -> ASRProtocol:
|
||||
"""解析并缓存 ASR 适配器(取注册表中第一个活跃 ASR 模型)。"""
|
||||
if self._asr_adapter is None:
|
||||
self._asr_adapter = self._resolve_asr()
|
||||
return self._asr_adapter
|
||||
|
||||
def asr_for(self, model_name: str) -> ASRProtocol:
|
||||
"""解析并缓存指定模型名的 ASR 适配器(覆盖默认第一个)。"""
|
||||
self._asr_adapter = self._factory.create_audio(model_name)
|
||||
return self._asr_adapter
|
||||
|
||||
async def transcribe(
|
||||
self,
|
||||
audio_data: bytes,
|
||||
format: str = "wav",
|
||||
language: str | None = None,
|
||||
model_name: str | None = None,
|
||||
) -> ASRResponse:
|
||||
"""快捷方法:构造请求并调用 ASR 适配器。
|
||||
|
||||
Args:
|
||||
model_name: 可选,指定使用哪个 ASR 模型(按注册表逻辑名)。
|
||||
省略时取注册表中第一个活跃的 ASR 模型。
|
||||
"""
|
||||
if model_name is not None:
|
||||
adapter = self.asr_for(model_name)
|
||||
else:
|
||||
adapter = self.asr
|
||||
request = ASRRequest(
|
||||
audio_data=audio_data,
|
||||
format=format,
|
||||
language=language,
|
||||
)
|
||||
return await adapter.transcribe(request)
|
||||
|
||||
# ── TTS ──────────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def tts(self) -> TTSProtocol:
|
||||
"""解析并缓存 TTS 适配器。"""
|
||||
if self._tts_adapter is None:
|
||||
self._tts_adapter = self._resolve_tts()
|
||||
return self._tts_adapter
|
||||
|
||||
async def synthesize(
|
||||
self,
|
||||
text: str,
|
||||
voice: str = "default",
|
||||
response_format: str = "wav",
|
||||
extra: dict | None = None,
|
||||
) -> TTSResponse:
|
||||
"""快捷方法:构造请求并调用 TTS 适配器。"""
|
||||
request = TTSRequest(
|
||||
text=text,
|
||||
voice=voice,
|
||||
response_format=response_format,
|
||||
extra=extra,
|
||||
)
|
||||
return await self.tts.synthesize(request)
|
||||
|
||||
# ── 解析 ───────────────────────────────────────────────────────
|
||||
|
||||
def _resolve_asr(self) -> ASRProtocol:
|
||||
"""查找第一个活跃的 ASR 模型并通过工厂创建。"""
|
||||
models = self._list_capability_models("asr")
|
||||
if not models:
|
||||
raise AudioServiceError(
|
||||
status_code=503,
|
||||
code="audio_asr_no_model",
|
||||
message="No ASR model configured in the model registry.",
|
||||
)
|
||||
factory = self._factory
|
||||
if factory is None:
|
||||
raise AudioServiceError(
|
||||
status_code=501,
|
||||
code="audio_asr_no_factory",
|
||||
message="Audio adapter factory not available (provider manager missing create_audio).",
|
||||
)
|
||||
return factory.create_audio(models[0].name)
|
||||
|
||||
def _resolve_tts(self) -> TTSProtocol:
|
||||
"""查找第一个活跃的 TTS 模型并通过工厂创建。"""
|
||||
models = self._list_capability_models("tts")
|
||||
if not models:
|
||||
raise AudioServiceError(
|
||||
status_code=503,
|
||||
code="audio_tts_no_model",
|
||||
message="No TTS model configured in the model registry.",
|
||||
)
|
||||
factory = self._factory
|
||||
if factory is None:
|
||||
raise AudioServiceError(
|
||||
status_code=501,
|
||||
code="audio_tts_no_factory",
|
||||
message="Audio adapter factory not available (provider manager missing create_audio).",
|
||||
)
|
||||
return factory.create_audio(models[0].name)
|
||||
|
||||
# ── 生命周期 ────────────────────────────────────────────────────────
|
||||
|
||||
async def close(self) -> None:
|
||||
"""释放适配器持有的 HTTP 连接。"""
|
||||
for adapter in (self._asr_adapter, self._tts_adapter):
|
||||
if adapter is None:
|
||||
continue
|
||||
close = getattr(adapter, "close", None)
|
||||
if close is not None:
|
||||
await close()
|
||||
|
||||
async def __aenter__(self) -> AudioService:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> None:
|
||||
await self.close()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ASRProtocol",
|
||||
"ASRRequest",
|
||||
"ASRResponse",
|
||||
"AudioService",
|
||||
"AudioServiceError",
|
||||
"TTSProtocol",
|
||||
"TTSRequest",
|
||||
"TTSResponse",
|
||||
]
|
||||
@@ -0,0 +1,263 @@
|
||||
"""MiMo ASR adapter — ``POST /v1/chat/completions`` with ``input_audio``.
|
||||
|
||||
MiMo 的 ASR 不走独立音频端点,而是通过聊天补全接口扩展实现:
|
||||
|
||||
POST {base_url}/chat/completions
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {api_key}
|
||||
|
||||
{
|
||||
"model": "mimo-v2.5-asr",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": {
|
||||
"data": "data:audio/wav;base64,<base64_encoded_audio>"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"asr_options": {
|
||||
"language": "auto"
|
||||
}
|
||||
}
|
||||
|
||||
响应::
|
||||
|
||||
{
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "转写后的文本"
|
||||
}
|
||||
}]
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.ai_provider.audio.base import (
|
||||
ASRProtocol,
|
||||
ASRRequest,
|
||||
ASRResponse,
|
||||
AudioAdapterBase,
|
||||
AudioServiceError,
|
||||
)
|
||||
from app.core.logging import logger
|
||||
|
||||
_MIME_MAP = {
|
||||
"wav": "audio/wav",
|
||||
"mp3": "audio/mpeg",
|
||||
"m4a": "audio/mp4",
|
||||
"flac": "audio/flac",
|
||||
}
|
||||
|
||||
# ASR 缓存:默认最多 128 条,TTL 1 小时
|
||||
_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 HunjingASROpenAI(AudioAdapterBase, ASRProtocol):
|
||||
"""MiMo ASR 适配器,通过 ``POST /chat/completions`` 携带 ``input_audio`` 实现。
|
||||
|
||||
将音频字节包装为 chat 消息中的 ``input_audio`` 内容类型的 data URL,
|
||||
然后从响应中提取转写文本。
|
||||
"""
|
||||
|
||||
_adapter_type = "asr"
|
||||
_default_timeout = 300
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
model: str = "mimo-v2.5-asr",
|
||||
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 /chat/completions`` 转写音频。
|
||||
|
||||
将音频编码为 data URL 并以 ``input_audio`` 内容块的形式发送到 chat 消息中。
|
||||
"""
|
||||
# ── 缓存查找 ──────────────────────────────────────────────────
|
||||
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)
|
||||
|
||||
# ── 构造请求 ──────────────────────────────────────────────────
|
||||
mime = _MIME_MAP.get(request.format, "audio/wav")
|
||||
b64_audio = base64.b64encode(request.audio_data).decode()
|
||||
data_url = f"data:{mime};base64,{b64_audio}"
|
||||
|
||||
payload: dict = {
|
||||
"model": self._model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": {
|
||||
"data": data_url,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"asr_options": {
|
||||
"language": request.language or "auto",
|
||||
},
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
url = f"{self._base_url}/chat/completions"
|
||||
|
||||
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, json=payload, headers=headers)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise AudioServiceError(
|
||||
status_code=504,
|
||||
code="audio_asr_timeout",
|
||||
message="ASR request timed out calling chat completions.",
|
||||
) 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:
|
||||
"""从 chat completion 响应中提取转写文本。"""
|
||||
try:
|
||||
choices = body.get("choices", [])
|
||||
if not choices:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_asr_no_choices",
|
||||
message="ASR response has no choices.",
|
||||
)
|
||||
return (choices[0].get("message") or {}).get("content") 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
|
||||
@@ -0,0 +1,245 @@
|
||||
"""音频服务类型 — 请求/响应模型和协议定义。
|
||||
|
||||
ASR(自动语音识别)和 TTS(文本转语音)适配器**不是** LangChain 的
|
||||
``BaseChatModel`` 子类 —— 它们是通过 HTTP 调用的服务,经由 ``AudioService``
|
||||
或直接从 ``ModelFactory`` 消费。
|
||||
|
||||
用法::
|
||||
|
||||
service = AudioService(factory)
|
||||
result = await service.transcribe(audio_data)
|
||||
result = await service.synthesize("你好")
|
||||
|
||||
# 或通过工厂直接调用:
|
||||
tts = factory.create_audio("mimo-v2.5-tts")
|
||||
result = await tts.synthesize(TTSRequest(text="你好"))
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
||||
|
||||
from app.ai_provider.types.errors import AIProviderError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.ai_provider.config.models import ModelConfig, ModelEndpointConfig
|
||||
|
||||
|
||||
class AudioServiceError(AIProviderError):
|
||||
"""ASR / TTS 服务失败的基础错误。"""
|
||||
|
||||
|
||||
# ── ASR ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ASRRequest:
|
||||
"""将音频转写为文本的请求。
|
||||
|
||||
属性:
|
||||
audio_data: 原始音频字节。
|
||||
format: 音频容器格式(如 ``"wav"``、``"mp3"``、``"m4a"``)。
|
||||
language: 可选的 ISO-639-1 语言提示(如 ``"zh"``、``"en"``)。
|
||||
"""
|
||||
|
||||
__slots__ = ("audio_data", "format", "language")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
audio_data: bytes,
|
||||
format: str = "wav",
|
||||
language: str | None = None,
|
||||
) -> None:
|
||||
self.audio_data = audio_data
|
||||
self.format = format
|
||||
self.language = language
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ASRResponse:
|
||||
"""成功转写的结果。"""
|
||||
|
||||
text: str
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ASRProtocol(Protocol):
|
||||
"""ASR 适配器的协议。"""
|
||||
|
||||
async def transcribe(self, request: ASRRequest) -> ASRResponse:
|
||||
"""将音频字节转写为文本。
|
||||
|
||||
异常:
|
||||
AudioServiceError: API 调用失败或响应异常时抛出。
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ── TTS ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TTSRequest:
|
||||
"""将文本合成为语音的请求。
|
||||
|
||||
属性:
|
||||
text: 待朗读的文本。
|
||||
voice: 音色标识符(模型决定可用音色)。
|
||||
response_format: 期望的音频格式(``"wav"``、``"mp3"``、``"opus"``)。
|
||||
extra: 厂商特定的额外参数(如音色克隆/设计参数)。
|
||||
"""
|
||||
|
||||
__slots__ = ("text", "voice", "response_format", "extra")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
text: str,
|
||||
voice: str = "default",
|
||||
response_format: str = "wav",
|
||||
extra: dict | None = None,
|
||||
) -> None:
|
||||
self.text = text
|
||||
self.voice = voice
|
||||
self.response_format = response_format
|
||||
self.extra = extra or {}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TTSResponse:
|
||||
"""成功合成的结果。"""
|
||||
|
||||
audio_data: bytes
|
||||
format: str
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TTSProtocol(Protocol):
|
||||
"""TTS 适配器的协议。"""
|
||||
|
||||
async def synthesize(self, request: TTSRequest) -> TTSResponse:
|
||||
"""将文本合成为音频字节。
|
||||
|
||||
异常:
|
||||
AudioServiceError: API 调用失败或响应异常时抛出。
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ── 共享工厂基类 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AudioAdapterBase:
|
||||
"""ASR/TTS 适配器的基类,提供共享的工厂方法。
|
||||
|
||||
子类必须设置 ``_adapter_type``(如 ``"asr"``、``"tts"``),并在
|
||||
``__init__`` 中接受 ``base_url``、``api_key``、``model``、
|
||||
``request_timeout_seconds`` 参数。
|
||||
"""
|
||||
|
||||
_adapter_type: str = "audio"
|
||||
_default_timeout: int = 120
|
||||
|
||||
@classmethod
|
||||
def from_endpoint(
|
||||
cls,
|
||||
endpoint: ModelEndpointConfig,
|
||||
*,
|
||||
provider: ProviderConfig | None = None,
|
||||
timeout_seconds: int | None = None,
|
||||
) -> Any:
|
||||
"""从已解析的端点配置构建适配器。
|
||||
|
||||
凭证解析优先级:
|
||||
1. endpoint 直填的 ``base_url`` / ``api_key``
|
||||
2. endpoint 的 ``base_url_env`` / ``api_key_env`` 指向的 settings 字段
|
||||
3. provider 级凭证(调用方按需传入 ProviderConfig)
|
||||
"""
|
||||
base_url = _resolve_base_url(endpoint, provider)
|
||||
api_key = _resolve_api_key(endpoint, provider)
|
||||
if not base_url:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code=f"audio_{cls._adapter_type}_endpoint_invalid",
|
||||
message=(
|
||||
f"{cls._adapter_type.upper()} endpoint '{endpoint.name}' "
|
||||
"has no resolved base_url."
|
||||
),
|
||||
)
|
||||
return cls(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
model=endpoint.model,
|
||||
request_timeout_seconds=timeout_seconds
|
||||
or endpoint.request_timeout_seconds
|
||||
or cls._default_timeout,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_model_config(
|
||||
cls,
|
||||
config: ModelConfig,
|
||||
*,
|
||||
provider: ProviderConfig | None = None,
|
||||
timeout_seconds: int | None = None,
|
||||
) -> Any:
|
||||
"""从逻辑模型配置构建适配器(使用第一个活跃端点)。"""
|
||||
for ep in config.endpoints:
|
||||
if ep.status == "active":
|
||||
return cls.from_endpoint(
|
||||
ep, provider=provider, timeout_seconds=timeout_seconds
|
||||
)
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code=f"audio_{cls._adapter_type}_no_endpoint",
|
||||
message=f"{cls._adapter_type.upper()} model '{config.name}' has no active endpoint.",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_base_url(
|
||||
endpoint: ModelEndpointConfig,
|
||||
provider: ProviderConfig | None,
|
||||
) -> str:
|
||||
"""端点 base_url → endpoint.base_url_env 对应 settings → provider.base_url(_env)。"""
|
||||
if endpoint.base_url:
|
||||
return endpoint.base_url
|
||||
if endpoint.base_url_env:
|
||||
val = _settings_get(endpoint.base_url_env)
|
||||
if val:
|
||||
return val
|
||||
if provider is not None:
|
||||
if provider.base_url:
|
||||
return provider.base_url
|
||||
if provider.base_url_env:
|
||||
val = _settings_get(provider.base_url_env)
|
||||
if val:
|
||||
return val
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_api_key(
|
||||
endpoint: ModelEndpointConfig,
|
||||
provider: ProviderConfig | None,
|
||||
) -> str:
|
||||
"""端点 api_key → endpoint.api_key_env 对应 settings → provider 级凭证。"""
|
||||
if endpoint.api_key:
|
||||
return endpoint.api_key
|
||||
if endpoint.api_key_env:
|
||||
val = _settings_get(endpoint.api_key_env)
|
||||
if val:
|
||||
return val
|
||||
if provider is not None:
|
||||
# ProviderConfig 的 api_key_env / base_url_env 也走 settings 解析
|
||||
val = _settings_get(provider.api_key_env)
|
||||
if val:
|
||||
return val
|
||||
return ""
|
||||
|
||||
|
||||
def _settings_get(env_name: str) -> str | None:
|
||||
"""按大写 env 名查 settings 上的小写字段(与现有 _resolve_credentials 约定一致)。"""
|
||||
from app.core.config import settings
|
||||
|
||||
key = env_name.lower()
|
||||
if hasattr(settings, key):
|
||||
value = getattr(settings, key)
|
||||
return value if isinstance(value, str) else None
|
||||
return None
|
||||
@@ -0,0 +1,325 @@
|
||||
"""MiMo TTS adapter — ``POST /v1/chat/completions`` with ``audio`` parameter.
|
||||
|
||||
MiMo 的 TTS 不走独立音频端点,而是通过聊天补全接口扩展实现:
|
||||
|
||||
POST {base_url}/chat/completions
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {api_key}
|
||||
|
||||
{
|
||||
"model": "mimo-v2.5-tts",
|
||||
"messages": [
|
||||
{"role": "user", "content": "(可选)音色风格描述,voicedesign 必填"},
|
||||
{"role": "assistant", "content": "要朗读的文本"}
|
||||
],
|
||||
"audio": {
|
||||
"format": "wav",
|
||||
"voice": "mimo_default"
|
||||
}
|
||||
}
|
||||
|
||||
响应::
|
||||
|
||||
{
|
||||
"choices": [{
|
||||
"message": {
|
||||
"audio": {
|
||||
"data": "<base64 编码的音频数据>",
|
||||
"id": "...",
|
||||
"expires_at": null,
|
||||
"transcript": null
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
模型差异:
|
||||
|
||||
- ``mimo-v2.5-tts`` — ``voice`` 可选,默认 ``mimo_default``,仅支持预置音色
|
||||
- ``mimo-v2.5-tts-voiceclone`` — ``voice`` 必填,传入音频样本的 base64(mp3/wav)
|
||||
- ``mimo-v2.5-tts-voicedesign`` — 不支持 ``voice``,\n ``user`` 消息必填(风格描述),支持 ``optimize_text_preview``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.ai_provider.audio.base import (
|
||||
AudioAdapterBase,
|
||||
AudioServiceError,
|
||||
TTSProtocol,
|
||||
TTSRequest,
|
||||
TTSResponse,
|
||||
)
|
||||
from app.core.logging import logger
|
||||
|
||||
# voicedesign 模型的默认风格描述(用户未提供时使用)
|
||||
_DEFAULT_VOICE_DESIGN_PROMPT = "自然、清晰的朗读风格,语气平和,语速适中,适合日常对话场景。"
|
||||
|
||||
# TTS 音频缓存:默认最多 256 条,TTL 1 小时
|
||||
_TTS_CACHE_MAX_SIZE = 256
|
||||
_TTS_CACHE_TTL_SECONDS = 3600
|
||||
|
||||
|
||||
class _TTSCacheEntry:
|
||||
__slots__ = ("data", "format", "stored_at")
|
||||
|
||||
def __init__(self, data: bytes, format: str) -> None:
|
||||
self.data = data
|
||||
self.format = format
|
||||
self.stored_at = time.monotonic()
|
||||
|
||||
|
||||
_tts_cache: dict[str, _TTSCacheEntry] = {}
|
||||
|
||||
|
||||
def _tts_cache_key(model: str, voice: str, fmt: str, text: str, extra_json: str) -> str:
|
||||
"""生成 TTS 缓存键:SHA256(model + voice + format + text + extra)。"""
|
||||
raw = f"{model}\0{voice}\0{fmt}\0{text}\0{extra_json}"
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
|
||||
def _tts_cache_get(key: str) -> _TTSCacheEntry | None:
|
||||
entry = _tts_cache.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if time.monotonic() - entry.stored_at > _TTS_CACHE_TTL_SECONDS:
|
||||
del _tts_cache[key]
|
||||
return None
|
||||
return entry
|
||||
|
||||
|
||||
def _tts_cache_set(key: str, entry: _TTSCacheEntry) -> None:
|
||||
if len(_tts_cache) >= _TTS_CACHE_MAX_SIZE:
|
||||
# 淘汰最旧的一半
|
||||
sorted_keys = sorted(_tts_cache.keys(), key=lambda k: _tts_cache[k].stored_at)
|
||||
for old_key in sorted_keys[: _TTS_CACHE_MAX_SIZE // 2]:
|
||||
del _tts_cache[old_key]
|
||||
_tts_cache[key] = entry
|
||||
|
||||
|
||||
class HunjingTTSOpenAI(AudioAdapterBase, TTSProtocol):
|
||||
"""MiMo TTS 适配器,通过 ``POST /chat/completions`` 携带 ``audio`` 参数实现。
|
||||
|
||||
调用兼容 OpenAI 格式的 chat completions 端点,在请求体中添加 ``audio`` 字段,
|
||||
然后从响应中提取并解码 base64 音频数据。
|
||||
"""
|
||||
|
||||
_adapter_type = "tts"
|
||||
_default_timeout = 120
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
model: str = "mimo-v2.5-tts",
|
||||
request_timeout_seconds: int = 120,
|
||||
) -> 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()
|
||||
|
||||
# ── TTSProtocol ──────────────────────────────────────────────────────
|
||||
|
||||
async def synthesize(self, request: TTSRequest) -> TTSResponse:
|
||||
"""通过 ``POST /chat/completions`` 将文本合成为音频。
|
||||
|
||||
构造 chat 消息和 ``audio`` 参数,匹配 MiMo 兼容 OpenAI 格式的 TTS,
|
||||
然后从响应中解码 base64 音频。
|
||||
|
||||
结果按 ``(model, voice, format, text)`` 在进程内缓存,避免重复 API 调用。
|
||||
"""
|
||||
# ── 缓存查找 ──────────────────────────────────────────────────────
|
||||
cache_key = _tts_cache_key(
|
||||
model=self._model,
|
||||
voice=request.voice,
|
||||
fmt=request.response_format,
|
||||
text=request.text,
|
||||
extra_json=str(sorted((request.extra or {}).items())),
|
||||
)
|
||||
cached = _tts_cache_get(cache_key)
|
||||
if cached is not None:
|
||||
logger.info("audio_tts.cache_hit", model=self._model, text_length=len(request.text))
|
||||
return TTSResponse(audio_data=cached.data, format=cached.format)
|
||||
|
||||
# ── API 调用 ──────────────────────────────────────────────────────
|
||||
url = f"{self._base_url}/chat/completions"
|
||||
|
||||
# 构造 messages
|
||||
messages = self._build_messages(request)
|
||||
|
||||
# 构造 audio 参数
|
||||
audio_params = self._build_audio_params(request)
|
||||
|
||||
payload: dict = {
|
||||
"model": self._model,
|
||||
"messages": messages,
|
||||
"audio": audio_params,
|
||||
"max_completion_tokens": 8192,
|
||||
}
|
||||
# 合并 extra(如 frequency_penalty, temperature 等)
|
||||
if request.extra:
|
||||
payload.update(request.extra)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"audio_tts.request",
|
||||
model=self._model,
|
||||
voice=request.voice,
|
||||
audio_format=request.response_format,
|
||||
text_length=len(request.text),
|
||||
url=url,
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self._http.post(url, json=payload, headers=headers)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise AudioServiceError(
|
||||
status_code=504,
|
||||
code="audio_tts_timeout",
|
||||
message="TTS request timed out calling chat completions.",
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise AudioServiceError(
|
||||
status_code=503,
|
||||
code="audio_tts_connection_error",
|
||||
message=f"TTS connection error: {exc}",
|
||||
) from exc
|
||||
|
||||
if response.status_code != 200:
|
||||
body = response.text[:500]
|
||||
raise AudioServiceError(
|
||||
status_code=response.status_code,
|
||||
code="audio_tts_api_error",
|
||||
message=f"TTS API returned {response.status_code}: {body}",
|
||||
)
|
||||
|
||||
# 解析响应,提取 base64 音频
|
||||
audio_data = self._extract_audio(response.json())
|
||||
|
||||
# ── 写入缓存 ──────────────────────────────────────────────────────
|
||||
_tts_cache_set(cache_key, _TTSCacheEntry(audio_data, request.response_format))
|
||||
|
||||
logger.info(
|
||||
"audio_tts.success",
|
||||
model=self._model,
|
||||
audio_bytes=len(audio_data),
|
||||
cache_size=len(_tts_cache),
|
||||
)
|
||||
return TTSResponse(audio_data=audio_data, format=request.response_format)
|
||||
|
||||
# ── 辅助方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
def _build_messages(self, request: TTSRequest) -> list[dict]:
|
||||
"""构造 chat messages 数组。
|
||||
|
||||
- voicedesign 模型:user 消息必填(风格描述)
|
||||
- 其他模型:user 消息可选,assistant 消息存放待朗读文本
|
||||
"""
|
||||
is_voicedesign = "voicedesign" in self._model
|
||||
|
||||
if is_voicedesign:
|
||||
# voicedesign:user 消息为风格提示,assistant 消息为朗读文本
|
||||
style_prompt = (request.extra or {}).pop("style_prompt", _DEFAULT_VOICE_DESIGN_PROMPT)
|
||||
return [
|
||||
{"role": "user", "content": style_prompt},
|
||||
{"role": "assistant", "content": request.text},
|
||||
]
|
||||
# 基础 TTS / voiceclone:只传 assistant 消息
|
||||
return [
|
||||
{"role": "assistant", "content": request.text},
|
||||
]
|
||||
|
||||
def _build_audio_params(self, request: TTSRequest) -> dict:
|
||||
"""构造 ``audio`` 参数字段,按模型处理 voice/format/optimize_text_preview 等。"""
|
||||
params: dict = {
|
||||
"format": _resolve_api_format(request.response_format),
|
||||
}
|
||||
|
||||
is_voicedesign = "voicedesign" in self._model
|
||||
|
||||
if is_voicedesign:
|
||||
# voicedesign 不支持 voice 字段
|
||||
optimize = (request.extra or {}).pop("optimize_text_preview", None)
|
||||
if optimize is not None:
|
||||
params["optimize_text_preview"] = optimize
|
||||
else:
|
||||
# 基础 TTS / voiceclone:按需传 voice
|
||||
if request.voice and request.voice != "default":
|
||||
params["voice"] = request.voice
|
||||
else:
|
||||
params["voice"] = "mimo_default"
|
||||
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def _extract_audio(body: dict) -> bytes:
|
||||
"""从 chat completion 响应中提取并解码 base64 音频数据。"""
|
||||
try:
|
||||
choices = body.get("choices", [])
|
||||
if not choices:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_tts_no_choices",
|
||||
message="TTS response has no choices.",
|
||||
)
|
||||
message = choices[0].get("message", {})
|
||||
audio_info = message.get("audio")
|
||||
if not audio_info:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_tts_no_audio_field",
|
||||
message="TTS response missing audio field in message.",
|
||||
)
|
||||
raw = audio_info.get("data")
|
||||
if not raw:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_tts_empty_audio_data",
|
||||
message="TTS response has empty audio.data.",
|
||||
)
|
||||
return base64.b64decode(raw)
|
||||
except AudioServiceError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_tts_response_parse_error",
|
||||
message=f"Failed to parse TTS response: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
def _resolve_api_format(user_format: str) -> str:
|
||||
"""将用户层面的格式名映射为 API 期望的格式。
|
||||
|
||||
MiMo TTS API 接受:``wav``、``mp3``、``pcm``、``pcm16``。
|
||||
"""
|
||||
fmt = user_format.lower()
|
||||
if fmt in ("pcm", "pcm16"):
|
||||
return "pcm16"
|
||||
if fmt in ("wav", "mp3"):
|
||||
return fmt
|
||||
return "wav"
|
||||
@@ -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
|
||||
Reference in new issue
Block a user