import io import wave import json import base64 import asyncio import websockets import numpy as np from datetime import datetime from config.logger import setup_logging from core.providers.tts.base import TTSProviderBase TAG = __name__ logger = setup_logging() class TTSProvider(TTSProviderBase): TTS_PARAM_CONFIG = [ ("ttsVolume", "volume", 0, 3, 1.0, lambda v: round(float(v), 1)), ("ttsRate", "speed", 0, 3, 1.0, lambda v: round(float(v), 1)), ] def __init__(self, config, delete_audio_file): super().__init__(config, delete_audio_file) self.url = config.get("url", "ws://192.168.1.10:8092/paddlespeech/tts/streaming") self.protocol = config.get("protocol", "websocket") if config.get("private_voice"): self.spk_id = int(config.get("private_voice")) else: self.spk_id = int(config.get("spk_id", "0")) speed = config.get("speed", 1.0) self.speed = float(speed) if speed else 1.0 volume = config.get("volume", 1.0) self.volume = float(volume) if volume else 1.0 self.delete_audio_file = config.get("delete_audio", True) # 应用百分比调整(如果存在),否则使用公有化配置 self._apply_percentage_params(config) if not self.delete_audio_file: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") save_path = config.get("save_path") if save_path: if not save_path.endswith('.wav'): save_path = f"{save_path}_{timestamp}.wav" else: other_path = save_path[:-4] save_path = f"{other_path}_{timestamp}.wav" self.save_path = save_path else: self.save_path = f"./streaming_tts_{timestamp}.wav" else: self.save_path = None async def pcm_to_wav(self, pcm_data: bytes, sample_rate: int = 24000, num_channels: int = 1, bits_per_sample: int = 16) -> bytes: """ 将 PCM 数据转换为 WAV 文件并返回字节数据 :param pcm_data: PCM 数据(原始字节流) :param sample_rate: 音频采样率,默认为24000 :param num_channels: 声道数,默认为单声道 :param bits_per_sample: 每个样本的位数,默认为16 :return: WAV 格式的字节数据 """ byte_data = np.frombuffer(pcm_data, dtype=np.int16) # 16位PCM wav_io = io.BytesIO() with wave.open(wav_io, "wb") as wav_file: wav_file.setnchannels(num_channels) wav_file.setsampwidth(bits_per_sample // 8) wav_file.setframerate(sample_rate) wav_file.writeframes(byte_data.tobytes()) return wav_io.getvalue() async def text_to_speak(self, text, output_file): if self.protocol == "websocket": return await self.text_streaming(text, output_file) else: raise ValueError("Unsupported protocol. Please use 'websocket' or 'http'.") async def text_streaming(self, text, output_file): try: # 使用 websockets 异步连接到 WebSocket 服务器 async with websockets.connect(self.url) as ws: # 发送开始请求 start_request = { "task": "tts", "signal": "start" } await ws.send(json.dumps(start_request)) # 接收开始响应并提取 session_id start_response = await ws.recv() start_response = json.loads(start_response) # 解析 JSON 响应 if start_response.get("status") != 0: raise Exception(f"连接失败: {start_response.get('signal')}") session_id = start_response.get("session") # 发送待合成的文本数据 data_request = { "text": text, "spk_id": self.spk_id, } await ws.send(json.dumps(data_request)) audio_chunks = b"" timeout_seconds = self.tts_timeout try: while True: response = await asyncio.wait_for(ws.recv(), timeout=timeout_seconds) response = json.loads(response) # 解析 JSON 响应 status = response.get("status") if status == 2: # 最后一个数据包 break else: # 拼接音频数据(base64 编码的 PCM 数据) audio_chunks += base64.b64decode(response.get("audio")) except asyncio.TimeoutError: raise Exception(f"WebSocket 超时:等待音频数据超过 {timeout_seconds} 秒") # 将拼接后的 PCM 数据转换为 WAV 格式 wav_data = await self.pcm_to_wav(audio_chunks) # 结束请求 end_request = { "task": "tts", "signal": "end", "session": session_id # 会话 ID 必须与开始请求中的一致 } await ws.send(json.dumps(end_request)) # 接收结束响应避免服务抛出异常 await ws.recv() # 根据配置决定是否保存文件 if not self.delete_audio_file and self.save_path: with open(self.save_path, "wb") as f: f.write(wav_data) logger.bind(tag=TAG).info(f"音频文件已保存到: {self.save_path}") # 返回或保存音频数据 if output_file: with open(output_file, "wb") as file_to_save: file_to_save.write(wav_data) else: return wav_data except Exception as e: raise Exception(f"Error during TTS WebSocket request: {e} while processing text: {text}")