import os import uuid import aiohttp import aiofiles import binascii import json from typing import Optional, List, Any, AsyncGenerator from config import settings from interfaces.tts import TTS from implementations.base_service import BaseService from utils.logger import session_logger from services.tts_audio_cleaner import TTSAudioCleaner class MiniMaxTTS(TTS, BaseService): def __init__(self, selected_role=None): BaseService.__init__(self, "tts", selected_role) self.api_key = settings.minimax_api_key self.group_id = settings.minimax_group_id self.base_url = settings.minimax_base_url self.connected = False self.device_id = "unknown" self.session_id = "unknown" self.voice_id = "cartoon-boy-01" # 默认音色 if selected_role and "minimax_voice_id" in selected_role: self.voice_id = selected_role["minimax_voice_id"] self.model = "speech-02-turbo" self.selected_role = selected_role self._client_session: Optional[aiohttp.ClientSession] = None async def connect(self): """连接到MiniMax服务""" if self._client_session is None or self._client_session.closed: connector = aiohttp.TCPConnector( limit=20, limit_per_host=5, force_close=True, enable_cleanup_closed=True ) timeout = aiohttp.ClientTimeout(total=120, connect=30) self._client_session = aiohttp.ClientSession( connector=connector, timeout=timeout, max_field_size=1024*1024, max_line_size=10*1024*1024 ) self.connected = True session_logger.info( self.device_id, self.session_id, f"MiniMax TTS服务已连接,使用音色: {self.voice_id}" ) async def close(self): """关闭MiniMax服务连接""" self.connected = False if self._client_session and not self._client_session.closed: await self._client_session.close() async def tts( self, text: str, output_file_prefix: Optional[str] = None, tts_format: str = "mp3", selected_role: dict = None, session: Optional[Any] = None, language: str = None, ) -> Optional[List[str]]: """ 将文本转换为语音 返回值为包含音频URL的列表 """ if selected_role: self.selected_role = selected_role if "minimax_voice_id" in selected_role: self.voice_id = selected_role["minimax_voice_id"] if session and hasattr(session, "device_id") and session.device_id: self.device_id = session.device_id if session and hasattr(session, "session_id") and session.session_id: self.session_id = session.session_id unique_id = f"{self.session_id}_{uuid.uuid4().hex[:8]}" if output_file_prefix: output_file = f"{output_file_prefix}.{tts_format}" else: await TTSAudioCleaner.prepare_output_directory() tts_dir = os.path.join(settings.assets_dir, "tts_audio") output_file = os.path.join(tts_dir, f"{unique_id}.{tts_format}") try: session_logger.info( self.device_id, self.session_id, f"开始MiniMax流式语音合成,文本长度: {len(text)},输出文件: {output_file}" ) urls = await self._process_stream(text, output_file, language) return urls if urls else None except Exception as e: session_logger.error( self.device_id, self.session_id, f"MiniMax TTS合成失败: {str(e)}", exc_info=True ) return None async def _process_stream(self, text: str, output_file: str, language: str = None) -> Optional[List[str]]: """使用流式方式处理文本并返回URL""" headers = { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json" } payload = { "model": self.model, "text": text, "stream": True, "voice_setting": { "voice_id": self.voice_id, "speed": 1.0, "vol": 2.0, "pitch": 0 }, "audio_setting": { "sample_rate": 16000, "bitrate": 32000, "format": "mp3", "channel": 1 } } if language: lang_mapping = { "zh": "Chinese", "en": "English", "fr": "French", "de": "German", "es": "Spanish", "yue": "Chinese,Yue" } payload["language_boost"] = lang_mapping.get(language, "auto") else: payload["language_boost"] = "auto" url = f"{self.base_url}?GroupId={self.group_id}" try: session_logger.info( self.device_id, self.session_id, f"发送MiniMax TTS流式请求: {url}" ) audio_buffer = bytearray() if self._client_session is None or self._client_session.closed: await self.connect() async with self._client_session.post(url, json=payload, headers=headers) as response: if response.status != 200: error_text = await response.text() session_logger.error( self.device_id, self.session_id, f"MiniMax TTS请求失败: {response.status}, {error_text}" ) return None session_logger.info( self.device_id, self.session_id, "开始接收MiniMax TTS流式响应" ) buffer = b"" chunk_size = 8192 async for chunk in response.content.iter_chunked(chunk_size): buffer += chunk while b'\n' in buffer: line, buffer = buffer.split(b'\n', 1) line = line.strip() if not line or not line.startswith(b'data:'): continue try: data_json = json.loads(line[5:]) if "data" in data_json and "audio" in data_json["data"]: status = data_json["data"].get("status", 1) if status == 1: audio_hex = data_json["data"]["audio"] audio_binary = binascii.unhexlify(audio_hex) audio_buffer.extend(audio_binary) if status == 2: session_logger.info( self.device_id, self.session_id, "MiniMax TTS流式合成完成" ) except json.JSONDecodeError as e: session_logger.warning( self.device_id, self.session_id, f"JSON解析失败: {str(e)}" ) except Exception as e: session_logger.error( self.device_id, self.session_id, f"处理MiniMax TTS流式响应出错: {str(e)}", exc_info=True ) async with aiofiles.open(output_file, 'wb') as f: await f.write(audio_buffer) relative_path = os.path.relpath(output_file, settings.assets_dir) url = f"assets/{relative_path}" session_logger.info( self.device_id, self.session_id, f"MiniMax TTS流式合成完成,生成URL: {url}" ) return [url] except Exception as e: session_logger.error( self.device_id, self.session_id, f"MiniMax TTS流式处理失败: {str(e)}", exc_info=True ) return None