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.
- package/CHANGELOG.md +388 -0
- package/README.md +229 -0
- package/XIAOYUAN-BUSINESS-FLOW.md +1214 -0
- package/example/App.vue +43 -0
- package/examples/main-siliconflow.js +24 -0
- package/examples/server/package.json +7 -0
- package/examples/server/siliconflow.cjs +117 -0
- package/package.json +18 -0
- package/scripts/cache-received-tts.mjs +69 -0
- package/src/assets/received-command.local.js +2 -0
- package/src/assets/received-command.mp3 +0 -0
- package/src/assets/received-command.zh-CN-XiaoxiaoNeural.mp3 +0 -0
- package/src/assets/received-command.zh-CN-XiaoyiNeural.mp3 +0 -0
- package/src/components/XiaoyuanAssistant.vue +519 -0
- package/src/core/builtins.js +188 -0
- package/src/core/dom.js +156 -0
- package/src/core/localCommandParser.js +434 -0
- package/src/core/manager.js +505 -0
- package/src/core/registry.js +49 -0
- package/src/index.js +100 -0
- package/src/prompts.js +24 -0
- package/src/providers/siliconflow.js +296 -0
- package/src/styles/index.css +166 -0
- package/src/vite-plugin.js +66 -0
- package/src/voice/freetts.js +238 -0
- package/src/voice/hewoyi.js +774 -0
- package/src/voice/localReceivedTTS.js +106 -0
- package/src/voice/speech.js +329 -0
|
@@ -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(/&/gi, '&')
|
|
260
|
+
.replace(/"/gi, '"')
|
|
261
|
+
.replace(/'/gi, "'")
|
|
262
|
+
.replace(/</gi, '<')
|
|
263
|
+
.replace(/>/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
|
+
}
|