@aiyiran/myclaw 1.0.101 → 1.0.103

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.
@@ -2,15 +2,18 @@
2
2
  * ============================================================================
3
3
  * MyClaw UI Inject — 浏览器端注入脚本
4
4
  * ============================================================================
5
- *
5
+ *
6
6
  * 功能:
7
7
  * 1. 页面顶部 fixed 显示 myclaw 版本号
8
8
  * 2. 在聊天工具栏新增讯飞语音输入按钮
9
9
  * - 光标处插入文字
10
10
  * - 持续录入,手动停止
11
11
  * - 讯飞 60 秒断开后自动重连
12
- *
13
- * 依赖:voice-input.js(讯飞 VoiceInput SDK,需先于本脚本加载)
12
+ * 3. 拦截 chat-tts-btn 按钮,点击后使用讯飞 TTS 播放消息
13
+ *
14
+ * 依赖:
15
+ * - voice-input.js(讯飞 VoiceInput SDK)
16
+ * - voice-output.js(讯飞 VoiceOutput SDK)
14
17
  * ============================================================================
15
18
  */
16
19
  (function () {
@@ -20,11 +23,13 @@
20
23
 
21
24
  // ═══ 状态 ═══
22
25
  var voice = null; // VoiceInput 实例
26
+ var tts = null; // VoiceOutput 实例
23
27
  var recording = false; // 用户层面的录音状态(独立于 SDK 的 status)
24
28
  var pendingText = ""; // 当前这轮识别的文字(实时更新)
25
29
  var committedText = ""; // 已经提交到 textarea 的文字(上一轮累积)
26
30
  var cursorOffset = 0; // 录音开始时光标在 textarea 中的位置
27
31
  var injected = false;
32
+ var ttsBtnHooked = false; // TTS 按钮是否已绑定
28
33
 
29
34
  // ═══ 1. 右下角版本标签(点击测试麦克风) ═══
30
35
  function createVersionBar() {
@@ -396,6 +401,92 @@
396
401
  console.log("[myclaw-inject] ✅ 发送按钮拦截器已挂载");
397
402
  }
398
403
 
404
+ // ═══ 7. TTS 按钮绑定(讯飞语音合成) ═══
405
+
406
+ function initTts() {
407
+ if (typeof window.VoiceOutput === "undefined") {
408
+ console.error("[myclaw-tts] VoiceOutput SDK 未加载");
409
+ return;
410
+ }
411
+ if (tts) return;
412
+
413
+ tts = new window.VoiceOutput({
414
+ APPID: 'de6ec59e',
415
+ APISecret: 'ZTA3ZDU3YjAyNDUyZGVkNjQwMGM5MWNi',
416
+ APIKey: '37104a0463a5460d7571869324b667d5',
417
+ vcn: 'aisbabyxu',
418
+ onAudio: function (audioBuffer) {
419
+ console.log('[myclaw-tts] audio chunk:', audioBuffer.byteLength, 'bytes');
420
+ },
421
+ onStatusChange: function (oldStatus, newStatus) {
422
+ console.log('[myclaw-tts] status:', oldStatus, '->', newStatus);
423
+ },
424
+ onError: function (err) {
425
+ console.error('[myclaw-tts] error:', err);
426
+ }
427
+ });
428
+
429
+ console.log("[myclaw-tts] ✅ 讯飞 TTS SDK 已初始化");
430
+ }
431
+
432
+ /**
433
+ * 从 .chat-tts-btn 按钮获取对应的消息文字
434
+ * DOM 结构:
435
+ * .chat-group-messages
436
+ * .chat-bubble
437
+ * .chat-text > p (消息文字)
438
+ * .chat-group-footer
439
+ * .chat-tts-btn
440
+ */
441
+ function extractMessageText(ttsBtn) {
442
+ // 向上找到 .chat-group-messages
443
+ var group = ttsBtn.closest('.chat-group-messages');
444
+ if (!group) return '';
445
+
446
+ // 在 group 内找 .chat-bubble
447
+ var bubble = group.querySelector('.chat-bubble');
448
+ if (!bubble) return '';
449
+
450
+ // 在气泡内找 .chat-text 的文字
451
+ var chatText = bubble.querySelector('.chat-text');
452
+ if (!chatText) return '';
453
+
454
+ // 获取文字,移除多余空白
455
+ var text = chatText.textContent || '';
456
+ return text.replace(/\s+/g, ' ').trim();
457
+ }
458
+
459
+ function hookTtsButton() {
460
+ if (ttsBtnHooked) return;
461
+ ttsBtnHooked = true;
462
+
463
+ // 使用捕获阶段拦截点击
464
+ document.addEventListener('click', function (e) {
465
+ var btn = e.target.closest('.chat-tts-btn');
466
+ if (!btn) return;
467
+
468
+ e.preventDefault();
469
+ e.stopPropagation();
470
+
471
+ var text = extractMessageText(btn);
472
+ if (!text) {
473
+ console.warn('[myclaw-tts] 未找到消息文字');
474
+ return;
475
+ }
476
+
477
+ console.log('[myclaw-tts] 播放:', text.substring(0, 50) + (text.length > 50 ? '...' : ''));
478
+
479
+ if (!tts) {
480
+ initTts();
481
+ }
482
+ if (tts) {
483
+ tts.speak(text);
484
+ }
485
+ }, true);
486
+
487
+ console.log("[myclaw-inject] ✅ TTS 按钮拦截器已挂载");
488
+ }
489
+
399
490
  function fallbackCopy(text) {
400
491
  var ta = document.createElement("textarea");
401
492
  ta.value = text;
@@ -415,6 +506,9 @@
415
506
  // 初始化 VoiceInput SDK
416
507
  initVoice();
417
508
 
509
+ // 初始化 TTS SDK
510
+ initTts();
511
+
418
512
  // 持续监听 DOM 变化,确保按钮始终在
419
513
  new MutationObserver(function () {
420
514
  if (!document.querySelector("#myclaw-voice-btn")) {
@@ -430,6 +524,9 @@
430
524
 
431
525
  // 挂载发送拦截
432
526
  hookSendButton();
527
+
528
+ // 挂载 TTS 按钮拦截
529
+ hookTtsButton();
433
530
  }
434
531
 
435
532
  if (document.readyState === "loading") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiyiran/myclaw",
3
- "version": "1.0.101",
3
+ "version": "1.0.103",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_doc": "MyClaw 注入清单 (auto-generated)。strategy: auto | on | off",
3
- "_generated": "2026-04-01T17:04:53.758Z",
3
+ "_generated": "2026-04-01T18:08:46.905Z",
4
4
  "agents": [
5
5
  {
6
6
  "id": "danci",
package/patch.js CHANGED
@@ -24,6 +24,7 @@ const path = require('path');
24
24
  const INJECT_MARKER = '<!-- myclaw-inject -->';
25
25
  const INJECT_FILENAME = 'myclaw-inject.js';
26
26
  const VOICE_SDK_FILENAME = 'voice-input.js';
27
+ const VOICE_OUTPUT_SDK_FILENAME = 'voice-output.js';
27
28
  const BACKUP_SUFFIX = '.myclaw-backup';
28
29
 
29
30
  /**
@@ -109,6 +110,8 @@ function patch() {
109
110
  const injectDest = path.join(uiDir, INJECT_FILENAME);
110
111
  const voiceSdkSrc = path.join(__dirname, 'voice-input', VOICE_SDK_FILENAME);
111
112
  const voiceSdkDest = path.join(uiDir, VOICE_SDK_FILENAME);
113
+ const voiceOutputSdkSrc = path.join(__dirname, 'voice-output', VOICE_OUTPUT_SDK_FILENAME);
114
+ const voiceOutputSdkDest = path.join(uiDir, VOICE_OUTPUT_SDK_FILENAME);
112
115
  const version = getMyclawVersion();
113
116
 
114
117
  console.log('[myclaw-patch] 📍 找到 control-ui: ' + uiDir);
@@ -145,6 +148,14 @@ function patch() {
145
148
  // 非致命错误,继续执行
146
149
  }
147
150
 
151
+ // 4. 复制 VoiceOutput SDK(TTS)
152
+ try {
153
+ fs.copyFileSync(voiceOutputSdkSrc, voiceOutputSdkDest);
154
+ console.log('[myclaw-patch] 📄 TTS SDK 已复制: ' + voiceOutputSdkDest);
155
+ } catch (err) {
156
+ console.error('[myclaw-patch] ⚠️ TTS SDK 复制失败 (非致命): ' + err.message);
157
+ }
158
+
148
159
  // 4. Patch index.html(幂等)
149
160
  try {
150
161
  let html = fs.readFileSync(indexPath, 'utf8');
@@ -159,6 +170,7 @@ function patch() {
159
170
  // 在 </body> 前注入(SDK 先加载,inject 后加载)
160
171
  const injection = [
161
172
  INJECT_MARKER,
173
+ '<script src="./' + VOICE_OUTPUT_SDK_FILENAME + '"></script>',
162
174
  '<script src="./' + VOICE_SDK_FILENAME + '"></script>',
163
175
  '<script src="./' + INJECT_FILENAME + '"></script>',
164
176
  '',
@@ -236,6 +248,7 @@ function unpatch() {
236
248
  const backupPath = indexPath + BACKUP_SUFFIX;
237
249
  const injectDest = path.join(uiDir, INJECT_FILENAME);
238
250
  const voiceSdkDest = path.join(uiDir, VOICE_SDK_FILENAME);
251
+ const voiceOutputSdkDest = path.join(uiDir, VOICE_OUTPUT_SDK_FILENAME);
239
252
 
240
253
  // 恢复备份
241
254
  if (fs.existsSync(backupPath)) {
@@ -256,6 +269,12 @@ function unpatch() {
256
269
  console.log('[myclaw-patch] ✅ 语音 SDK 已删除');
257
270
  }
258
271
 
272
+ // 删除 TTS SDK
273
+ if (fs.existsSync(voiceOutputSdkDest)) {
274
+ fs.unlinkSync(voiceOutputSdkDest);
275
+ console.log('[myclaw-patch] ✅ TTS SDK 已删除');
276
+ }
277
+
259
278
  // 恢复 gateway-cli-*.js 的 Permissions-Policy
260
279
  try {
261
280
  const distParent = path.resolve(uiDir, '..');
@@ -0,0 +1,121 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>语音合成演示</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body { font-family: -apple-system, sans-serif; background: #f5f5f5; padding: 40px 20px; }
10
+ .container { max-width: 500px; margin: 0 auto; }
11
+ h1 { font-size: 20px; color: #333; margin-bottom: 20px; }
12
+ textarea {
13
+ width: 100%; height: 120px; padding: 12px; font-size: 15px;
14
+ border: 1px solid #ddd; border-radius: 8px; resize: vertical;
15
+ line-height: 1.6;
16
+ }
17
+ .controls { margin-top: 12px; display: flex; gap: 10px; flex-wrap: wrap; }
18
+ .btn {
19
+ padding: 12px 24px; font-size: 15px;
20
+ border: none; border-radius: 8px; cursor: pointer;
21
+ color: white; transition: all 0.2s;
22
+ }
23
+ .btn-speak { background: #28a745; }
24
+ .btn-speak:hover { background: #218838; }
25
+ .btn-stop { background: #dc3545; }
26
+ .btn-stop:hover { background: #c82333; }
27
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
28
+ .status { margin-top: 10px; font-size: 13px; color: #888; }
29
+ .voice-select { margin-top: 12px; }
30
+ .voice-select label { font-size: 13px; color: #666; margin-right: 8px; }
31
+ .voice-select select { padding: 6px 10px; font-size: 14px; border-radius: 6px; border: 1px solid #ddd; }
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <div class="container">
36
+ <h1>🔊 讯飞语音合成演示</h1>
37
+ <textarea id="text" placeholder="输入要合成的文字...">你好世界,这是语音合成测试</textarea>
38
+
39
+ <div class="voice-select">
40
+ <label>音色:</label>
41
+ <select id="vcn">
42
+ <option value="x4_xiaoyan">小燕 - 普通话女声</option>
43
+ <option value="x4_yezi">小露 - 普通话女声</option>
44
+ <option value="aisjiuxu">许久 - 普通话男声</option>
45
+ <option value="aisjinger">小婧 - 普通话女声</option>
46
+ <option value="aisbabyxu">许小宝 - 童声</option>
47
+ </select>
48
+ </div>
49
+
50
+ <div class="controls">
51
+ <button class="btn btn-speak" id="speakBtn">🔊 开始合成</button>
52
+ <button class="btn btn-stop" id="stopBtn" disabled>⏹ 停止</button>
53
+ </div>
54
+
55
+ <div class="status" id="status">就绪</div>
56
+ </div>
57
+
58
+ <script src="voice-output.js"></script>
59
+ <script>
60
+ var textEl = document.getElementById('text');
61
+ var vcnEl = document.getElementById('vcn');
62
+ var speakBtn = document.getElementById('speakBtn');
63
+ var stopBtn = document.getElementById('stopBtn');
64
+ var statusEl = document.getElementById('status');
65
+
66
+ var tts = null;
67
+
68
+ function updateStatus(text) {
69
+ statusEl.textContent = text;
70
+ }
71
+
72
+ speakBtn.onclick = function () {
73
+ var text = textEl.value.trim();
74
+ if (!text) {
75
+ alert('请输入要合成的文字');
76
+ return;
77
+ }
78
+
79
+ if (!tts) {
80
+ // 使用 OpenClaw 配置的讯飞账号
81
+ tts = new VoiceOutput({
82
+ APPID: 'de6ec59e',
83
+ APISecret: 'ZTA3ZDU3YjAyNDUyZGVkNjQwMGM5MWNi',
84
+ APIKey: '37104a0463a5460d7571869324b667d5',
85
+ vcn: vcnEl.value,
86
+ onAudio: function (audioBuffer) {
87
+ console.log('[demo] received audio chunk:', audioBuffer.byteLength, 'bytes');
88
+ },
89
+ onStatusChange: function (oldStatus, newStatus) {
90
+ console.log('[tts] status:', oldStatus, '->', newStatus);
91
+ var labels = {
92
+ idle: '就绪',
93
+ connecting: '连接中...',
94
+ synthesizing: '🔊 合成中...',
95
+ stopping: '停止中...',
96
+ stopped: '已停止'
97
+ };
98
+ statusEl.textContent = labels[newStatus] || newStatus;
99
+
100
+ speakBtn.disabled = (newStatus !== 'idle');
101
+ stopBtn.disabled = (newStatus === 'idle' || newStatus === 'stopping');
102
+ },
103
+ onError: function (err) {
104
+ console.error('[tts] error:', err);
105
+ statusEl.textContent = '❌ ' + err;
106
+ }
107
+ });
108
+ }
109
+
110
+ tts.vcn = vcnEl.value;
111
+ tts.speak(text);
112
+ };
113
+
114
+ stopBtn.onclick = function () {
115
+ if (tts) {
116
+ tts.stop();
117
+ }
118
+ };
119
+ </script>
120
+ </body>
121
+ </html>
@@ -0,0 +1,299 @@
1
+ /**
2
+ * VoiceOutput - 独立语音合成 SDK
3
+ * 基于讯飞语音合成 WebAPI(流式版)
4
+ *
5
+ * 零依赖,纯原生 JS,任何静态 HTML 页面都能用
6
+ *
7
+ * 用法:
8
+ * const tts = new VoiceOutput({
9
+ * onAudio: (audioData) => { ... }
10
+ * });
11
+ * tts.speak("你好世界"); // 开始合成
12
+ * tts.stop(); // 停止
13
+ */
14
+ (function (global) {
15
+ 'use strict';
16
+
17
+ // ============ 默认配置 ============
18
+ var DEFAULT_CONFIG = {
19
+ APPID: '',
20
+ APISecret: '',
21
+ APIKey: '',
22
+ url: 'wss://tts-api.xfyun.cn/v2/tts',
23
+ host: 'tts-api.xfyun.cn',
24
+ vcn: 'x4_xiaoyan', // 默认语音
25
+ speed: 50, // 语速 0-100
26
+ pitch: 50, // 音调 0-100
27
+ volume: 50, // 音量 0-100
28
+ aue: 'lame', // 音频格式: lame=mp3, raw=pcm
29
+ };
30
+
31
+ // ============ 工具函数 ============
32
+
33
+ function hmacSHA256(key, message) {
34
+ var enc = new TextEncoder();
35
+ return crypto.subtle.importKey(
36
+ 'raw', enc.encode(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
37
+ ).then(function (cryptoKey) {
38
+ return crypto.subtle.sign('HMAC', cryptoKey, enc.encode(message));
39
+ }).then(function (sig) {
40
+ return arrayBufferToBase64(sig);
41
+ });
42
+ }
43
+
44
+ function arrayBufferToBase64(buffer) {
45
+ var binary = '';
46
+ var bytes = new Uint8Array(buffer);
47
+ for (var i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
48
+ return btoa(binary);
49
+ }
50
+
51
+ function base64ToArrayBuffer(base64) {
52
+ var binaryStr = atob(base64);
53
+ var bytes = new Uint8Array(binaryStr.length);
54
+ for (var i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
55
+ return bytes.buffer;
56
+ }
57
+
58
+ // ============ VoiceOutput 类 ============
59
+
60
+ function VoiceOutput(opts) {
61
+ opts = opts || {};
62
+
63
+ this.APPID = opts.APPID || DEFAULT_CONFIG.APPID;
64
+ this.APISecret = opts.APISecret || DEFAULT_CONFIG.APISecret;
65
+ this.APIKey = opts.APIKey || DEFAULT_CONFIG.APIKey;
66
+ this.url = opts.url || DEFAULT_CONFIG.url;
67
+ this.host = opts.host || DEFAULT_CONFIG.host;
68
+ this.vcn = opts.vcn || DEFAULT_CONFIG.vcn;
69
+ this.speed = opts.speed || DEFAULT_CONFIG.speed;
70
+ this.pitch = opts.pitch || DEFAULT_CONFIG.pitch;
71
+ this.volume = opts.volume || DEFAULT_CONFIG.volume;
72
+ this.aue = opts.aue || DEFAULT_CONFIG.aue;
73
+
74
+ // 回调
75
+ this.onAudio = opts.onAudio || function (audioBuffer) {}; // 音频数据回调
76
+ this.onStatusChange = opts.onStatusChange || function () {};
77
+ this.onError = opts.onError || function () {};
78
+
79
+ // 内部状态
80
+ this.status = 'idle'; // idle | connecting | synthesizing | stopping | stopped
81
+ this.webSocket = null;
82
+ this.audioContext = null;
83
+ this.audioChunks = [];
84
+ this.audioSource = null;
85
+ this.onAudioEnd = null;
86
+ }
87
+
88
+ VoiceOutput.prototype._getWebSocketUrl = function () {
89
+ var self = this;
90
+ var date = new Date().toUTCString();
91
+ var signatureOrigin = 'host: ' + self.host + '\ndate: ' + date + '\nGET /v2/tts HTTP/1.1';
92
+
93
+ return hmacSHA256(self.APISecret, signatureOrigin).then(function (signature) {
94
+ var authOrigin = 'api_key="' + self.APIKey + '", algorithm="hmac-sha256", headers="host date request-line", signature="' + signature + '"';
95
+ var authorization = btoa(authOrigin);
96
+ return self.url + '?authorization=' + authorization + '&date=' + encodeURIComponent(date) + '&host=' + self.host;
97
+ });
98
+ };
99
+
100
+ VoiceOutput.prototype._initAudioContext = function () {
101
+ if (!this.audioContext) {
102
+ this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
103
+ }
104
+ return this.audioContext;
105
+ };
106
+
107
+ VoiceOutput.prototype._playAudio = function (audioBuffer) {
108
+ var self = this;
109
+
110
+ // 创建 Blob URL 用于 <audio> 元素播放
111
+ var mimeType = this.aue === 'lame' ? 'audio/mpeg' : 'audio/wav';
112
+ var blob = new Blob([audioBuffer], { type: mimeType });
113
+ var blobUrl = URL.createObjectURL(blob);
114
+
115
+ // 停止之前的播放
116
+ if (this.currentAudio) {
117
+ this.currentAudio.pause();
118
+ URL.revokeObjectURL(this.currentAudioSrc);
119
+ }
120
+
121
+ // 创建 audio 元素播放
122
+ var audio = new Audio(blobUrl);
123
+ this.currentAudio = audio;
124
+ this.currentAudioSrc = blobUrl;
125
+
126
+ audio.play().then(function () {
127
+ console.log('[tts] playing...');
128
+ }).catch(function (err) {
129
+ console.error('[tts] play error:', err);
130
+ self.onError('播放失败: ' + err.message);
131
+ });
132
+
133
+ audio.onended = function () {
134
+ URL.revokeObjectURL(blobUrl);
135
+ if (self.onAudioEnd) {
136
+ self.onAudioEnd();
137
+ }
138
+ };
139
+ };
140
+
141
+ VoiceOutput.prototype._playBuffer = function (audioBuffer) {
142
+ var source = this.audioContext.createBufferSource();
143
+ source.buffer = audioBuffer;
144
+ source.connect(this.audioContext.destination);
145
+ source.start(0);
146
+ this.audioSource = source;
147
+
148
+ source.onended = function () {
149
+ if (self.onAudioEnd) {
150
+ self.onAudioEnd();
151
+ }
152
+ };
153
+ };
154
+
155
+ VoiceOutput.prototype.speak = function (text, opts) {
156
+ if (this.status === 'synthesizing') {
157
+ console.warn('[tts] already synthesizing');
158
+ return;
159
+ }
160
+
161
+ opts = opts || {};
162
+ this.audioChunks = [];
163
+ this._setStatus('connecting');
164
+
165
+ var self = this;
166
+ this._getWebSocketUrl().then(function (url) {
167
+ var ws = new WebSocket(url);
168
+ self.webSocket = ws;
169
+
170
+ var timeout = setTimeout(function () {
171
+ if (ws.readyState === WebSocket.CONNECTING) {
172
+ ws.close();
173
+ self.onError('WebSocket 连接超时');
174
+ self._setStatus('idle');
175
+ }
176
+ }, 5000);
177
+
178
+ ws.onopen = function () {
179
+ clearTimeout(timeout);
180
+ self._setStatus('synthesizing');
181
+
182
+ // 构造 TTS 请求
183
+ var textBase64 = btoa(unescape(encodeURIComponent(text)));
184
+
185
+ var payload = {
186
+ common: { app_id: self.APPID },
187
+ business: {
188
+ aue: self.aue,
189
+ auf: 'audio/L16;rate=16000',
190
+ vcn: opts.vcn || self.vcn,
191
+ speed: opts.speed || self.speed,
192
+ pitch: opts.pitch || self.pitch,
193
+ volume: opts.volume || self.volume,
194
+ tte: 'UTF8',
195
+ },
196
+ data: {
197
+ status: 2,
198
+ text: textBase64,
199
+ },
200
+ };
201
+
202
+ ws.send(JSON.stringify(payload));
203
+ console.log('[tts] sent text:', text.substring(0, 50) + (text.length > 50 ? '...' : ''));
204
+ };
205
+
206
+ ws.onmessage = function (event) {
207
+ try {
208
+ var msg = JSON.parse(event.data);
209
+
210
+ if (msg.code !== 0) {
211
+ self.onError('TTS error ' + msg.code + ': ' + msg.message);
212
+ self._cleanup();
213
+ return;
214
+ }
215
+
216
+ // 音频数据
217
+ if (msg.data && msg.data.audio) {
218
+ var audioData = base64ToArrayBuffer(msg.data.audio);
219
+ self.audioChunks.push(audioData);
220
+ self.onAudio(audioData);
221
+
222
+ // 全部接收完成后播放
223
+ if (msg.data.status === 2) {
224
+ console.log('[tts] synthesis complete, chunks:', self.audioChunks.length);
225
+ // 合并所有 chunks
226
+ var totalLen = 0;
227
+ for (var i = 0; i < self.audioChunks.length; i++) {
228
+ totalLen += self.audioChunks[i].byteLength;
229
+ }
230
+ var merged = new Uint8Array(totalLen);
231
+ var offset = 0;
232
+ for (var j = 0; j < self.audioChunks.length; j++) {
233
+ merged.set(new Uint8Array(self.audioChunks[j]), offset);
234
+ offset += self.audioChunks[j].byteLength;
235
+ }
236
+ self._playAudio(merged.buffer);
237
+ self._setStatus('idle');
238
+ }
239
+ }
240
+ } catch (err) {
241
+ console.error('[tts] parse error:', err);
242
+ }
243
+ };
244
+
245
+ ws.onerror = function (err) {
246
+ console.error('[tts] WebSocket error:', err);
247
+ self.onError('WebSocket 连接错误');
248
+ self._cleanup();
249
+ };
250
+
251
+ ws.onclose = function () {
252
+ clearTimeout(timeout);
253
+ if (self.status !== 'idle') {
254
+ self._setStatus('idle');
255
+ }
256
+ };
257
+ }).catch(function (err) {
258
+ self.onError('签名失败: ' + err);
259
+ self._setStatus('idle');
260
+ });
261
+ };
262
+
263
+ VoiceOutput.prototype.stop = function () {
264
+ this._setStatus('stopping');
265
+ this._cleanup();
266
+ this._setStatus('idle');
267
+ };
268
+
269
+ VoiceOutput.prototype._cleanup = function () {
270
+ if (this.webSocket) {
271
+ this.webSocket.close();
272
+ this.webSocket = null;
273
+ }
274
+ if (this.audioSource) {
275
+ try { this.audioSource.stop(); } catch (e) {}
276
+ this.audioSource = null;
277
+ }
278
+ };
279
+
280
+ VoiceOutput.prototype._setStatus = function (status) {
281
+ if (this.status !== status) {
282
+ var old = this.status;
283
+ this.status = status;
284
+ this.onStatusChange(old, status);
285
+ }
286
+ };
287
+
288
+ VoiceOutput.prototype.destroy = function () {
289
+ this.stop();
290
+ if (this.audioContext) {
291
+ this.audioContext.close();
292
+ this.audioContext = null;
293
+ }
294
+ };
295
+
296
+ // 暴露到全局
297
+ global.VoiceOutput = VoiceOutput;
298
+
299
+ })(typeof window !== 'undefined' ? window : this);