wangzhibo
2026-08-30 c8515170fd399b4d2dfaebb59128d4674e58915e
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
import os
import json
import time
import queue
import asyncio
import aiohttp
import requests
import traceback
 
from core.utils import textUtils
from config.logger import setup_logging
from core.utils.util import parse_string_to_list
from core.providers.tts.base import TTSProviderBase
from core.providers.tts.dto.dto import SentenceType, ContentType
from core.utils.tts import MarkdownCleaner, convert_percentage_to_range
 
 
TAG = __name__
logger = setup_logging()
 
 
class TTSProvider(TTSProviderBase):
    def __init__(self, config, delete_audio_file):
        super().__init__(config, delete_audio_file)
        self.group_id = config.get("group_id")
        self.api_key = config.get("api_key")
        self.model = config.get("model")
        if config.get("private_voice"):
            self.voice = config.get("private_voice")
        else:
            self.voice = config.get("voice_id")
 
        default_voice_setting = {
            "voice_id": "female-shaonv",
            "speed": 1,
            "vol": 1,
            "pitch": 0,
            "emotion": "happy",
        }
        default_pronunciation_dict = {"tone": ["处理/(chu3)(li3)", "危险/dangerous"]}
        defult_audio_setting = {
            "sample_rate": 24000,
            "bitrate": 128000,
            "format": "pcm",
            "channel": 1,
        }
        self.voice_setting = {
            **default_voice_setting,
            **config.get("voice_setting", {}),
        }
        self.pronunciation_dict = {
            **default_pronunciation_dict,
            **config.get("pronunciation_dict", {}),
        }
        self.audio_setting = {**defult_audio_setting, **config.get("audio_setting", {})}
        self.timber_weights = parse_string_to_list(config.get("timber_weights"))
 
        if self.voice:
            self.voice_setting["voice_id"] = self.voice
 
        # 应用百分比调整(如果存在),否则使用公有化配置
        if "ttsVolume" in config:
            self.voice_setting["vol"] = round(convert_percentage_to_range(
                config["ttsVolume"], min_val=0.1, max_val=10, base_val=1.0
            ), 1)
 
        if "ttsRate" in config:
            self.voice_setting["speed"] = round(convert_percentage_to_range(
                config["ttsRate"], min_val=0.5, max_val=2, base_val=1.0
            ), 1)
 
        if "ttsPitch" in config:
            self.voice_setting["pitch"] = int(convert_percentage_to_range(
                config["ttsPitch"], min_val=-12, max_val=12, base_val=0
            ))
 
        self.host = "api.minimaxi.com"  # 备用地址:api-bj.minimaxi.com
        self.api_url = f"https://{self.host}/v1/t2a_v2?GroupId={self.group_id}"
        self.header = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {self.api_key}",
        }
        self.audio_file_type = defult_audio_setting.get("format", "pcm")
 
        # PCM缓冲区
        self.pcm_buffer = bytearray()
 
    async def open_audio_channels(self, conn):
        """初始化音频通道,并根据conn.sample_rate更新配置"""
        # 调用父类方法
        await super().open_audio_channels(conn)
 
        # 更新audio_setting中的采样率为实际的conn.sample_rate
        self.audio_setting["sample_rate"] = conn.sample_rate
 
    def tts_text_priority_thread(self):
        """流式文本处理线程"""
        while not self.conn.stop_event.is_set():
            try:
                message = self.tts_text_queue.get(timeout=1)
                if message.sentence_type == SentenceType.FIRST:
                    # 初始化参数
                    self.tts_stop_request = False
                    self.processed_chars = 0
                    self.tts_text_buff = []
                    self.before_stop_play_files.clear()
                elif ContentType.TEXT == message.content_type:
                    self.tts_text_buff.append(message.content_detail)
                    segment_text = self._get_segment_text()
                    if segment_text:
                        self.to_tts_single_stream(segment_text)
 
                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:
                    # 处理剩余的文本
                    self._process_remaining_text_stream(True)
 
            except queue.Empty:
                continue
            except Exception as e:
                logger.bind(tag=TAG).error(
                    f"处理TTS文本失败: {str(e)}, 类型: {type(e).__name__}, 堆栈: {traceback.format_exc()}"
                )
 
    def _process_remaining_text_stream(self, is_last=False):
        """处理剩余的文本并生成语音
        Returns:
            bool: 是否成功处理了文本
        """
        full_text = "".join(self.tts_text_buff)
        remaining_text = full_text[self.processed_chars :]
        if remaining_text:
            segment_text = textUtils.get_string_no_punctuation_or_emoji(remaining_text)
            if segment_text:
                self.to_tts_single_stream(segment_text, is_last)
                self.processed_chars += len(full_text)
            else:
                self._process_before_stop_play_files()
        else:
            self._process_before_stop_play_files()
 
    def to_tts_single_stream(self, text, is_last=False):
        try:
            max_repeat_time = 5
            original_text = text
            text = MarkdownCleaner.clean_markdown(text)
            if self._correct_words_pattern:
                text = self._correct_words_pattern.sub(lambda m: self.correct_words[m.group(0)], text)
            try:
                asyncio.run(self.text_to_speak(text, is_last))
            except Exception as e:
                logger.bind(tag=TAG).warning(
                    f"语音生成失败{5 - max_repeat_time + 1}次: {original_text},错误: {e}"
                )
                max_repeat_time -= 1
 
            if max_repeat_time > 0:
                logger.bind(tag=TAG).info(
                    f"语音生成成功: {original_text},重试{5 - max_repeat_time}次"
                )
            else:
                logger.bind(tag=TAG).error(
                    f"语音生成失败: {original_text},请检查网络或服务是否正常"
                )
        except Exception as e:
            logger.bind(tag=TAG).error(f"Failed to generate TTS file: {e}")
        finally:
            return None
 
    async def text_to_speak(self, text, is_last):
        """流式处理TTS音频,每句只推送一次音频列表"""
        payload = {
            "model": self.model,
            "text": text,
            "stream": True,
            "voice_setting": self.voice_setting,
            "pronunciation_dict": self.pronunciation_dict,
            "audio_setting": self.audio_setting,
        }
 
        if type(self.timber_weights) is list and len(self.timber_weights) > 0:
            payload["timber_weights"] = self.timber_weights
            payload["voice_setting"]["voice_id"] = ""
 
        frame_bytes = int(
            self.opus_encoder.sample_rate
            * self.opus_encoder.channels  # 1
            * self.opus_encoder.frame_size_ms
            / 1000
            * 2
        )  # 16-bit = 2 bytes
        try:
            async with aiohttp.ClientSession() as session:
                async with session.post(
                    self.api_url,
                    headers=self.header,
                    data=json.dumps(payload),
                    timeout=self.tts_timeout,
                ) as resp:
 
                    if resp.status != 200:
                        logger.bind(tag=TAG).error(
                            f"TTS请求失败: {resp.status}, {await resp.text()}"
                        )
                        self.tts_audio_queue.put((SentenceType.LAST, [], None))
                        return
 
                    self.pcm_buffer.clear()
                    self.tts_audio_queue.put((SentenceType.FIRST, [], text))
 
                    # 处理音频流数据
                    buffer = b""
                    async for chunk in resp.content.iter_any():
                        if not chunk:
                            continue
 
                        buffer += chunk
                        while True:
                            # 查找数据块分隔符
                            header_pos = buffer.find(b"data: ")
                            if header_pos == -1:
                                break
 
                            end_pos = buffer.find(b"\n\n", header_pos)
                            if end_pos == -1:
                                break
 
                            # 提取单个完整JSON块
                            json_str = buffer[header_pos + 6 : end_pos].decode("utf-8")
                            buffer = buffer[end_pos + 2 :]
 
                            try:
                                data = json.loads(json_str)
 
                                # 检查业务层错误
                                base_resp = data.get("base_resp", {})
                                status_code = base_resp.get("status_code", 0)
                                if status_code != 0:
                                    status_msg = base_resp.get("status_msg", "未知错误")
                                    logger.bind(tag=TAG).error(
                                        f"TTS请求失败, 错误码:{status_code}, 错误消息:{status_msg}"
                                    )
                                    self.tts_audio_queue.put((SentenceType.LAST, [], None))
                                    return
 
                                status = data.get("data", {}).get("status", 1)
                                audio_hex = data.get("data", {}).get("audio")
 
                                # 仅处理status=1的有效音频块 忽略status=2的结束汇总块
                                if status == 1 and audio_hex:
                                    pcm_data = bytes.fromhex(audio_hex)
                                    self.pcm_buffer.extend(pcm_data)
 
                            except json.JSONDecodeError as e:
                                logger.bind(tag=TAG).error(f"JSON解析失败: {e}")
                                continue
 
                        while len(self.pcm_buffer) >= frame_bytes:
                            frame = bytes(self.pcm_buffer[:frame_bytes])
                            del self.pcm_buffer[:frame_bytes]
 
                            self.opus_encoder.encode_pcm_to_opus_stream(
                                frame, end_of_stream=False, callback=self.handle_opus
                            )
 
                    # flush 剩余不足一帧的数据
                    if self.pcm_buffer:
                        self.opus_encoder.encode_pcm_to_opus_stream(
                            bytes(self.pcm_buffer),
                            end_of_stream=True,
                            callback=self.handle_opus,
                        )
                        self.pcm_buffer.clear()
 
                    # 如果是最后一段,输出音频获取完毕
                    if is_last:
                        self._process_before_stop_play_files()
 
        except Exception as e:
            logger.bind(tag=TAG).error(f"TTS请求异常: {e}")
            self.tts_audio_queue.put((SentenceType.LAST, [], None))
 
    async def close(self):
        """资源清理"""
        await super().close()
        if hasattr(self, "opus_encoder"):
            self.opus_encoder.close()
 
    def to_tts(self, text: str) -> list:
        """非流式TTS处理,用于测试及保存音频文件的场景
        Args:
            text: 要转换的文本
        Returns:
            list: 返回opus编码后的音频数据列表
        """
        start_time = time.time()
        text = MarkdownCleaner.clean_markdown(text)
        if self._correct_words_pattern:
            text = self._correct_words_pattern.sub(lambda m: self.correct_words[m.group(0)], text)
 
        payload = {
            "model": self.model,
            "text": text,
            "stream": True,
            "voice_setting": self.voice_setting,
            "pronunciation_dict": self.pronunciation_dict,
            "audio_setting": self.audio_setting,
        }
 
        if type(self.timber_weights) is list and len(self.timber_weights) > 0:
            payload["timber_weights"] = self.timber_weights
            payload["voice_setting"]["voice_id"] = ""
 
        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {self.api_key}",
        }
 
        try:
            with requests.post(
                self.api_url,
                data=json.dumps(payload),
                headers=headers,
                timeout=self.tts_timeout,
            ) as response:
                if response.status_code != 200:
                    logger.bind(tag=TAG).error(
                        f"TTS请求失败: {response.status_code}, {response.text}"
                    )
                    return []
 
                logger.info(f"TTS请求成功: {text}, 耗时: {time.time() - start_time}秒")
 
                # 使用opus编码器处理PCM数据
                opus_datas = []
                full_content = response.content.decode('utf-8')
                pcm_data = bytearray()
                for data_block in full_content.split('\n\n'):
                    if not data_block.startswith('data: '):
                        continue
 
                    try:
                        json_str = data_block[6:]  # 去除'data: '前缀
                        data = json.loads(json_str)
                        if data.get('data', {}).get('status') == 1:
                            audio_hex = data['data']['audio']
                            pcm_data.extend(bytes.fromhex(audio_hex))
                    except (json.JSONDecodeError, KeyError) as e:
                        logger.bind(tag=TAG).warning(f"无效数据块: {e}")
                        continue
 
                # 计算每帧的字节数
                frame_bytes = int(
                    self.opus_encoder.sample_rate
                    * self.opus_encoder.channels
                    * self.opus_encoder.frame_size_ms
                    / 1000
                    * 2
                )
 
                # 分帧处理合并后的PCM数据
                for i in range(0, len(pcm_data), frame_bytes):
                    frame = bytes(pcm_data[i:i+frame_bytes])
                    if len(frame) < frame_bytes:
                        frame += b"\x00" * (frame_bytes - len(frame))
 
                    self.opus_encoder.encode_pcm_to_opus_stream(
                        frame,
                        end_of_stream=(i + frame_bytes >= len(pcm_data)),
                        callback=lambda opus: opus_datas.append(opus)
                    )
 
                return opus_datas
 
        except Exception as e:
            logger.bind(tag=TAG).error(f"TTS请求异常: {e}")
            return []