246 lines
7.5 KiB
Python
246 lines
7.5 KiB
Python
"""音频服务类型 — 请求/响应模型和协议定义。
|
||
|
||
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
|