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