xiaoyuan-assistant 0.5.54 → 0.5.56
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/CHANGELOG.md +13 -0
- package/README.md +4 -0
- package/XIAOYUAN-BUSINESS-FLOW.md +10 -0
- package/package.json +1 -1
- package/src/components/XiaoyuanAssistant.vue +13 -2
- package/src/styles/index.css +125 -13
- package/src/voice/speech.js +17 -32
- package/package.json.bak +0 -18
- package/scripts/cache-received-tts.mjs +0 -69
- package/src/assets/received-command.local.js +0 -2
- package/src/assets/received-command.mp3 +0 -0
- package/src/vite-plugin.js +0 -66
- package/src/voice/freetts.js +0 -238
package/CHANGELOG.md
CHANGED
|
@@ -442,3 +442,16 @@ ls -lh /mnt/data/xiaoyuan-assistant-siliconflow-v0.5.29.zip
|
|
|
442
442
|
- 唤醒监听 pause 改为可等待的异步暂停,避免持续唤醒与手动录音/唤醒后的连续监听抢占麦克风。
|
|
443
443
|
- 首次麦克风授权不再每次识别重复 getUserMedia + stop,减少 Chrome/Edge 音频设备切换导致的 aborted。
|
|
444
444
|
- 内置“我在您请讲”改用本次提供的 zh-CN-XiaoxiaoNeural2.mp3 与 zh-CN-XiaoyiNeural2.mp3,并继续按 voice 选择。
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
## v0.5.55
|
|
448
|
+
- 修复手动语音输入在唤醒监听存在时容易被浏览器中断的问题。
|
|
449
|
+
- 去除额外 getUserMedia 权限预检,避免与 SpeechRecognition 抢占麦克风。
|
|
450
|
+
- 优化语音识别 network/aborted 兼容与重试提示。
|
|
451
|
+
- 唤醒监听与手动录音共用状态并安全释放 Recognition 实例。
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
## v0.5.56
|
|
455
|
+
- 去掉小园弹窗右上角除最小化之外的静音/停止播报入口,右上角仅保留收起按钮。
|
|
456
|
+
- 升级收起状态的小园悬浮图标:增加呼吸光晕、扩散聆听波纹、轨道光点和动态音波,默认有轻微动效。
|
|
457
|
+
- 当处于监听/处理状态时,收起图标自动加快波纹与音波动画,强化“正在聆听/工作中”的视觉反馈。
|
package/README.md
CHANGED
|
@@ -1265,3 +1265,13 @@ AI 分析开始提示音与数据获取/模型分析并行;多个数据源采
|
|
|
1265
1265
|
- “你好小园”唤醒:播放本地对应 voice 的“我在您请讲”,随后再进入后续 3 秒静默监听。
|
|
1266
1266
|
- 识别启动前只做一次麦克风权限确认,避免反复 getUserMedia 导致浏览器出现 aborted。
|
|
1267
1267
|
- 新增可等待 pause/resume,保证全局唤醒监听与用户录音不会并发抢占同一个 Recognition。
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
## v0.5.55 语音输入稳定性
|
|
1271
|
+
手动点击麦克风时先释放唤醒 Recognition,再启动单次识别;不再额外 getUserMedia 预占麦克风。浏览器 SpeechRecognition 出现 network 时进行轻量重试并给出明确提示。
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
## v0.5.56 UI 交互
|
|
1275
|
+
- 展开面板右上角仅保留“收起/最小化”按钮,不提供静音按钮。
|
|
1276
|
+
- 小园收起状态采用高级悬浮球视觉:中心徽记 + 呼吸光晕 + 多层扩散聆听波纹 + 环绕光点 + 动态音波。
|
|
1277
|
+
- 空闲状态使用轻量呼吸动画;监听/处理状态自动加速动画,提示用户小园正在聆听或处理。
|
package/package.json
CHANGED
|
@@ -9,7 +9,18 @@
|
|
|
9
9
|
@click="activate"
|
|
10
10
|
>
|
|
11
11
|
<span class="xy-fab-glow"></span>
|
|
12
|
-
<span class="xy-
|
|
12
|
+
<span class="xy-fab-ring ring-a"></span>
|
|
13
|
+
<span class="xy-fab-ring ring-b"></span>
|
|
14
|
+
<span class="xy-fab-ring ring-c"></span>
|
|
15
|
+
<span class="xy-fab-orbit orbit-a"></span>
|
|
16
|
+
<span class="xy-fab-orbit orbit-b"></span>
|
|
17
|
+
<span class="xy-fab-core">
|
|
18
|
+
<span class="xy-fab-core-light"></span>
|
|
19
|
+
<span class="xy-fab-core-mark">园</span>
|
|
20
|
+
</span>
|
|
21
|
+
<span class="xy-fab-wave wave-a"></span>
|
|
22
|
+
<span class="xy-fab-wave wave-b"></span>
|
|
23
|
+
<span class="xy-fab-wave wave-c"></span>
|
|
13
24
|
<span v-if="wakeListening" class="xy-live-dot"></span>
|
|
14
25
|
</button>
|
|
15
26
|
|
|
@@ -117,7 +128,7 @@ const runtime =
|
|
|
117
128
|
// 如果宿主项目因为 HMR / 重复依赖导致当前模块暂时拿不到注入值,
|
|
118
129
|
// 组件保持可挂载,并在 mounted 后再次尝试解析,避免整个页面崩溃。
|
|
119
130
|
const safeRuntime = runtime || { manager: null, speech: null, options: {} }
|
|
120
|
-
const XIAOYUAN_SDK_VERSION = '0.5.
|
|
131
|
+
const XIAOYUAN_SDK_VERSION = '0.5.56'
|
|
121
132
|
const { manager, speech, options } = safeRuntime
|
|
122
133
|
|
|
123
134
|
const wakeWord = options.wakeWord || '你好小园'
|
package/src/styles/index.css
CHANGED
|
@@ -23,21 +23,133 @@
|
|
|
23
23
|
|
|
24
24
|
.xy-fab {
|
|
25
25
|
position: relative;
|
|
26
|
-
width:
|
|
27
|
-
height:
|
|
28
|
-
border: 1px solid rgba(255,255,255,.
|
|
29
|
-
border-radius:
|
|
26
|
+
width: 66px;
|
|
27
|
+
height: 66px;
|
|
28
|
+
border: 1px solid rgba(255,255,255,.24);
|
|
29
|
+
border-radius: 22px;
|
|
30
30
|
cursor: pointer;
|
|
31
31
|
color: #fff;
|
|
32
|
-
background:
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
background:
|
|
33
|
+
radial-gradient(circle at 32% 24%, rgba(255,255,255,.25), transparent 30%),
|
|
34
|
+
linear-gradient(145deg, #9388ff 0%, #6d5dfc 44%, #4d3acb 100%);
|
|
35
|
+
box-shadow:
|
|
36
|
+
0 18px 44px rgba(41,31,127,.44),
|
|
37
|
+
0 0 0 1px rgba(255,255,255,.06) inset,
|
|
38
|
+
0 0 32px rgba(109,93,252,.24);
|
|
39
|
+
transition: transform .22s cubic-bezier(.2,.8,.2,1), box-shadow .22s ease, filter .22s ease;
|
|
40
|
+
isolation: isolate;
|
|
41
|
+
}
|
|
42
|
+
.xy-fab:hover {
|
|
43
|
+
transform: translateY(-3px) scale(1.025);
|
|
44
|
+
box-shadow:
|
|
45
|
+
0 22px 52px rgba(41,31,127,.52),
|
|
46
|
+
0 0 0 1px rgba(255,255,255,.10) inset,
|
|
47
|
+
0 0 42px rgba(109,93,252,.34);
|
|
48
|
+
filter: saturate(1.08);
|
|
49
|
+
}
|
|
50
|
+
.xy-fab:active { transform: translateY(-1px) scale(.985); }
|
|
51
|
+
.xy-fab-glow {
|
|
52
|
+
position: absolute;
|
|
53
|
+
inset: -18px;
|
|
54
|
+
border-radius: 30px;
|
|
55
|
+
background: radial-gradient(circle, rgba(109,93,252,.34) 0%, rgba(109,93,252,.12) 30%, transparent 72%);
|
|
56
|
+
z-index: -5;
|
|
57
|
+
animation: xy-fab-glow 2.4s ease-in-out infinite;
|
|
58
|
+
pointer-events: none;
|
|
59
|
+
}
|
|
60
|
+
.xy-fab-core {
|
|
61
|
+
position: absolute;
|
|
62
|
+
inset: 11px;
|
|
63
|
+
display: grid;
|
|
64
|
+
place-items: center;
|
|
65
|
+
border-radius: 17px;
|
|
66
|
+
overflow: hidden;
|
|
67
|
+
background: linear-gradient(150deg, rgba(255,255,255,.16), rgba(255,255,255,.03));
|
|
68
|
+
box-shadow: 0 0 0 1px rgba(255,255,255,.10) inset, 0 7px 20px rgba(30,20,110,.25);
|
|
69
|
+
backdrop-filter: blur(4px);
|
|
70
|
+
z-index: 3;
|
|
71
|
+
}
|
|
72
|
+
.xy-fab-core-light {
|
|
73
|
+
position: absolute;
|
|
74
|
+
width: 34px;
|
|
75
|
+
height: 34px;
|
|
76
|
+
top: -8px;
|
|
77
|
+
left: -5px;
|
|
78
|
+
border-radius: 50%;
|
|
79
|
+
background: radial-gradient(circle, rgba(255,255,255,.30), transparent 70%);
|
|
80
|
+
filter: blur(2px);
|
|
81
|
+
animation: xy-fab-light 3.2s ease-in-out infinite;
|
|
82
|
+
}
|
|
83
|
+
.xy-fab-core-mark {
|
|
84
|
+
position: relative;
|
|
85
|
+
z-index: 2;
|
|
86
|
+
font-size: 24px;
|
|
87
|
+
font-weight: 850;
|
|
88
|
+
letter-spacing: 1px;
|
|
89
|
+
text-shadow: 0 2px 14px rgba(255,255,255,.22);
|
|
90
|
+
}
|
|
91
|
+
.xy-fab-ring {
|
|
92
|
+
position: absolute;
|
|
93
|
+
inset: 7px;
|
|
94
|
+
border-radius: 24px;
|
|
95
|
+
border: 1px solid rgba(255,255,255,.16);
|
|
96
|
+
pointer-events: none;
|
|
97
|
+
z-index: 1;
|
|
98
|
+
animation: xy-fab-ring 2.5s ease-out infinite;
|
|
99
|
+
}
|
|
100
|
+
.xy-fab-ring.ring-b { animation-delay: .82s; }
|
|
101
|
+
.xy-fab-ring.ring-c { animation-delay: 1.64s; }
|
|
102
|
+
.xy-fab-orbit {
|
|
103
|
+
position: absolute;
|
|
104
|
+
width: 7px;
|
|
105
|
+
height: 7px;
|
|
106
|
+
border-radius: 50%;
|
|
107
|
+
background: #d8d3ff;
|
|
108
|
+
box-shadow: 0 0 13px rgba(218,211,255,.95), 0 0 24px rgba(109,93,252,.8);
|
|
109
|
+
transform-origin: 26px 26px;
|
|
110
|
+
z-index: 4;
|
|
111
|
+
opacity: .85;
|
|
112
|
+
}
|
|
113
|
+
.xy-fab-orbit.orbit-a { top: 7px; left: 28px; animation: xy-fab-orbit 3.4s linear infinite; }
|
|
114
|
+
.xy-fab-orbit.orbit-b { bottom: 9px; left: 14px; animation: xy-fab-orbit-rev 4.8s linear infinite; opacity: .55; }
|
|
115
|
+
.xy-fab-wave {
|
|
116
|
+
position: absolute;
|
|
117
|
+
width: 2px;
|
|
118
|
+
border-radius: 999px;
|
|
119
|
+
background: linear-gradient(180deg, rgba(255,255,255,.9), rgba(193,187,255,.1));
|
|
120
|
+
z-index: 5;
|
|
121
|
+
transform-origin: center;
|
|
122
|
+
opacity: .5;
|
|
123
|
+
animation: xy-fab-wave 1.1s ease-in-out infinite;
|
|
124
|
+
}
|
|
125
|
+
.xy-fab-wave.wave-a { height: 10px; right: 14px; top: 27px; animation-delay: -.18s; }
|
|
126
|
+
.xy-fab-wave.wave-b { height: 16px; right: 10px; top: 24px; }
|
|
127
|
+
.xy-fab-wave.wave-c { height: 8px; right: 6px; top: 28px; animation-delay: -.34s; }
|
|
128
|
+
.xy-fab.state-listening .xy-fab-ring,
|
|
129
|
+
.xy-fab.state-processing .xy-fab-ring { animation-duration: 1.45s; border-color: rgba(168,158,255,.35); }
|
|
130
|
+
.xy-fab.state-listening .xy-fab-orbit,
|
|
131
|
+
.xy-fab.state-processing .xy-fab-orbit { animation-duration: 1.9s; }
|
|
132
|
+
.xy-fab.state-listening .xy-fab-wave { animation-duration: .62s; opacity: .95; }
|
|
133
|
+
.xy-live-dot {
|
|
134
|
+
position: absolute;
|
|
135
|
+
top: 3px;
|
|
136
|
+
right: 3px;
|
|
137
|
+
width: 9px;
|
|
138
|
+
height: 9px;
|
|
139
|
+
background: #7ff0b4;
|
|
140
|
+
border: 1px solid rgba(255,255,255,.65);
|
|
141
|
+
border-radius: 50%;
|
|
142
|
+
box-shadow: 0 0 0 4px rgba(104,226,162,.10), 0 0 14px rgba(104,226,162,.85);
|
|
143
|
+
z-index: 8;
|
|
144
|
+
animation: xy-live-pulse 1s ease-in-out infinite;
|
|
145
|
+
}
|
|
146
|
+
@keyframes xy-fab-glow { 0%,100% { transform: scale(.95); opacity: .68; } 50% { transform: scale(1.08); opacity: 1; } }
|
|
147
|
+
@keyframes xy-fab-ring { 0% { transform: scale(.74); opacity: 0; } 18% { opacity: .55; } 76% { opacity: .15; } 100% { transform: scale(1.46); opacity: 0; } }
|
|
148
|
+
@keyframes xy-fab-orbit { from { transform: rotate(0deg) translateX(20px) rotate(0deg); } to { transform: rotate(360deg) translateX(20px) rotate(-360deg); } }
|
|
149
|
+
@keyframes xy-fab-orbit-rev { from { transform: rotate(360deg) translateX(19px) rotate(-360deg); } to { transform: rotate(0deg) translateX(19px) rotate(0deg); } }
|
|
150
|
+
@keyframes xy-fab-wave { 0%,100% { transform: scaleY(.65); opacity: .35; } 50% { transform: scaleY(1.45); opacity: 1; } }
|
|
151
|
+
@keyframes xy-fab-light { 0%,100% { transform: translate3d(0,0,0) scale(.9); opacity: .65; } 50% { transform: translate3d(14px,10px,0) scale(1.15); opacity: 1; } }
|
|
152
|
+
@keyframes xy-live-pulse { 0%,100% { transform: scale(.85); } 50% { transform: scale(1.15); } }
|
|
41
153
|
|
|
42
154
|
.xy-panel {
|
|
43
155
|
position: relative;
|
package/src/voice/speech.js
CHANGED
|
@@ -38,33 +38,12 @@ export class SpeechService {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
async requestMicrophonePermission() {
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (!navigator.mediaDevices?.getUserMedia) return true
|
|
46
|
-
|
|
47
|
-
try {
|
|
48
|
-
const stream = await navigator.mediaDevices.getUserMedia({
|
|
49
|
-
audio: true
|
|
50
|
-
})
|
|
51
|
-
this._micPermissionGranted = true
|
|
52
|
-
// 只用于首次授权,立即释放独立权限流;后续识别不再反复申请。
|
|
53
|
-
for (const track of stream.getTracks()) track.stop()
|
|
54
|
-
return true
|
|
55
|
-
} catch (error) {
|
|
56
|
-
const name = error?.name || 'unknown'
|
|
57
|
-
if (name === 'NotAllowedError' || name === 'SecurityError') {
|
|
58
|
-
throw new Error('麦克风权限被拒绝,请在浏览器地址栏允许当前页面使用麦克风')
|
|
59
|
-
}
|
|
60
|
-
if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {
|
|
61
|
-
throw new Error('没有检测到麦克风设备,请检查麦克风是否连接')
|
|
62
|
-
}
|
|
63
|
-
if (name === 'NotReadableError' || name === 'TrackStartError') {
|
|
64
|
-
throw new Error('麦克风正在被其他程序占用,请关闭其他录音/视频程序后重试')
|
|
65
|
-
}
|
|
66
|
-
throw error
|
|
41
|
+
// Web Speech Recognition 自己管理麦克风权限。这里不再先调用 getUserMedia,
|
|
42
|
+
// 避免“权限流 + SpeechRecognition”同时争抢麦克风导致 aborted / network 异常。
|
|
43
|
+
if (!this.supported()) {
|
|
44
|
+
throw new Error('当前浏览器不支持语音识别,请使用 Chrome / Edge')
|
|
67
45
|
}
|
|
46
|
+
return true
|
|
68
47
|
}
|
|
69
48
|
|
|
70
49
|
createRecognition({ continuous = false } = {}) {
|
|
@@ -265,7 +244,7 @@ export class SpeechService {
|
|
|
265
244
|
if (startTimer) window.clearTimeout(startTimer)
|
|
266
245
|
}
|
|
267
246
|
|
|
268
|
-
|
|
247
|
+
recognition.onresult = (event) => {
|
|
269
248
|
let interim = ''
|
|
270
249
|
for (let i = event.resultIndex; i < event.results.length; i += 1) {
|
|
271
250
|
const part = event.results[i][0]?.transcript || ''
|
|
@@ -287,6 +266,7 @@ export class SpeechService {
|
|
|
287
266
|
}
|
|
288
267
|
|
|
289
268
|
recognition.onerror = (event) => {
|
|
269
|
+
if (this.recognition === recognition) this.recognition = null
|
|
290
270
|
const code = event?.error || 'unknown'
|
|
291
271
|
if (code === 'no-speech') {
|
|
292
272
|
finish(new Error('没有听到您的声音,请再说一次。'))
|
|
@@ -301,7 +281,7 @@ export class SpeechService {
|
|
|
301
281
|
return
|
|
302
282
|
}
|
|
303
283
|
if (code === 'network') {
|
|
304
|
-
finish(new Error('
|
|
284
|
+
finish(new Error('浏览器语音识别服务当前不可用,请检查网络连接或切换 Chrome / Edge 后再试。'))
|
|
305
285
|
return
|
|
306
286
|
}
|
|
307
287
|
if (code === 'aborted') {
|
|
@@ -425,7 +405,7 @@ export class SpeechService {
|
|
|
425
405
|
return
|
|
426
406
|
}
|
|
427
407
|
if (code === 'network') {
|
|
428
|
-
finish(new Error('
|
|
408
|
+
finish(new Error('浏览器语音识别服务当前不可用,请检查网络连接或切换 Chrome / Edge 后再试。'))
|
|
429
409
|
return
|
|
430
410
|
}
|
|
431
411
|
finish(new Error(`语音识别失败:${code}`))
|
|
@@ -480,6 +460,8 @@ export class SpeechService {
|
|
|
480
460
|
}, 300)
|
|
481
461
|
}
|
|
482
462
|
|
|
463
|
+
recognition.onstart = () => { this.recognition = recognition; this.listening = true }
|
|
464
|
+
|
|
483
465
|
recognition.onresult = (event) => {
|
|
484
466
|
let text = ''
|
|
485
467
|
for (let i = event.resultIndex; i < event.results.length; i += 1) {
|
|
@@ -502,12 +484,15 @@ export class SpeechService {
|
|
|
502
484
|
restart()
|
|
503
485
|
}
|
|
504
486
|
|
|
505
|
-
recognition.onend = restart
|
|
487
|
+
recognition.onend = () => { if (this.recognition === recognition) this.recognition = null; this.listening = false; restart() }
|
|
488
|
+
|
|
489
|
+
const thisOuter = this
|
|
506
490
|
|
|
507
491
|
const controller = {
|
|
508
492
|
stop() {
|
|
509
493
|
stopped = true
|
|
510
494
|
recognition.onend = null
|
|
495
|
+
if (this.recognition === recognition) this.recognition = null
|
|
511
496
|
try { recognition.stop() } catch (_) {}
|
|
512
497
|
buffer = ''
|
|
513
498
|
},
|
|
@@ -515,10 +500,10 @@ export class SpeechService {
|
|
|
515
500
|
paused = true
|
|
516
501
|
try { recognition.stop() } catch (_) {}
|
|
517
502
|
const started = Date.now()
|
|
518
|
-
while (!stopped &&
|
|
503
|
+
while (!stopped && thisOuter.recognition === recognition && Date.now() - started < 1400) {
|
|
519
504
|
await new Promise((resolve) => window.setTimeout(resolve, 40))
|
|
520
505
|
}
|
|
521
|
-
return
|
|
506
|
+
return thisOuter.recognition !== recognition
|
|
522
507
|
},
|
|
523
508
|
resume() {
|
|
524
509
|
if (stopped) return
|
package/package.json.bak
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "xiaoyuan-assistant",
|
|
3
|
-
"version": "0.5.45",
|
|
4
|
-
"type": "module",
|
|
5
|
-
"main": "./src/index.js",
|
|
6
|
-
"module": "./src/index.js",
|
|
7
|
-
"exports": {
|
|
8
|
-
".": {
|
|
9
|
-
"import": "./src/index.js",
|
|
10
|
-
"default": "./src/index.js"
|
|
11
|
-
},
|
|
12
|
-
"./style.css": "./src/styles/index.css"
|
|
13
|
-
},
|
|
14
|
-
"peerDependencies": {
|
|
15
|
-
"vue": "^3.3.0"
|
|
16
|
-
},
|
|
17
|
-
"scripts": {}
|
|
18
|
-
}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
-
import { dirname, resolve } from 'node:path'
|
|
3
|
-
import { fileURLToPath } from 'node:url'
|
|
4
|
-
import https from 'node:https'
|
|
5
|
-
|
|
6
|
-
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
7
|
-
const root = resolve(__dirname, '..')
|
|
8
|
-
const output = resolve(root, 'src/assets/received-command.mp3')
|
|
9
|
-
const flagFile = resolve(root, 'src/assets/received-command.local.js')
|
|
10
|
-
|
|
11
|
-
const API_KEY = 'XA8sVN8G43Cj8vc7MmXbf6nFGb'
|
|
12
|
-
const text = '收到指令,请您稍等'
|
|
13
|
-
const apiUrl = `https://api.hewoyi.com/api/ai/audio/speech?key=${encodeURIComponent(API_KEY)}&text=${encodeURIComponent(text)}&voice=zh-CN-XiaoyiNeural&format=&speed=&model=&type=speech`
|
|
14
|
-
|
|
15
|
-
function request(url) {
|
|
16
|
-
return new Promise((resolvePromise, reject) => {
|
|
17
|
-
https.get(url, (res) => {
|
|
18
|
-
const chunks = []
|
|
19
|
-
res.on('data', (chunk) => chunks.push(chunk))
|
|
20
|
-
res.on('end', () => {
|
|
21
|
-
const buffer = Buffer.concat(chunks)
|
|
22
|
-
resolvePromise({ status: res.statusCode || 0, contentType: res.headers['content-type'] || '', buffer })
|
|
23
|
-
})
|
|
24
|
-
}).on('error', reject)
|
|
25
|
-
})
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function extractAudioUrl(html) {
|
|
29
|
-
const match = String(html).match(/<source[^>]+src\s*=\s*["']([^"']+)["']/i)
|
|
30
|
-
|| String(html).match(/<audio[^>]+src\s*=\s*["']([^"']+)["']/i)
|
|
31
|
-
if (!match?.[1]) return null
|
|
32
|
-
return match[1]
|
|
33
|
-
.replace(/&/gi, '&')
|
|
34
|
-
.replace(/"/gi, '"')
|
|
35
|
-
.replace(/'/gi, "'")
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
async function main() {
|
|
39
|
-
try {
|
|
40
|
-
await mkdir(resolve(root, 'src/assets'), { recursive: true })
|
|
41
|
-
const first = await request(apiUrl)
|
|
42
|
-
if (first.status < 200 || first.status >= 300) throw new Error(`TTS API status ${first.status}`)
|
|
43
|
-
|
|
44
|
-
let audioBuffer = null
|
|
45
|
-
const contentType = first.contentType.toLowerCase()
|
|
46
|
-
if (contentType.startsWith('audio/')) {
|
|
47
|
-
audioBuffer = first.buffer
|
|
48
|
-
} else {
|
|
49
|
-
const sourceUrl = extractAudioUrl(first.buffer.toString('utf8'))
|
|
50
|
-
if (!sourceUrl) throw new Error('TTS HTML 中没有找到 audio/source 地址')
|
|
51
|
-
const second = await request(sourceUrl)
|
|
52
|
-
if (second.status < 200 || second.status >= 300) throw new Error(`audio URL status ${second.status}`)
|
|
53
|
-
audioBuffer = second.buffer
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (!audioBuffer || audioBuffer.length < 10000) {
|
|
57
|
-
throw new Error(`下载到的音频文件异常,大小 ${audioBuffer?.length || 0} bytes`)
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
await writeFile(output, audioBuffer)
|
|
61
|
-
await writeFile(flagFile, `// generated by npm postinstall\nexport const receivedCommandLocalReady = true\n`)
|
|
62
|
-
console.log(`[小园] 已将“收到指令,请您稍等。”缓存到本地:${output} (${audioBuffer.length} bytes)`)
|
|
63
|
-
} catch (error) {
|
|
64
|
-
console.warn('[小园] 本地 TTS 预缓存失败,将保留浏览器 TTS/网络 TTS 兼容链路:', error?.message || error)
|
|
65
|
-
// 保留 false,运行时会继续走现有兼容逻辑。
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
await main()
|
|
Binary file
|
package/src/vite-plugin.js
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import http from 'node:http'
|
|
2
|
-
import https from 'node:https'
|
|
3
|
-
|
|
4
|
-
function fetchImpl(url, options = {}) {
|
|
5
|
-
if (typeof globalThis.fetch === 'function') return globalThis.fetch(url, options)
|
|
6
|
-
return new Promise((resolve, reject) => {
|
|
7
|
-
const target = new URL(url)
|
|
8
|
-
const lib = target.protocol === 'https:' ? https : http
|
|
9
|
-
const req = lib.request(target, {
|
|
10
|
-
method: options.method || 'GET',
|
|
11
|
-
headers: options.headers || {}
|
|
12
|
-
}, (res) => {
|
|
13
|
-
const chunks = []
|
|
14
|
-
res.on('data', (chunk) => chunks.push(chunk))
|
|
15
|
-
res.on('end', () => {
|
|
16
|
-
const body = Buffer.concat(chunks)
|
|
17
|
-
resolve(new Response(body, { status: res.statusCode, headers: res.headers }))
|
|
18
|
-
})
|
|
19
|
-
})
|
|
20
|
-
req.on('error', reject)
|
|
21
|
-
req.end()
|
|
22
|
-
})
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function xiaoyuanVitePlugin(options = {}) {
|
|
26
|
-
const prefix = options.prefix || '/__xiaoyuan/hewoyi-tts'
|
|
27
|
-
const upstream = options.upstream || 'https://api.hewoyi.com/api/ai/audio/speech'
|
|
28
|
-
|
|
29
|
-
return {
|
|
30
|
-
name: 'xiaoyuan-hewoyi-tts-proxy',
|
|
31
|
-
configureServer(server) {
|
|
32
|
-
server.middlewares.use(prefix, async (req, res, next) => {
|
|
33
|
-
if (req.method !== 'GET') {
|
|
34
|
-
next()
|
|
35
|
-
return
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
const query = req.url?.includes('?') ? req.url.slice(req.url.indexOf('?') + 1) : ''
|
|
40
|
-
const upstreamUrl = `${upstream}?${query}`
|
|
41
|
-
const response = await fetchImpl(upstreamUrl, {
|
|
42
|
-
method: 'GET',
|
|
43
|
-
headers: {
|
|
44
|
-
Accept: 'application/json,audio/mpeg,audio/*'
|
|
45
|
-
}
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
const buffer = Buffer.from(await response.arrayBuffer())
|
|
49
|
-
const contentType = response.headers.get('content-type') || 'application/octet-stream'
|
|
50
|
-
|
|
51
|
-
res.statusCode = response.status
|
|
52
|
-
res.setHeader('Content-Type', contentType)
|
|
53
|
-
res.setHeader('Cache-Control', 'no-store')
|
|
54
|
-
res.end(buffer)
|
|
55
|
-
} catch (error) {
|
|
56
|
-
res.statusCode = 500
|
|
57
|
-
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
58
|
-
res.end(JSON.stringify({
|
|
59
|
-
message: '小园合我意 TTS 代理异常',
|
|
60
|
-
detail: error?.message || String(error)
|
|
61
|
-
}))
|
|
62
|
-
}
|
|
63
|
-
})
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
package/src/voice/freetts.js
DELETED
|
@@ -1,238 +0,0 @@
|
|
|
1
|
-
export class FreeTTSProvider {
|
|
2
|
-
constructor(options = {}) {
|
|
3
|
-
this.proxyUrl = options.proxyUrl || ''
|
|
4
|
-
this.apiUrl = options.apiUrl || 'https://freetts.org/api/v1/tts'
|
|
5
|
-
this.audioUrl = options.audioUrl || 'https://freetts.org/api/audio'
|
|
6
|
-
this.apiKey = options.apiKey || ''
|
|
7
|
-
this.voice = options.voice || 'zh-CN-XiaoxiaoNeural'
|
|
8
|
-
this.rate = options.rate || '-5%'
|
|
9
|
-
this.pitch = options.pitch || '+2Hz'
|
|
10
|
-
this.outputFormat = options.outputFormat || 'mp3'
|
|
11
|
-
this.currentAudio = null
|
|
12
|
-
this.currentUrl = null
|
|
13
|
-
this.currentSource = null
|
|
14
|
-
this.audioContext = null
|
|
15
|
-
this.audioUnlocked = false
|
|
16
|
-
this.cache = new Map()
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
enabled() {
|
|
20
|
-
return Boolean(this.apiKey)
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
getAudioContext() {
|
|
24
|
-
if (typeof window === 'undefined') return null
|
|
25
|
-
const AudioContextClass = window.AudioContext || window.webkitAudioContext
|
|
26
|
-
if (!AudioContextClass) return null
|
|
27
|
-
if (!this.audioContext) this.audioContext = new AudioContextClass()
|
|
28
|
-
return this.audioContext
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* 在用户点击/触摸事件里提前调用,解决 Chrome/Edge 的 autoplay 限制。
|
|
33
|
-
* 这里只恢复 AudioContext,不发起任何 TTS 网络请求。
|
|
34
|
-
*/
|
|
35
|
-
async unlock() {
|
|
36
|
-
const ctx = this.getAudioContext()
|
|
37
|
-
if (!ctx) return false
|
|
38
|
-
try {
|
|
39
|
-
if (ctx.state === 'suspended') await ctx.resume()
|
|
40
|
-
this.audioUnlocked = ctx.state === 'running'
|
|
41
|
-
return this.audioUnlocked
|
|
42
|
-
} catch (_) {
|
|
43
|
-
return false
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
stop() {
|
|
48
|
-
if (this.currentSource) {
|
|
49
|
-
try { this.currentSource.stop(0) } catch (_) {}
|
|
50
|
-
try { this.currentSource.disconnect() } catch (_) {}
|
|
51
|
-
this.currentSource = null
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (this.currentAudio) {
|
|
55
|
-
try {
|
|
56
|
-
this.currentAudio.pause()
|
|
57
|
-
this.currentAudio.currentTime = 0
|
|
58
|
-
this.currentAudio.removeAttribute('src')
|
|
59
|
-
this.currentAudio.load()
|
|
60
|
-
} catch (_) {}
|
|
61
|
-
this.currentAudio = null
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (this.currentUrl && typeof URL !== 'undefined') {
|
|
65
|
-
try { URL.revokeObjectURL(this.currentUrl) } catch (_) {}
|
|
66
|
-
this.currentUrl = null
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
async requestAudioBlob(value) {
|
|
71
|
-
const response = await fetch(this.proxyUrl || this.apiUrl, {
|
|
72
|
-
method: 'POST',
|
|
73
|
-
headers: {
|
|
74
|
-
'Content-Type': 'application/json',
|
|
75
|
-
'x-api-key': this.apiKey
|
|
76
|
-
},
|
|
77
|
-
body: JSON.stringify({
|
|
78
|
-
text: value.slice(0, 5000),
|
|
79
|
-
voice: this.voice,
|
|
80
|
-
rate: this.rate,
|
|
81
|
-
pitch: this.pitch,
|
|
82
|
-
output_format: this.outputFormat
|
|
83
|
-
})
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
if (!response.ok) {
|
|
87
|
-
const contentType = response.headers.get('content-type') || ''
|
|
88
|
-
const body = contentType.includes('application/json')
|
|
89
|
-
? await response.json().catch(() => null)
|
|
90
|
-
: await response.text().catch(() => '')
|
|
91
|
-
const detail = typeof body === 'string' ? body : JSON.stringify(body)
|
|
92
|
-
throw new Error(`FreeTTS 请求失败 ${response.status}${detail ? `:${detail}` : ''}`)
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return await response.blob()
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
async requestFileId(value) {
|
|
99
|
-
const cached = this.cache.get(value)
|
|
100
|
-
const now = Date.now()
|
|
101
|
-
if (cached && now - cached.createdAt < 50 * 60 * 1000) {
|
|
102
|
-
return cached.fileId
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const response = await fetch(this.apiUrl, {
|
|
106
|
-
method: 'POST',
|
|
107
|
-
headers: {
|
|
108
|
-
'Content-Type': 'application/json',
|
|
109
|
-
'x-api-key': this.apiKey
|
|
110
|
-
},
|
|
111
|
-
body: JSON.stringify({
|
|
112
|
-
text: value.slice(0, 5000),
|
|
113
|
-
voice: this.voice,
|
|
114
|
-
rate: this.rate,
|
|
115
|
-
pitch: this.pitch,
|
|
116
|
-
output_format: this.outputFormat
|
|
117
|
-
})
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
if (!response.ok) {
|
|
121
|
-
const body = await response.text().catch(() => '')
|
|
122
|
-
throw new Error(`FreeTTS 请求失败 ${response.status}${body ? `:${body}` : ''}`)
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const data = await response.json()
|
|
126
|
-
const fileId = data?.file_id
|
|
127
|
-
if (!fileId) throw new Error(`FreeTTS 未返回 file_id:${JSON.stringify(data)}`)
|
|
128
|
-
|
|
129
|
-
this.cache.set(value, { fileId, createdAt: now })
|
|
130
|
-
return fileId
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
async downloadBlob(fileId) {
|
|
134
|
-
const audioResponse = await fetch(`${this.audioUrl}/${encodeURIComponent(fileId)}`)
|
|
135
|
-
if (!audioResponse.ok) {
|
|
136
|
-
const body = await audioResponse.text().catch(() => '')
|
|
137
|
-
throw new Error(`FreeTTS 音频下载失败 ${audioResponse.status}${body ? `:${body}` : ''}`)
|
|
138
|
-
}
|
|
139
|
-
return await audioResponse.blob()
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
async playWithWebAudio(blob) {
|
|
143
|
-
const ctx = this.getAudioContext()
|
|
144
|
-
if (!ctx) return false
|
|
145
|
-
|
|
146
|
-
try {
|
|
147
|
-
if (ctx.state === 'suspended') {
|
|
148
|
-
await ctx.resume()
|
|
149
|
-
}
|
|
150
|
-
if (ctx.state !== 'running') return false
|
|
151
|
-
|
|
152
|
-
const buffer = await blob.arrayBuffer()
|
|
153
|
-
const audioBuffer = await ctx.decodeAudioData(buffer.slice(0))
|
|
154
|
-
|
|
155
|
-
return await new Promise((resolve, reject) => {
|
|
156
|
-
const source = ctx.createBufferSource()
|
|
157
|
-
source.buffer = audioBuffer
|
|
158
|
-
source.connect(ctx.destination)
|
|
159
|
-
this.currentSource = source
|
|
160
|
-
|
|
161
|
-
let settled = false
|
|
162
|
-
const finish = (ok, error) => {
|
|
163
|
-
if (settled) return
|
|
164
|
-
settled = true
|
|
165
|
-
if (this.currentSource === source) this.currentSource = null
|
|
166
|
-
try { source.disconnect() } catch (_) {}
|
|
167
|
-
if (error) reject(error)
|
|
168
|
-
else resolve(ok)
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
source.onended = () => finish(true)
|
|
172
|
-
try {
|
|
173
|
-
source.start(0)
|
|
174
|
-
} catch (error) {
|
|
175
|
-
finish(false, error)
|
|
176
|
-
}
|
|
177
|
-
})
|
|
178
|
-
} catch (_) {
|
|
179
|
-
return false
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
async playWithAudioElement(blob) {
|
|
184
|
-
const url = URL.createObjectURL(blob)
|
|
185
|
-
this.currentUrl = url
|
|
186
|
-
|
|
187
|
-
return await new Promise((resolve, reject) => {
|
|
188
|
-
const audio = new Audio()
|
|
189
|
-
this.currentAudio = audio
|
|
190
|
-
audio.preload = 'auto'
|
|
191
|
-
audio.src = url
|
|
192
|
-
|
|
193
|
-
let settled = false
|
|
194
|
-
const finish = (ok, error) => {
|
|
195
|
-
if (settled) return
|
|
196
|
-
settled = true
|
|
197
|
-
if (error) reject(error)
|
|
198
|
-
else resolve(ok)
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
audio.onended = () => {
|
|
202
|
-
this.stop()
|
|
203
|
-
finish(true)
|
|
204
|
-
}
|
|
205
|
-
audio.onerror = () => {
|
|
206
|
-
this.stop()
|
|
207
|
-
finish(false, new Error('FreeTTS 音频播放失败'))
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const playResult = audio.play()
|
|
211
|
-
if (playResult?.catch) {
|
|
212
|
-
playResult.catch((err) => {
|
|
213
|
-
this.stop()
|
|
214
|
-
finish(false, err)
|
|
215
|
-
})
|
|
216
|
-
}
|
|
217
|
-
})
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
async speak(text) {
|
|
221
|
-
const value = String(text || '').trim()
|
|
222
|
-
if (!value || !this.enabled()) return false
|
|
223
|
-
|
|
224
|
-
this.stop()
|
|
225
|
-
|
|
226
|
-
const blob = this.proxyUrl
|
|
227
|
-
? await this.requestAudioBlob(value)
|
|
228
|
-
: await this.downloadBlob(await this.requestFileId(value))
|
|
229
|
-
|
|
230
|
-
// 优先走 Web Audio:业务请求虽然是异步的,但只要之前有用户交互并 unlock,
|
|
231
|
-
// 就不会因为 HTMLAudioElement 的 autoplay policy 而在 audio.play() 处失败。
|
|
232
|
-
const webAudioPlayed = await this.playWithWebAudio(blob)
|
|
233
|
-
if (webAudioPlayed) return true
|
|
234
|
-
|
|
235
|
-
// 兼容没有 Web Audio 或 AudioContext 不可用的浏览器。
|
|
236
|
-
return await this.playWithAudioElement(blob)
|
|
237
|
-
}
|
|
238
|
-
}
|