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