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 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
@@ -246,3 +246,7 @@ app.use(Xiaoyuan, {
246
246
  - `wakeWord` 默认:`你好小园`。
247
247
  - 唤醒成功后播放本地 `我在您请讲`,按 `voice` 匹配。
248
248
  - 唤醒后的后续语音连续收音,3 秒静默后进入指令处理。
249
+
250
+
251
+ ### v0.5.55
252
+ 语音输入稳定性修复:避免额外麦克风权限流与 SpeechRecognition 并发,优化 network/aborted 处理。
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xiaoyuan-assistant",
3
- "version": "0.5.54",
3
+ "version": "0.5.56",
4
4
  "type": "module",
5
5
  "main": "./src/index.js",
6
6
  "module": "./src/index.js",
@@ -9,7 +9,18 @@
9
9
  @click="activate"
10
10
  >
11
11
  <span class="xy-fab-glow"></span>
12
- <span class="xy-avatar">园</span>
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.54'
131
+ const XIAOYUAN_SDK_VERSION = '0.5.56'
121
132
  const { manager, speech, options } = safeRuntime
122
133
 
123
134
  const wakeWord = options.wakeWord || '你好小园'
@@ -23,21 +23,133 @@
23
23
 
24
24
  .xy-fab {
25
25
  position: relative;
26
- width: 62px;
27
- height: 62px;
28
- border: 1px solid rgba(255,255,255,.20);
29
- border-radius: 20px;
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: linear-gradient(145deg, #877aff 0%, #6d5dfc 46%, #5142d0 100%);
33
- box-shadow: 0 14px 38px rgba(41,31,127,.42), 0 0 0 1px rgba(255,255,255,.05) inset;
34
- transition: transform .18s ease, box-shadow .18s ease;
35
- }
36
- .xy-fab:hover { transform: translateY(-2px); box-shadow: 0 18px 44px rgba(41,31,127,.52), 0 0 0 1px rgba(255,255,255,.08) inset; }
37
- .xy-fab-glow { position: absolute; inset: -14px; border-radius: 26px; background: radial-gradient(circle, rgba(109,93,252,.34), transparent 68%); z-index: -1; }
38
- .xy-avatar { display: inline-flex; align-items: center; justify-content: center; width: 100%; height: 100%; font-size: 23px; font-weight: 800; letter-spacing: 1px; }
39
- .xy-avatar.small { width: 42px; height: 42px; flex: 0 0 42px; font-size: 18px; border-radius: 14px; background: linear-gradient(145deg,#9388ff,#5948dc); box-shadow: 0 7px 18px rgba(87,71,220,.35); }
40
- .xy-live-dot { position: absolute; top: 4px; right: 4px; width: 9px; height: 9px; background: var(--xy-green); border-radius: 50%; box-shadow: 0 0 0 4px rgba(104,226,162,.10), 0 0 14px rgba(104,226,162,.75); }
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;
@@ -38,33 +38,12 @@ export class SpeechService {
38
38
  }
39
39
 
40
40
  async requestMicrophonePermission() {
41
- if (typeof navigator === 'undefined') throw new Error('当前环境无法访问麦克风')
42
- // Web Speech 自己持有识别麦克风;不要在每次 start 前额外 getUserMedia -> stop,
43
- // 否则 Chrome/Edge 容易在两个音频会话切换瞬间返回 aborted。
44
- if (this._micPermissionGranted) return true
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
- recognition.onresult = (event) => {
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 && this.recognition === recognition && Date.now() - started < 1200) {
503
+ while (!stopped && thisOuter.recognition === recognition && Date.now() - started < 1400) {
519
504
  await new Promise((resolve) => window.setTimeout(resolve, 40))
520
505
  }
521
- return this.recognition !== recognition
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(/&amp;/gi, '&')
34
- .replace(/&quot;/gi, '"')
35
- .replace(/&#39;/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()
@@ -1,2 +0,0 @@
1
- // generated by npm postinstall
2
- export const receivedCommandLocalReady = true
Binary file
@@ -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
- }
@@ -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
- }