@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.
@@ -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
-