240 lines
8.9 KiB
Python
240 lines
8.9 KiB
Python
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"
|
|
}
|
|
speed = 1.0
|
|
if self.selected_role and "boshi" in self.selected_role:
|
|
speed = 2.0
|
|
payload = {
|
|
"model": self.model,
|
|
"text": text,
|
|
"stream": True,
|
|
"voice_setting": {
|
|
"voice_id": self.voice_id,
|
|
"speed": speed,
|
|
"vol": 0.7,
|
|
"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
|