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,106 @@
1
+ const DB_NAME = 'xiaoyuan-tts-cache'
2
+ const DB_VERSION = 1
3
+ const STORE_NAME = 'audio'
4
+ const CACHE_PREFIX = 'received-command::'
5
+
6
+ const BUILTIN_RECEIVED_COMMAND_AUDIO = Object.freeze({
7
+ 'zh-CN-XiaoyiNeural': new URL('../assets/received-command.zh-CN-XiaoyiNeural.mp3', import.meta.url).href,
8
+ 'zh-CN-XiaoxiaoNeural': new URL('../assets/received-command.zh-CN-XiaoxiaoNeural.mp3', import.meta.url).href
9
+ })
10
+
11
+ function isBrowser() {
12
+ return typeof window !== 'undefined' && typeof indexedDB !== 'undefined'
13
+ }
14
+
15
+ function openDB() {
16
+ if (!isBrowser()) return Promise.reject(new Error('IndexedDB 不可用'))
17
+ return new Promise((resolve, reject) => {
18
+ const request = indexedDB.open(DB_NAME, DB_VERSION)
19
+ request.onupgradeneeded = () => {
20
+ const db = request.result
21
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
22
+ db.createObjectStore(STORE_NAME, { keyPath: 'key' })
23
+ }
24
+ }
25
+ request.onsuccess = () => resolve(request.result)
26
+ request.onerror = () => reject(request.error || new Error('打开 TTS 本地缓存失败'))
27
+ })
28
+ }
29
+
30
+ function normalizeVoice(voice = '') {
31
+ return String(voice || '').trim() || 'default'
32
+ }
33
+
34
+ function makeKey(voice = '') {
35
+ return `${CACHE_PREFIX}${normalizeVoice(voice)}`
36
+ }
37
+
38
+ export async function getReceivedCommandAudio(voice) {
39
+ if (!isBrowser()) return null
40
+
41
+ const normalized = normalizeVoice(voice)
42
+ const builtinUrl = BUILTIN_RECEIVED_COMMAND_AUDIO[normalized]
43
+ if (builtinUrl) {
44
+ try {
45
+ const response = await fetch(builtinUrl, { cache: 'force-cache' })
46
+ if (response.ok) {
47
+ const blob = await response.blob()
48
+ if (blob.size) return blob
49
+ }
50
+ } catch (_) {
51
+ // 内置资源读取失败时继续走 IndexedDB / 首次网络 TTS 逻辑。
52
+ }
53
+ }
54
+
55
+ const db = await openDB()
56
+ return await new Promise((resolve, reject) => {
57
+ const tx = db.transaction(STORE_NAME, 'readonly')
58
+ const request = tx.objectStore(STORE_NAME).get(makeKey(voice))
59
+ request.onsuccess = () => resolve(request.result?.blob || null)
60
+ request.onerror = () => reject(request.error || new Error('读取 TTS 本地缓存失败'))
61
+ tx.oncomplete = () => db.close()
62
+ tx.onerror = () => {
63
+ try { db.close() } catch (_) {}
64
+ reject(tx.error || new Error('读取 TTS 本地缓存失败'))
65
+ }
66
+ })
67
+ }
68
+
69
+ export async function saveReceivedCommandAudio(voice, blob) {
70
+ if (!isBrowser() || !(blob instanceof Blob) || !blob.size) return false
71
+ const db = await openDB()
72
+ return await new Promise((resolve, reject) => {
73
+ const tx = db.transaction(STORE_NAME, 'readwrite')
74
+ tx.objectStore(STORE_NAME).put({
75
+ key: makeKey(voice),
76
+ voice: normalizeVoice(voice),
77
+ blob,
78
+ updatedAt: Date.now()
79
+ })
80
+ tx.oncomplete = () => {
81
+ db.close()
82
+ resolve(true)
83
+ }
84
+ tx.onerror = () => {
85
+ try { db.close() } catch (_) {}
86
+ reject(tx.error || new Error('保存 TTS 本地缓存失败'))
87
+ }
88
+ })
89
+ }
90
+
91
+ export async function removeReceivedCommandAudio(voice) {
92
+ if (!isBrowser()) return false
93
+ const db = await openDB()
94
+ return await new Promise((resolve, reject) => {
95
+ const tx = db.transaction(STORE_NAME, 'readwrite')
96
+ tx.objectStore(STORE_NAME).delete(makeKey(voice))
97
+ tx.oncomplete = () => {
98
+ db.close()
99
+ resolve(true)
100
+ }
101
+ tx.onerror = () => {
102
+ try { db.close() } catch (_) {}
103
+ reject(tx.error || new Error('删除 TTS 本地缓存失败'))
104
+ }
105
+ })
106
+ }
@@ -0,0 +1,329 @@
1
+ export class SpeechService {
2
+ constructor(options = {}) {
3
+ this.lang = options.lang || 'zh-CN'
4
+ this.voice = options.voice || null
5
+ this.rate = options.rate ?? 1
6
+ this.pitch = options.pitch ?? 1
7
+ this.volume = options.volume ?? 1
8
+ this.recognition = null
9
+ this.listening = false
10
+ this.onInterim = options.onInterim || (() => {})
11
+ this.onFinal = options.onFinal || (() => {})
12
+ this.onError = options.onError || (() => {})
13
+ this.onEnd = options.onEnd || (() => {})
14
+ this.ttsProvider = options.ttsProvider || null
15
+ }
16
+
17
+ async unlockTTS() {
18
+ return await this.ttsProvider?.unlock?.()
19
+ }
20
+
21
+ supported() {
22
+ return typeof window !== 'undefined' && !!(
23
+ window.SpeechRecognition ||
24
+ window.webkitSpeechRecognition
25
+ )
26
+ }
27
+
28
+ async requestMicrophonePermission() {
29
+ if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
30
+ return true
31
+ }
32
+
33
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
34
+ for (const track of stream.getTracks()) track.stop()
35
+ return true
36
+ }
37
+
38
+ createRecognition({ continuous = false } = {}) {
39
+ const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition
40
+ if (!Recognition) throw new Error('当前浏览器不支持 Web Speech Recognition')
41
+
42
+ const recognition = new Recognition()
43
+ recognition.lang = this.lang
44
+ recognition.continuous = continuous
45
+ recognition.interimResults = true
46
+ recognition.maxAlternatives = 1
47
+ return recognition
48
+ }
49
+
50
+ async speakReceivedPromptAsync() {
51
+ // 固定“收到指令,请您稍等”只负责异步播放,不参与主业务流程门禁。
52
+ // 即使本地语音正在加载/播放,manager.run 也可以立即继续执行。
53
+ if (this.ttsProvider?.speakReceivedCommandAsync) {
54
+ try {
55
+ return await this.ttsProvider.speakReceivedCommandAsync()
56
+ } catch (error) {
57
+ this.onError?.(error?.message || '固定前置提示音播放失败')
58
+ return false
59
+ }
60
+ }
61
+
62
+ // 兼容没有专用 provider 的版本:异步触发,不等待。
63
+ void this.speak('收到指令,请您稍等。', { providerOnly: false }).catch(() => {})
64
+ return true
65
+ }
66
+
67
+ async speak(text, options = {}) {
68
+ const providerOnly = options?.providerOnly === true
69
+ const waitForEnd = options?.waitForEnd === true
70
+ const value = String(text || '').trim()
71
+ if (!value) return false
72
+
73
+ const isReceivedPrompt = value.replace(/[,。!?、,.!?\s]/g, '') === '收到指令请您稍等'
74
+
75
+ if (this.ttsProvider?.speak) {
76
+ try {
77
+ const spoken = await this.ttsProvider.speak(value, { waitForEnd })
78
+ if (spoken !== false) return true
79
+ // 前置提示音本地资源失败时,直接进入浏览器 TTS 兜底;
80
+ // 不允许再回退到网络 TTS,确保“收到指令”不会因为网络波动丢播。
81
+ if (isReceivedPrompt) return await this.speakWithBrowser(value, { waitForEnd })
82
+ if (providerOnly) {
83
+ throw new Error('第三方 TTS 未成功播放音频')
84
+ }
85
+ } catch (error) {
86
+ // providerOnly=true 仍允许浏览器 TTS 作为兼容兜底。
87
+ // 只有明确要求“绝不兜底”的场景才可在这里抛错;当前小园执行前门禁需要兼容旧浏览器,
88
+ // 因此默认继续走浏览器 speechSynthesis。
89
+ this.onError?.(error?.message || '第三方语音播报失败,正在切换为浏览器语音')
90
+ }
91
+ } else if (providerOnly && !this.canUseBrowserTTS()) {
92
+ throw new Error('TTS Provider 未配置,且当前浏览器不支持内置语音播报')
93
+ }
94
+
95
+ return await this.speakWithBrowser(value, { waitForEnd })
96
+ }
97
+
98
+ canUseBrowserTTS() {
99
+ return typeof window !== 'undefined' &&
100
+ 'speechSynthesis' in window &&
101
+ typeof SpeechSynthesisUtterance !== 'undefined'
102
+ }
103
+
104
+ async speakWithBrowser(text, options = {}) {
105
+ const waitForEnd = options?.waitForEnd === true
106
+ if (!this.canUseBrowserTTS()) return false
107
+
108
+ return await new Promise((resolve) => {
109
+ const synth = window.speechSynthesis
110
+ let started = false
111
+ let settled = false
112
+ let fallbackTimer = null
113
+
114
+ const finish = (value) => {
115
+ if (settled) return
116
+ settled = true
117
+ if (fallbackTimer) window.clearTimeout(fallbackTimer)
118
+ resolve(value)
119
+ }
120
+
121
+ try {
122
+ synth.cancel()
123
+ try { synth.resume() } catch (_) {}
124
+
125
+ const utterance = new SpeechSynthesisUtterance(String(text))
126
+ utterance.lang = this.lang
127
+ utterance.rate = this.rate
128
+ utterance.pitch = this.pitch
129
+ utterance.volume = this.volume
130
+ if (this.voice) utterance.voice = this.voice
131
+
132
+ // 对小园的执行门禁来说,“真正开始播放”就是 onstart。
133
+ utterance.onstart = () => {
134
+ started = true
135
+ if (!waitForEnd) finish(true)
136
+ }
137
+
138
+ utterance.onend = () => {
139
+ // waitForEnd=true 用于 AI 分析结果:必须等整段语音播报完,才能进入下一步。
140
+ if (waitForEnd) {
141
+ finish(started || true)
142
+ return
143
+ }
144
+ // 某些浏览器没有稳定触发 onstart,但已经完成了播报。
145
+ // 这种情况下也允许放行,避免工作流永久卡住。
146
+ if (!started) finish(true)
147
+ }
148
+
149
+ utterance.onerror = () => finish(false)
150
+
151
+ synth.speak(utterance)
152
+
153
+ window.setTimeout(() => {
154
+ try { synth.resume() } catch (_) {}
155
+ }, 50)
156
+
157
+ // 浏览器偶发不触发 onstart/onend;这里仅作为最后兜底。
158
+ fallbackTimer = window.setTimeout(() => finish(true), Math.max(1200, String(text).length * 120))
159
+ } catch (_) {
160
+ finish(false)
161
+ }
162
+ })
163
+ }
164
+
165
+ stopSpeaking() {
166
+ try { this.ttsProvider?.stop?.() } catch (_) {}
167
+ if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
168
+ window.speechSynthesis.cancel()
169
+ }
170
+ }
171
+
172
+ listenOnce() {
173
+ return new Promise(async (resolve, reject) => {
174
+ if (!this.supported()) {
175
+ reject(new Error('当前浏览器不支持语音识别'))
176
+ return
177
+ }
178
+
179
+ try {
180
+ await this.requestMicrophonePermission()
181
+ } catch (error) {
182
+ const reason = error?.name || error?.message || error
183
+ reject(new Error(`麦克风权限不可用:${reason}`))
184
+ return
185
+ }
186
+
187
+ const recognition = this.createRecognition({ continuous: false })
188
+ let finalText = ''
189
+ let settled = false
190
+
191
+ const finish = (error = null) => {
192
+ if (settled) return
193
+ settled = true
194
+ this.listening = false
195
+ this.recognition = null
196
+ this.onEnd()
197
+ if (error) reject(error)
198
+ else resolve(finalText.trim())
199
+ }
200
+
201
+ recognition.onstart = () => {
202
+ this.listening = true
203
+ }
204
+
205
+ recognition.onresult = (event) => {
206
+ let interim = ''
207
+ for (let i = event.resultIndex; i < event.results.length; i += 1) {
208
+ const part = event.results[i][0]?.transcript || ''
209
+ if (event.results[i].isFinal) {
210
+ finalText += part
211
+ this.onFinal(part)
212
+ } else {
213
+ interim += part
214
+ }
215
+ }
216
+ this.onInterim(interim)
217
+
218
+ // 已得到 final 文本就尽快结束本轮,减少“说完以后还要等很久”的感觉。
219
+ if (finalText.trim()) {
220
+ window.setTimeout(() => {
221
+ try { recognition.stop() } catch (_) {}
222
+ }, 80)
223
+ }
224
+ }
225
+
226
+ recognition.onerror = (event) => {
227
+ const code = event?.error || 'unknown'
228
+ // no-speech 更像“没有听到”,不是系统故障。
229
+ if (code === 'no-speech') {
230
+ finish(new Error('没有听到您的声音,请再说一次。'))
231
+ return
232
+ }
233
+ if (code === 'not-allowed' || code === 'service-not-allowed') {
234
+ finish(new Error('麦克风权限被拒绝,请允许当前页面使用麦克风后再试。'))
235
+ return
236
+ }
237
+ if (code === 'audio-capture') {
238
+ finish(new Error('没有检测到可用麦克风,请检查麦克风设备。'))
239
+ return
240
+ }
241
+ if (code === 'network') {
242
+ finish(new Error('语音识别网络异常,请检查网络后再试。'))
243
+ return
244
+ }
245
+ finish(new Error(`语音识别失败:${code}`))
246
+ }
247
+
248
+ recognition.onend = () => {
249
+ finish()
250
+ }
251
+
252
+ try {
253
+ recognition.start()
254
+ this.recognition = recognition
255
+ this.listening = true
256
+ } catch (error) {
257
+ finish(error instanceof Error ? error : new Error('语音识别启动失败'))
258
+ }
259
+ })
260
+ }
261
+
262
+ startWakeWordListener({ wakeWord = '你好小园', onWake, onError }) {
263
+ if (!this.supported()) return { stop: () => {}, pause: () => {}, resume: () => {} }
264
+
265
+ const recognition = this.createRecognition({ continuous: true })
266
+ let buffer = ''
267
+ let stopped = false
268
+ let paused = false
269
+ let restarting = false
270
+
271
+ const normalizedWakeWord = wakeWord.replace(/[,。!?、,.!?\s]/g, '')
272
+
273
+ const restart = () => {
274
+ if (stopped || paused || restarting) return
275
+ restarting = true
276
+ window.setTimeout(() => {
277
+ restarting = false
278
+ if (stopped) return
279
+ try { recognition.start() } catch (_) {}
280
+ }, 300)
281
+ }
282
+
283
+ recognition.onresult = (event) => {
284
+ let text = ''
285
+ for (let i = event.resultIndex; i < event.results.length; i += 1) {
286
+ text += event.results[i][0]?.transcript || ''
287
+ }
288
+ buffer = `${buffer}${text}`.replace(/\s+/g, '')
289
+ const normalized = buffer.replace(/[,。!?、,.!?]/g, '')
290
+ if (normalized.includes(normalizedWakeWord)) {
291
+ buffer = ''
292
+ onWake?.()
293
+ } else if (buffer.length > 80) {
294
+ buffer = buffer.slice(-80)
295
+ }
296
+ }
297
+
298
+ recognition.onerror = (event) => {
299
+ const code = event?.error || 'unknown'
300
+ // 唤醒监听失败不连续弹错误消息,避免屏幕不断出现“语音识别失败”。
301
+ if (code !== 'no-speech') onError?.(code)
302
+ restart()
303
+ }
304
+
305
+ recognition.onend = restart
306
+
307
+ const controller = {
308
+ stop() {
309
+ stopped = true
310
+ recognition.onend = null
311
+ try { recognition.stop() } catch (_) {}
312
+ buffer = ''
313
+ },
314
+ pause() {
315
+ paused = true
316
+ try { recognition.stop() } catch (_) {}
317
+ },
318
+ resume() {
319
+ if (stopped) return
320
+ paused = false
321
+ try { recognition.start() } catch (_) {}
322
+ },
323
+ recognition
324
+ }
325
+
326
+ try { recognition.start() } catch (_) {}
327
+ return controller
328
+ }
329
+ }