import os
|
import uuid
|
import json
|
import time
|
import queue
|
import asyncio
|
import traceback
|
import websockets
|
|
from asyncio import Task
|
from typing import Callable, Any
|
from config.logger import setup_logging
|
from core.utils.tts import MarkdownCleaner
|
from core.providers.tts.base import TTSProviderBase
|
from core.providers.tts.dto.dto import SentenceType, ContentType, InterfaceType
|
|
TAG = __name__
|
logger = setup_logging()
|
|
|
class TTSProvider(TTSProviderBase):
|
TTS_PARAM_CONFIG = [
|
("ttsVolume", "volume", 0, 100, 50, int),
|
("ttsRate", "rate", 0.5, 2.0, 1.0, lambda v: round(v, 1)),
|
("ttsPitch", "pitch", 0.5, 2.0, 1.0, lambda v: round(v, 1)),
|
]
|
|
def __init__(self, config, delete_audio_file):
|
super().__init__(config, delete_audio_file)
|
|
self.interface_type = InterfaceType.DUAL_STREAM
|
# 基础配置
|
self.api_key = config.get("api_key")
|
if not self.api_key:
|
raise ValueError("api_key is required for CosyVoice TTS")
|
self.report_on_last = True
|
|
# WebSocket配置
|
self.ws_url = "wss://dashscope.aliyuncs.com/api-ws/v1/inference/"
|
self.ws = None
|
self._monitor_task = None
|
self.activate_session = False
|
self.last_active_time = None
|
|
# 模型和音色配置
|
self.model = config.get("model", "cosyvoice-v2")
|
self.voice = config.get("voice", "longxiaochun_v2") # 默认音色
|
if config.get("private_voice"):
|
self.voice = config.get("private_voice")
|
|
# 音频参数配置
|
self.format = config.get("format", "pcm")
|
|
volume = config.get("volume", "50")
|
self.volume = int(volume) if volume else 50
|
|
rate = config.get("rate", "1.0")
|
self.rate = float(rate) if rate else 1.0
|
|
pitch = config.get("pitch", "1.0")
|
self.pitch = float(pitch) if pitch else 1.0
|
|
# 应用百分比调整(如果存在),否则使用公有化配置
|
self._apply_percentage_params(config)
|
|
self.header = {
|
"Authorization": f"Bearer {self.api_key}",
|
# "user-agent": "your_platform_info", // 可选
|
# "X-DashScope-WorkSpace": workspace, // 可选,阿里云百炼业务空间ID
|
"X-DashScope-DataInspection": "enable",
|
}
|
|
async def _ensure_connection(self):
|
"""确保WebSocket连接可用,支持60秒内连接复用"""
|
try:
|
current_time = time.time()
|
if self.ws and current_time - self.last_active_time < 60:
|
# 一分钟内才可以复用链接进行连续对话
|
logger.bind(tag=TAG).debug(f"使用已有链接...")
|
return self.ws
|
logger.bind(tag=TAG).debug("开始建立新连接...")
|
|
# 建立新连接前取消旧监听任务
|
await self._cancel_monitor_task()
|
|
self.ws = await websockets.connect(
|
self.ws_url,
|
additional_headers=self.header,
|
ping_interval=30,
|
ping_timeout=10,
|
close_timeout=10,
|
)
|
|
logger.bind(tag=TAG).debug("WebSocket连接建立成功")
|
self.last_active_time = current_time
|
return self.ws
|
except Exception as e:
|
logger.bind(tag=TAG).error(f"建立连接失败: {str(e)}")
|
self.ws = None
|
self.last_active_time = None
|
raise
|
|
def tts_text_priority_thread(self):
|
"""流式TTS文本处理线程"""
|
while not self.conn.stop_event.is_set():
|
try:
|
message = self.tts_text_queue.get(timeout=1)
|
|
if self.conn.client_abort:
|
try:
|
logger.bind(tag=TAG).info("收到打断信息,终止TTS文本处理线程")
|
asyncio.run_coroutine_threadsafe(
|
self.finish_session(self.conn.sentence_id),
|
loop=self.conn.loop,
|
)
|
continue
|
except Exception as e:
|
logger.bind(tag=TAG).error(f"取消TTS会话失败: {str(e)}")
|
continue
|
|
# 过滤旧消息:检查sentence_id是否匹配
|
if message.sentence_id != self.conn.sentence_id:
|
continue
|
|
logger.bind(tag=TAG).debug(
|
f"收到TTS任务|{message.sentence_type.name} | {message.content_type.name} | 会话ID: {message.sentence_id}"
|
)
|
|
if message.sentence_type == SentenceType.FIRST:
|
# 重置流式处理状态
|
self.reset_stream_state()
|
# 初始化会话
|
try:
|
if not getattr(self.conn, "sentence_id", None):
|
self.conn.sentence_id = uuid.uuid4().hex
|
logger.bind(tag=TAG).debug(f"自动生成新的 会话ID: {self.conn.sentence_id}")
|
|
logger.bind(tag=TAG).debug("开始启动TTS会话...")
|
future = asyncio.run_coroutine_threadsafe(
|
self.start_session(self.conn.sentence_id),
|
loop=self.conn.loop,
|
)
|
future.result(timeout=self.tts_timeout)
|
self.before_stop_play_files.clear()
|
logger.bind(tag=TAG).debug("TTS会话启动成功")
|
except Exception as e:
|
logger.bind(tag=TAG).error(f"启动TTS会话失败: {str(e)}")
|
continue
|
|
elif ContentType.TEXT == message.content_type:
|
if message.content_detail:
|
try:
|
logger.bind(tag=TAG).debug(
|
f"开始发送TTS文本: {message.content_detail}"
|
)
|
future = asyncio.run_coroutine_threadsafe(
|
self.text_to_speak(message.content_detail, None),
|
loop=self.conn.loop,
|
)
|
future.result(timeout=self.tts_timeout)
|
except Exception as e:
|
logger.bind(tag=TAG).error(f"发送TTS文本失败: {str(e)}")
|
continue
|
|
elif ContentType.FILE == message.content_type:
|
logger.bind(tag=TAG).info(
|
f"添加音频文件到待播放列表: {message.content_file}"
|
)
|
if message.content_file and os.path.exists(message.content_file):
|
# 先处理文件音频数据
|
self._process_audio_file_stream(message.content_file, callback=lambda audio_data: self.handle_audio_file(audio_data, message.content_detail))
|
|
if message.sentence_type == SentenceType.LAST:
|
try:
|
logger.bind(tag=TAG).debug("开始结束TTS会话...")
|
future = asyncio.run_coroutine_threadsafe(
|
self.finish_session(self.conn.sentence_id),
|
loop=self.conn.loop,
|
)
|
future.result()
|
except Exception as e:
|
logger.bind(tag=TAG).error(f"结束TTS会话失败: {str(e)}")
|
continue
|
|
except queue.Empty:
|
continue
|
except Exception as e:
|
logger.bind(tag=TAG).error(
|
f"处理TTS文本失败: {str(e)}, 类型: {type(e).__name__}, 堆栈: {traceback.format_exc()}"
|
)
|
continue
|
|
async def text_to_speak(self, text, _):
|
"""发送文本到TTS服务进行合成"""
|
try:
|
if self.ws is None:
|
logger.bind(tag=TAG).warning("WebSocket连接不存在,终止发送文本")
|
return
|
|
# 过滤Markdown
|
filtered_text = MarkdownCleaner.clean_markdown(text)
|
|
if filtered_text:
|
# 使用滑动窗口匹配处理跨分片的替换词
|
confirmed_texts, self._pending_prefix = self._match_stream_text(filtered_text)
|
|
# 发送每个确定的文本片段
|
for txt in confirmed_texts:
|
if txt and self.ws:
|
continue_task_message = {
|
"header": {
|
"action": "continue-task",
|
"task_id": self.conn.sentence_id,
|
"streaming": "duplex",
|
},
|
"payload": {"input": {"text": txt}},
|
}
|
await self.ws.send(json.dumps(continue_task_message))
|
self.last_active_time = time.time()
|
return
|
except Exception as e:
|
logger.bind(tag=TAG).error(f"发送TTS文本失败: {str(e)}")
|
if self.ws:
|
try:
|
await self.ws.close()
|
except:
|
pass
|
self.ws = None
|
raise
|
|
async def start_session(self, session_id):
|
"""启动TTS会话"""
|
logger.bind(tag=TAG).debug(f"开始会话~~{session_id}")
|
try:
|
# 上个会话处于激活状态时关闭上个连接新建链接
|
if self.activate_session:
|
await self.close()
|
|
# 设置会话激活标志
|
self.activate_session = True
|
|
# 确保连接可用
|
await self._ensure_connection()
|
|
# 启动监听任务
|
if self._monitor_task is None or self._monitor_task.done():
|
logger.bind(tag=TAG).debug("启动监听任务...")
|
self._monitor_task = asyncio.create_task(self._start_monitor_tts_response())
|
|
# 发送run-task消息启动会话
|
run_task_message = {
|
"header": {
|
"action": "run-task",
|
"task_id": session_id,
|
"streaming": "duplex",
|
},
|
"payload": {
|
"task_group": "audio",
|
"task": "tts",
|
"function": "SpeechSynthesizer",
|
"model": self.model,
|
"parameters": {
|
"text_type": "PlainText",
|
"voice": self.voice,
|
"format": self.format,
|
"sample_rate": self.conn.sample_rate,
|
"volume": self.volume,
|
"rate": self.rate,
|
"pitch": self.pitch,
|
},
|
"input": {}
|
},
|
}
|
|
await self.ws.send(json.dumps(run_task_message))
|
self.last_active_time = time.time()
|
logger.bind(tag=TAG).debug("会话启动请求已发送")
|
except Exception as e:
|
logger.bind(tag=TAG).error(f"启动会话失败: {str(e)}")
|
await self.close()
|
raise
|
|
async def finish_session(self, session_id):
|
"""结束TTS会话"""
|
logger.bind(tag=TAG).debug(f"关闭会话~~{session_id}")
|
try:
|
if self.ws and session_id:
|
# 发送finish-task消息
|
finish_task_message = {
|
"header": {
|
"action": "finish-task",
|
"task_id": session_id,
|
"streaming": "duplex",
|
},
|
"payload": {
|
"input": {}
|
}
|
}
|
|
await self.ws.send(json.dumps(finish_task_message))
|
self.last_active_time = time.time()
|
|
except Exception as e:
|
logger.bind(tag=TAG).error(f"关闭会话失败: {str(e)}")
|
await self.close()
|
raise
|
|
async def close(self):
|
"""清理资源"""
|
await super().close()
|
self.activate_session = False
|
await self._cancel_monitor_task()
|
|
# 关闭WebSocket连接
|
if self.ws:
|
try:
|
await self.ws.close()
|
except:
|
pass
|
self.ws = None
|
self.last_active_time = None
|
|
async def _cancel_monitor_task(self):
|
"""取消监听任务"""
|
if self._monitor_task and not self._monitor_task.done():
|
self._monitor_task.cancel()
|
try:
|
await self._monitor_task
|
except asyncio.CancelledError:
|
pass
|
except Exception as e:
|
logger.bind(tag=TAG).warning(f"取消监听任务错误: {e}")
|
self._monitor_task = None
|
|
async def _start_monitor_tts_response(self):
|
"""监听TTS响应 - 长期运行"""
|
try:
|
while not self.conn.stop_event.is_set():
|
try:
|
msg = await self.ws.recv()
|
self.last_active_time = time.time()
|
|
if isinstance(msg, str): # JSON控制消息
|
try:
|
data = json.loads(msg)
|
header = data.get("header", {})
|
event = header.get("event")
|
task_id = header.get("task_id")
|
|
# 只处理当前活跃会话的响应
|
if task_id and self.conn.sentence_id != task_id:
|
if event in ["task-finished", "task-failed"]:
|
logger.bind(tag=TAG).debug(f"收到残余下行结束响应重置会话状态~~")
|
self.activate_session = False
|
continue
|
|
if event == "task-started":
|
logger.bind(tag=TAG).debug("TTS任务启动成功~")
|
self.tts_audio_queue.put((SentenceType.FIRST, [], None))
|
elif event == "result-generated":
|
# 发送缓存的数据
|
tts_text = self.get_tts_text(self.conn.sentence_id)
|
if tts_text:
|
logger.bind(tag=TAG).info(
|
f"句子语音生成成功: {tts_text}"
|
)
|
self.tts_audio_queue.put(
|
(SentenceType.FIRST, [], tts_text)
|
)
|
self.clear_tts_text(self.conn.sentence_id)
|
elif event == "task-finished":
|
logger.bind(tag=TAG).debug("TTS任务完成~")
|
self.activate_session = False
|
self._process_before_stop_play_files()
|
elif event == "task-failed":
|
error_code = header.get("error_code", "unknown")
|
error_message = header.get("error_message", "未知错误")
|
logger.bind(tag=TAG).error(
|
f"TTS任务失败: {error_code} - {error_message}"
|
)
|
break
|
except json.JSONDecodeError:
|
logger.bind(tag=TAG).warning("收到无效的JSON消息")
|
elif isinstance(msg, (bytes, bytearray)):
|
self.opus_encoder.encode_pcm_to_opus_stream(
|
msg, False, callback=self.handle_opus
|
)
|
except websockets.ConnectionClosed:
|
logger.bind(tag=TAG).warning("WebSocket连接已关闭")
|
break
|
except Exception as e:
|
logger.bind(tag=TAG).error(
|
f"处理TTS响应时出错: {e}\n{traceback.format_exc()}"
|
)
|
break
|
|
# 连接异常时关闭WebSocket
|
if self.ws:
|
try:
|
await self.ws.close()
|
except:
|
pass
|
self.ws = None
|
# 监听任务退出时清理引用
|
finally:
|
self.activate_session = False
|
self._monitor_task = None
|
|
def audio_to_opus_data_stream(
|
self, audio_file_path, callback: Callable[[Any], Any] = None
|
):
|
"""重写父类方法:使用独立的临时编码器处理音频文件,避免与TTS流式编码器并发冲突。
|
双流式TTS中,monitor任务在event loop线程接收TTS音频并使用self.opus_encoder编码,
|
同时tts_text_priority_thread处理音乐文件也使用self.opus_encoder,
|
共享的encoder.buffer非线程安全,并发访问会导致SILK resampler断言失败。
|
"""
|
from core.utils.util import audio_to_data_stream
|
|
return audio_to_data_stream(
|
audio_file_path,
|
is_opus=True,
|
callback=callback,
|
sample_rate=self.conn.sample_rate,
|
opus_encoder=None,
|
)
|
|
def to_tts(self, text: str) -> list:
|
"""非流式生成音频数据,用于生成音频及测试场景"""
|
try:
|
# 创建事件循环
|
loop = asyncio.new_event_loop()
|
asyncio.set_event_loop(loop)
|
|
# 生成会话ID
|
session_id = uuid.uuid4().hex
|
# 存储音频数据
|
audio_data = []
|
|
async def _generate_audio():
|
ws = await websockets.connect(
|
self.ws_url,
|
additional_headers=self.header,
|
ping_interval=30,
|
ping_timeout=10,
|
close_timeout=10,
|
max_size=10 * 1024 * 1024,
|
)
|
|
try:
|
# 发送run-task消息启动会话
|
run_task_message = {
|
"header": {
|
"action": "run-task",
|
"task_id": session_id,
|
"streaming": "duplex",
|
},
|
"payload": {
|
"task_group": "audio",
|
"task": "tts",
|
"function": "SpeechSynthesizer",
|
"model": self.model,
|
"parameters": {
|
"text_type": "PlainText",
|
"voice": self.voice,
|
"format": self.format,
|
"sample_rate": self.conn.sample_rate,
|
"volume": self.volume,
|
"rate": self.rate,
|
"pitch": self.pitch,
|
},
|
"input": {}
|
},
|
}
|
await ws.send(json.dumps(run_task_message))
|
|
# 等待任务启动
|
task_started = False
|
while not task_started:
|
msg = await ws.recv()
|
if isinstance(msg, str):
|
data = json.loads(msg)
|
header = data.get("header", {})
|
if header.get("event") == "task-started":
|
task_started = True
|
logger.bind(tag=TAG).debug("TTS任务已启动")
|
elif header.get("event") == "task-failed":
|
error_code = header.get("error_code", "unknown")
|
error_message = header.get("error_message", "未知错误")
|
raise Exception(
|
f"启动任务失败: {error_code} - {error_message}"
|
)
|
|
# 发送文本
|
filtered_text = MarkdownCleaner.clean_markdown(text)
|
if self._correct_words_pattern:
|
filtered_text = self._correct_words_pattern.sub(lambda m: self.correct_words[m.group(0)], filtered_text)
|
# 发送continue-task消息
|
continue_task_message = {
|
"header": {
|
"action": "continue-task",
|
"task_id": session_id,
|
"streaming": "duplex",
|
},
|
"payload": {"input": {"text": filtered_text}},
|
}
|
await ws.send(json.dumps(continue_task_message))
|
|
# 发送finish-task消息
|
finish_task_message = {
|
"header": {
|
"action": "finish-task",
|
"task_id": session_id,
|
"streaming": "duplex",
|
},
|
"payload": {
|
"input": {}
|
}
|
}
|
await ws.send(json.dumps(finish_task_message))
|
|
# 接收音频数据
|
task_finished = False
|
while not task_finished:
|
msg = await ws.recv()
|
if isinstance(msg, (bytes, bytearray)):
|
self.opus_encoder.encode_pcm_to_opus_stream(
|
msg,
|
end_of_stream=False,
|
callback=lambda opus: audio_data.append(opus)
|
)
|
elif isinstance(msg, str):
|
data = json.loads(msg)
|
header = data.get("header", {})
|
if header.get("event") == "task-finished":
|
task_finished = True
|
logger.bind(tag=TAG).debug("TTS任务完成")
|
elif header.get("event") == "task-failed":
|
error_code = header.get("error_code", "unknown")
|
error_message = header.get("error_message", "未知错误")
|
raise Exception(
|
f"合成失败: {error_code} - {error_message}"
|
)
|
|
finally:
|
# 清理资源
|
try:
|
await ws.close()
|
except:
|
pass
|
|
# 运行异步任务
|
loop.run_until_complete(_generate_audio())
|
loop.close()
|
|
return audio_data
|
|
except Exception as e:
|
logger.bind(tag=TAG).error(f"生成音频数据失败: {str(e)}")
|
return []
|