@goodandready/dsh-tts 0.4.27 → 0.4.29
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 +68 -0
- package/README.md +18 -13
- package/README.ru.md +11 -10
- package/README.zh.md +5 -5
- package/lib/cache.js +70 -40
- package/lib/client.js +338 -166
- package/lib/engines/manager.js +137 -75
- package/lib/index.js +345 -64
- package/lib/local.js +1 -1
- package/lib/providers/cloud.js +44 -5
- package/lib/providers/constants.js +4 -4
- package/lib/providers/local.js +1 -1
- package/lib/providers.js +11 -2
- package/lib/routes.js +107 -36
- package/lib/stream-hub.js +55 -8
- package/lib/text.js +25 -2
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -21,10 +21,12 @@ window.__ModuleLoader__.load({
|
|
|
21
21
|
const ROW_CONFIG_KEY = PKG + '#' + ROW_ID
|
|
22
22
|
const PROVIDERS = [
|
|
23
23
|
'openai', 'elevenlabs', 'google', 'azure', 'groq', 'deepgram', 'openrouter',
|
|
24
|
-
'
|
|
24
|
+
'siliconflow', 'deepinfra', 'fireworks', 'mimo', 'custom',
|
|
25
|
+
'edge', 'piper', 'espeak', 'minimax',
|
|
25
26
|
]
|
|
26
27
|
const CLOUD = {
|
|
27
|
-
openai: 1, elevenlabs: 1, google: 1, azure: 1, groq: 1, deepgram: 1, openrouter: 1,
|
|
28
|
+
openai: 1, elevenlabs: 1, google: 1, azure: 1, groq: 1, deepgram: 1, openrouter: 1,
|
|
29
|
+
siliconflow: 1, deepinfra: 1, fireworks: 1, mimo: 1, custom: 1,
|
|
28
30
|
}
|
|
29
31
|
// Hints stay generic: concrete provider model IDs rot quickly.
|
|
30
32
|
const MODEL_HINT = {
|
|
@@ -35,14 +37,28 @@ window.__ModuleLoader__.load({
|
|
|
35
37
|
groq: 'provider default',
|
|
36
38
|
deepgram: 'provider default (English-only voices)',
|
|
37
39
|
openrouter: 'provider/model id',
|
|
38
|
-
|
|
40
|
+
siliconflow: 'model id (e.g. FunAudioLLM/CosyVoice2-0.5B)',
|
|
41
|
+
deepinfra: 'model id (e.g. hexgrad/Kokoro-82M)',
|
|
42
|
+
fireworks: 'model id (e.g. kokoro)',
|
|
43
|
+
mimo: 'model id (e.g. mimo-v2.5-tts)',
|
|
44
|
+
custom: 'custom model',
|
|
45
|
+
minimax: 'model id',
|
|
46
|
+
edge: 'BCP-47 voice, e.g. en-US-AriaNeural',
|
|
39
47
|
piper: 'path to .onnx',
|
|
40
|
-
espeak: 'language code, e.g.
|
|
48
|
+
espeak: 'language code, e.g. en',
|
|
41
49
|
kokoro: 'not bundled — offline neural runtime unavailable',
|
|
42
50
|
f5: 'not bundled — offline neural runtime unavailable',
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
|
|
54
|
+
function getModelHint(provider, t) {
|
|
55
|
+
if (typeof t === 'function') {
|
|
56
|
+
const loc = t('modelHint_' + provider)
|
|
57
|
+
if (loc && !loc.startsWith('modelHint_')) return loc
|
|
58
|
+
}
|
|
59
|
+
return MODEL_HINT[provider] || ''
|
|
60
|
+
}
|
|
61
|
+
|
|
46
62
|
function createErrorBoundary() {
|
|
47
63
|
if (!React || typeof React.Component !== 'function') {
|
|
48
64
|
return function NoopBoundary(props) { return (props && props.children) || null }
|
|
@@ -66,7 +82,7 @@ window.__ModuleLoader__.load({
|
|
|
66
82
|
className: 'dts-alert dts-alert-err',
|
|
67
83
|
style: { margin: '12px 0', padding: '14px', borderRadius: '8px' },
|
|
68
84
|
},
|
|
69
|
-
React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ TTS UI Error:'),
|
|
85
|
+
React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, (typeof fallbackDockText === 'function' ? fallbackDockText('ttsUiError') : '⚠️ TTS UI Error:')),
|
|
70
86
|
React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String((this.state.error && this.state.error.message) || this.state.error)),
|
|
71
87
|
React.createElement(
|
|
72
88
|
'button',
|
|
@@ -76,7 +92,7 @@ window.__ModuleLoader__.load({
|
|
|
76
92
|
style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
|
|
77
93
|
onClick: () => this.setState({ hasError: false, error: null }),
|
|
78
94
|
},
|
|
79
|
-
'Retry',
|
|
95
|
+
(typeof fallbackDockText === 'function' ? fallbackDockText('retry') : 'Retry'),
|
|
80
96
|
),
|
|
81
97
|
)
|
|
82
98
|
}
|
|
@@ -157,7 +173,16 @@ window.__ModuleLoader__.load({
|
|
|
157
173
|
}
|
|
158
174
|
}
|
|
159
175
|
|
|
176
|
+
let clearGestureListeners = null
|
|
177
|
+
function removeGestureListeners() {
|
|
178
|
+
if (typeof clearGestureListeners === 'function') {
|
|
179
|
+
clearGestureListeners()
|
|
180
|
+
clearGestureListeners = null
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
160
184
|
function unlockAudio() {
|
|
185
|
+
removeGestureListeners()
|
|
161
186
|
try {
|
|
162
187
|
const AC = window.AudioContext || window.webkitAudioContext
|
|
163
188
|
if (AC) {
|
|
@@ -167,6 +192,8 @@ window.__ModuleLoader__.load({
|
|
|
167
192
|
}
|
|
168
193
|
} catch { /* no AudioContext */ }
|
|
169
194
|
setAutoplayBlocked(false)
|
|
195
|
+
player.blocked = false
|
|
196
|
+
playerChanged()
|
|
170
197
|
drainQueue()
|
|
171
198
|
}
|
|
172
199
|
|
|
@@ -189,6 +216,7 @@ window.__ModuleLoader__.load({
|
|
|
189
216
|
bargeIn: true,
|
|
190
217
|
busy: false,
|
|
191
218
|
paused: false,
|
|
219
|
+
blocked: false,
|
|
192
220
|
listeners: new Set(),
|
|
193
221
|
unlockAudio,
|
|
194
222
|
useAutoplayGate,
|
|
@@ -293,6 +321,12 @@ window.__ModuleLoader__.load({
|
|
|
293
321
|
if (doneCalled) return
|
|
294
322
|
doneCalled = true
|
|
295
323
|
currentAudioResolve = null
|
|
324
|
+
if (player.audio) {
|
|
325
|
+
try {
|
|
326
|
+
player.audio.pause()
|
|
327
|
+
player.audio.currentTime = 0
|
|
328
|
+
} catch { /* already paused */ }
|
|
329
|
+
}
|
|
296
330
|
try { URL.revokeObjectURL(url) } catch { /* URL already revoked */ }
|
|
297
331
|
item.prepared = null
|
|
298
332
|
player.audio = null
|
|
@@ -303,9 +337,32 @@ window.__ModuleLoader__.load({
|
|
|
303
337
|
audio.onended = done
|
|
304
338
|
audio.onerror = done
|
|
305
339
|
audio.play().catch((playErr) => {
|
|
306
|
-
// Autoplay without a user gesture is blocked — surface unlock UX
|
|
340
|
+
// Autoplay without a user gesture is blocked — retain queue and surface unlock UX (#160)
|
|
307
341
|
if (playErr && (playErr.name === 'NotAllowedError' || playErr.name === 'SecurityError')) {
|
|
342
|
+
player.blocked = true
|
|
308
343
|
setAutoplayBlocked(true)
|
|
344
|
+
player.queue.unshift(item)
|
|
345
|
+
currentAudioResolve = null
|
|
346
|
+
player.audio = null
|
|
347
|
+
player.busy = false
|
|
348
|
+
playerChanged()
|
|
349
|
+
if (typeof window !== 'undefined') {
|
|
350
|
+
removeGestureListeners()
|
|
351
|
+
const onUserGesture = () => {
|
|
352
|
+
removeGestureListeners()
|
|
353
|
+
unlockAudio()
|
|
354
|
+
}
|
|
355
|
+
window.addEventListener('click', onUserGesture, { once: true })
|
|
356
|
+
window.addEventListener('keydown', onUserGesture, { once: true })
|
|
357
|
+
window.addEventListener('touchstart', onUserGesture, { once: true })
|
|
358
|
+
clearGestureListeners = () => {
|
|
359
|
+
window.removeEventListener('click', onUserGesture)
|
|
360
|
+
window.removeEventListener('keydown', onUserGesture)
|
|
361
|
+
window.removeEventListener('touchstart', onUserGesture)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
resolve()
|
|
365
|
+
return
|
|
309
366
|
}
|
|
310
367
|
done()
|
|
311
368
|
})
|
|
@@ -317,12 +374,11 @@ window.__ModuleLoader__.load({
|
|
|
317
374
|
}
|
|
318
375
|
|
|
319
376
|
async function drainQueue() {
|
|
320
|
-
if (player.busy) return
|
|
377
|
+
if (player.busy || player.blocked || player.paused) return
|
|
321
378
|
player.busy = true
|
|
322
379
|
playerChanged()
|
|
323
380
|
try {
|
|
324
|
-
while (player.queue.length) {
|
|
325
|
-
if (player.paused) break
|
|
381
|
+
while (player.queue.length && !player.blocked && !player.paused) {
|
|
326
382
|
const item = player.queue.shift()
|
|
327
383
|
if (!item || item.kind === 'reserved') continue
|
|
328
384
|
if (item.kind === 'chime') { await playChime(item.chime || player.chime); continue }
|
|
@@ -336,6 +392,9 @@ window.__ModuleLoader__.load({
|
|
|
336
392
|
}
|
|
337
393
|
|
|
338
394
|
function stopPlayback() {
|
|
395
|
+
removeGestureListeners()
|
|
396
|
+
player.blocked = false
|
|
397
|
+
setAutoplayBlocked(false)
|
|
339
398
|
for (const item of player.queue) {
|
|
340
399
|
if (item && item.prepared && item.prepared.url) {
|
|
341
400
|
try { URL.revokeObjectURL(item.prepared.url) } catch { /* already revoked */ }
|
|
@@ -345,7 +404,10 @@ window.__ModuleLoader__.load({
|
|
|
345
404
|
player.queue.length = 0
|
|
346
405
|
player.paused = false
|
|
347
406
|
if (player.audio) {
|
|
348
|
-
try {
|
|
407
|
+
try {
|
|
408
|
+
player.audio.pause()
|
|
409
|
+
player.audio.currentTime = 0
|
|
410
|
+
} catch { /* already paused */ }
|
|
349
411
|
}
|
|
350
412
|
if (currentAudioResolve) {
|
|
351
413
|
currentAudioResolve()
|
|
@@ -365,11 +427,22 @@ window.__ModuleLoader__.load({
|
|
|
365
427
|
}
|
|
366
428
|
|
|
367
429
|
function skipCurrent() {
|
|
430
|
+
try {
|
|
431
|
+
if (typeof window !== 'undefined' && window.speechSynthesis) {
|
|
432
|
+
window.speechSynthesis.cancel()
|
|
433
|
+
}
|
|
434
|
+
} catch (_err) { /* no speechSynthesis */ }
|
|
435
|
+
if (player.audio) {
|
|
436
|
+
try {
|
|
437
|
+
player.audio.pause()
|
|
438
|
+
player.audio.currentTime = 0
|
|
439
|
+
} catch (_err) { /* ignore pause error */ }
|
|
440
|
+
}
|
|
368
441
|
if (currentAudioResolve) {
|
|
369
442
|
currentAudioResolve()
|
|
370
|
-
} else
|
|
371
|
-
try { player.audio.pause() } catch (already) { /* already stopped */ }
|
|
443
|
+
} else {
|
|
372
444
|
player.audio = null
|
|
445
|
+
player.busy = false
|
|
373
446
|
playerChanged()
|
|
374
447
|
if (player.queue.length) drainQueue()
|
|
375
448
|
}
|
|
@@ -392,70 +465,83 @@ window.__ModuleLoader__.load({
|
|
|
392
465
|
return () => window.removeEventListener('dsh-voice:speaking', onSpeaking)
|
|
393
466
|
}
|
|
394
467
|
|
|
395
|
-
|
|
396
|
-
let
|
|
397
|
-
let
|
|
398
|
-
let
|
|
468
|
+
// Unified SSE Realtime Stream Connection (#158)
|
|
469
|
+
let activeSse = null
|
|
470
|
+
let activeSseAttempt = 0
|
|
471
|
+
let activeSseTimer = null
|
|
472
|
+
let sseDisposed = false
|
|
473
|
+
|
|
474
|
+
function startSseStream() {
|
|
475
|
+
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return () => {}
|
|
476
|
+
sseDisposed = false
|
|
399
477
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
478
|
+
const onItem = (e) => {
|
|
479
|
+
try {
|
|
480
|
+
const item = JSON.parse(e.data)
|
|
481
|
+
if (!item || !item.id) return
|
|
482
|
+
if (player.enabled === false) return
|
|
483
|
+
player.after = item.id
|
|
484
|
+
if (seenIds.has(item.id)) return
|
|
485
|
+
seenIds.add(item.id)
|
|
486
|
+
if (seenIds.size > 500) {
|
|
487
|
+
const first = seenIds.values().next().value
|
|
488
|
+
seenIds.delete(first)
|
|
489
|
+
}
|
|
490
|
+
if (item.text) recent.add(item.text)
|
|
491
|
+
player.queue.push(item)
|
|
492
|
+
drainQueue()
|
|
493
|
+
} catch { /* ignore malformed SSE */ }
|
|
405
494
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
495
|
+
|
|
496
|
+
const onChime = (e) => {
|
|
497
|
+
try {
|
|
498
|
+
const item = JSON.parse(e.data)
|
|
499
|
+
if (!item) return
|
|
500
|
+
if (player.enabled === false) return
|
|
501
|
+
player.queue.push(item)
|
|
502
|
+
drainQueue()
|
|
503
|
+
} catch (_err) { /* ignore malformed chime payload */ }
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const connect = () => {
|
|
507
|
+
if (sseDisposed) return
|
|
508
|
+
if (activeSse) {
|
|
509
|
+
try { activeSse.close() } catch (_err) { /* active sse already closed */ }
|
|
510
|
+
activeSse = null
|
|
410
511
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
seenIds.delete(first)
|
|
422
|
-
}
|
|
423
|
-
if (item.text) recent.add(item.text)
|
|
424
|
-
player.queue.push(item)
|
|
425
|
-
drainQueue()
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
} catch (parseErr) { /* ignore malformed SSE payload */ }
|
|
429
|
-
})
|
|
430
|
-
sseSource.addEventListener('chime', (e) => {
|
|
431
|
-
sseAttempt = 0
|
|
432
|
-
try {
|
|
433
|
-
const item = JSON.parse(e.data)
|
|
434
|
-
if (item) {
|
|
435
|
-
player.queue.push(item)
|
|
436
|
-
drainQueue()
|
|
512
|
+
try {
|
|
513
|
+
const es = new EventSource('/dsh-tts/stream')
|
|
514
|
+
activeSse = es
|
|
515
|
+
es.onopen = () => { activeSseAttempt = 0 }
|
|
516
|
+
es.addEventListener('utterance', onItem)
|
|
517
|
+
es.addEventListener('chime', onChime)
|
|
518
|
+
es.onerror = () => {
|
|
519
|
+
if (activeSse === es) {
|
|
520
|
+
try { es.close() } catch (_err) { /* eventsource already closed */ }
|
|
521
|
+
activeSse = null
|
|
437
522
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
sseSource = null
|
|
523
|
+
if (sseDisposed) return
|
|
524
|
+
activeSseAttempt++
|
|
525
|
+
const delay = Math.min(10000, 500 * Math.pow(2, Math.min(activeSseAttempt, 5)))
|
|
526
|
+
if (activeSseTimer) clearTimeout(activeSseTimer)
|
|
527
|
+
activeSseTimer = setTimeout(connect, delay)
|
|
444
528
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
529
|
+
} catch { /* EventSource failed */ }
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
connect()
|
|
533
|
+
|
|
534
|
+
return () => {
|
|
535
|
+
sseDisposed = true
|
|
536
|
+
if (activeSseTimer) clearTimeout(activeSseTimer)
|
|
537
|
+
if (activeSse) {
|
|
538
|
+
try { activeSse.close() } catch (_err) { /* active sse already closed on dispose */ }
|
|
539
|
+
activeSse = null
|
|
453
540
|
}
|
|
454
|
-
}
|
|
541
|
+
}
|
|
455
542
|
}
|
|
456
543
|
|
|
457
|
-
async function pollPending() {
|
|
458
|
-
connectSseStream()
|
|
544
|
+
async function pollPending() {
|
|
459
545
|
try {
|
|
460
546
|
const st = await fetch('/dsh-tts/status', { cache: 'no-store' })
|
|
461
547
|
if (!st.ok) return
|
|
@@ -463,7 +549,7 @@ async function pollPending() {
|
|
|
463
549
|
player.enabled = !!(meta && meta.speakReplies)
|
|
464
550
|
if (meta && typeof meta.rate === 'number') player.rate = meta.rate
|
|
465
551
|
if (meta && meta.chime) player.chime = meta.chime
|
|
466
|
-
|
|
552
|
+
if (meta && meta.roles) player.roles = meta.roles
|
|
467
553
|
if (meta && typeof meta.bargeIn === 'boolean') player.bargeIn = meta.bargeIn
|
|
468
554
|
if (!player.enabled) return
|
|
469
555
|
const res = await fetch('/dsh-tts/pending?after=' + encodeURIComponent(player.after || ''), { cache: 'no-store' })
|
|
@@ -471,8 +557,6 @@ async function pollPending() {
|
|
|
471
557
|
const data = await res.json()
|
|
472
558
|
const items = data && Array.isArray(data.items) ? data.items : []
|
|
473
559
|
for (const item of items) {
|
|
474
|
-
// Queue slot reserved but synthesis still running — wait for the next poll
|
|
475
|
-
// or phrase order would break.
|
|
476
560
|
if (item.kind === 'reserved') break
|
|
477
561
|
player.after = item.id
|
|
478
562
|
if (seenIds.has(item.id)) continue
|
|
@@ -485,7 +569,7 @@ async function pollPending() {
|
|
|
485
569
|
player.queue.push(item)
|
|
486
570
|
}
|
|
487
571
|
if (player.queue.length) drainQueue()
|
|
488
|
-
} catch
|
|
572
|
+
} catch { /* host restarting */ }
|
|
489
573
|
}
|
|
490
574
|
|
|
491
575
|
// Recent utterances: last reply texts and favorites (localStorage).
|
|
@@ -514,8 +598,16 @@ async function pollPending() {
|
|
|
514
598
|
}
|
|
515
599
|
const recent = recentStore()
|
|
516
600
|
|
|
517
|
-
//
|
|
601
|
+
// Unified playback through the shared player controller (#159)
|
|
602
|
+
function playAudioItem(item) {
|
|
603
|
+
stopPlayback()
|
|
604
|
+
player.queue.push(item)
|
|
605
|
+
drainQueue()
|
|
606
|
+
}
|
|
607
|
+
|
|
518
608
|
function playStandalone(text) {
|
|
609
|
+
if (!text) return
|
|
610
|
+
stopPlayback()
|
|
519
611
|
fetch('/dsh-tts/speak', {
|
|
520
612
|
method: 'POST',
|
|
521
613
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -523,13 +615,41 @@ async function pollPending() {
|
|
|
523
615
|
})
|
|
524
616
|
.then((r) => r.json())
|
|
525
617
|
.then((d) => {
|
|
526
|
-
if (!d || !d.ok) return
|
|
527
|
-
|
|
528
|
-
|
|
618
|
+
if (!d || !d.ok || !d.audioBase64) return
|
|
619
|
+
playAudioItem({
|
|
620
|
+
id: `replay-${Date.now()}`,
|
|
621
|
+
text,
|
|
622
|
+
mime: d.mime || 'audio/mpeg',
|
|
623
|
+
audioBase64: d.audioBase64,
|
|
624
|
+
})
|
|
529
625
|
})
|
|
530
|
-
.catch(() => {
|
|
626
|
+
.catch(() => {})
|
|
531
627
|
}
|
|
532
628
|
|
|
629
|
+
function previewSpeech(provider, model, voice, text) {
|
|
630
|
+
stopPlayback()
|
|
631
|
+
return fetch('/dsh-tts/preview', {
|
|
632
|
+
method: 'POST',
|
|
633
|
+
headers: { 'Content-Type': 'application/json' },
|
|
634
|
+
body: JSON.stringify({ provider, model, voice, text }),
|
|
635
|
+
})
|
|
636
|
+
.then((r) => r.json())
|
|
637
|
+
.then((d) => {
|
|
638
|
+
if (!d || !d.ok) throw new Error((d && d.error && d.error.message) || 'HTTP error')
|
|
639
|
+
playAudioItem({
|
|
640
|
+
id: `preview-${Date.now()}`,
|
|
641
|
+
text,
|
|
642
|
+
mime: d.mime || 'audio/mpeg',
|
|
643
|
+
audioBase64: d.audioBase64,
|
|
644
|
+
})
|
|
645
|
+
})
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
player.playAudioItem = playAudioItem
|
|
649
|
+
player.previewSpeech = previewSpeech
|
|
650
|
+
player.startSseStream = startSseStream
|
|
651
|
+
player.skipCurrent = skipCurrent
|
|
652
|
+
|
|
533
653
|
function exportAudioClip(text) {
|
|
534
654
|
let phrase = text || player.lastText
|
|
535
655
|
if (!phrase) {
|
|
@@ -565,8 +685,26 @@ async function pollPending() {
|
|
|
565
685
|
function credOf(map, provider) {
|
|
566
686
|
return (map && map[provider]) || { configured: false, writable: true, ref: '' }
|
|
567
687
|
}
|
|
568
|
-
|
|
569
688
|
const en = {
|
|
689
|
+
'ttsUiError': '⚠️ TTS UI Error:',
|
|
690
|
+
'modelHint_openai': 'provider default',
|
|
691
|
+
'modelHint_elevenlabs': 'provider default',
|
|
692
|
+
'modelHint_google': 'provider default',
|
|
693
|
+
'modelHint_azure': 'region voice name',
|
|
694
|
+
'modelHint_groq': 'provider default',
|
|
695
|
+
'modelHint_deepgram': 'provider default (English-only voices)',
|
|
696
|
+
'modelHint_openrouter': 'provider/model id',
|
|
697
|
+
'modelHint_siliconflow': 'model id (e.g. FunAudioLLM/CosyVoice2-0.5B)',
|
|
698
|
+
'modelHint_deepinfra': 'model id (e.g. hexgrad/Kokoro-82M)',
|
|
699
|
+
'modelHint_fireworks': 'model id (e.g. kokoro)',
|
|
700
|
+
'modelHint_mimo': 'model id (e.g. mimo-v2.5-tts)',
|
|
701
|
+
'modelHint_custom': 'custom model',
|
|
702
|
+
'modelHint_minimax': 'model id',
|
|
703
|
+
'modelHint_edge': 'BCP-47 voice, e.g. en-US-AriaNeural',
|
|
704
|
+
'modelHint_piper': 'path to .onnx',
|
|
705
|
+
'modelHint_espeak': 'language code, e.g. en',
|
|
706
|
+
'modelHint_kokoro': 'not bundled — offline neural runtime unavailable',
|
|
707
|
+
'modelHint_f5': 'not bundled — offline neural runtime unavailable',
|
|
570
708
|
'dockLabel': 'Speech',
|
|
571
709
|
'dockPause': 'Pause',
|
|
572
710
|
'dockResume': 'Resume speech',
|
|
@@ -760,9 +898,39 @@ async function pollPending() {
|
|
|
760
898
|
'updaterUpdating': 'Updating…',
|
|
761
899
|
'updaterSuccess': 'Plugin updated successfully! Please restart DSH service.',
|
|
762
900
|
'updaterFailed': 'Update check failed: ',
|
|
901
|
+
'exportJson': '📥 Export JSON',
|
|
902
|
+
'importJson': '📤 Import JSON',
|
|
903
|
+
'invalidJsonFormat': 'Invalid dictionary JSON format',
|
|
904
|
+
'subtitle': 'Text-to-speech for DeepSeek Harness',
|
|
905
|
+
'available': 'available',
|
|
906
|
+
'dockSkip': 'Skip',
|
|
907
|
+
'longReplyTruncate': 'Truncate',
|
|
908
|
+
'longReplySummarize': 'Summarize',
|
|
909
|
+
'longReplyFull': 'Full',
|
|
910
|
+
'unlockAudio': 'Click to unlock audio',
|
|
911
|
+
'errorLoading': 'Error loading settings: ',
|
|
763
912
|
}
|
|
764
913
|
|
|
765
914
|
const zh = {
|
|
915
|
+
'ttsUiError': '⚠️ TTS 界面错误:',
|
|
916
|
+
'modelHint_openai': '服务商默认',
|
|
917
|
+
'modelHint_elevenlabs': '服务商默认',
|
|
918
|
+
'modelHint_google': '服务商默认',
|
|
919
|
+
'modelHint_azure': '区域语音名称',
|
|
920
|
+
'modelHint_groq': '服务商默认',
|
|
921
|
+
'modelHint_deepgram': '服务商默认 (仅英语语音)',
|
|
922
|
+
'modelHint_openrouter': '服务商/模型 ID',
|
|
923
|
+
'modelHint_siliconflow': '模型 ID (如 FunAudioLLM/CosyVoice2-0.5B)',
|
|
924
|
+
'modelHint_deepinfra': '模型 ID (如 hexgrad/Kokoro-82M)',
|
|
925
|
+
'modelHint_fireworks': '模型 ID (如 kokoro)',
|
|
926
|
+
'modelHint_mimo': '模型 ID (如 mimo-v2.5-tts)',
|
|
927
|
+
'modelHint_custom': '自定义模型',
|
|
928
|
+
'modelHint_minimax': '模型 ID',
|
|
929
|
+
'modelHint_edge': 'BCP-47 语音名称,如 en-US-AriaNeural',
|
|
930
|
+
'modelHint_piper': '.onnx 模型文件路径',
|
|
931
|
+
'modelHint_espeak': '语言代码,如 en',
|
|
932
|
+
'modelHint_kokoro': '未内置 — 离线神经语音运行时不可用',
|
|
933
|
+
'modelHint_f5': '未内置 — 离线神经语音运行时不可用',
|
|
766
934
|
'dockLabel': '语音朗读',
|
|
767
935
|
'dockPause': '暂停朗读',
|
|
768
936
|
'dockResume': '继续朗读',
|
|
@@ -959,6 +1127,14 @@ async function pollPending() {
|
|
|
959
1127
|
'updaterUpdating': '正在更新…',
|
|
960
1128
|
'updaterSuccess': '插件更新成功!请重启 DSH 服务以生效。',
|
|
961
1129
|
'updaterFailed': '更新检查失败:',
|
|
1130
|
+
'subtitle': 'DeepSeek Harness 语音合成插件',
|
|
1131
|
+
'available': '可用',
|
|
1132
|
+
'dockSkip': '跳过',
|
|
1133
|
+
'longReplyTruncate': '截断',
|
|
1134
|
+
'longReplySummarize': '总结',
|
|
1135
|
+
'longReplyFull': '完整朗读',
|
|
1136
|
+
'unlockAudio': '点击解锁音频',
|
|
1137
|
+
'errorLoading': '加载设置失败:',
|
|
962
1138
|
}
|
|
963
1139
|
function ChainEditor(props) {
|
|
964
1140
|
// Translator comes from the card: the slot passes props.t only to it.
|
|
@@ -993,7 +1169,7 @@ async function pollPending() {
|
|
|
993
1169
|
}, PROVIDERS.map((p) => React.createElement('option', { key: p, value: p }, p))),
|
|
994
1170
|
React.createElement('input', {
|
|
995
1171
|
className: 'dts-model dts-input', value: row.model || '', disabled: !props.writable,
|
|
996
|
-
placeholder: MODEL_HINT[row.provider] || '', onChange: (e) => change(i, { model: e.target.value }),
|
|
1172
|
+
placeholder: (typeof getModelHint === 'function' ? getModelHint(row.provider, t) : (MODEL_HINT[row.provider] || '')), onChange: (e) => change(i, { model: e.target.value }),
|
|
997
1173
|
}),
|
|
998
1174
|
React.createElement('input', {
|
|
999
1175
|
className: 'dts-model dts-input', value: row.voice || '', disabled: !props.writable,
|
|
@@ -1094,7 +1270,24 @@ async function pollPending() {
|
|
|
1094
1270
|
)
|
|
1095
1271
|
}
|
|
1096
1272
|
|
|
1097
|
-
const CLIENT_IT_TERMS = [
|
|
1273
|
+
const CLIENT_IT_TERMS = [
|
|
1274
|
+
{ from: 'API', to: 'A P I', whole: true, lang: '' },
|
|
1275
|
+
{ from: 'CLI', to: 'C L I', whole: true, lang: '' },
|
|
1276
|
+
{ from: 'CPU', to: 'C P U', whole: true, lang: '' },
|
|
1277
|
+
{ from: 'GPU', to: 'G P U', whole: true, lang: '' },
|
|
1278
|
+
{ from: 'RAM', to: 'ram', whole: true, lang: '' },
|
|
1279
|
+
{ from: 'SQL', to: 'sequel', whole: true, lang: '' },
|
|
1280
|
+
{ from: 'UI', to: 'U I', whole: true, lang: '' },
|
|
1281
|
+
{ from: 'URL', to: 'U R L', whole: true, lang: '' },
|
|
1282
|
+
{ from: 'UUID', to: 'U U I D', whole: true, lang: '' },
|
|
1283
|
+
{ from: 'JSON', to: 'j-son', whole: true, lang: '' },
|
|
1284
|
+
{ from: 'YAML', to: 'yam-el', whole: true, lang: '' },
|
|
1285
|
+
{ from: 'HTML', to: 'H T M L', whole: true, lang: '' },
|
|
1286
|
+
{ from: 'CSS', to: 'C S S', whole: true, lang: '' },
|
|
1287
|
+
{ from: 'SSH', to: 'S S H', whole: true, lang: '' },
|
|
1288
|
+
{ from: 'DNS', to: 'D N S', whole: true, lang: '' },
|
|
1289
|
+
{ from: 'IP', to: 'I P', whole: true, lang: '' },
|
|
1290
|
+
]
|
|
1098
1291
|
|
|
1099
1292
|
// Pronunciation dictionary editor: rules apply top-down.
|
|
1100
1293
|
function PronEditor(props) {
|
|
@@ -1426,7 +1619,7 @@ async function pollPending() {
|
|
|
1426
1619
|
{ style: { display: 'flex', alignItems: 'center', gap: '8px' } },
|
|
1427
1620
|
React.createElement('span', { className: 'dts-title' }, '🔄 ' + t('updaterTitle')),
|
|
1428
1621
|
React.createElement('span', { className: 'dts-badge ' + (updateAvailable ? 'dts-badge-warn' : 'dts-badge-on') },
|
|
1429
|
-
updateAvailable ? 'v' + latestVer + ' available' : 'v' + currentVer + ' (' + t('updaterUpToDate') + ')'
|
|
1622
|
+
updateAvailable ? 'v' + latestVer + ' ' + (t('available') || 'available') : 'v' + currentVer + ' (' + t('updaterUpToDate') + ')'
|
|
1430
1623
|
)
|
|
1431
1624
|
),
|
|
1432
1625
|
React.createElement(
|
|
@@ -1542,13 +1735,17 @@ async function pollPending() {
|
|
|
1542
1735
|
// ready — values are present.
|
|
1543
1736
|
// On unavailable, writable defaults true; without a status check the card
|
|
1544
1737
|
// looks like a working empty form.
|
|
1545
|
-
|
|
1546
|
-
|
|
1738
|
+
// Snapshot status contract (#163):
|
|
1739
|
+
// Without valid scope, or when loading/unavailable, editing is disabled.
|
|
1740
|
+
const [revision, setRevision] = React.useState(null)
|
|
1741
|
+
const ready = !!scope && !!snap && snap.status === 'ready'
|
|
1742
|
+
const writable = ready && snap.writable !== false
|
|
1547
1743
|
|
|
1548
1744
|
const applyPayload = (data) => {
|
|
1549
1745
|
const cfg = data && data.config ? data.config : {}
|
|
1550
1746
|
setDraft(JSON.parse(JSON.stringify(cfg)))
|
|
1551
1747
|
setCredentials(data && data.credentials ? data.credentials : {})
|
|
1748
|
+
if (data && data.revision !== undefined) setRevision(data.revision)
|
|
1552
1749
|
player.enabled = !!cfg.speakReplies
|
|
1553
1750
|
}
|
|
1554
1751
|
|
|
@@ -1561,7 +1758,7 @@ async function pollPending() {
|
|
|
1561
1758
|
|
|
1562
1759
|
fetch('/dsh-tts/integrations', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
|
|
1563
1760
|
if (!alive) return
|
|
1564
|
-
if (data && data.ok) setIntegrations(data)
|
|
1761
|
+
if (data && data.ok) setIntegrations(data.integrations || data)
|
|
1565
1762
|
}).catch(() => {})
|
|
1566
1763
|
|
|
1567
1764
|
const loadTelemetry = () => {
|
|
@@ -1593,6 +1790,11 @@ async function pollPending() {
|
|
|
1593
1790
|
return () => { alive = false; clearInterval(telTimer) }
|
|
1594
1791
|
}, [])
|
|
1595
1792
|
|
|
1793
|
+
if (err && !draft) {
|
|
1794
|
+
return React.createElement('div', { className: 'dts-wrap dts-warn' },
|
|
1795
|
+
React.createElement('span', null, (t('errorLoading') || 'Error loading settings: ') + err),
|
|
1796
|
+
)
|
|
1797
|
+
}
|
|
1596
1798
|
if (!draft) return React.createElement('div', { className: 'dts-wrap' }, t('loading'))
|
|
1597
1799
|
|
|
1598
1800
|
const setTop = (key, v) => setDraft((d) => Object.assign({}, d || {}, { [key]: v }))
|
|
@@ -1637,10 +1839,14 @@ async function pollPending() {
|
|
|
1637
1839
|
const v = String(keyDrafts[p] || '').trim()
|
|
1638
1840
|
if (v) keys[p] = v
|
|
1639
1841
|
})
|
|
1842
|
+
const bodyPayload = Object.assign({}, draft, { keys: keys })
|
|
1843
|
+
if (revision !== null && revision !== undefined) {
|
|
1844
|
+
bodyPayload.revision = revision
|
|
1845
|
+
}
|
|
1640
1846
|
const res = await fetch('/dsh-tts/config', {
|
|
1641
1847
|
method: 'PUT',
|
|
1642
1848
|
headers: { 'Content-Type': 'application/json' },
|
|
1643
|
-
body: JSON.stringify(
|
|
1849
|
+
body: JSON.stringify(bodyPayload),
|
|
1644
1850
|
})
|
|
1645
1851
|
const data = await res.json().catch(() => ({ /* parse error */ }))
|
|
1646
1852
|
if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
|
|
@@ -1668,15 +1874,7 @@ async function pollPending() {
|
|
|
1668
1874
|
}
|
|
1669
1875
|
|
|
1670
1876
|
const onPreview = (provider, model, voice, text) => {
|
|
1671
|
-
|
|
1672
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1673
|
-
body: JSON.stringify({ provider, model, voice, text }),
|
|
1674
|
-
})
|
|
1675
|
-
.then((r) => r.json())
|
|
1676
|
-
.then((d) => {
|
|
1677
|
-
if (!d.ok) throw new Error((d.error && d.error.message) || 'HTTP error')
|
|
1678
|
-
new Audio('data:' + d.mime + ';base64,' + d.audioBase64).play().catch(() => { /* autoplay blocked */ })
|
|
1679
|
-
})
|
|
1877
|
+
previewSpeech(provider, model, voice, text)
|
|
1680
1878
|
.catch((e) => setErr(String(e && e.message ? e.message : e)))
|
|
1681
1879
|
}
|
|
1682
1880
|
|
|
@@ -1686,9 +1884,9 @@ async function pollPending() {
|
|
|
1686
1884
|
onPreview((first && first.provider) || 'kokoro', (first && first.model) || '', (first && first.voice) || '', phrase)
|
|
1687
1885
|
}
|
|
1688
1886
|
|
|
1689
|
-
const boolField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
|
|
1887
|
+
const boolField = (key, label, hint, disabled = false) => React.createElement('label', { className: 'dts-field' }, label,
|
|
1690
1888
|
React.createElement('input', {
|
|
1691
|
-
type: 'checkbox', checked: !!(draft && draft[key]), disabled: !writable,
|
|
1889
|
+
type: 'checkbox', checked: !!(draft && draft[key]), disabled: !writable || !!disabled,
|
|
1692
1890
|
onChange: (e) => setTop(key, e.target.checked),
|
|
1693
1891
|
}),
|
|
1694
1892
|
React.createElement('span', { className: 'dts-sub' }, hint))
|
|
@@ -1762,12 +1960,12 @@ async function pollPending() {
|
|
|
1762
1960
|
React.createElement(VoiceDuplexEditor, {
|
|
1763
1961
|
t: t, writable: writable, draft: draft,
|
|
1764
1962
|
boolField: boolField, setTop: setTop,
|
|
1765
|
-
installed: !!(integrations && integrations.voice
|
|
1963
|
+
installed: !!(integrations && (integrations.voice?.installed ?? integrations.integrations?.voice?.installed ?? integrations.voiceInstalled)),
|
|
1766
1964
|
}),
|
|
1767
1965
|
React.createElement(MessengerIntegrationEditor, {
|
|
1768
1966
|
t: t, writable: writable, draft: draft,
|
|
1769
1967
|
boolField: boolField, setTop: setTop,
|
|
1770
|
-
installed: !!(integrations && integrations.messenger
|
|
1968
|
+
installed: !!(integrations && (integrations.messenger?.installed ?? integrations.integrations?.messenger?.installed ?? integrations.messengerInstalled)),
|
|
1771
1969
|
}),
|
|
1772
1970
|
React.createElement('div', { className: 'dts-block' },
|
|
1773
1971
|
React.createElement('div', { className: 'dts-h' }, t('pronTitle')),
|
|
@@ -1827,7 +2025,7 @@ async function pollPending() {
|
|
|
1827
2025
|
React.createElement('select', {
|
|
1828
2026
|
className: 'dts-input', value: (draft && draft.longReply) || 'truncate', disabled: !writable,
|
|
1829
2027
|
onChange: (e) => setTop('longReply', e.target.value),
|
|
1830
|
-
}, ['truncate', 'summarize', 'full'].map((mode) => React.createElement('option', { key: mode, value: mode }, mode))),
|
|
2028
|
+
}, ['truncate', 'summarize', 'full'].map((mode) => React.createElement('option', { key: mode, value: mode }, t('longReply' + mode.charAt(0).toUpperCase() + mode.slice(1)) || mode))),
|
|
1831
2029
|
React.createElement('span', { className: 'dts-sub' }, t('longReplyHint'))),
|
|
1832
2030
|
textField('summaryModel', t('summaryModel'), t('summaryModelHint')),
|
|
1833
2031
|
numberField('summarySentences', t('summarySentences'), t('summarySentencesHint'), 1),
|
|
@@ -1929,13 +2127,17 @@ async function pollPending() {
|
|
|
1929
2127
|
const [showList, setShowList] = React.useState(false)
|
|
1930
2128
|
const active = !!p.audio || p.queue.length > 0 || p.busy
|
|
1931
2129
|
const [, force] = React.useReducer((n) => n + 1, 0)
|
|
1932
|
-
if (!active && !showList) return null
|
|
1933
2130
|
const lists = recent.list()
|
|
2131
|
+
const hasHistory = (lists.items && lists.items.length > 0) || (lists.favs && lists.favs.length > 0)
|
|
2132
|
+
|
|
2133
|
+
// Hide only if completely idle and no recent/favorites history (#161)
|
|
2134
|
+
if (!active && !hasHistory && !showList && !p.blocked) return null
|
|
2135
|
+
|
|
1934
2136
|
const row = (text, starred) => React.createElement('div', { className: 'dts-row', key: text.slice(0, 24) + starred },
|
|
1935
2137
|
React.createElement('button', {
|
|
1936
2138
|
type: 'button', className: 'dts-link', style: { flex: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
1937
2139
|
title: text,
|
|
1938
|
-
onClick: () => playStandalone(text),
|
|
2140
|
+
onClick: () => playStandalone(text),
|
|
1939
2141
|
}, text.slice(0, 80)),
|
|
1940
2142
|
React.createElement('button', {
|
|
1941
2143
|
type: 'button', className: 'dts-link', title: '★',
|
|
@@ -1947,10 +2149,14 @@ async function pollPending() {
|
|
|
1947
2149
|
}, '⤓'),
|
|
1948
2150
|
)
|
|
1949
2151
|
return React.createElement('div', { style: { position: 'relative', display: 'inline-flex', gap: '4px', alignItems: 'center' } },
|
|
1950
|
-
React.createElement('button', {
|
|
2152
|
+
p.blocked ? React.createElement('button', {
|
|
2153
|
+
type: 'button', className: 'dts-link dts-warn', title: t('unlockAudio') || 'Click to unlock audio',
|
|
2154
|
+
onClick: unlockAudio,
|
|
2155
|
+
}, '🔔') : null,
|
|
2156
|
+
(hasHistory || active) ? React.createElement('button', {
|
|
1951
2157
|
type: 'button', className: 'dts-link', title: t('recent') || 'Recent',
|
|
1952
2158
|
onClick: () => setShowList((v) => !v),
|
|
1953
|
-
}, '☰'),
|
|
2159
|
+
}, '☰') : null,
|
|
1954
2160
|
showList ? React.createElement('div', {
|
|
1955
2161
|
className: 'dts-wrap',
|
|
1956
2162
|
style: { position: 'absolute', bottom: '36px', right: 0, zIndex: 30, maxHeight: '320px', overflowY: 'auto', background: 'var(--dsw-alias-bg-layer-2)', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '12px', padding: '10px 12px', minWidth: '260px', flexDirection: 'column', gap: '6px', display: 'flex' },
|
|
@@ -1961,24 +2167,32 @@ async function pollPending() {
|
|
|
1961
2167
|
lists.items.filter((x) => !lists.favs.includes(x)).map((text) => row(text, false)),
|
|
1962
2168
|
!lists.items.length ? React.createElement('span', { className: 'dts-sub' }, t('recentEmpty') || 'No recent speech yet') : null,
|
|
1963
2169
|
) : null,
|
|
1964
|
-
React.createElement(
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
2170
|
+
active ? React.createElement(React.Fragment, null,
|
|
2171
|
+
React.createElement('button', {
|
|
2172
|
+
type: 'button',
|
|
2173
|
+
className: 'dts-link',
|
|
2174
|
+
title: p.paused ? t('dockResume') : t('dockPause'),
|
|
2175
|
+
onClick: togglePause,
|
|
2176
|
+
}, p.paused ? '▶' : '❚❚'),
|
|
2177
|
+
React.createElement('button', {
|
|
2178
|
+
type: 'button',
|
|
2179
|
+
className: 'dts-link',
|
|
2180
|
+
title: t('dockStop'),
|
|
2181
|
+
onClick: stopPlayback,
|
|
2182
|
+
}, '■'),
|
|
2183
|
+
React.createElement('button', {
|
|
2184
|
+
type: 'button',
|
|
2185
|
+
className: 'dts-link',
|
|
2186
|
+
title: t('dockSkip') || 'Skip',
|
|
2187
|
+
onClick: skipCurrent,
|
|
2188
|
+
}, '⏭'),
|
|
2189
|
+
React.createElement('button', {
|
|
2190
|
+
type: 'button',
|
|
2191
|
+
className: 'dts-link',
|
|
2192
|
+
title: t('exportAudio') || 'Export clip',
|
|
2193
|
+
onClick: () => exportAudioClip(player.lastText),
|
|
2194
|
+
}, '⤓'),
|
|
2195
|
+
) : null,
|
|
1982
2196
|
)
|
|
1983
2197
|
}
|
|
1984
2198
|
|
|
@@ -2071,7 +2285,7 @@ async function pollPending() {
|
|
|
2071
2285
|
name: 'plugins.item',
|
|
2072
2286
|
id: ROW_ID,
|
|
2073
2287
|
order: 60,
|
|
2074
|
-
label: () => 'Text to Speech',
|
|
2288
|
+
label: () => fallbackDockText('title') || 'Text to Speech',
|
|
2075
2289
|
locale: NS,
|
|
2076
2290
|
inject: () => ({ ctx: ctx }),
|
|
2077
2291
|
},
|
|
@@ -2109,49 +2323,7 @@ async function pollPending() {
|
|
|
2109
2323
|
window.addEventListener('keydown', onKey)
|
|
2110
2324
|
return () => window.removeEventListener('keydown', onKey)
|
|
2111
2325
|
}, 'dsh-tts: hotkeys')
|
|
2112
|
-
ctx.effect(() =>
|
|
2113
|
-
if (typeof window === 'undefined' || typeof EventSource === 'undefined') return () => {}
|
|
2114
|
-
let es = null
|
|
2115
|
-
let closed = false
|
|
2116
|
-
let attempt = 0
|
|
2117
|
-
let retryTimer = null
|
|
2118
|
-
const onItem = (e) => {
|
|
2119
|
-
try {
|
|
2120
|
-
const item = JSON.parse(e.data)
|
|
2121
|
-
if (!item || !item.id) return
|
|
2122
|
-
if (seenIds.has(item.id)) return
|
|
2123
|
-
seenIds.add(item.id)
|
|
2124
|
-
if (seenIds.size > 500) {
|
|
2125
|
-
const first = seenIds.values().next().value
|
|
2126
|
-
seenIds.delete(first)
|
|
2127
|
-
}
|
|
2128
|
-
player.queue.push(item)
|
|
2129
|
-
drainQueue()
|
|
2130
|
-
} catch { /* ignore malformed SSE frame */ }
|
|
2131
|
-
}
|
|
2132
|
-
const connect = () => {
|
|
2133
|
-
if (closed) return
|
|
2134
|
-
try {
|
|
2135
|
-
es = new EventSource('/dsh-tts/stream')
|
|
2136
|
-
es.addEventListener('open', () => { attempt = 0 })
|
|
2137
|
-
es.addEventListener('utterance', onItem)
|
|
2138
|
-
es.addEventListener('chime', onItem)
|
|
2139
|
-
es.onerror = () => {
|
|
2140
|
-
try { es.close() } catch { /* already closed */ }
|
|
2141
|
-
if (closed) return
|
|
2142
|
-
attempt += 1
|
|
2143
|
-
const delay = Math.min(15000, 500 * Math.pow(2, Math.min(attempt, 5)))
|
|
2144
|
-
retryTimer = setTimeout(connect, delay)
|
|
2145
|
-
}
|
|
2146
|
-
} catch { /* EventSource constructor failed — poll remains */ }
|
|
2147
|
-
}
|
|
2148
|
-
connect()
|
|
2149
|
-
return () => {
|
|
2150
|
-
closed = true
|
|
2151
|
-
if (retryTimer) clearTimeout(retryTimer)
|
|
2152
|
-
if (es) { try { es.close() } catch { /* already closed */ } }
|
|
2153
|
-
}
|
|
2154
|
-
}, 'dsh-tts: SSE audio stream')
|
|
2326
|
+
ctx.effect(() => startSseStream(), 'dsh-tts: SSE audio stream')
|
|
2155
2327
|
ctx.effect(() => {
|
|
2156
2328
|
const timer = setInterval(pollPending, 1000)
|
|
2157
2329
|
return () => clearInterval(timer)
|