@aiyiran/myclaw 1.0.101 → 1.0.102
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/assets/myclaw-inject.js +100 -3
- package/package.json +1 -1
- package/patch-manifest.json +1 -1
- package/voice-output/index.html +121 -0
- package/voice-output/voice-output.js +299 -0
package/assets/myclaw-inject.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
+
* 按钮通常在消息气泡下方,需要向上查找
|
|
435
|
+
*/
|
|
436
|
+
function extractMessageText(ttsBtn) {
|
|
437
|
+
// 尝试找到包含此按钮的消息气泡
|
|
438
|
+
var bubble = ttsBtn.closest('.chat-message__bubble');
|
|
439
|
+
if (!bubble) {
|
|
440
|
+
// 尝试上一级元素的兄弟元素
|
|
441
|
+
var parent = ttsBtn.parentElement;
|
|
442
|
+
if (parent) {
|
|
443
|
+
var prevSibling = parent.previousElementSibling;
|
|
444
|
+
if (prevSibling) {
|
|
445
|
+
bubble = prevSibling.querySelector('.chat-message__bubble');
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (bubble) {
|
|
451
|
+
// 获取气泡内的文字,移除多余空白
|
|
452
|
+
var text = bubble.textContent || '';
|
|
453
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return '';
|
|
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
package/patch-manifest.json
CHANGED
|
@@ -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);
|