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,238 @@
|
|
|
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
|
+
}
|