@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.
- package/assets/myclaw-inject.js +100 -3
- package/package.json +1 -1
- package/patch-manifest.json +1 -1
- package/patch.js +19 -0
- 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
|
+
* 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
package/patch-manifest.json
CHANGED
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);
|