@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/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
- 'edge', 'piper', 'espeak',
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, custom: 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
- edge: 'BCP-47 voice, e.g. ru-RU-SvetlanaNeural',
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. ru',
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, then stop this chunk.
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 { player.audio.pause() } catch { /* already paused */ }
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 if (player.audio) {
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
- // SSE Realtime Stream Connection with Exponential Backoff & Jitter
396
- let sseAttempt = 0
397
- let sseTimer = null
398
- let sseSource = null
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
- function connectSseStream() {
401
- if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return
402
- if (sseSource) {
403
- try { sseSource.close() } catch (alreadyClosed) { /* intentional cleanup */ }
404
- sseSource = null
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
- try {
407
- sseSource = new EventSource('/dsh-tts/stream')
408
- sseSource.onopen = () => {
409
- sseAttempt = 0
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
- sseSource.addEventListener('utterance', (e) => {
412
- sseAttempt = 0
413
- try {
414
- const item = JSON.parse(e.data)
415
- if (item && item.id) {
416
- player.after = item.id
417
- if (!seenIds.has(item.id)) {
418
- seenIds.add(item.id)
419
- if (seenIds.size > 500) {
420
- const first = seenIds.values().next().value
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
- } catch (parseErr) { /* ignore malformed chime payload */ }
439
- })
440
- sseSource.onerror = () => {
441
- if (sseSource) {
442
- try { sseSource.close() } catch (alreadyClosed) { /* intentional cleanup */ }
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
- sseAttempt++
446
- const baseDelay = Math.min(10000, 500 * Math.pow(2, sseAttempt - 1))
447
- const jitter = baseDelay * (Math.random() * 0.6 - 0.3)
448
- const delay = Math.max(200, Math.floor(baseDelay + jitter))
449
- if (sseTimer) clearTimeout(sseTimer)
450
- sseTimer = setTimeout(() => {
451
- connectSseStream()
452
- }, delay)
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
- } catch (esErr) { /* EventSource failed */ }
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
- if (meta && meta.roles) player.roles = meta.roles
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 (hostRestarting) { /* host restarting */ }
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
- // One-shot playback outside the queue: replay from recents.
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
- const audio = new Audio('data:' + (d.mime || 'audio/mpeg') + ';base64,' + d.audioBase64)
528
- audio.play().catch(() => { /* chime autoplay blocked or interrupted */ })
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(() => { /* chime fetch failed or not found */ })
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
- const ready = !scope || (!!snap && snap.status === 'ready')
1546
- const writable = !scope ? true : (ready && snap.writable !== false)
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(Object.assign({}, draft, { keys: keys })),
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
- fetch('/dsh-tts/preview', {
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 && integrations.voice.installed),
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 && integrations.messenger.installed),
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), title: 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('button', {
1965
- type: 'button',
1966
- className: 'dts-link',
1967
- title: p.paused ? t('dockResume') : t('dockPause'),
1968
- onClick: togglePause,
1969
- }, p.paused ? '▶' : '❚❚'),
1970
- React.createElement('button', {
1971
- type: 'button',
1972
- className: 'dts-link',
1973
- title: t('dockStop'),
1974
- onClick: stopPlayback,
1975
- }, '■'),
1976
- React.createElement('button', {
1977
- type: 'button',
1978
- className: 'dts-link',
1979
- title: t('exportAudio') || 'Export clip',
1980
- onClick: () => exportAudioClip(player.lastText),
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)