@goodandready/dsh-tts 0.4.22 → 0.4.24
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 +18 -0
- package/lib/client.js +125 -0
- package/lib/engines/manager.js +14 -2
- package/lib/http-util.js +70 -15
- package/lib/routes.js +7 -0
- package/package.json +7 -6
- package/lib/client-src/00-open.js +0 -89
- package/lib/client-src/10-styles.js +0 -55
- package/lib/client-src/20-player.js +0 -424
- package/lib/client-src/30-locales.js +0 -374
- package/lib/client-src/40-editors.js +0 -422
- package/lib/client-src/50-settings.js +0 -399
- package/lib/client-src/60-card-dock.js +0 -278
- package/scripts/build-client.mjs +0 -19
- package/scripts/f5_daemon.py +0 -40
- package/scripts/publish-github.sh +0 -184
|
@@ -1,424 +0,0 @@
|
|
|
1
|
-
// Player.
|
|
2
|
-
//
|
|
3
|
-
// Each incoming chunk used to replace the previous one immediately; while reading
|
|
4
|
-
// For full replies this was invisible; with speak-as-it-goes only the last
|
|
5
|
-
// chunk would be heard. Now items queue: the next starts after the previous ends.
|
|
6
|
-
const autoplay = { blocked: false, listeners: new Set() }
|
|
7
|
-
|
|
8
|
-
function setAutoplayBlocked(next) {
|
|
9
|
-
if (autoplay.blocked === next) return
|
|
10
|
-
autoplay.blocked = next
|
|
11
|
-
for (const l of [...autoplay.listeners]) {
|
|
12
|
-
try { l() } catch { /* listener isolation */ }
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function unlockAudio() {
|
|
17
|
-
try {
|
|
18
|
-
const AC = window.AudioContext || window.webkitAudioContext
|
|
19
|
-
if (AC) {
|
|
20
|
-
const ctx = new AC()
|
|
21
|
-
if (ctx.state === 'suspended') ctx.resume().catch(() => { /* audio context unlock failure ignored */ })
|
|
22
|
-
setTimeout(() => { try { ctx.close() } catch { /* closed */ } }, 300)
|
|
23
|
-
}
|
|
24
|
-
} catch { /* no AudioContext */ }
|
|
25
|
-
setAutoplayBlocked(false)
|
|
26
|
-
drainQueue()
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function useAutoplayGate() {
|
|
30
|
-
const [, force] = React.useReducer((n) => n + 1, 0)
|
|
31
|
-
React.useEffect(() => {
|
|
32
|
-
autoplay.listeners.add(force)
|
|
33
|
-
return () => { autoplay.listeners.delete(force) }
|
|
34
|
-
}, [])
|
|
35
|
-
return autoplay
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const player = {
|
|
39
|
-
audio: null,
|
|
40
|
-
after: '',
|
|
41
|
-
enabled: false,
|
|
42
|
-
rate: 1,
|
|
43
|
-
chime: 'ding',
|
|
44
|
-
queue: [],
|
|
45
|
-
bargeIn: true,
|
|
46
|
-
busy: false,
|
|
47
|
-
paused: false,
|
|
48
|
-
listeners: new Set(),
|
|
49
|
-
unlockAudio,
|
|
50
|
-
useAutoplayGate,
|
|
51
|
-
get autoplayBlocked() { return autoplay.blocked },
|
|
52
|
-
}
|
|
53
|
-
const seenIds = new Set()
|
|
54
|
-
let currentAudioResolve = null
|
|
55
|
-
function playerChanged() {
|
|
56
|
-
for (const listener of [...player.listeners]) {
|
|
57
|
-
try { listener() } catch (listenerFailure) { /* foreign listener failure is not ours */ }
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function usePlayer() {
|
|
62
|
-
const [, force] = React.useReducer((n) => n + 1, 0)
|
|
63
|
-
React.useEffect(() => {
|
|
64
|
-
player.listeners.add(force)
|
|
65
|
-
return () => { player.listeners.delete(force) }
|
|
66
|
-
}, [])
|
|
67
|
-
return player
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Short chime is synthesized in-browser: no file to download, store, or configure.
|
|
71
|
-
function playChime(kind) {
|
|
72
|
-
return new Promise((resolve) => {
|
|
73
|
-
try {
|
|
74
|
-
const AC = window.AudioContext || window.webkitAudioContext
|
|
75
|
-
if (!AC) { resolve(); return }
|
|
76
|
-
const audioCtx = new AC()
|
|
77
|
-
const gain = audioCtx.createGain()
|
|
78
|
-
gain.connect(audioCtx.destination)
|
|
79
|
-
const tones = kind === 'beep' ? [880, 880] : [660, 990]
|
|
80
|
-
let at = audioCtx.currentTime
|
|
81
|
-
for (const freq of tones) {
|
|
82
|
-
const osc = audioCtx.createOscillator()
|
|
83
|
-
osc.frequency.value = freq
|
|
84
|
-
osc.connect(gain)
|
|
85
|
-
gain.gain.setValueAtTime(0.0001, at)
|
|
86
|
-
gain.gain.exponentialRampToValueAtTime(0.25, at + 0.02)
|
|
87
|
-
gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.18)
|
|
88
|
-
osc.start(at)
|
|
89
|
-
osc.stop(at + 0.2)
|
|
90
|
-
at += 0.22
|
|
91
|
-
}
|
|
92
|
-
setTimeout(() => { try { audioCtx.close() } catch (already) { /* already closed */ } resolve() }, 600)
|
|
93
|
-
} catch { resolve() /* no AudioContext */ }
|
|
94
|
-
})
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Provider failed — fall back to the browser voice. Worse quality, but silence
|
|
98
|
-
// is worse: the user would not know the reply is ready.
|
|
99
|
-
function speakInBrowser(text) {
|
|
100
|
-
return new Promise((resolve) => {
|
|
101
|
-
try {
|
|
102
|
-
if (!window.speechSynthesis || !text) { resolve(); return }
|
|
103
|
-
const utterance = new SpeechSynthesisUtterance(String(text))
|
|
104
|
-
utterance.rate = Math.max(0.5, Math.min(2, player.rate || 1))
|
|
105
|
-
utterance.onend = () => resolve()
|
|
106
|
-
utterance.onerror = () => resolve()
|
|
107
|
-
window.speechSynthesis.speak(utterance)
|
|
108
|
-
} catch { resolve() /* no speechSynthesis */ }
|
|
109
|
-
})
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function prepareAudioItem(item) {
|
|
113
|
-
if (!item || !item.audioBase64 || item.prepared) return item
|
|
114
|
-
try {
|
|
115
|
-
const bin = atob(item.audioBase64)
|
|
116
|
-
const bytes = new Uint8Array(bin.length)
|
|
117
|
-
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
|
118
|
-
const blob = new Blob([bytes], { type: item.mime || 'audio/mpeg' })
|
|
119
|
-
const url = URL.createObjectURL(blob)
|
|
120
|
-
const audio = new Audio(url)
|
|
121
|
-
audio.preload = 'auto'
|
|
122
|
-
item.prepared = { audio, url }
|
|
123
|
-
} catch { /* decode error — item stays unprepared */ }
|
|
124
|
-
return item
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function prebufferNext() {
|
|
128
|
-
for (const next of player.queue) {
|
|
129
|
-
if (next && next.audioBase64 && !next.prepared) {
|
|
130
|
-
prepareAudioItem(next)
|
|
131
|
-
break
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function playAudio(item) {
|
|
137
|
-
return new Promise((resolve) => {
|
|
138
|
-
try {
|
|
139
|
-
prepareAudioItem(item)
|
|
140
|
-
if (!item.prepared) return resolve()
|
|
141
|
-
const { audio, url } = item.prepared
|
|
142
|
-
audio.playbackRate = Math.max(0.5, Math.min(2, player.rate || 1))
|
|
143
|
-
player.audio = audio
|
|
144
|
-
if (item.text) player.lastText = item.text
|
|
145
|
-
playerChanged()
|
|
146
|
-
prebufferNext()
|
|
147
|
-
let doneCalled = false
|
|
148
|
-
const done = () => {
|
|
149
|
-
if (doneCalled) return
|
|
150
|
-
doneCalled = true
|
|
151
|
-
currentAudioResolve = null
|
|
152
|
-
try { URL.revokeObjectURL(url) } catch { /* URL already revoked */ }
|
|
153
|
-
item.prepared = null
|
|
154
|
-
player.audio = null
|
|
155
|
-
playerChanged()
|
|
156
|
-
resolve()
|
|
157
|
-
}
|
|
158
|
-
currentAudioResolve = done
|
|
159
|
-
audio.onended = done
|
|
160
|
-
audio.onerror = done
|
|
161
|
-
audio.play().catch((playErr) => {
|
|
162
|
-
// Autoplay without a user gesture is blocked — surface unlock UX, then stop this chunk.
|
|
163
|
-
if (playErr && (playErr.name === 'NotAllowedError' || playErr.name === 'SecurityError')) {
|
|
164
|
-
setAutoplayBlocked(true)
|
|
165
|
-
}
|
|
166
|
-
done()
|
|
167
|
-
})
|
|
168
|
-
} catch { /* cannot decode — skip chunk */
|
|
169
|
-
currentAudioResolve = null
|
|
170
|
-
resolve()
|
|
171
|
-
}
|
|
172
|
-
})
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
async function drainQueue() {
|
|
176
|
-
if (player.busy) return
|
|
177
|
-
player.busy = true
|
|
178
|
-
playerChanged()
|
|
179
|
-
try {
|
|
180
|
-
while (player.queue.length) {
|
|
181
|
-
if (player.paused) break
|
|
182
|
-
const item = player.queue.shift()
|
|
183
|
-
if (!item || item.kind === 'reserved') continue
|
|
184
|
-
if (item.kind === 'chime') { await playChime(item.chime || player.chime); continue }
|
|
185
|
-
if (item.audioBase64) await playAudio(item)
|
|
186
|
-
else if (item.error && item.text) await speakInBrowser(item.text)
|
|
187
|
-
}
|
|
188
|
-
} finally {
|
|
189
|
-
player.busy = false
|
|
190
|
-
playerChanged()
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function stopPlayback() {
|
|
195
|
-
for (const item of player.queue) {
|
|
196
|
-
if (item && item.prepared && item.prepared.url) {
|
|
197
|
-
try { URL.revokeObjectURL(item.prepared.url) } catch { /* already revoked */ }
|
|
198
|
-
item.prepared = null
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
player.queue.length = 0
|
|
202
|
-
player.paused = false
|
|
203
|
-
if (player.audio) {
|
|
204
|
-
try { player.audio.pause() } catch { /* already paused */ }
|
|
205
|
-
}
|
|
206
|
-
if (currentAudioResolve) {
|
|
207
|
-
currentAudioResolve()
|
|
208
|
-
}
|
|
209
|
-
player.audio = null
|
|
210
|
-
try { if (window.speechSynthesis) window.speechSynthesis.cancel() } catch { /* no speechSynthesis */ }
|
|
211
|
-
playerChanged()
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function togglePause() {
|
|
215
|
-
player.paused = !player.paused
|
|
216
|
-
if (player.audio) {
|
|
217
|
-
try { player.paused ? player.audio.pause() : player.audio.play() } catch (already) { /* ignore play/pause failure */ }
|
|
218
|
-
}
|
|
219
|
-
if (!player.paused) drainQueue()
|
|
220
|
-
playerChanged()
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function skipCurrent() {
|
|
224
|
-
if (currentAudioResolve) {
|
|
225
|
-
currentAudioResolve()
|
|
226
|
-
} else if (player.audio) {
|
|
227
|
-
try { player.audio.pause() } catch (already) { /* already stopped */ }
|
|
228
|
-
player.audio = null
|
|
229
|
-
playerChanged()
|
|
230
|
-
if (player.queue.length) drainQueue()
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// User started talking — stop playback.
|
|
235
|
-
//
|
|
236
|
-
// The event may come from a voice-input plugin, but there is no hard dependency:
|
|
237
|
-
// without that plugin the event simply never fires. We listen on window on purpose.
|
|
238
|
-
function listenForVoice() {
|
|
239
|
-
if (typeof window === 'undefined') return () => {}
|
|
240
|
-
const onSpeaking = (event) => {
|
|
241
|
-
const phase = event && event.detail && event.detail.phase
|
|
242
|
-
if (phase !== 'start') return
|
|
243
|
-
if (!player.bargeIn) return
|
|
244
|
-
if (!player.audio && !player.queue.length && !player.busy) return
|
|
245
|
-
stopPlayback()
|
|
246
|
-
}
|
|
247
|
-
window.addEventListener('dsh-voice:speaking', onSpeaking)
|
|
248
|
-
return () => window.removeEventListener('dsh-voice:speaking', onSpeaking)
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// SSE Realtime Stream Connection with Exponential Backoff & Jitter
|
|
252
|
-
let sseAttempt = 0
|
|
253
|
-
let sseTimer = null
|
|
254
|
-
let sseSource = null
|
|
255
|
-
|
|
256
|
-
function connectSseStream() {
|
|
257
|
-
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return
|
|
258
|
-
if (sseSource) {
|
|
259
|
-
try { sseSource.close() } catch (alreadyClosed) { /* intentional cleanup */ }
|
|
260
|
-
sseSource = null
|
|
261
|
-
}
|
|
262
|
-
try {
|
|
263
|
-
sseSource = new EventSource('/dsh-tts/stream')
|
|
264
|
-
sseSource.onopen = () => {
|
|
265
|
-
sseAttempt = 0
|
|
266
|
-
}
|
|
267
|
-
sseSource.addEventListener('utterance', (e) => {
|
|
268
|
-
sseAttempt = 0
|
|
269
|
-
try {
|
|
270
|
-
const item = JSON.parse(e.data)
|
|
271
|
-
if (item && item.id) {
|
|
272
|
-
player.after = item.id
|
|
273
|
-
if (!seenIds.has(item.id)) {
|
|
274
|
-
seenIds.add(item.id)
|
|
275
|
-
if (seenIds.size > 500) {
|
|
276
|
-
const first = seenIds.values().next().value
|
|
277
|
-
seenIds.delete(first)
|
|
278
|
-
}
|
|
279
|
-
if (item.text) recent.add(item.text)
|
|
280
|
-
player.queue.push(item)
|
|
281
|
-
drainQueue()
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
} catch (parseErr) { /* ignore malformed SSE payload */ }
|
|
285
|
-
})
|
|
286
|
-
sseSource.addEventListener('chime', (e) => {
|
|
287
|
-
sseAttempt = 0
|
|
288
|
-
try {
|
|
289
|
-
const item = JSON.parse(e.data)
|
|
290
|
-
if (item) {
|
|
291
|
-
player.queue.push(item)
|
|
292
|
-
drainQueue()
|
|
293
|
-
}
|
|
294
|
-
} catch (parseErr) { /* ignore malformed chime payload */ }
|
|
295
|
-
})
|
|
296
|
-
sseSource.onerror = () => {
|
|
297
|
-
if (sseSource) {
|
|
298
|
-
try { sseSource.close() } catch (alreadyClosed) { /* intentional cleanup */ }
|
|
299
|
-
sseSource = null
|
|
300
|
-
}
|
|
301
|
-
sseAttempt++
|
|
302
|
-
const baseDelay = Math.min(10000, 500 * Math.pow(2, sseAttempt - 1))
|
|
303
|
-
const jitter = baseDelay * (Math.random() * 0.6 - 0.3)
|
|
304
|
-
const delay = Math.max(200, Math.floor(baseDelay + jitter))
|
|
305
|
-
if (sseTimer) clearTimeout(sseTimer)
|
|
306
|
-
sseTimer = setTimeout(() => {
|
|
307
|
-
connectSseStream()
|
|
308
|
-
}, delay)
|
|
309
|
-
}
|
|
310
|
-
} catch (esErr) { /* EventSource failed */ }
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
async function pollPending() {
|
|
314
|
-
connectSseStream()
|
|
315
|
-
try {
|
|
316
|
-
const st = await fetch('/dsh-tts/status', { cache: 'no-store' })
|
|
317
|
-
if (!st.ok) return
|
|
318
|
-
const meta = await st.json()
|
|
319
|
-
player.enabled = !!(meta && meta.speakReplies)
|
|
320
|
-
if (meta && typeof meta.rate === 'number') player.rate = meta.rate
|
|
321
|
-
if (meta && meta.chime) player.chime = meta.chime
|
|
322
|
-
if (meta && meta.roles) player.roles = meta.roles
|
|
323
|
-
if (meta && typeof meta.bargeIn === 'boolean') player.bargeIn = meta.bargeIn
|
|
324
|
-
if (!player.enabled) return
|
|
325
|
-
const res = await fetch('/dsh-tts/pending?after=' + encodeURIComponent(player.after || ''), { cache: 'no-store' })
|
|
326
|
-
if (!res.ok) return
|
|
327
|
-
const data = await res.json()
|
|
328
|
-
const items = data && Array.isArray(data.items) ? data.items : []
|
|
329
|
-
for (const item of items) {
|
|
330
|
-
// Queue slot reserved but synthesis still running — wait for the next poll
|
|
331
|
-
// or phrase order would break.
|
|
332
|
-
if (item.kind === 'reserved') break
|
|
333
|
-
player.after = item.id
|
|
334
|
-
if (seenIds.has(item.id)) continue
|
|
335
|
-
seenIds.add(item.id)
|
|
336
|
-
if (seenIds.size > 500) {
|
|
337
|
-
const first = seenIds.values().next().value
|
|
338
|
-
seenIds.delete(first)
|
|
339
|
-
}
|
|
340
|
-
if (item.text) recent.add(item.text)
|
|
341
|
-
player.queue.push(item)
|
|
342
|
-
}
|
|
343
|
-
if (player.queue.length) drainQueue()
|
|
344
|
-
} catch (hostRestarting) { /* host restarting */ }
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// Recent utterances: last reply texts and favorites (localStorage).
|
|
348
|
-
function recentStore() {
|
|
349
|
-
const KEY = 'dsh-tts/recent'
|
|
350
|
-
let st
|
|
351
|
-
try {
|
|
352
|
-
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null
|
|
353
|
-
st = JSON.parse(raw) || { items: [], favs: [] }
|
|
354
|
-
} catch (broken) { st = { items: [], favs: [] } }
|
|
355
|
-
const persist = () => { try { if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(st)) } catch (full) { /* private mode / quota */ } }
|
|
356
|
-
return {
|
|
357
|
-
add(text) {
|
|
358
|
-
if (!text || text.length < 2) return
|
|
359
|
-
st.items = [text, ...st.items.filter((x) => x !== text)].slice(0, 30)
|
|
360
|
-
persist()
|
|
361
|
-
},
|
|
362
|
-
toggleFav(text) {
|
|
363
|
-
st.favs = st.favs.includes(text)
|
|
364
|
-
? st.favs.filter((x) => x !== text)
|
|
365
|
-
: [text, ...st.favs].slice(0, 20)
|
|
366
|
-
persist()
|
|
367
|
-
},
|
|
368
|
-
list() { return { items: st.items, favs: st.favs } },
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
const recent = recentStore()
|
|
372
|
-
|
|
373
|
-
// One-shot playback outside the queue: replay from recents.
|
|
374
|
-
function playStandalone(text) {
|
|
375
|
-
fetch('/dsh-tts/speak', {
|
|
376
|
-
method: 'POST',
|
|
377
|
-
headers: { 'Content-Type': 'application/json' },
|
|
378
|
-
body: JSON.stringify({ text }),
|
|
379
|
-
})
|
|
380
|
-
.then((r) => r.json())
|
|
381
|
-
.then((d) => {
|
|
382
|
-
if (!d || !d.ok) return
|
|
383
|
-
const audio = new Audio('data:' + (d.mime || 'audio/mpeg') + ';base64,' + d.audioBase64)
|
|
384
|
-
audio.play().catch(() => { /* chime autoplay blocked or interrupted */ })
|
|
385
|
-
})
|
|
386
|
-
.catch(() => { /* chime fetch failed or not found */ })
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
function exportAudioClip(text) {
|
|
390
|
-
let phrase = text || player.lastText
|
|
391
|
-
if (!phrase) {
|
|
392
|
-
const list = recent.list()
|
|
393
|
-
if (list && list.items && list.items.length) phrase = list.items[0]
|
|
394
|
-
}
|
|
395
|
-
if (!phrase) return
|
|
396
|
-
fetch('/dsh-tts/speak', {
|
|
397
|
-
method: 'POST',
|
|
398
|
-
headers: { 'Content-Type': 'application/json' },
|
|
399
|
-
body: JSON.stringify({ text: phrase }),
|
|
400
|
-
})
|
|
401
|
-
.then((r) => r.json())
|
|
402
|
-
.then((d) => {
|
|
403
|
-
if (!d || !d.ok || !d.audioBase64) return
|
|
404
|
-
const bin = atob(d.audioBase64)
|
|
405
|
-
const bytes = new Uint8Array(bin.length)
|
|
406
|
-
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
|
407
|
-
const mime = d.mime || 'audio/mpeg'
|
|
408
|
-
const blob = new Blob([bytes], { type: mime })
|
|
409
|
-
const url = URL.createObjectURL(blob)
|
|
410
|
-
const a = document.createElement('a')
|
|
411
|
-
a.href = url
|
|
412
|
-
a.download = 'dsh-tts-' + Date.now() + (mime.indexOf('wav') !== -1 ? '.wav' : '.mp3')
|
|
413
|
-
document.body.appendChild(a)
|
|
414
|
-
a.click()
|
|
415
|
-
document.body.removeChild(a)
|
|
416
|
-
setTimeout(() => { try { URL.revokeObjectURL(url) } catch (alreadyRevoked) { /* already revoked */ } }, 2000)
|
|
417
|
-
})
|
|
418
|
-
.catch(() => {})
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
function credOf(map, provider) {
|
|
422
|
-
return (map && map[provider]) || { configured: false, writable: true, ref: '' }
|
|
423
|
-
}
|
|
424
|
-
|