xiaoyuan-assistant 0.5.45

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.
@@ -0,0 +1,774 @@
1
+ import { getReceivedCommandAudio, saveReceivedCommandAudio } from './localReceivedTTS.js'
2
+
3
+ const RECEIVED_COMMAND_TEXT = '收到指令,请您稍等。'
4
+
5
+ export class HewoyiTTSProvider {
6
+ constructor(options = {}) {
7
+ this.apiUrl = options.apiUrl || 'https://api.hewoyi.com/api/ai/audio/speech'
8
+ this.apiKey = options.apiKey || ''
9
+ this.voice = options.voice || 'zh-CN-XiaoyiNeural'
10
+ this.format = options.format || 'mp3'
11
+ this.speed = options.speed ?? ''
12
+ this.model = options.model ?? ''
13
+ this.type = options.type || 'speech'
14
+ this.requestTimeoutMs = Math.max(2500, Number(options.requestTimeoutMs ?? 7000))
15
+ this.debug = options.debug !== false
16
+ this.currentAudio = null
17
+ this.currentUrl = null
18
+ this.localReceivedAudio = null
19
+ this.localReceivedUrl = null
20
+ this.currentSource = null
21
+ this.audioContext = null
22
+ this.receivedCacheBlob = null
23
+ this.receivedCacheLoaded = false
24
+ this.receivedCacheLoadPromise = null
25
+ }
26
+
27
+ log(...args) {
28
+ if (this.debug && typeof console !== 'undefined') console.log('[小园 TTS]', ...args)
29
+ }
30
+
31
+ error(...args) {
32
+ if (this.debug && typeof console !== 'undefined') console.error('[小园 TTS]', ...args)
33
+ }
34
+
35
+ enabled() {
36
+ return Boolean(this.apiKey)
37
+ }
38
+
39
+ getAudioContext() {
40
+ if (typeof window === 'undefined') return null
41
+ const AudioContextClass = window.AudioContext || window.webkitAudioContext
42
+ if (!AudioContextClass) return null
43
+ if (!this.audioContext) this.audioContext = new AudioContextClass()
44
+ return this.audioContext
45
+ }
46
+
47
+ async unlock() {
48
+ let contextReady = true
49
+ const ctx = this.getAudioContext()
50
+ if (ctx) {
51
+ try {
52
+ if (ctx.state === 'suspended') await ctx.resume()
53
+ contextReady = ctx.state === 'running'
54
+ } catch (_) {
55
+ contextReady = false
56
+ }
57
+ }
58
+
59
+ // 前置提示音优先读取 NPM 包内置的 voice 音频;包内没有时再读取浏览器 IndexedDB。
60
+ // 当前 voice 第一次没有本地资源时,才走接口并在成功后缓存;后续直接本地播放。
61
+ try {
62
+ await this.loadReceivedCommandCache()
63
+ } catch (error) {
64
+ this.error('读取本地“收到指令”TTS 缓存失败:', error)
65
+ }
66
+
67
+ return contextReady
68
+ }
69
+
70
+ async loadReceivedCommandCache() {
71
+ // voice 可能被宿主在运行时切换;切换 voice 后必须重新查该 voice 对应的本地缓存。
72
+ if (this.receivedCacheVoice !== this.voice) {
73
+ this.receivedCacheVoice = this.voice
74
+ this.receivedCacheBlob = null
75
+ this.receivedCacheLoaded = false
76
+ this.receivedCacheLoadPromise = null
77
+ }
78
+
79
+ if (this.receivedCacheLoaded) return this.receivedCacheBlob
80
+ if (this.receivedCacheLoadPromise) return await this.receivedCacheLoadPromise
81
+
82
+ this.receivedCacheLoadPromise = getReceivedCommandAudio(this.voice)
83
+ .then((blob) => {
84
+ this.receivedCacheBlob = blob
85
+ this.receivedCacheLoaded = true
86
+ if (blob) this.log('命中本地“收到指令”TTS缓存,voice:', this.voice, 'size:', blob.size)
87
+ else this.log('未命中本地“收到指令”TTS缓存,voice:', this.voice)
88
+ return blob
89
+ })
90
+ .catch((error) => {
91
+ this.receivedCacheLoaded = true
92
+ this.receivedCacheBlob = null
93
+ this.error('读取本地“收到指令”TTS缓存失败:', error?.message || error)
94
+ return null
95
+ })
96
+
97
+ return await this.receivedCacheLoadPromise
98
+ }
99
+
100
+ async cacheReceivedCommandFromResult(result) {
101
+ if (!result) return false
102
+
103
+ let blob = null
104
+ if (result.kind === 'blob' && result.blob) {
105
+ blob = result.blob
106
+ } else if (result.kind === 'url' && result.url) {
107
+ // 接口返回的 source URL 可以直接播放;如果音频服务器允许 CORS,则顺便把同一份音频落到 IndexedDB。
108
+ // CORS 不允许时仍保持在线播放,不影响本次请求。
109
+ try {
110
+ const response = await fetch(result.url, {
111
+ method: 'GET',
112
+ mode: 'cors',
113
+ cache: 'no-store'
114
+ })
115
+ if (response.ok) blob = await response.blob()
116
+ } catch (error) {
117
+ this.error('收到指令音频本地缓存失败(不影响当前在线播放):', error?.message || error)
118
+ return false
119
+ }
120
+ }
121
+
122
+ if (!blob || !blob.size) return false
123
+
124
+ try {
125
+ const saved = await saveReceivedCommandAudio(this.voice, blob)
126
+ if (saved) {
127
+ this.receivedCacheBlob = blob
128
+ this.receivedCacheLoaded = true
129
+ this.log('已缓存“收到指令,请您稍等。”到 IndexedDB,voice:', this.voice, 'size:', blob.size)
130
+ }
131
+ return saved
132
+ } catch (error) {
133
+ this.error('保存“收到指令”TTS本地缓存失败:', error?.message || error)
134
+ return false
135
+ }
136
+ }
137
+
138
+ stop() {
139
+ this.stopLocalReceivedOnly()
140
+ if (this.currentSource) {
141
+ try { this.currentSource.stop(0) } catch (_) {}
142
+ try { this.currentSource.disconnect() } catch (_) {}
143
+ this.currentSource = null
144
+ }
145
+
146
+ if (this.currentAudio) {
147
+ try {
148
+ this.currentAudio.pause()
149
+ this.currentAudio.currentTime = 0
150
+ this.currentAudio.removeAttribute('src')
151
+ this.currentAudio.load()
152
+ } catch (_) {}
153
+ this.currentAudio = null
154
+ }
155
+
156
+ if (this.currentUrl && typeof URL !== 'undefined') {
157
+ try { URL.revokeObjectURL(this.currentUrl) } catch (_) {}
158
+ this.currentUrl = null
159
+ }
160
+ }
161
+
162
+ buildQuery(text) {
163
+ const params = new URLSearchParams()
164
+ params.set('key', this.apiKey)
165
+ params.set('text', String(text || '').slice(0, 4096))
166
+ params.set('voice', this.voice)
167
+ params.set('format', this.format)
168
+ if (this.speed !== '') params.set('speed', this.speed)
169
+ if (this.model !== '') params.set('model', this.model)
170
+ params.set('type', this.type)
171
+ return params
172
+ }
173
+
174
+ async requestAudioSource(text) {
175
+ if (!this.enabled()) {
176
+ throw new Error('合我意 TTS 未配置 apiKey')
177
+ }
178
+
179
+ const params = this.buildQuery(text)
180
+ const url = `${this.apiUrl}?${params.toString()}`
181
+ this.log('发起请求:', url.replace(/([?&]key=)[^&]*/i, '$1***'))
182
+
183
+ let response
184
+ let timeoutId = null
185
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
186
+ try {
187
+ if (controller) timeoutId = window.setTimeout(() => controller.abort(), this.requestTimeoutMs)
188
+ response = await fetch(url, {
189
+ method: 'GET',
190
+ headers: {
191
+ Accept: 'text/html,application/json,audio/mpeg,audio/wav,audio/*,*/*;q=0.8'
192
+ },
193
+ signal: controller?.signal
194
+ })
195
+ } catch (error) {
196
+ this.error('请求没有到达接口:', error)
197
+ throw new Error(`合我意 TTS 请求失败:${error?.message || error}`)
198
+ }
199
+
200
+ if (timeoutId) window.clearTimeout(timeoutId)
201
+
202
+ const contentType = (response.headers.get('content-type') || '').toLowerCase()
203
+ this.log('接口响应:', response.status, contentType)
204
+
205
+ if (!response.ok) {
206
+ const body = await response.text().catch(() => '')
207
+ throw new Error(`合我意 TTS 请求失败 ${response.status}${body ? `:${body.slice(0, 500)}` : ''}`)
208
+ }
209
+
210
+ // 直接返回音频:优先返回 Blob
211
+ if (contentType.startsWith('audio/')) {
212
+ const blob = await response.blob()
213
+ return { kind: 'blob', blob }
214
+ }
215
+
216
+ // 当前接口实际返回的是 HTML,例如:
217
+ // <audio controls><source src="https://.../speech?..." type="audio/mpeg"></audio>
218
+ // 所以这里不能只按 JSON 解析。
219
+ const body = await response.text()
220
+ const trimmed = body.trim()
221
+
222
+ if (contentType.includes('text/html') || /<audio\b|<source\b/i.test(trimmed)) {
223
+ const sourceUrl = this.extractAudioSourceFromHtml(trimmed)
224
+ if (sourceUrl) {
225
+ this.log('HTML 音频地址:', sourceUrl)
226
+ return { kind: 'url', url: sourceUrl }
227
+ }
228
+ }
229
+
230
+ // 某些环境 content-type 可能不标准,但 body 实际是 JSON
231
+ try {
232
+ const payload = JSON.parse(trimmed)
233
+ return await this.parseAudioResponse(payload)
234
+ } catch (_) {
235
+ throw new Error(`合我意 TTS 返回无法解析,原始内容:${trimmed.slice(0, 1200)}`)
236
+ }
237
+ }
238
+
239
+ extractAudioSourceFromHtml(html) {
240
+ if (!html) return null
241
+
242
+ // 优先 source[src]
243
+ const sourceMatch = html.match(/<source[^>]+src\s*=\s*["']([^"']+)["']/i)
244
+ if (sourceMatch?.[1]) {
245
+ return this.decodeHtmlAttribute(sourceMatch[1])
246
+ }
247
+
248
+ // 再兼容 audio[src]
249
+ const audioMatch = html.match(/<audio[^>]+src\s*=\s*["']([^"']+)["']/i)
250
+ if (audioMatch?.[1]) {
251
+ return this.decodeHtmlAttribute(audioMatch[1])
252
+ }
253
+
254
+ return null
255
+ }
256
+
257
+ decodeHtmlAttribute(value) {
258
+ return String(value || '')
259
+ .replace(/&amp;/gi, '&')
260
+ .replace(/&quot;/gi, '"')
261
+ .replace(/&#39;/gi, "'")
262
+ .replace(/&lt;/gi, '<')
263
+ .replace(/&gt;/gi, '>')
264
+ }
265
+
266
+ findAudioCandidate(value, seen = new Set()) {
267
+ if (!value || typeof value !== 'object' || seen.has(value)) return null
268
+ seen.add(value)
269
+
270
+ if (Array.isArray(value)) {
271
+ for (const item of value) {
272
+ const found = this.findAudioCandidate(item, seen)
273
+ if (found) return found
274
+ }
275
+ return null
276
+ }
277
+
278
+ for (const [key, item] of Object.entries(value)) {
279
+ if (typeof item === 'string' && item.trim()) {
280
+ const normalizedKey = key.toLowerCase().replace(/[-_]/g, '')
281
+ const candidate = item.trim()
282
+ const looksLikeAudioKey = /audio|sound|speech|file|url|src|path/.test(normalizedKey)
283
+ const looksLikeAudioValue = candidate.startsWith('http://') || candidate.startsWith('https://') || candidate.startsWith('data:audio/') || candidate.startsWith('base64,')
284
+ if (looksLikeAudioValue && (looksLikeAudioKey || candidate.startsWith('data:') || candidate.startsWith('base64,'))) {
285
+ return candidate
286
+ }
287
+ }
288
+
289
+ const nested = this.findAudioCandidate(item, seen)
290
+ if (nested) return nested
291
+ }
292
+
293
+ return null
294
+ }
295
+
296
+ async parseAudioResponse(payload) {
297
+ const code = payload?.code
298
+ if (code != null && Number(code) !== 200) {
299
+ throw new Error(`合我意 TTS 失败:${payload?.msg || JSON.stringify(payload)}`)
300
+ }
301
+
302
+ const candidate = this.findAudioCandidate(payload)
303
+ this.log('JSON 音频结果:', candidate ? candidate.slice(0, 180) : '未找到')
304
+
305
+ if (!candidate) {
306
+ throw new Error(`合我意 TTS 未返回可播放音频,请检查接口返回:${JSON.stringify(payload).slice(0, 1000)}`)
307
+ }
308
+
309
+ if (candidate.startsWith('data:')) {
310
+ const response = await fetch(candidate)
311
+ return await response.blob()
312
+ }
313
+
314
+ if (candidate.startsWith('base64,')) {
315
+ return await this.base64ToBlob(candidate.slice(7), `audio/${this.format}`)
316
+ }
317
+
318
+ const audioResponse = await fetch(candidate)
319
+ if (!audioResponse.ok) {
320
+ throw new Error(`合我意 TTS 音频下载失败 ${audioResponse.status}`)
321
+ }
322
+ return await audioResponse.blob()
323
+ }
324
+
325
+ async base64ToBlob(base64, mimeType) {
326
+ const binary = atob(base64)
327
+ const bytes = new Uint8Array(binary.length)
328
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
329
+ return new Blob([bytes], { type: mimeType })
330
+ }
331
+
332
+ async playWithWebAudio(blob, options = {}) {
333
+ const waitForEnd = options?.waitForEnd === true
334
+ const ctx = this.getAudioContext()
335
+ if (!ctx) return false
336
+
337
+ try {
338
+ if (ctx.state === 'suspended') await ctx.resume()
339
+ if (ctx.state !== 'running') return false
340
+
341
+ const buffer = await blob.arrayBuffer()
342
+ const audioBuffer = await ctx.decodeAudioData(buffer.slice(0))
343
+
344
+ return await new Promise((resolve, reject) => {
345
+ const source = ctx.createBufferSource()
346
+ source.buffer = audioBuffer
347
+ source.connect(ctx.destination)
348
+ this.currentSource = source
349
+
350
+ let settled = false
351
+ const finish = (ok, error) => {
352
+ if (settled) return
353
+ settled = true
354
+ if (this.currentSource === source) this.currentSource = null
355
+ try { source.disconnect() } catch (_) {}
356
+ if (error) reject(error)
357
+ else resolve(ok)
358
+ }
359
+
360
+ source.onended = () => {
361
+ if (this.currentSource === source) this.currentSource = null
362
+ try { source.disconnect() } catch (_) {}
363
+ if (waitForEnd) finish(true)
364
+ }
365
+ try {
366
+ source.start(0)
367
+ // 普通步骤:source.start() 后立即放行。AI 分析结果:等待 onended。
368
+ if (!waitForEnd) finish(true)
369
+ } catch (error) {
370
+ finish(false, error)
371
+ }
372
+ })
373
+ } catch (error) {
374
+ this.error('WebAudio 播放失败,准备回退 Audio:', error)
375
+ return false
376
+ }
377
+ }
378
+
379
+ async playWithAudioElement(blob, options = {}) {
380
+ const waitForEnd = options?.waitForEnd === true
381
+ const minStartHoldMs = Number(options?.minStartHoldMs || 0)
382
+ const url = URL.createObjectURL(blob)
383
+ this.currentUrl = url
384
+
385
+ return await new Promise((resolve, reject) => {
386
+ const audio = new Audio()
387
+ this.currentAudio = audio
388
+ audio.preload = 'auto'
389
+ audio.src = url
390
+
391
+ let settled = false
392
+ const finish = (ok, error) => {
393
+ if (settled) return
394
+ settled = true
395
+ if (error) reject(error)
396
+ else resolve(ok)
397
+ }
398
+
399
+ audio.onended = () => {
400
+ this.log('AudioElement 播放完成')
401
+ if (waitForEnd) finish(true)
402
+ }
403
+ audio.onerror = () => {
404
+ this.stop()
405
+ finish(false, new Error('合我意 TTS 音频播放失败'))
406
+ }
407
+
408
+ // 这里只在真正触发 HTMLMediaElement `playing` 事件后放行。
409
+ // play() Promise 仅代表浏览器接受了播放请求,并不等于已经开始出声。
410
+ let playPromise
411
+ const onPlaying = () => {
412
+ audio.removeEventListener('playing', onPlaying)
413
+ this.log('AudioElement 已真正开始播放')
414
+ if (!waitForEnd) {
415
+ if (minStartHoldMs > 0) {
416
+ window.setTimeout(() => finish(true), minStartHoldMs)
417
+ } else {
418
+ finish(true)
419
+ }
420
+ }
421
+ }
422
+ audio.addEventListener('playing', onPlaying, { once: true })
423
+
424
+ try {
425
+ playPromise = audio.play()
426
+ } catch (err) {
427
+ audio.removeEventListener('playing', onPlaying)
428
+ this.stop()
429
+ finish(false, err)
430
+ return
431
+ }
432
+
433
+ if (playPromise?.catch) {
434
+ playPromise.catch((err) => {
435
+ audio.removeEventListener('playing', onPlaying)
436
+ this.stop()
437
+ finish(false, err)
438
+ })
439
+ }
440
+ })
441
+ }
442
+
443
+ async playReceivedCommandLocal(options = {}) {
444
+ const waitForEnd = options?.waitForEnd === true
445
+
446
+ if (!this.receivedCacheLoaded) {
447
+ await this.loadReceivedCommandCache()
448
+ }
449
+
450
+ if (!this.receivedCacheBlob) return false
451
+
452
+ // “收到指令,请您稍等”使用独立 Audio 通道。
453
+ // 后续步骤的 TTS 不再调用 stop() 时把这条前置提示音一并切掉,
454
+ // 否则多步骤指令会出现日志命中本地缓存、但耳朵没听到的情况。
455
+ return await this.playLocalReceivedElement(this.receivedCacheBlob, { waitForEnd })
456
+ }
457
+
458
+ async playLocalReceivedElement(blob, options = {}) {
459
+ const waitForEnd = options?.waitForEnd === true
460
+ if (!(blob instanceof Blob) || !blob.size) return false
461
+
462
+ this.stopLocalReceivedOnly()
463
+
464
+ const url = URL.createObjectURL(blob)
465
+ this.localReceivedUrl = url
466
+ const audio = new Audio()
467
+ this.localReceivedAudio = audio
468
+ audio.preload = 'auto'
469
+ audio.playsInline = true
470
+ audio.src = url
471
+
472
+ return await new Promise((resolve) => {
473
+ let settled = false
474
+ const finish = (ok) => {
475
+ if (settled) return
476
+ settled = true
477
+ if (waitForEnd) this.stopLocalReceivedOnly()
478
+ resolve(ok)
479
+ }
480
+
481
+ const onPlaying = () => {
482
+ audio.removeEventListener('playing', onPlaying)
483
+ this.log('本地“收到指令”音频已真正开始播放')
484
+ // 只要真正开始播放就放行后续流程,不等待整段语音结束。
485
+ if (!waitForEnd) finish(true)
486
+ }
487
+ audio.addEventListener('playing', onPlaying, { once: true })
488
+ audio.onended = () => {
489
+ if (this.localReceivedAudio === audio) this.localReceivedAudio = null
490
+ if (this.localReceivedUrl === url) {
491
+ try { URL.revokeObjectURL(url) } catch (_) {}
492
+ this.localReceivedUrl = null
493
+ }
494
+ if (waitForEnd) finish(true)
495
+ }
496
+ audio.onerror = (event) => {
497
+ this.error('本地“收到指令”音频播放失败:', event)
498
+ finish(false)
499
+ }
500
+
501
+ let playPromise
502
+ try {
503
+ playPromise = audio.play()
504
+ } catch (error) {
505
+ finish(false)
506
+ return
507
+ }
508
+ if (playPromise?.catch) {
509
+ playPromise.catch((error) => {
510
+ this.error('本地“收到指令”音频 play() 失败:', error)
511
+ finish(false)
512
+ })
513
+ }
514
+ })
515
+ }
516
+
517
+ stopLocalReceivedOnly() {
518
+ if (this.localReceivedAudio) {
519
+ try { this.localReceivedAudio.pause() } catch (_) {}
520
+ try { this.localReceivedAudio.currentTime = 0 } catch (_) {}
521
+ try { this.localReceivedAudio.removeAttribute('src') } catch (_) {}
522
+ try { this.localReceivedAudio.load() } catch (_) {}
523
+ this.localReceivedAudio = null
524
+ }
525
+ if (this.localReceivedUrl) {
526
+ try { URL.revokeObjectURL(this.localReceivedUrl) } catch (_) {}
527
+ this.localReceivedUrl = null
528
+ }
529
+ }
530
+
531
+ async playBlobForGate(blob, options = {}) {
532
+ const waitForEnd = options?.waitForEnd === true
533
+ const localReceived = options?.localReceived === true
534
+ // 固定“收到指令”提示音优先使用 HTMLAudioElement。
535
+ // 原因:WebAudio 在 source.start() 后立即 resolve,再被下一条 TTS stop(),
536
+ // 浏览器可能还没来得及把首个音频帧送到扬声器,表现为“日志显示播放,但用户听不到”。
537
+ if (localReceived) {
538
+ return await this.playWithAudioElement(blob, {
539
+ waitForEnd,
540
+ minStartHoldMs: waitForEnd ? 0 : 120
541
+ })
542
+ }
543
+
544
+ const played = await this.playWithWebAudio(blob, { waitForEnd })
545
+ if (played) return true
546
+ return await this.playWithAudioElement(blob, { waitForEnd })
547
+ }
548
+
549
+ async playWithSource(url, options = {}) {
550
+ const waitForEnd = options?.waitForEnd === true
551
+ const isLocalReceived = options?.localReceived === true
552
+ if (!url) return false
553
+
554
+ // 普通网络音频只替换上一条“动态播报”,不要把独立的本地“收到指令”提示音切掉。
555
+ this.stopCurrentSpeechOnly()
556
+
557
+ return await new Promise((resolve) => {
558
+ const audio = isLocalReceived && this.localReceivedAudio
559
+ ? this.localReceivedAudio
560
+ : new Audio()
561
+
562
+ this.currentAudio = audio
563
+ if (!isLocalReceived) {
564
+ audio.preload = 'auto'
565
+ audio.playsInline = true
566
+ audio.src = url
567
+ } else {
568
+ audio.preload = 'auto'
569
+ audio.playsInline = true
570
+ if (audio.src !== new URL(url, window.location.href).href) {
571
+ audio.src = url
572
+ audio.load()
573
+ } else if (audio.readyState === 0) {
574
+ audio.load()
575
+ }
576
+ }
577
+
578
+ let settled = false
579
+ let fallbackTimer = null
580
+ const finish = (ok, error) => {
581
+ if (settled) return
582
+ settled = true
583
+ if (fallbackTimer) window.clearTimeout(fallbackTimer)
584
+ if (error) {
585
+ if (this.currentAudio === audio) this.currentAudio = null
586
+ this.error(isLocalReceived ? '本地“收到指令”音频播放失败:' : 'Audio URL 播放失败:', error)
587
+ }
588
+ resolve(ok)
589
+ }
590
+
591
+ const onPlaying = () => {
592
+ audio.removeEventListener('playing', onPlaying)
593
+ this.log(isLocalReceived ? '本地“收到指令”音频已真正开始播放' : 'Audio URL 已真正开始播放:', url)
594
+ if (!waitForEnd) finish(true)
595
+ }
596
+ audio.addEventListener('playing', onPlaying, { once: true })
597
+ audio.onended = () => {
598
+ if (this.currentAudio === audio) this.currentAudio = null
599
+ if (waitForEnd) finish(true)
600
+ }
601
+ audio.onerror = () => finish(false, new Error('音频元素播放失败'))
602
+
603
+ let playPromise
604
+ try {
605
+ audio.currentTime = 0
606
+ playPromise = audio.play()
607
+ } catch (err) {
608
+ finish(false, err)
609
+ return
610
+ }
611
+ if (playPromise?.catch) {
612
+ playPromise.catch((err) => finish(false, err))
613
+ }
614
+
615
+ // 某些浏览器在本地资源很短或缓存命中时,事件顺序可能异常;
616
+ // readyState 已经可播放时,用 requestAnimationFrame 再检查一次。
617
+ if (!waitForEnd && audio.readyState >= 3) {
618
+ fallbackTimer = window.setTimeout(() => {
619
+ if (!settled && !audio.paused && audio.currentTime > 0) finish(true)
620
+ }, 80)
621
+ }
622
+ })
623
+ }
624
+
625
+ stopCurrentSpeechOnly() {
626
+ if (this.currentSource) {
627
+ try { this.currentSource.stop(0) } catch (_) {}
628
+ try { this.currentSource.disconnect() } catch (_) {}
629
+ this.currentSource = null
630
+ }
631
+ if (this.currentAudio) {
632
+ try { this.currentAudio.pause() } catch (_) {}
633
+ try { this.currentAudio.currentTime = 0 } catch (_) {}
634
+ try { this.currentAudio.removeAttribute('src') } catch (_) {}
635
+ try { this.currentAudio.load() } catch (_) {}
636
+ this.currentAudio = null
637
+ }
638
+ if (this.currentUrl && typeof URL !== 'undefined') {
639
+ try { URL.revokeObjectURL(this.currentUrl) } catch (_) {}
640
+ this.currentUrl = null
641
+ }
642
+ }
643
+
644
+ async speakReceivedCommandAsync() {
645
+ const value = RECEIVED_COMMAND_TEXT
646
+
647
+ // 这是独立于普通 TTS 的异步通道:绝不阻塞 manager.run。
648
+ // 当前 voice 有本地包内资源/IndexedDB 时,直接播放;没有时才后台请求一次并缓存。
649
+ try {
650
+ await this.loadReceivedCommandCache()
651
+
652
+ if (this.receivedCacheBlob) {
653
+ this.log('异步播放本地“收到指令”TTS,不请求网络,voice:', this.voice)
654
+ void this.playReceivedCommandLocal({ waitForEnd: false }).catch((error) => {
655
+ this.error('异步播放本地“收到指令”失败:', error?.message || error)
656
+ })
657
+ return true
658
+ }
659
+
660
+ if (!this.enabled()) {
661
+ return false
662
+ }
663
+
664
+ this.log('异步首次获取“收到指令”TTS,voice:', this.voice)
665
+ const result = await this.requestAudioSource(value)
666
+ if (result?.kind === 'url' && result.url) {
667
+ void this.playWithSource(result.url, { waitForEnd: false }).catch((error) => {
668
+ this.error('异步播放“收到指令”TTS失败:', error?.message || error)
669
+ })
670
+ void this.cacheReceivedCommandFromResult(result)
671
+ return true
672
+ }
673
+ if (result?.kind === 'blob' && result.blob) {
674
+ void this.playBlobForGate(result.blob, { waitForEnd: false }).catch((error) => {
675
+ this.error('异步播放“收到指令”TTS失败:', error?.message || error)
676
+ })
677
+ void this.cacheReceivedCommandFromResult(result)
678
+ return true
679
+ }
680
+ } catch (error) {
681
+ this.error('异步前置“收到指令”TTS失败:', error?.message || error)
682
+ }
683
+
684
+ // 不阻塞主流程;浏览器原生 TTS 作为最后兼容由 SpeechService 自己决定。
685
+ return false
686
+ }
687
+
688
+ async speak(text, options = {}) {
689
+ const waitForEnd = options?.waitForEnd === true
690
+ const value = String(text || '').trim()
691
+ if (!value || !this.enabled()) return false
692
+
693
+ this.stopCurrentSpeechOnly()
694
+
695
+ // 固定前置提示音:当前 voice 优先命中 NPM 包内置音频,其次命中浏览器 IndexedDB。
696
+ // 同 voice 已有本地资源:完全不请求 API。
697
+ // 同 voice 没有本地资源:第一次正常请求 TTS,播放后异步缓存;后续直接本地播放。
698
+ if (value.replace(/[,。!?、,.!?\s]/g, '') === RECEIVED_COMMAND_TEXT.replace(/[,。!?、,.!?\s]/g, '')) {
699
+ await this.loadReceivedCommandCache()
700
+
701
+ if (this.receivedCacheBlob) {
702
+ this.log('使用本地“收到指令”TTS缓存,不请求网络,voice:', this.voice)
703
+ const played = await this.playReceivedCommandLocal({ waitForEnd })
704
+ if (played) return true
705
+ this.error('本地“收到指令”缓存播放失败,切换浏览器原生 TTS')
706
+ return await this.speakWithBrowser(value, { waitForEnd })
707
+ }
708
+
709
+ this.log('当前 voice 没有本地缓存,第一次调用合我意 TTS,voice:', this.voice)
710
+ try {
711
+ const result = await this.requestAudioSource(value)
712
+
713
+ // 首次请求先播放;缓存作为后台任务,不能拖慢“收到指令”门禁。
714
+ if (result?.kind === 'url' && result.url) {
715
+ const played = await this.playWithSource(result.url, { waitForEnd })
716
+ if (played) {
717
+ void this.cacheReceivedCommandFromResult(result)
718
+ return true
719
+ }
720
+ } else if (result?.kind === 'blob' && result.blob) {
721
+ const played = await this.playBlobForGate(result.blob, { waitForEnd })
722
+ if (played) {
723
+ void this.cacheReceivedCommandFromResult(result)
724
+ return true
725
+ }
726
+ }
727
+ } catch (error) {
728
+ this.error('首次“收到指令”TTS请求失败,切换浏览器原生 TTS:', error?.message || error)
729
+ }
730
+
731
+ return await this.speakWithBrowser(value, { waitForEnd })
732
+ }
733
+
734
+ this.log('开始播报:', value)
735
+
736
+ const result = await this.requestAudioSource(value)
737
+
738
+ // HTML 返回的是 <source src="...">,优先直接使用接口返回的音频地址播放。
739
+ // 不先 fetch 这个 URL,避免再次触发 CORS 导致 WebAudio 无法读取跨域音频。
740
+ if (result?.kind === 'url' && result.url) {
741
+ const played = await this.playWithSource(result.url, { waitForEnd })
742
+ if (played) return true
743
+
744
+ // URL 直接播放失败时,再尝试 fetch + Blob(有 CORS 时可用)
745
+ try {
746
+ const audioResponse = await fetch(result.url, { mode: 'cors' })
747
+ if (audioResponse.ok) {
748
+ const blob = await audioResponse.blob()
749
+ const webAudioPlayed = await this.playWithWebAudio(blob, { waitForEnd })
750
+ if (webAudioPlayed) return true
751
+ return await this.playWithAudioElement(blob, { waitForEnd })
752
+ }
753
+ } catch (error) {
754
+ this.error('音频 URL 读取失败:', error)
755
+ }
756
+ return false
757
+ }
758
+
759
+ if (result?.kind === 'blob' && result.blob) {
760
+ const webAudioPlayed = await this.playWithWebAudio(result.blob, { waitForEnd })
761
+ if (webAudioPlayed) {
762
+ this.log('WebAudio 已开始播放,放行后续业务')
763
+ return true
764
+ }
765
+
766
+ const played = await this.playWithAudioElement(result.blob)
767
+ if (played) this.log('AudioElement 已开始播放,放行后续业务')
768
+ return played
769
+ }
770
+
771
+ return false
772
+ }
773
+
774
+ }