@flexem/chat-box 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +638 -0
- package/miniprogram_dist/TEST_CASES.md +256 -0
- package/miniprogram_dist/assets/icons/icon-arrow-down.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-arrow-up.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-avatar-default.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-back.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-camera.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-close.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-copy.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-delete.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-edit-msg.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-edit.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-file.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-image.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-keyboard.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-menu.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-play-voice.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-plus.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-regenerate.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-thinking.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-voice.svg +1 -0
- package/miniprogram_dist/components/attachment/index.js +169 -0
- package/miniprogram_dist/components/attachment/index.json +4 -0
- package/miniprogram_dist/components/attachment/index.wxml +40 -0
- package/miniprogram_dist/components/attachment/index.wxss +119 -0
- package/miniprogram_dist/components/input-bar/index.js +934 -0
- package/miniprogram_dist/components/input-bar/index.json +6 -0
- package/miniprogram_dist/components/input-bar/index.wxml +132 -0
- package/miniprogram_dist/components/input-bar/index.wxss +324 -0
- package/miniprogram_dist/components/message/index.js +988 -0
- package/miniprogram_dist/components/message/index.json +4 -0
- package/miniprogram_dist/components/message/index.wxml +285 -0
- package/miniprogram_dist/components/message/index.wxss +575 -0
- package/miniprogram_dist/components/sidebar/index.js +506 -0
- package/miniprogram_dist/components/sidebar/index.json +4 -0
- package/miniprogram_dist/components/sidebar/index.wxml +137 -0
- package/miniprogram_dist/components/sidebar/index.wxss +264 -0
- package/miniprogram_dist/index.js +1316 -0
- package/miniprogram_dist/index.json +8 -0
- package/miniprogram_dist/index.wxml +172 -0
- package/miniprogram_dist/index.wxss +291 -0
- package/miniprogram_dist/package.json +5 -0
- package/miniprogram_dist/utils/api.js +474 -0
- package/miniprogram_dist/utils/audio.js +860 -0
- package/miniprogram_dist/utils/storage.js +168 -0
- package/package.json +27 -0
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 音频工具模块
|
|
3
|
+
* 处理语音播放(TTS)和录音
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// 音频播放器实例
|
|
7
|
+
let innerAudioContext = null;
|
|
8
|
+
|
|
9
|
+
// 当前播放状态
|
|
10
|
+
let currentPlayingId = null;
|
|
11
|
+
let onPlayStateChange = null;
|
|
12
|
+
let currentSegmentPlayer = null; // 当前正在播放的片段播放器(用于分段播放)
|
|
13
|
+
let currentPlayState = null; // 当前的 playState 对象引用(用于停止时标记)
|
|
14
|
+
|
|
15
|
+
// 流式播放队列相关
|
|
16
|
+
let streamingId = null; // 当前流式播放的消息 ID
|
|
17
|
+
let lastPlayedLength = 0; // 上次已处理的文本长度
|
|
18
|
+
let isStreamingMode = false; // 是否处于流式播放模式
|
|
19
|
+
let streamingComplete = false; // 流式输出是否已完成
|
|
20
|
+
|
|
21
|
+
// 双队列机制:文本队列 -> TTS合成 -> 音频队列 -> 播放
|
|
22
|
+
let textQueue = []; // 待合成的文本片段队列
|
|
23
|
+
let audioQueue = []; // 已合成的音频队列(按顺序)
|
|
24
|
+
let isSynthesizing = false; // 是否正在合成
|
|
25
|
+
let isPlayingAudio = false; // 是否正在播放音频
|
|
26
|
+
let currentSegmentIndex = 0; // 当前处理的片段序号(用于保证顺序)
|
|
27
|
+
|
|
28
|
+
// 分段配置
|
|
29
|
+
const SEGMENT_MIN_LENGTH = 30; // 最小分段长度(增大以减少分段数量)
|
|
30
|
+
const SEGMENT_DELIMITERS = ['。', '!', '?', ';', '\n', '.', '!', '?', ';']; // 分段符号
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 初始化音频播放器
|
|
34
|
+
*/
|
|
35
|
+
function initAudioPlayer() {
|
|
36
|
+
if (innerAudioContext) {
|
|
37
|
+
return innerAudioContext;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
innerAudioContext = wx.createInnerAudioContext();
|
|
41
|
+
|
|
42
|
+
innerAudioContext.onPlay(() => {
|
|
43
|
+
console.log('音频开始播放');
|
|
44
|
+
if (onPlayStateChange) {
|
|
45
|
+
onPlayStateChange({ playing: true, id: currentPlayingId });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
innerAudioContext.onPause(() => {
|
|
50
|
+
console.log('音频暂停');
|
|
51
|
+
if (onPlayStateChange) {
|
|
52
|
+
onPlayStateChange({ playing: false, id: currentPlayingId });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
innerAudioContext.onStop(() => {
|
|
57
|
+
console.log('音频停止');
|
|
58
|
+
if (onPlayStateChange) {
|
|
59
|
+
onPlayStateChange({ playing: false, id: currentPlayingId });
|
|
60
|
+
}
|
|
61
|
+
currentPlayingId = null;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
innerAudioContext.onEnded(() => {
|
|
65
|
+
console.log('音频播放结束');
|
|
66
|
+
// 流式模式下,播放完当前段后继续播放队列中的下一段
|
|
67
|
+
if (isStreamingMode && streamingId) {
|
|
68
|
+
isPlayingAudio = false;
|
|
69
|
+
playNextAudio();
|
|
70
|
+
} else {
|
|
71
|
+
if (onPlayStateChange) {
|
|
72
|
+
onPlayStateChange({ playing: false, id: currentPlayingId });
|
|
73
|
+
}
|
|
74
|
+
currentPlayingId = null;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
innerAudioContext.onError((err) => {
|
|
79
|
+
console.error('音频播放错误', err);
|
|
80
|
+
if (onPlayStateChange) {
|
|
81
|
+
onPlayStateChange({ playing: false, id: currentPlayingId, error: err });
|
|
82
|
+
}
|
|
83
|
+
currentPlayingId = null;
|
|
84
|
+
// 流式模式下出错也要继续尝试播放下一段
|
|
85
|
+
if (isStreamingMode && streamingId) {
|
|
86
|
+
isPlayingAudio = false;
|
|
87
|
+
playNextAudio();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return innerAudioContext;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 设置播放状态回调
|
|
96
|
+
*/
|
|
97
|
+
function setPlayStateCallback(callback) {
|
|
98
|
+
onPlayStateChange = callback;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 播放音频
|
|
103
|
+
* @param {string} src - 音频地址
|
|
104
|
+
* @param {string} id - 消息ID(用于标识当前播放的消息)
|
|
105
|
+
*/
|
|
106
|
+
function play(src, id) {
|
|
107
|
+
const player = initAudioPlayer();
|
|
108
|
+
|
|
109
|
+
// 如果正在播放同一个,则暂停
|
|
110
|
+
if (currentPlayingId === id && !player.paused) {
|
|
111
|
+
player.pause();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 如果正在播放其他的,先停止
|
|
116
|
+
if (currentPlayingId && currentPlayingId !== id) {
|
|
117
|
+
player.stop();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
currentPlayingId = id;
|
|
121
|
+
player.src = src;
|
|
122
|
+
player.play();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 暂停播放
|
|
127
|
+
*/
|
|
128
|
+
function pause() {
|
|
129
|
+
if (innerAudioContext) {
|
|
130
|
+
innerAudioContext.pause();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 停止播放
|
|
136
|
+
*/
|
|
137
|
+
function stop() {
|
|
138
|
+
// 停止分段播放的片段播放器
|
|
139
|
+
if (currentSegmentPlayer) {
|
|
140
|
+
try {
|
|
141
|
+
currentSegmentPlayer.stop();
|
|
142
|
+
currentSegmentPlayer.destroy();
|
|
143
|
+
} catch (e) {
|
|
144
|
+
console.log('[Audio] 停止片段播放器时出错', e);
|
|
145
|
+
}
|
|
146
|
+
currentSegmentPlayer = null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 标记当前的 playState 为已停止
|
|
150
|
+
if (currentPlayState) {
|
|
151
|
+
currentPlayState.stopped = true;
|
|
152
|
+
currentPlayState = null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 停止全局播放器
|
|
156
|
+
if (innerAudioContext) {
|
|
157
|
+
innerAudioContext.stop();
|
|
158
|
+
}
|
|
159
|
+
currentPlayingId = null;
|
|
160
|
+
// 停止流式播放
|
|
161
|
+
stopStreamingPlay();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 获取当前播放ID
|
|
166
|
+
*/
|
|
167
|
+
function getCurrentPlayingId() {
|
|
168
|
+
return currentPlayingId;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 是否正在播放
|
|
173
|
+
*/
|
|
174
|
+
function isPlaying() {
|
|
175
|
+
return innerAudioContext && !innerAudioContext.paused && currentPlayingId;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 销毁播放器
|
|
180
|
+
*/
|
|
181
|
+
function destroy() {
|
|
182
|
+
if (innerAudioContext) {
|
|
183
|
+
innerAudioContext.destroy();
|
|
184
|
+
innerAudioContext = null;
|
|
185
|
+
}
|
|
186
|
+
currentPlayingId = null;
|
|
187
|
+
onPlayStateChange = null;
|
|
188
|
+
stopStreamingPlay();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 清理文本,移除 Markdown 格式和特殊字符
|
|
193
|
+
* @param {string} text - 原始文本
|
|
194
|
+
* @returns {string} - 清理后的纯文本
|
|
195
|
+
*/
|
|
196
|
+
function cleanTextForTTS(text) {
|
|
197
|
+
if (!text) return '';
|
|
198
|
+
|
|
199
|
+
let cleaned = text;
|
|
200
|
+
|
|
201
|
+
// 移除代码块(```...```)
|
|
202
|
+
cleaned = cleaned.replace(/```[\s\S]*?```/g, '');
|
|
203
|
+
|
|
204
|
+
// 移除行内代码(`...`)
|
|
205
|
+
cleaned = cleaned.replace(/`[^`]+`/g, '');
|
|
206
|
+
|
|
207
|
+
// 移除链接,保留链接文本 [text](url) -> text
|
|
208
|
+
cleaned = cleaned.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1');
|
|
209
|
+
|
|
210
|
+
// 移除图片 
|
|
211
|
+
cleaned = cleaned.replace(/!\[([^\]]*)\]\([^\)]+\)/g, '');
|
|
212
|
+
|
|
213
|
+
// 移除标题标记 (# ## ### 等)
|
|
214
|
+
cleaned = cleaned.replace(/^#{1,6}\s+/gm, '');
|
|
215
|
+
|
|
216
|
+
// 移除粗体和斜体标记 (**text** *text*)
|
|
217
|
+
cleaned = cleaned.replace(/\*\*([^\*]+)\*\*/g, '$1');
|
|
218
|
+
cleaned = cleaned.replace(/\*([^\*]+)\*/g, '$1');
|
|
219
|
+
|
|
220
|
+
// 移除删除线 (~~text~~)
|
|
221
|
+
cleaned = cleaned.replace(/~~([^~]+)~~/g, '$1');
|
|
222
|
+
|
|
223
|
+
// 移除引用标记 (> )
|
|
224
|
+
cleaned = cleaned.replace(/^>\s+/gm, '');
|
|
225
|
+
|
|
226
|
+
// 移除列表标记 (- * 1. 等)
|
|
227
|
+
cleaned = cleaned.replace(/^[\s]*[-*+]\s+/gm, '');
|
|
228
|
+
cleaned = cleaned.replace(/^[\s]*\d+\.\s+/gm, '');
|
|
229
|
+
|
|
230
|
+
// 移除水平分隔线
|
|
231
|
+
cleaned = cleaned.replace(/^[\s]*[-*_]{3,}[\s]*$/gm, '');
|
|
232
|
+
|
|
233
|
+
// 移除 HTML 标签
|
|
234
|
+
cleaned = cleaned.replace(/<[^>]+>/g, '');
|
|
235
|
+
|
|
236
|
+
// 移除 URL
|
|
237
|
+
cleaned = cleaned.replace(/https?:\/\/[^\s]+/g, '');
|
|
238
|
+
|
|
239
|
+
// 移除特殊标点符号(TTS 可能不支持)
|
|
240
|
+
cleaned = cleaned.replace(/[【】「」『』〖〗《》〈〉]/g, '');
|
|
241
|
+
|
|
242
|
+
// 将英文括号替换为空格(避免 TTS 问题)
|
|
243
|
+
cleaned = cleaned.replace(/[()]/g, ' ');
|
|
244
|
+
|
|
245
|
+
// 移除多余的空白字符
|
|
246
|
+
cleaned = cleaned.replace(/\s+/g, ' ');
|
|
247
|
+
|
|
248
|
+
// 移除首尾空格
|
|
249
|
+
cleaned = cleaned.trim();
|
|
250
|
+
|
|
251
|
+
return cleaned;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 文字转语音(使用微信同声传译插件)
|
|
256
|
+
* @param {Object} options - 配置选项
|
|
257
|
+
* @param {string} options.content - 要转换的文字
|
|
258
|
+
* @param {Function} options.onSuccess - 成功回调,返回音频地址
|
|
259
|
+
* @param {Function} options.onError - 失败回调
|
|
260
|
+
*/
|
|
261
|
+
function textToSpeech(options) {
|
|
262
|
+
const { content, onSuccess, onError } = options;
|
|
263
|
+
|
|
264
|
+
console.log('[TTS] 原始内容长度:', content ? content.length : 0);
|
|
265
|
+
|
|
266
|
+
if (!content) {
|
|
267
|
+
if (onError) {
|
|
268
|
+
onError({ message: '内容为空' });
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 清理文本,移除 Markdown 格式
|
|
274
|
+
const cleanedContent = cleanTextForTTS(content);
|
|
275
|
+
|
|
276
|
+
console.log('[TTS] 清理后内容长度:', cleanedContent ? cleanedContent.length : 0);
|
|
277
|
+
console.log('[TTS] 清理后内容预览:', cleanedContent ? cleanedContent.substring(0, 200) : '');
|
|
278
|
+
|
|
279
|
+
if (!cleanedContent) {
|
|
280
|
+
if (onError) {
|
|
281
|
+
onError({ message: '内容为空' });
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
// 尝试获取微信同声传译插件
|
|
288
|
+
const plugin = requirePlugin('WechatSI');
|
|
289
|
+
if (!plugin || !plugin.textToSpeech) {
|
|
290
|
+
if (onError) {
|
|
291
|
+
onError({ message: '未配置语音合成插件' });
|
|
292
|
+
}
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 限制长度为 150 字符(更保守的限制,避免服务器错误)
|
|
297
|
+
const finalContent = cleanedContent.substring(0, 150);
|
|
298
|
+
|
|
299
|
+
console.log('[TTS] 最终发送内容长度:', finalContent.length);
|
|
300
|
+
console.log('[TTS] 最终发送内容:', finalContent);
|
|
301
|
+
|
|
302
|
+
plugin.textToSpeech({
|
|
303
|
+
lang: 'zh_CN',
|
|
304
|
+
content: finalContent,
|
|
305
|
+
success: (res) => {
|
|
306
|
+
console.log('[TTS] 成功', res);
|
|
307
|
+
if (onSuccess) {
|
|
308
|
+
onSuccess({ src: res.filename });
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
fail: (err) => {
|
|
312
|
+
console.error('[TTS] 失败', err);
|
|
313
|
+
console.error('[TTS] 失败时的内容:', finalContent);
|
|
314
|
+
if (onError) {
|
|
315
|
+
onError(err);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
} catch (e) {
|
|
320
|
+
console.error('[TTS] 异常', e);
|
|
321
|
+
if (onError) {
|
|
322
|
+
onError({ message: '语音合成失败' });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* 播放文字(先转语音再播放)
|
|
329
|
+
* 支持长文本分段连续播放:边播放边合成下一段
|
|
330
|
+
* @param {string} content - 要播放的文字
|
|
331
|
+
* @param {string} id - 消息ID
|
|
332
|
+
* @param {Object} callbacks - 回调函数
|
|
333
|
+
* @param {Function} callbacks.onSynthesizing - 开始合成回调
|
|
334
|
+
* @param {Function} callbacks.onSynthesized - 合成完成回调(首段合成完成时触发)
|
|
335
|
+
* @param {Function} callbacks.onError - 错误回调
|
|
336
|
+
*/
|
|
337
|
+
function playText(content, id, callbacks = {}) {
|
|
338
|
+
// 如果正在播放同一个,则停止
|
|
339
|
+
if (currentPlayingId === id && isPlaying()) {
|
|
340
|
+
stop();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 停止之前的播放
|
|
345
|
+
stop();
|
|
346
|
+
|
|
347
|
+
// 清理文本
|
|
348
|
+
const cleanedContent = cleanTextForTTS(content);
|
|
349
|
+
if (!cleanedContent) {
|
|
350
|
+
if (callbacks.onError) {
|
|
351
|
+
callbacks.onError({ message: '内容为空' });
|
|
352
|
+
}
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 分段配置
|
|
357
|
+
const SEGMENT_LENGTH = 150;
|
|
358
|
+
|
|
359
|
+
// 将文本按句子边界分段,每段不超过 SEGMENT_LENGTH 字符
|
|
360
|
+
const segments = splitTextIntoSegments(cleanedContent, SEGMENT_LENGTH);
|
|
361
|
+
const totalSegments = segments.length;
|
|
362
|
+
|
|
363
|
+
if (totalSegments === 0) {
|
|
364
|
+
if (callbacks.onError) {
|
|
365
|
+
callbacks.onError({ message: '内容为空' });
|
|
366
|
+
}
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
console.log('[TTS] 文本分段数:', totalSegments);
|
|
371
|
+
segments.forEach((seg, idx) => {
|
|
372
|
+
console.log('[TTS] 片段', idx, '长度:', seg.length);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// 通知开始合成
|
|
376
|
+
if (callbacks.onSynthesizing) {
|
|
377
|
+
callbacks.onSynthesizing();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 初始化分段播放状态
|
|
381
|
+
const playState = {
|
|
382
|
+
id: id,
|
|
383
|
+
segments: segments,
|
|
384
|
+
totalSegments: totalSegments,
|
|
385
|
+
audioSegments: [], // 已合成的音频片段 { index, src }
|
|
386
|
+
synthesizingIdx: 0, // 下一个要合成的片段索引
|
|
387
|
+
playingIdx: 0, // 下一个要播放的片段索引
|
|
388
|
+
isPlayingSegment: false,
|
|
389
|
+
firstSynthesized: false,
|
|
390
|
+
stopped: false
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
currentPlayingId = id;
|
|
394
|
+
currentPlayState = playState; // 保存引用,用于 stop() 时标记
|
|
395
|
+
|
|
396
|
+
// 合成下一个片段
|
|
397
|
+
function synthesizeNext() {
|
|
398
|
+
if (playState.stopped || playState.synthesizingIdx >= playState.totalSegments) {
|
|
399
|
+
return; // 已停止或全部合成完成
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const idx = playState.synthesizingIdx;
|
|
403
|
+
const segmentText = playState.segments[idx];
|
|
404
|
+
playState.synthesizingIdx++;
|
|
405
|
+
|
|
406
|
+
console.log('[TTS] 开始合成片段', idx, ':', segmentText.substring(0, 30) + '...');
|
|
407
|
+
|
|
408
|
+
textToSpeechRaw({
|
|
409
|
+
content: segmentText,
|
|
410
|
+
onSuccess: (res) => {
|
|
411
|
+
if (playState.stopped) return;
|
|
412
|
+
|
|
413
|
+
console.log('[TTS] 片段', idx, '合成成功');
|
|
414
|
+
playState.audioSegments.push({ index: idx, src: res.src });
|
|
415
|
+
// 按索引排序
|
|
416
|
+
playState.audioSegments.sort((a, b) => a.index - b.index);
|
|
417
|
+
|
|
418
|
+
// 首段合成完成时通知
|
|
419
|
+
if (!playState.firstSynthesized && idx === 0) {
|
|
420
|
+
playState.firstSynthesized = true;
|
|
421
|
+
if (callbacks.onSynthesized) {
|
|
422
|
+
callbacks.onSynthesized();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// 继续合成下一段(预加载)
|
|
427
|
+
if (playState.synthesizingIdx < playState.totalSegments) {
|
|
428
|
+
synthesizeNext();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// 尝试播放
|
|
432
|
+
tryPlayNext();
|
|
433
|
+
},
|
|
434
|
+
onError: (err) => {
|
|
435
|
+
if (playState.stopped) return;
|
|
436
|
+
|
|
437
|
+
console.error('[TTS] 片段', idx, '合成失败', err);
|
|
438
|
+
// 标记该片段失败,增加 playingIdx 跳过
|
|
439
|
+
playState.playingIdx++;
|
|
440
|
+
|
|
441
|
+
// 继续合成下一段
|
|
442
|
+
if (playState.synthesizingIdx < playState.totalSegments) {
|
|
443
|
+
synthesizeNext();
|
|
444
|
+
}
|
|
445
|
+
// 尝试播放下一段
|
|
446
|
+
tryPlayNext();
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// 尝试播放下一个片段
|
|
452
|
+
function tryPlayNext() {
|
|
453
|
+
if (playState.stopped) return;
|
|
454
|
+
|
|
455
|
+
if (playState.isPlayingSegment) {
|
|
456
|
+
console.log('[TTS] 正在播放中,跳过');
|
|
457
|
+
return; // 正在播放中
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (currentPlayingId !== playState.id) {
|
|
461
|
+
console.log('[TTS] ID 不匹配,停止');
|
|
462
|
+
return; // 已停止或切换到其他消息
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// 检查是否全部播放完成
|
|
466
|
+
if (playState.playingIdx >= playState.totalSegments) {
|
|
467
|
+
console.log('[TTS] 全部播放完成');
|
|
468
|
+
currentPlayingId = null;
|
|
469
|
+
if (onPlayStateChange) {
|
|
470
|
+
onPlayStateChange({ playing: false, id: playState.id });
|
|
471
|
+
}
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// 查找下一个要播放的片段
|
|
476
|
+
const audioIdx = playState.audioSegments.findIndex(a => a.index === playState.playingIdx);
|
|
477
|
+
if (audioIdx === -1) {
|
|
478
|
+
console.log('[TTS] 片段', playState.playingIdx, '还未合成完成,等待');
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// 取出并播放
|
|
483
|
+
const audio = playState.audioSegments.splice(audioIdx, 1)[0];
|
|
484
|
+
playState.isPlayingSegment = true;
|
|
485
|
+
const currentIdx = playState.playingIdx;
|
|
486
|
+
playState.playingIdx++;
|
|
487
|
+
|
|
488
|
+
console.log('[TTS] 开始播放片段', currentIdx, ', 剩余待播放:', playState.totalSegments - playState.playingIdx);
|
|
489
|
+
|
|
490
|
+
// 创建新的播放器实例避免事件冲突
|
|
491
|
+
const segmentPlayer = wx.createInnerAudioContext();
|
|
492
|
+
currentSegmentPlayer = segmentPlayer; // 保存引用,用于 stop() 时停止
|
|
493
|
+
|
|
494
|
+
segmentPlayer.onEnded(() => {
|
|
495
|
+
console.log('[TTS] 片段', currentIdx, '播放结束');
|
|
496
|
+
playState.isPlayingSegment = false;
|
|
497
|
+
currentSegmentPlayer = null; // 清除引用
|
|
498
|
+
segmentPlayer.destroy();
|
|
499
|
+
// 继续播放下一段
|
|
500
|
+
tryPlayNext();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
segmentPlayer.onError((err) => {
|
|
504
|
+
console.error('[TTS] 片段', currentIdx, '播放错误', err);
|
|
505
|
+
playState.isPlayingSegment = false;
|
|
506
|
+
currentSegmentPlayer = null; // 清除引用
|
|
507
|
+
segmentPlayer.destroy();
|
|
508
|
+
// 继续播放下一段
|
|
509
|
+
tryPlayNext();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
segmentPlayer.src = audio.src;
|
|
513
|
+
segmentPlayer.play();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// 开始合成第一段和第二段(预加载)
|
|
517
|
+
synthesizeNext();
|
|
518
|
+
if (totalSegments > 1) {
|
|
519
|
+
synthesizeNext();
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* 将文本按句子边界分段
|
|
525
|
+
* @param {string} text - 清理后的文本
|
|
526
|
+
* @param {number} maxLength - 每段最大长度
|
|
527
|
+
* @returns {string[]} - 分段后的文本数组
|
|
528
|
+
*/
|
|
529
|
+
function splitTextIntoSegments(text, maxLength) {
|
|
530
|
+
const segments = [];
|
|
531
|
+
let remaining = text;
|
|
532
|
+
|
|
533
|
+
// 句子分隔符
|
|
534
|
+
const delimiters = ['。', '!', '?', ';', '\n', '.', '!', '?', ';'];
|
|
535
|
+
|
|
536
|
+
while (remaining.length > 0) {
|
|
537
|
+
if (remaining.length <= maxLength) {
|
|
538
|
+
segments.push(remaining);
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// 在 maxLength 范围内查找最后一个分隔符
|
|
543
|
+
let splitPos = -1;
|
|
544
|
+
const searchRange = remaining.substring(0, maxLength);
|
|
545
|
+
|
|
546
|
+
for (const delimiter of delimiters) {
|
|
547
|
+
const lastPos = searchRange.lastIndexOf(delimiter);
|
|
548
|
+
if (lastPos > splitPos) {
|
|
549
|
+
splitPos = lastPos;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (splitPos === -1 || splitPos < maxLength / 2) {
|
|
554
|
+
// 没找到合适的分隔符,或者分隔符太靠前,直接按长度截断
|
|
555
|
+
splitPos = maxLength - 1;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// 包含分隔符
|
|
559
|
+
segments.push(remaining.substring(0, splitPos + 1).trim());
|
|
560
|
+
remaining = remaining.substring(splitPos + 1).trim();
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return segments.filter(s => s.length > 0);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* 内部 TTS 函数(不做文本清理,直接调用插件)
|
|
568
|
+
*/
|
|
569
|
+
function textToSpeechRaw(options) {
|
|
570
|
+
const { content, onSuccess, onError } = options;
|
|
571
|
+
|
|
572
|
+
if (!content) {
|
|
573
|
+
if (onError) {
|
|
574
|
+
onError({ message: '内容为空' });
|
|
575
|
+
}
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
try {
|
|
580
|
+
const plugin = requirePlugin('WechatSI');
|
|
581
|
+
if (!plugin || !plugin.textToSpeech) {
|
|
582
|
+
if (onError) {
|
|
583
|
+
onError({ message: '未配置语音合成插件' });
|
|
584
|
+
}
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
plugin.textToSpeech({
|
|
589
|
+
lang: 'zh_CN',
|
|
590
|
+
content: content,
|
|
591
|
+
success: (res) => {
|
|
592
|
+
if (onSuccess) {
|
|
593
|
+
onSuccess({ src: res.filename });
|
|
594
|
+
}
|
|
595
|
+
},
|
|
596
|
+
fail: (err) => {
|
|
597
|
+
if (onError) {
|
|
598
|
+
onError(err);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
} catch (e) {
|
|
603
|
+
if (onError) {
|
|
604
|
+
onError({ message: '语音合成失败' });
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ==================== 流式播放功能 ====================
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* 开始流式播放
|
|
613
|
+
* @param {string} id - 消息ID
|
|
614
|
+
*/
|
|
615
|
+
function startStreamingPlay(id) {
|
|
616
|
+
console.log('开始流式播放, id:', id);
|
|
617
|
+
// 如果已经在播放其他消息,先停止
|
|
618
|
+
if (streamingId && streamingId !== id) {
|
|
619
|
+
stopStreamingPlay();
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
streamingId = id;
|
|
623
|
+
textQueue = [];
|
|
624
|
+
audioQueue = [];
|
|
625
|
+
lastPlayedLength = 0;
|
|
626
|
+
isStreamingMode = true;
|
|
627
|
+
isSynthesizing = false;
|
|
628
|
+
isPlayingAudio = false;
|
|
629
|
+
streamingComplete = false;
|
|
630
|
+
currentPlayingId = id;
|
|
631
|
+
currentSegmentIndex = 0;
|
|
632
|
+
nextPlayIndex = 0; // 重置播放索引
|
|
633
|
+
|
|
634
|
+
// 通知播放状态
|
|
635
|
+
if (onPlayStateChange) {
|
|
636
|
+
onPlayStateChange({ playing: true, id: id });
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* 停止流式播放
|
|
642
|
+
*/
|
|
643
|
+
function stopStreamingPlay() {
|
|
644
|
+
console.log('停止流式播放');
|
|
645
|
+
textQueue = [];
|
|
646
|
+
audioQueue = [];
|
|
647
|
+
streamingId = null;
|
|
648
|
+
lastPlayedLength = 0;
|
|
649
|
+
isStreamingMode = false;
|
|
650
|
+
isSynthesizing = false;
|
|
651
|
+
isPlayingAudio = false;
|
|
652
|
+
streamingComplete = false;
|
|
653
|
+
currentSegmentIndex = 0;
|
|
654
|
+
nextPlayIndex = 0; // 重置播放索引
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* 更新流式内容(推送新的文本内容)
|
|
659
|
+
* @param {string} id - 消息ID
|
|
660
|
+
* @param {string} fullContent - 完整的文本内容
|
|
661
|
+
*/
|
|
662
|
+
function updateStreamingContent(id, fullContent) {
|
|
663
|
+
// 检查是否是当前流式播放的消息
|
|
664
|
+
if (!isStreamingMode || streamingId !== id) {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (!fullContent || fullContent.length <= lastPlayedLength) {
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// 获取新增的内容
|
|
673
|
+
const newContent = fullContent.substring(lastPlayedLength);
|
|
674
|
+
|
|
675
|
+
// 查找分段点
|
|
676
|
+
let segmentEnd = -1;
|
|
677
|
+
for (const delimiter of SEGMENT_DELIMITERS) {
|
|
678
|
+
const idx = newContent.indexOf(delimiter);
|
|
679
|
+
if (idx !== -1 && idx >= SEGMENT_MIN_LENGTH - 1) {
|
|
680
|
+
if (segmentEnd === -1 || idx < segmentEnd) {
|
|
681
|
+
segmentEnd = idx + 1; // 包含分隔符
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// 如果找到分段点,将这一段加入队列
|
|
687
|
+
if (segmentEnd > 0) {
|
|
688
|
+
const segment = newContent.substring(0, segmentEnd).trim();
|
|
689
|
+
if (segment) {
|
|
690
|
+
const index = currentSegmentIndex++;
|
|
691
|
+
console.log('添加语音片段到队列, index:', index, 'text:', segment.substring(0, 20) + '...');
|
|
692
|
+
textQueue.push({ index, text: segment });
|
|
693
|
+
lastPlayedLength += segmentEnd;
|
|
694
|
+
|
|
695
|
+
// 触发合成流水线
|
|
696
|
+
processSynthesisQueue();
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* 完成流式输出(处理剩余内容)
|
|
703
|
+
* @param {string} id - 消息ID
|
|
704
|
+
* @param {string} fullContent - 完整的文本内容
|
|
705
|
+
*/
|
|
706
|
+
function finishStreamingContent(id, fullContent) {
|
|
707
|
+
console.log('流式输出完成, id:', id);
|
|
708
|
+
|
|
709
|
+
// 检查是否是当前流式播放的消息
|
|
710
|
+
if (!isStreamingMode || streamingId !== id) {
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
streamingComplete = true;
|
|
715
|
+
|
|
716
|
+
// 将剩余内容加入队列
|
|
717
|
+
if (fullContent && fullContent.length > lastPlayedLength) {
|
|
718
|
+
const remaining = fullContent.substring(lastPlayedLength).trim();
|
|
719
|
+
if (remaining) {
|
|
720
|
+
const index = currentSegmentIndex++;
|
|
721
|
+
console.log('添加剩余内容到队列, index:', index);
|
|
722
|
+
textQueue.push({ index, text: remaining });
|
|
723
|
+
lastPlayedLength = fullContent.length;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// 触发合成流水线
|
|
728
|
+
processSynthesisQueue();
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* 处理合成队列 - 将文本转换为音频
|
|
733
|
+
* 支持并行合成多个片段,但保证按顺序播放
|
|
734
|
+
*/
|
|
735
|
+
function processSynthesisQueue() {
|
|
736
|
+
if (!isStreamingMode) {
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// 如果正在合成,等待当前合成完成
|
|
741
|
+
if (isSynthesizing) {
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// 如果文本队列为空,检查是否需要结束
|
|
746
|
+
if (textQueue.length === 0) {
|
|
747
|
+
// 尝试播放已合成的音频
|
|
748
|
+
playNextAudio();
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// 取出下一个待合成的文本
|
|
753
|
+
const item = textQueue.shift();
|
|
754
|
+
isSynthesizing = true;
|
|
755
|
+
|
|
756
|
+
console.log('开始合成语音, index:', item.index);
|
|
757
|
+
|
|
758
|
+
// 转换为语音
|
|
759
|
+
textToSpeech({
|
|
760
|
+
content: item.text,
|
|
761
|
+
onSuccess: (res) => {
|
|
762
|
+
if (!isStreamingMode) {
|
|
763
|
+
return; // 已停止,忽略结果
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
console.log('语音合成完成, index:', item.index);
|
|
767
|
+
// 将合成结果加入音频队列(保持顺序)
|
|
768
|
+
audioQueue.push({ index: item.index, src: res.src });
|
|
769
|
+
// 按 index 排序,确保顺序
|
|
770
|
+
audioQueue.sort((a, b) => a.index - b.index);
|
|
771
|
+
|
|
772
|
+
isSynthesizing = false;
|
|
773
|
+
|
|
774
|
+
// 继续合成下一个
|
|
775
|
+
processSynthesisQueue();
|
|
776
|
+
|
|
777
|
+
// 尝试播放
|
|
778
|
+
playNextAudio();
|
|
779
|
+
},
|
|
780
|
+
onError: (err) => {
|
|
781
|
+
console.error('语音合成失败, index:', item.index, err);
|
|
782
|
+
isSynthesizing = false;
|
|
783
|
+
|
|
784
|
+
// 继续合成下一个
|
|
785
|
+
processSynthesisQueue();
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* 播放下一个音频
|
|
792
|
+
* 按顺序从音频队列中取出并播放
|
|
793
|
+
*/
|
|
794
|
+
let nextPlayIndex = 0; // 下一个应该播放的 index
|
|
795
|
+
|
|
796
|
+
function playNextAudio() {
|
|
797
|
+
if (!isStreamingMode) {
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// 如果正在播放,等待播放完成
|
|
802
|
+
if (isPlayingAudio) {
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// 查找下一个应该播放的音频(按 index 顺序)
|
|
807
|
+
const audioIndex = audioQueue.findIndex(a => a.index === nextPlayIndex);
|
|
808
|
+
|
|
809
|
+
if (audioIndex === -1) {
|
|
810
|
+
// 下一个音频还没合成完成,等待
|
|
811
|
+
// 检查是否全部完成
|
|
812
|
+
if (streamingComplete && textQueue.length === 0 && audioQueue.length === 0 && !isSynthesizing) {
|
|
813
|
+
console.log('流式播放全部完成');
|
|
814
|
+
const id = streamingId;
|
|
815
|
+
stopStreamingPlay();
|
|
816
|
+
if (onPlayStateChange) {
|
|
817
|
+
onPlayStateChange({ playing: false, id: id });
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// 取出音频并播放
|
|
824
|
+
const audio = audioQueue.splice(audioIndex, 1)[0];
|
|
825
|
+
isPlayingAudio = true;
|
|
826
|
+
nextPlayIndex++;
|
|
827
|
+
|
|
828
|
+
console.log('播放语音片段, index:', audio.index);
|
|
829
|
+
|
|
830
|
+
const player = initAudioPlayer();
|
|
831
|
+
player.src = audio.src;
|
|
832
|
+
player.play();
|
|
833
|
+
// onEnded 回调会设置 isPlayingAudio = false 并调用 playNextAudio
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* 是否正在流式播放
|
|
838
|
+
*/
|
|
839
|
+
function isStreamingPlaying() {
|
|
840
|
+
return isStreamingMode && streamingId;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
module.exports = {
|
|
844
|
+
initAudioPlayer,
|
|
845
|
+
setPlayStateCallback,
|
|
846
|
+
play,
|
|
847
|
+
pause,
|
|
848
|
+
stop,
|
|
849
|
+
getCurrentPlayingId,
|
|
850
|
+
isPlaying,
|
|
851
|
+
destroy,
|
|
852
|
+
textToSpeech,
|
|
853
|
+
playText,
|
|
854
|
+
// 流式播放相关
|
|
855
|
+
startStreamingPlay,
|
|
856
|
+
stopStreamingPlay,
|
|
857
|
+
updateStreamingContent,
|
|
858
|
+
finishStreamingContent,
|
|
859
|
+
isStreamingPlaying
|
|
860
|
+
};
|