@goodandready/dsh-tts 0.4.1 → 0.4.5

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
@@ -107,8 +107,8 @@ window.__ModuleLoader__.load({
107
107
  '.dts-save:hover:not(:disabled){opacity:0.88}' +
108
108
  '.dts-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 12px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}' +
109
109
  '.dts-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}' +
110
- '.dts-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:rgba(239,68,68,0.3)}' +
111
- '.dts-btn-danger:hover:not(:disabled){background:rgba(239,68,68,0.12);border-color:rgba(239,68,68,0.5)}' +
110
+ '.dts-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}' +
111
+ '.dts-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 50%, transparent)}' +
112
112
  '.dts-entry{display:flex;flex-direction:column;gap:8px;padding:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}' +
113
113
  '.dts-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}' +
114
114
  '.dts-row .dts-model{flex:1;min-width:120px}' +
@@ -118,15 +118,15 @@ window.__ModuleLoader__.load({
118
118
  '.dts-ok{font-size:12px;color:var(--dsw-alias-state-success-primary);font-weight:500}' +
119
119
  '.dts-bad{font-size:12px;color:var(--dsw-alias-state-error-primary);font-weight:500}' +
120
120
  '.dts-badge{font-size:11px;padding:3px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap;display:inline-flex;align-items:center;gap:4px;font-weight:500}' +
121
- '.dts-badge-on{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);background:rgba(16,185,129,0.08)}' +
122
- '.dts-badge-warn{color:var(--dsw-alias-state-warning-primary);border-color:var(--dsw-alias-state-warning-primary);background:rgba(245,158,11,0.08)}' +
123
- '.dts-badge-bad{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);background:rgba(239,68,68,0.08)}' +
121
+ '.dts-badge-on{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}' +
122
+ '.dts-badge-warn{color:var(--dsw-alias-state-warning-primary);border-color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent)}' +
123
+ '.dts-badge-bad{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}' +
124
124
  '.dts-link{background:none;border:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:12px;padding:0;text-decoration:underline}' +
125
125
  '.dts-link:hover:not(:disabled){color:var(--dsw-alias-label-primary)}' +
126
126
  '.dts-alert{padding:10px 14px;border-radius:8px;font-size:13px}' +
127
- '.dts-alert-err{background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary);border:1px solid rgba(239,68,68,0.2)}' +
128
- '.dts-alert-warn{background:rgba(245,158,11,0.1);color:var(--dsw-alias-state-warning-primary);border:1px solid rgba(245,158,11,0.2)}' +
129
- '.dts-alert-ok{background:rgba(16,185,129,0.1);color:var(--dsw-alias-state-success-primary);border:1px solid rgba(16,185,129,0.2)}' +
127
+ '.dts-alert-err{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 20%, transparent)}' +
128
+ '.dts-alert-warn{background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 10%, transparent);color:var(--dsw-alias-state-warning-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-warning-primary) 20%, transparent)}' +
129
+ '.dts-alert-ok{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 20%, transparent)}' +
130
130
  '.dts-grow{flex:1;min-width:100px}'
131
131
 
132
132
  const setCssId = 'dsh-tts/settings.module.css'
@@ -142,6 +142,38 @@ window.__ModuleLoader__.load({
142
142
  // Each incoming chunk used to replace the previous one immediately; while reading
143
143
  // For full replies this was invisible; with speak-as-it-goes only the last
144
144
  // chunk would be heard. Now items queue: the next starts after the previous ends.
145
+ const autoplay = { blocked: false, listeners: new Set() }
146
+
147
+ function setAutoplayBlocked(next) {
148
+ if (autoplay.blocked === next) return
149
+ autoplay.blocked = next
150
+ for (const l of [...autoplay.listeners]) {
151
+ try { l() } catch { /* listener isolation */ }
152
+ }
153
+ }
154
+
155
+ function unlockAudio() {
156
+ try {
157
+ const AC = window.AudioContext || window.webkitAudioContext
158
+ if (AC) {
159
+ const ctx = new AC()
160
+ if (ctx.state === 'suspended') ctx.resume().catch(() => { /* audio context unlock failure ignored */ })
161
+ setTimeout(() => { try { ctx.close() } catch { /* closed */ } }, 300)
162
+ }
163
+ } catch { /* no AudioContext */ }
164
+ setAutoplayBlocked(false)
165
+ drainQueue()
166
+ }
167
+
168
+ function useAutoplayGate() {
169
+ const [, force] = React.useReducer((n) => n + 1, 0)
170
+ React.useEffect(() => {
171
+ autoplay.listeners.add(force)
172
+ return () => { autoplay.listeners.delete(force) }
173
+ }, [])
174
+ return autoplay
175
+ }
176
+
145
177
  const player = {
146
178
  audio: null,
147
179
  after: '',
@@ -153,13 +185,15 @@ window.__ModuleLoader__.load({
153
185
  busy: false,
154
186
  paused: false,
155
187
  listeners: new Set(),
188
+ unlockAudio,
189
+ useAutoplayGate,
190
+ get autoplayBlocked() { return autoplay.blocked },
156
191
  }
157
192
  const seenIds = new Set()
158
193
  let currentAudioResolve = null
159
-
160
194
  function playerChanged() {
161
195
  for (const listener of [...player.listeners]) {
162
- try { listener() } catch (listenerFailure) { /* чужой слушатель не наша забота */ }
196
+ try { listener() } catch (listenerFailure) { /* foreign listener failure is not ours */ }
163
197
  }
164
198
  }
165
199
 
@@ -194,8 +228,8 @@ window.__ModuleLoader__.load({
194
228
  osc.stop(at + 0.2)
195
229
  at += 0.22
196
230
  }
197
- setTimeout(() => { try { audioCtx.close() } catch (already) { /* закрыт */ } resolve() }, 600)
198
- } catch (noAudio) { resolve() }
231
+ setTimeout(() => { try { audioCtx.close() } catch (already) { /* already closed */ } resolve() }, 600)
232
+ } catch { resolve() /* no AudioContext */ }
199
233
  })
200
234
  }
201
235
 
@@ -210,7 +244,7 @@ window.__ModuleLoader__.load({
210
244
  utterance.onend = () => resolve()
211
245
  utterance.onerror = () => resolve()
212
246
  window.speechSynthesis.speak(utterance)
213
- } catch (noSpeech) { resolve() }
247
+ } catch { resolve() /* no speechSynthesis */ }
214
248
  })
215
249
  }
216
250
 
@@ -225,7 +259,7 @@ window.__ModuleLoader__.load({
225
259
  const audio = new Audio(url)
226
260
  audio.preload = 'auto'
227
261
  item.prepared = { audio, url }
228
- } catch (err) { /* decode error */ }
262
+ } catch { /* decode error — item stays unprepared */ }
229
263
  return item
230
264
  }
231
265
 
@@ -253,7 +287,7 @@ window.__ModuleLoader__.load({
253
287
  if (doneCalled) return
254
288
  doneCalled = true
255
289
  currentAudioResolve = null
256
- try { URL.revokeObjectURL(url) } catch (_) {}
290
+ try { URL.revokeObjectURL(url) } catch { /* URL already revoked */ }
257
291
  item.prepared = null
258
292
  player.audio = null
259
293
  playerChanged()
@@ -262,11 +296,14 @@ window.__ModuleLoader__.load({
262
296
  currentAudioResolve = done
263
297
  audio.onended = done
264
298
  audio.onerror = done
265
- audio.play().catch(() => {
266
- // Autoplay without a user gesture is blocked — not a bug; just no sound.
299
+ audio.play().catch((playErr) => {
300
+ // Autoplay without a user gesture is blocked — surface unlock UX, then stop this chunk.
301
+ if (playErr && (playErr.name === 'NotAllowedError' || playErr.name === 'SecurityError')) {
302
+ setAutoplayBlocked(true)
303
+ }
267
304
  done()
268
305
  })
269
- } catch (cannotDecode) {
306
+ } catch { /* cannot decode — skip chunk */
270
307
  currentAudioResolve = null
271
308
  resolve()
272
309
  }
@@ -295,26 +332,27 @@ window.__ModuleLoader__.load({
295
332
  function stopPlayback() {
296
333
  for (const item of player.queue) {
297
334
  if (item && item.prepared && item.prepared.url) {
298
- try { URL.revokeObjectURL(item.prepared.url) } catch (_) {}
335
+ try { URL.revokeObjectURL(item.prepared.url) } catch { /* already revoked */ }
299
336
  item.prepared = null
300
337
  }
301
338
  }
302
339
  player.queue.length = 0
303
340
  player.paused = false
341
+ if (player.audio) {
342
+ try { player.audio.pause() } catch { /* already paused */ }
343
+ }
304
344
  if (currentAudioResolve) {
305
345
  currentAudioResolve()
306
- } else if (player.audio) {
307
- try { player.audio.pause() } catch (already) { /* стоит */ }
308
- player.audio = null
309
346
  }
310
- try { if (window.speechSynthesis) window.speechSynthesis.cancel() } catch (noSpeech) { /* нет синтеза */ }
347
+ player.audio = null
348
+ try { if (window.speechSynthesis) window.speechSynthesis.cancel() } catch { /* no speechSynthesis */ }
311
349
  playerChanged()
312
350
  }
313
351
 
314
352
  function togglePause() {
315
353
  player.paused = !player.paused
316
354
  if (player.audio) {
317
- try { player.paused ? player.audio.pause() : player.audio.play() } catch (already) { /* всё равно */ }
355
+ try { player.paused ? player.audio.pause() : player.audio.play() } catch (already) { /* ignore play/pause failure */ }
318
356
  }
319
357
  if (!player.paused) drainQueue()
320
358
  playerChanged()
@@ -324,7 +362,7 @@ window.__ModuleLoader__.load({
324
362
  if (currentAudioResolve) {
325
363
  currentAudioResolve()
326
364
  } else if (player.audio) {
327
- try { player.audio.pause() } catch (already) { /* стоит */ }
365
+ try { player.audio.pause() } catch (already) { /* already stopped */ }
328
366
  player.audio = null
329
367
  playerChanged()
330
368
  if (player.queue.length) drainQueue()
@@ -378,7 +416,7 @@ window.__ModuleLoader__.load({
378
416
  player.queue.push(item)
379
417
  }
380
418
  if (player.queue.length) drainQueue()
381
- } catch (hostRestarting) { /* хост перезапускается */ }
419
+ } catch (hostRestarting) { /* host restarting */ }
382
420
  }
383
421
 
384
422
  // Recent utterances: last reply texts and favorites (localStorage).
@@ -389,7 +427,7 @@ window.__ModuleLoader__.load({
389
427
  const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null
390
428
  st = JSON.parse(raw) || { items: [], favs: [] }
391
429
  } catch (broken) { st = { items: [], favs: [] } }
392
- const persist = () => { try { if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(st)) } catch (full) { /* приватный режим */ } }
430
+ const persist = () => { try { if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(st)) } catch (full) { /* private mode / quota */ } }
393
431
  return {
394
432
  add(text) {
395
433
  if (!text || text.length < 2) return
@@ -418,9 +456,9 @@ window.__ModuleLoader__.load({
418
456
  .then((d) => {
419
457
  if (!d || !d.ok) return
420
458
  const audio = new Audio('data:' + (d.mime || 'audio/mpeg') + ';base64,' + d.audioBase64)
421
- audio.play().catch(() => {})
459
+ audio.play().catch(() => { /* chime autoplay blocked or interrupted */ })
422
460
  })
423
- .catch(() => {})
461
+ .catch(() => { /* chime fetch failed or not found */ })
424
462
  }
425
463
 
426
464
  function credOf(map, provider) {
@@ -429,6 +467,24 @@ window.__ModuleLoader__.load({
429
467
 
430
468
  const en = {
431
469
  'statusOnline': 'Host online',
470
+ 'recent': 'Recent',
471
+ 'recentEmpty': 'No recent speech yet',
472
+ 'retry': 'Retry',
473
+ 'advancedCollapsed': 'Advanced',
474
+ 'confirmClearCache': 'Clear the synthesis cache? This cannot be undone.',
475
+ 'confirmDeleteModel': 'Delete downloaded weights for this engine?',
476
+ 'autoplayBlocked': 'Sound is blocked until you click or tap once',
477
+ 'enableSound': 'Enable sound',
478
+ 'runtimeNotBundled': 'Neural inference runtime is not bundled',
479
+ 'runtimeNotBundledHint': 'Install weights does not enable speech. Use Edge, Piper, or eSpeak for offline TTS.',
480
+ 'testProvider': 'Test provider',
481
+ 'breakerOpen': 'Circuit open',
482
+ 'lastError': 'Last error',
483
+ 'activeProvider': 'Active provider',
484
+ 'queueLen': 'Queue',
485
+ 'sseState': 'SSE',
486
+ 'currentVoice': 'Voice',
487
+
432
488
  'statusOffline': 'Host unreachable',
433
489
  'cacheStats': 'Cache',
434
490
 
@@ -591,6 +647,24 @@ window.__ModuleLoader__.load({
591
647
  }
592
648
  const ru = {
593
649
  'statusOnline': 'Хост на связи',
650
+ 'recent': 'Недавние',
651
+ 'recentEmpty': 'Пока нет реплик',
652
+ 'retry': 'Повторить',
653
+ 'advancedCollapsed': 'Дополнительно',
654
+ 'confirmClearCache': 'Очистить кэш синтеза? Действие необратимо.',
655
+ 'confirmDeleteModel': 'Удалить скачанные веса движка?',
656
+ 'autoplayBlocked': 'Звук заблокирован — один раз кликните или коснитесь экрана',
657
+ 'enableSound': 'Включить звук',
658
+ 'runtimeNotBundled': 'Рантайм нейросинтеза не входит в пакет',
659
+ 'runtimeNotBundledHint': 'Установка весов не включает речь. Для offline используйте Edge, Piper или eSpeak.',
660
+ 'testProvider': 'Проверить провайдера',
661
+ 'breakerOpen': 'Переключатель открыт',
662
+ 'lastError': 'Последняя ошибка',
663
+ 'activeProvider': 'Активный провайдер',
664
+ 'queueLen': 'Очередь',
665
+ 'sseState': 'SSE',
666
+ 'currentVoice': 'Голос',
667
+
594
668
  'statusOffline': 'Хост недоступен',
595
669
  'cacheStats': 'Кэш',
596
670
 
@@ -793,7 +867,7 @@ window.__ModuleLoader__.load({
793
867
  }),
794
868
  React.createElement('button', { type: 'button', className: 'dts-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '\u2191'),
795
869
  React.createElement('button', { type: 'button', className: 'dts-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, '\u2193'),
796
- React.createElement('button', { type: 'button', className: 'dts-mini', title: t('preview'), disabled: !props.writable, onClick: () => props.onPreview(row.provider, row.model, row.voice) }, '\u25b6'),
870
+ React.createElement('button', { type: 'button', className: 'dts-mini', title: t('testProvider') || t('preview'), disabled: !props.writable, onClick: () => props.onPreview(row.provider, row.model, row.voice) }, '\u25b6'),
797
871
  React.createElement('button', { type: 'button', className: 'dts-mini', title: t('remove'), disabled: !props.writable, onClick: () => remove(i) }, '\u00d7'),
798
872
  ),
799
873
  cloud ? React.createElement('div', { className: 'dts-row' },
@@ -811,6 +885,13 @@ window.__ModuleLoader__.load({
811
885
  }, t('clear')) : null,
812
886
  ) : React.createElement('span', { className: 'dts-sub' }, t('localProvider')),
813
887
  cloud && cred.ref ? React.createElement('span', { className: 'dts-sub' }, 'Stored as ' + cred.ref) : null,
888
+ (() => {
889
+ const hit = Array.isArray(props.breaker) ? props.breaker.find((b) => b && b.id === row.provider) : null
890
+ if (!hit || (!hit.open && !hit.lastError)) return null
891
+ return React.createElement('div', { className: 'dts-sub dts-bad' },
892
+ (hit.open ? t('breakerOpen') + ' · ' : '') + (hit.lastError || '')
893
+ )
894
+ })(),
814
895
  )
815
896
  }),
816
897
  React.createElement('div', { className: 'dts-row' },
@@ -826,86 +907,59 @@ window.__ModuleLoader__.load({
826
907
  function LocalEnginesEditor(props) {
827
908
  const t = props.t || ((key) => key)
828
909
  const [models, setModels] = React.useState({})
829
- const [loading, setLoading] = React.useState(false)
830
910
 
831
911
  const loadStatus = React.useCallback(() => {
832
912
  if (typeof fetch === 'undefined') return
833
913
  fetch('/dsh-tts/models/status')
834
914
  .then((r) => r.json())
835
915
  .then((d) => { if (d && d.ok && d.models) setModels(d.models) })
836
- .catch(() => {})
916
+ .catch(() => {}) // status poll best-effort
837
917
  }, [])
838
918
 
839
919
  React.useEffect(() => {
840
920
  loadStatus()
841
- const timer = setInterval(loadStatus, 3000)
921
+ const timer = setInterval(loadStatus, 5000)
842
922
  return () => clearInterval(timer)
843
923
  }, [loadStatus])
844
924
 
845
- const install = (engine) => {
846
- setLoading(true)
847
- fetch('/dsh-tts/models/install', {
848
- method: 'POST',
849
- headers: { 'content-type': 'application/json' },
850
- body: JSON.stringify({ engine }),
851
- }).then(() => loadStatus()).finally(() => setLoading(false))
852
- }
853
-
854
925
  const remove = (engine) => {
855
- setLoading(true)
926
+ if (typeof window !== 'undefined' && window.confirm) {
927
+ if (!window.confirm(t('confirmDeleteModel'))) return
928
+ }
856
929
  fetch('/dsh-tts/models/delete', {
857
930
  method: 'DELETE',
858
931
  headers: { 'content-type': 'application/json' },
859
932
  body: JSON.stringify({ engine }),
860
- }).then(() => loadStatus()).finally(() => setLoading(false))
933
+ }).then(() => loadStatus()).catch(() => {})
861
934
  }
862
935
 
863
936
  const kokoro = models.kokoro || { installed: false, downloading: false, progress: 0 }
864
937
  const f5 = models.f5 || { installed: false, downloading: false, progress: 0 }
865
938
 
939
+ const engineRow = (label, state) => React.createElement('div', { className: 'dts-entry' },
940
+ React.createElement('div', { className: 'dts-row' },
941
+ React.createElement('span', { className: 'dts-h', style: { minWidth: '150px' } }, label),
942
+ React.createElement('span', {
943
+ className: 'dts-badge dts-badge-warn',
944
+ }, t('runtimeNotBundled')),
945
+ state.installed ? React.createElement('button', {
946
+ type: 'button', className: 'dts-link', disabled: !props.writable,
947
+ onClick: () => remove(label.toLowerCase().includes('kokoro') ? 'kokoro' : 'f5'),
948
+ }, t('deleteModel')) : null,
949
+ ),
950
+ React.createElement('div', { className: 'dts-sub' }, t('runtimeNotBundledHint')),
951
+ )
952
+
866
953
  return React.createElement('div', { className: 'dts-block' },
867
954
  React.createElement('div', { className: 'dts-h' }, t('localEnginesTitle')),
868
955
  React.createElement('div', { className: 'dts-sub' }, t('localEnginesHint')),
869
- props.boolField('enableLocalEngines', t('enableLocalEngines'), t('enableLocalEnginesHint')),
956
+ React.createElement('div', { className: 'dts-alert-warn dts-sub' }, t('runtimeNotBundledHint')),
870
957
  props.boolField('streamingEnabled', t('streamingEnabled'), t('streamingEnabledHint')),
871
- React.createElement('div', { className: 'dts-entry' },
872
- React.createElement('div', { className: 'dts-row' },
873
- React.createElement('span', { className: 'dts-h', style: { minWidth: '150px' } }, 'Kokoro-82M (CPU)'),
874
- React.createElement('span', {
875
- className: 'dts-badge' + (kokoro.installed ? ' dts-badge-on' : ''),
876
- }, kokoro.downloading ? `${t('downloading')} ${kokoro.progress}%` : (kokoro.installed ? t('installed') : t('notInstalled'))),
877
- !kokoro.installed && !kokoro.downloading ? React.createElement('button', {
878
- type: 'button', className: 'dts-save', disabled: !props.writable || loading,
879
- onClick: () => install('kokoro'),
880
- }, t('installKokoro')) : null,
881
- kokoro.installed ? React.createElement('button', {
882
- type: 'button', className: 'dts-link', disabled: !props.writable || loading,
883
- onClick: () => remove('kokoro'),
884
- }, t('deleteModel')) : null,
885
- ),
886
- kokoro.installed ? props.boolField('kokoroEnabled', t('useKokoro'), t('useKokoroHint')) : null,
887
- ),
888
- React.createElement('div', { className: 'dts-entry' },
889
- React.createElement('div', { className: 'dts-row' },
890
- React.createElement('span', { className: 'dts-h', style: { minWidth: '150px' } }, 'F5-TTS (GPU)'),
891
- React.createElement('span', {
892
- className: 'dts-badge' + (f5.installed ? ' dts-badge-on' : ''),
893
- }, f5.downloading ? `${t('downloading')} ${f5.progress}%` : (f5.installed ? t('installed') : t('notInstalled'))),
894
- !f5.installed && !f5.downloading ? React.createElement('button', {
895
- type: 'button', className: 'dts-save', disabled: !props.writable || loading,
896
- onClick: () => install('f5'),
897
- }, t('installF5')) : null,
898
- f5.installed ? React.createElement('button', {
899
- type: 'button', className: 'dts-link', disabled: !props.writable || loading,
900
- onClick: () => remove('f5'),
901
- }, t('deleteModel')) : null,
902
- ),
903
- f5.installed ? props.boolField('f5Enabled', t('useF5'), t('useF5Hint')) : null,
904
- ),
958
+ engineRow('Kokoro-82M (CPU)', kokoro),
959
+ engineRow('F5-TTS (GPU)', f5),
905
960
  )
906
961
  }
907
962
 
908
-
909
963
  const CLIENT_IT_TERMS = [
910
964
  { from: 'SQL', to: 'сиквел', whole: true, lang: 'ru' },
911
965
  { from: 'Nginx', to: 'энджинкс', whole: true, lang: 'ru' },
@@ -1171,14 +1225,19 @@ window.__ModuleLoader__.load({
1171
1225
  const [saved, setSaved] = React.useState(false)
1172
1226
  const [err, setErr] = React.useState('')
1173
1227
  const [integrations, setIntegrations] = React.useState({ voice: { installed: false }, messenger: { installed: false } })
1174
- const [telemetry, setTelemetry] = React.useState({ online: true, cacheHits: 0, sseOk: true, total: 0 })
1228
+ const [telemetry, setTelemetry] = React.useState({ online: true, cacheHits: 0, sseOk: true, total: 0, sseClients: 0, breaker: [], provider: '', queue: 0 })
1229
+ const [advancedOpen, setAdvancedOpen] = React.useState(() => {
1230
+ try { return localStorage.getItem('dsh-tts/advancedOpen') === '1' } catch { /* localStorage unavailable */ return false }
1231
+ })
1232
+ const autoplay = useAutoplayGate()
1233
+
1175
1234
  const ctx = props && props.ctx
1176
1235
  const scope = React.useMemo(() => {
1177
1236
  const s = (ctx && ctx.get && ctx.get('lanSettings')) || (ctx && ctx.settingsScope)
1178
1237
  if (!s || !s.bind) return null
1179
1238
  try {
1180
1239
  return s.bind({ namespace: NS })
1181
- } catch (_) {
1240
+ } catch (_) { /* settings scope binding failed */
1182
1241
  return null
1183
1242
  }
1184
1243
  }, [ctx])
@@ -1188,7 +1247,7 @@ window.__ModuleLoader__.load({
1188
1247
  if (!scope || !scope.subscribe) return () => {}
1189
1248
  try {
1190
1249
  return scope.subscribe(cb) || (() => {})
1191
- } catch (_) {
1250
+ } catch (_) { /* settings scope binding failed */
1192
1251
  return () => {}
1193
1252
  }
1194
1253
  }
@@ -1198,7 +1257,7 @@ window.__ModuleLoader__.load({
1198
1257
  if (!scope || !scope.getSnapshot) return SNAPSHOT_LOADING
1199
1258
  try {
1200
1259
  return scope.getSnapshot() || SNAPSHOT_LOADING
1201
- } catch (_) {
1260
+ } catch (_) { /* settings scope binding failed */
1202
1261
  return SNAPSHOT_LOADING
1203
1262
  }
1204
1263
  }, [scope])
@@ -1235,18 +1294,33 @@ window.__ModuleLoader__.load({
1235
1294
  if (data && data.ok) setIntegrations(data)
1236
1295
  }).catch(() => {})
1237
1296
 
1238
- fetch('/dsh-tts/stats', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
1239
- if (!alive || !st) return
1240
- setTelemetry((prev) => Object.assign({}, prev, {
1241
- cacheHits: st.cacheHits || 0,
1242
- total: st.total || 0,
1243
- online: true,
1244
- }))
1245
- }).catch(() => {
1246
- if (alive) setTelemetry((prev) => Object.assign({}, prev, { online: false }))
1247
- })
1297
+ const loadTelemetry = () => {
1298
+ fetch('/dsh-tts/stats', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
1299
+ if (!alive || !st) return
1300
+ setTelemetry((prev) => Object.assign({}, prev, {
1301
+ cacheHits: st.cacheHits || 0,
1302
+ total: st.total || 0,
1303
+ online: true,
1304
+ breaker: st.breaker || prev.breaker || [],
1305
+ }))
1306
+ }).catch(() => {
1307
+ if (alive) setTelemetry((prev) => Object.assign({}, prev, { online: false }))
1308
+ })
1309
+ fetch('/dsh-tts/status', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
1310
+ if (!alive || !st) return
1311
+ setTelemetry((prev) => Object.assign({}, prev, {
1312
+ sseClients: st.sseClients || 0,
1313
+ sseOk: (st.sseClients || 0) >= 0,
1314
+ provider: (player.audio && player.provider) || (player.queue[0] && player.queue[0].provider) || '',
1315
+ queue: player.queue.length,
1316
+ }))
1317
+ }).catch(() => {})
1318
+ }
1319
+ loadTelemetry()
1320
+ const telTimer = setInterval(loadTelemetry, 3000)
1321
+ // interval cleaned by effect return below
1248
1322
 
1249
- return () => { alive = false }
1323
+ return () => { alive = false; clearInterval(telTimer) }
1250
1324
  }, [])
1251
1325
 
1252
1326
  if (!draft) return React.createElement('div', { className: 'dts-wrap' }, t('loading'))
@@ -1263,7 +1337,7 @@ window.__ModuleLoader__.load({
1263
1337
  headers: { 'Content-Type': 'application/json' },
1264
1338
  body: JSON.stringify({ provider: provider, value: value }),
1265
1339
  })
1266
- const data = await res.json().catch(() => ({}))
1340
+ const data = await res.json().catch(() => ({ /* parse error */ }))
1267
1341
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
1268
1342
  if (data && data.credentials) setCredentials(data.credentials)
1269
1343
  setKeyDrafts((d) => Object.assign({}, d, { [provider]: '' }))
@@ -1277,7 +1351,7 @@ window.__ModuleLoader__.load({
1277
1351
  headers: { 'Content-Type': 'application/json' },
1278
1352
  body: JSON.stringify({ provider: provider }),
1279
1353
  })
1280
- const data = await res.json().catch(() => ({}))
1354
+ const data = await res.json().catch(() => ({ /* parse error */ }))
1281
1355
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
1282
1356
  if (data && data.credentials) setCredentials(data.credentials)
1283
1357
  setKeyDrafts((d) => Object.assign({}, d, { [provider]: '' }))
@@ -1298,7 +1372,7 @@ window.__ModuleLoader__.load({
1298
1372
  headers: { 'Content-Type': 'application/json' },
1299
1373
  body: JSON.stringify(Object.assign({}, draft, { keys: keys })),
1300
1374
  })
1301
- const data = await res.json().catch(() => ({}))
1375
+ const data = await res.json().catch(() => ({ /* parse error */ }))
1302
1376
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
1303
1377
  applyPayload(data)
1304
1378
  setKeyDrafts({})
@@ -1317,6 +1391,9 @@ window.__ModuleLoader__.load({
1317
1391
  setSaved(true); setTimeout(() => setSaved(false), 2000)
1318
1392
  }
1319
1393
  const onClearCache = () => {
1394
+ if (typeof window !== 'undefined' && window.confirm) {
1395
+ if (!window.confirm(t('confirmClearCache'))) return
1396
+ }
1320
1397
  clearCacheNow().catch((e) => setErr(String(e && e.message ? e.message : e)))
1321
1398
  }
1322
1399
 
@@ -1328,7 +1405,7 @@ window.__ModuleLoader__.load({
1328
1405
  .then((r) => r.json())
1329
1406
  .then((d) => {
1330
1407
  if (!d.ok) throw new Error((d.error && d.error.message) || 'HTTP error')
1331
- new Audio('data:' + d.mime + ';base64,' + d.audioBase64).play().catch(() => {})
1408
+ new Audio('data:' + d.mime + ';base64,' + d.audioBase64).play().catch(() => { /* autoplay blocked */ })
1332
1409
  })
1333
1410
  .catch((e) => setErr(String(e && e.message ? e.message : e)))
1334
1411
  }
@@ -1363,16 +1440,31 @@ window.__ModuleLoader__.load({
1363
1440
  }),
1364
1441
  React.createElement('span', { className: 'dts-sub' }, hint))
1365
1442
 
1366
- const telemetryBadges = React.createElement('div', { className: 'dts-row', style: { marginBottom: '8px' } },
1443
+ const autoplayBanner = autoplay.blocked
1444
+ ? React.createElement('div', { className: 'dts-unlock' },
1445
+ React.createElement('span', null, t('autoplayBlocked')),
1446
+ React.createElement('button', {
1447
+ type: 'button', className: 'dts-unlock-btn',
1448
+ onClick: () => player.unlockAudio && player.unlockAudio(),
1449
+ }, t('enableSound')),
1450
+ )
1451
+ : null
1452
+ const breakerBadges = (telemetry.breaker || []).filter((b) => b && b.open).slice(0, 2)
1453
+ .map((b) => React.createElement('span', { key: b.id, className: 'dts-badge dts-badge-bad' }, t('breakerOpen') + ': ' + b.id))
1454
+ const telemetryBadges = React.createElement('div', { className: 'dts-row', style: { marginBottom: '8px' }, 'data-dsh-tts-telemetry': '1' },
1367
1455
  React.createElement('span', {
1368
1456
  className: 'dts-badge ' + (telemetry.online ? 'dts-badge-on' : 'dts-badge-bad'),
1369
- }, telemetry.online ? '🟢 ' + (t('statusOnline') || 'Host online') : '🔴 ' + (t('statusOffline') || 'Host offline')),
1370
- React.createElement('span', {
1371
- className: 'dts-badge dts-badge-on',
1372
- }, '⚡ SSE Active'),
1457
+ }, telemetry.online ? t('statusOnline') : t('statusOffline')),
1373
1458
  React.createElement('span', {
1374
- className: 'dts-badge',
1375
- }, '💾 ' + (t('cacheStats') || 'Cache') + ': ' + telemetry.cacheHits + ' / ' + telemetry.total),
1459
+ className: 'dts-badge ' + (telemetry.sseClients > 0 ? 'dts-badge-on' : ''),
1460
+ }, t('sseState') + ': ' + (telemetry.sseClients || 0)),
1461
+ React.createElement('span', { className: 'dts-badge' },
1462
+ t('queueLen') + ': ' + (telemetry.queue || player.queue.length || 0)),
1463
+ telemetry.provider ? React.createElement('span', { className: 'dts-badge' },
1464
+ t('activeProvider') + ': ' + telemetry.provider) : null,
1465
+ React.createElement('span', { className: 'dts-badge' },
1466
+ t('cacheStats') + ': ' + telemetry.cacheHits + ' / ' + telemetry.total),
1467
+ ...breakerBadges,
1376
1468
  )
1377
1469
  const titleLine = props && props.compact ? null : React.createElement('div', { className: 'dts-header' },
1378
1470
  React.createElement('div', { className: 'dts-page-title' }, '🔊 ' + t('title')),
@@ -1380,6 +1472,7 @@ window.__ModuleLoader__.load({
1380
1472
  telemetryBadges,
1381
1473
  )
1382
1474
  return React.createElement('div', { className: 'dts-wrap' },
1475
+ autoplayBanner,
1383
1476
  React.createElement('div', { className: 'dts-block' },
1384
1477
  titleLine,
1385
1478
  React.createElement('label', { className: 'dts-row' },
@@ -1433,6 +1526,7 @@ window.__ModuleLoader__.load({
1433
1526
  React.createElement('div', { className: 'dts-sub' },
1434
1527
  t('chainHintCard')),
1435
1528
  React.createElement(ChainEditor, {
1529
+ breaker: telemetry.breaker,
1436
1530
  t: t,
1437
1531
  value: draft && draft.chain ? draft.chain : [], writable: writable,
1438
1532
  credentials: credentials, keyDrafts: keyDrafts,
@@ -1482,16 +1576,33 @@ window.__ModuleLoader__.load({
1482
1576
  textField('azureRegion', t('azureRegion'), t('azureHint')),
1483
1577
  ),
1484
1578
  React.createElement('div', { className: 'dts-block' },
1485
- React.createElement('div', { className: 'dts-h' }, t('advancedTitle')),
1486
- React.createElement('div', { className: 'dts-sub' }, t('advancedHint')),
1487
- numberField('maxChars', t('maxChars'), t('maxCharsHint'), 100),
1488
- numberField('sentenceChars', t('sentenceChars'), t('sentenceCharsHint'), 20),
1489
- numberField('timeoutMs', t('timeoutMs'), t('timeoutMsHint'), 1000),
1490
- numberField('maxQueue', t('maxQueue'), t('maxQueueHint'), 1),
1491
- textField('openaiBaseUrl', t('openaiBaseUrl'), t('openaiBaseUrlHint')),
1492
- textField('mimoBaseUrl', t('mimoBaseUrl'), t('mimoBaseUrlHint')),
1493
- textField('mimoFormat', t('mimoFormat'), t('mimoFormatHint')),
1494
- textField('minimaxBin', t('minimaxBin'), t('minimaxBinHint')),
1579
+ React.createElement('button', {
1580
+ type: 'button',
1581
+ className: 'dts-head',
1582
+ 'aria-expanded': advancedOpen,
1583
+ onClick: () => {
1584
+ setAdvancedOpen((v) => {
1585
+ const next = !v
1586
+ try { localStorage.setItem('dsh-tts/advancedOpen', next ? '1' : '0') } catch { /* private mode */ }
1587
+ return next
1588
+ })
1589
+ },
1590
+ },
1591
+ React.createElement('div', { className: 'dts-headText' },
1592
+ React.createElement('div', { className: 'dts-title' }, t('advancedTitle')),
1593
+ React.createElement('div', { className: 'dts-sub' }, t('advancedHint')),
1594
+ ),
1595
+ ),
1596
+ advancedOpen ? React.createElement('div', { className: 'dts-body' },
1597
+ numberField('maxChars', t('maxChars'), t('maxCharsHint'), 100),
1598
+ numberField('sentenceChars', t('sentenceChars'), t('sentenceCharsHint'), 20),
1599
+ numberField('timeoutMs', t('timeoutMs'), t('timeoutMsHint'), 1000),
1600
+ numberField('maxQueue', t('maxQueue'), t('maxQueueHint'), 1),
1601
+ textField('openaiBaseUrl', t('openaiBaseUrl'), t('openaiBaseUrlHint')),
1602
+ textField('mimoBaseUrl', t('mimoBaseUrl'), t('mimoBaseUrlHint')),
1603
+ textField('mimoFormat', t('mimoFormat'), t('mimoFormatHint')),
1604
+ textField('minimaxBin', t('minimaxBin'), t('minimaxBinHint')),
1605
+ ) : null,
1495
1606
  ),
1496
1607
  React.createElement('div', { className: 'dts-foot' },
1497
1608
  React.createElement('button', { type: 'button', className: 'dts-link', disabled: !writable, onClick: onClearCache }, t('clearCache')),
@@ -1552,7 +1663,7 @@ window.__ModuleLoader__.load({
1552
1663
  React.createElement('button', {
1553
1664
  type: 'button', className: 'dts-link', style: { flex: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
1554
1665
  title: text,
1555
- onClick: () => playStandalone(text),
1666
+ onClick: () => playStandalone(text), title: text,
1556
1667
  }, text.slice(0, 80)),
1557
1668
  React.createElement('button', {
1558
1669
  type: 'button', className: 'dts-link', title: '★',
@@ -1561,7 +1672,7 @@ window.__ModuleLoader__.load({
1561
1672
  )
1562
1673
  return React.createElement('div', { style: { position: 'relative', display: 'inline-flex', gap: '4px', alignItems: 'center' } },
1563
1674
  React.createElement('button', {
1564
- type: 'button', className: 'dts-link', title: 'Недавние',
1675
+ type: 'button', className: 'dts-link', title: t('recent') || 'Recent',
1565
1676
  onClick: () => setShowList((v) => !v),
1566
1677
  }, '☰'),
1567
1678
  showList ? React.createElement('div', {
@@ -1572,7 +1683,7 @@ window.__ModuleLoader__.load({
1572
1683
  lists.favs.map((text) => row(text, true)),
1573
1684
  React.createElement('div', { className: 'dts-h' }, '⟳'),
1574
1685
  lists.items.filter((x) => !lists.favs.includes(x)).map((text) => row(text, false)),
1575
- !lists.items.length ? React.createElement('span', { className: 'dts-sub' }, '—') : null,
1686
+ !lists.items.length ? React.createElement('span', { className: 'dts-sub' }, t('recentEmpty') || 'No recent speech yet') : null,
1576
1687
  ) : null,
1577
1688
  React.createElement('button', {
1578
1689
  type: 'button',
@@ -1590,11 +1701,10 @@ window.__ModuleLoader__.load({
1590
1701
  }
1591
1702
 
1592
1703
  /**
1593
- * Запасной перевод дока.
1704
+ * Fallback translator for the dock.
1594
1705
  *
1595
- * Ставится при регистрации и живёт до конца работы страницы. Нужен
1596
- * потому, что подпись слота рисует ядро: `props.t` туда не доходит в
1597
- * принципе, и привязка — единственный способ дать ей язык.
1706
+ * Bound at registration for the page lifetime. The core draws the slot label;
1707
+ * props.t never reaches it, so the binder is the only way to give it a language.
1598
1708
  */
1599
1709
  let fallbackDockText = (key) => key
1600
1710
 
@@ -1629,7 +1739,7 @@ window.__ModuleLoader__.load({
1629
1739
  ctx.effect(() => {
1630
1740
  const undo = [addLocale('en', en), addLocale('ru', ru)]
1631
1741
  return () => { for (const off of undo) off() }
1632
- }, 'dsh-tts: словари')
1742
+ }, 'dsh-tts: locales')
1633
1743
  // The sidebar draws the section label, not our component: props.t never
1634
1744
  // reaches it, so bind the translator to the namespace ourselves.
1635
1745
  const t = ctx.locale.bind(NS)
@@ -1673,7 +1783,7 @@ window.__ModuleLoader__.load({
1673
1783
  exports.apply = function apply(ctx) {
1674
1784
  registerSettings(ctx)
1675
1785
  registerSpeaker(ctx)
1676
- ctx.effect(() => listenForVoice(), 'dsh-tts: замолкать, когда человек заговорил')
1786
+ ctx.effect(() => listenForVoice(), 'dsh-tts: barge-in on user speech')
1677
1787
  ctx.effect(() => {
1678
1788
  if (typeof window === 'undefined') return () => {}
1679
1789
  const onKey = (e) => {
@@ -1683,47 +1793,54 @@ window.__ModuleLoader__.load({
1683
1793
  }
1684
1794
  window.addEventListener('keydown', onKey)
1685
1795
  return () => window.removeEventListener('keydown', onKey)
1686
- }, 'dsh-tts: горячие клавиши')
1796
+ }, 'dsh-tts: hotkeys')
1687
1797
  ctx.effect(() => {
1688
1798
  if (typeof window === 'undefined' || typeof EventSource === 'undefined') return () => {}
1689
1799
  let es = null
1690
- try {
1691
- es = new EventSource('/dsh-tts/stream')
1692
- es.addEventListener('utterance', (e) => {
1693
- try {
1694
- const item = JSON.parse(e.data)
1695
- if (!item || !item.id) return
1696
- if (seenIds.has(item.id)) return
1697
- seenIds.add(item.id)
1698
- if (seenIds.size > 500) {
1699
- const first = seenIds.values().next().value
1700
- seenIds.delete(first)
1701
- }
1702
- player.queue.push(item)
1703
- drainQueue()
1704
- } catch {}
1705
- })
1706
- es.addEventListener('chime', (e) => {
1707
- try {
1708
- const item = JSON.parse(e.data)
1709
- if (!item || !item.id) return
1710
- if (seenIds.has(item.id)) return
1711
- seenIds.add(item.id)
1712
- if (seenIds.size > 500) {
1713
- const first = seenIds.values().next().value
1714
- seenIds.delete(first)
1715
- }
1716
- player.queue.push(item)
1717
- drainQueue()
1718
- } catch {}
1719
- })
1720
- } catch {}
1721
- return () => { if (es) es.close() }
1722
- }, 'dsh-tts: потоковое получение звука по SSE')
1800
+ let closed = false
1801
+ let attempt = 0
1802
+ let retryTimer = null
1803
+ const onItem = (e) => {
1804
+ try {
1805
+ const item = JSON.parse(e.data)
1806
+ if (!item || !item.id) return
1807
+ if (seenIds.has(item.id)) return
1808
+ seenIds.add(item.id)
1809
+ if (seenIds.size > 500) {
1810
+ const first = seenIds.values().next().value
1811
+ seenIds.delete(first)
1812
+ }
1813
+ player.queue.push(item)
1814
+ drainQueue()
1815
+ } catch { /* ignore malformed SSE frame */ }
1816
+ }
1817
+ const connect = () => {
1818
+ if (closed) return
1819
+ try {
1820
+ es = new EventSource('/dsh-tts/stream')
1821
+ es.addEventListener('open', () => { attempt = 0 })
1822
+ es.addEventListener('utterance', onItem)
1823
+ es.addEventListener('chime', onItem)
1824
+ es.onerror = () => {
1825
+ try { es.close() } catch { /* already closed */ }
1826
+ if (closed) return
1827
+ attempt += 1
1828
+ const delay = Math.min(15000, 500 * Math.pow(2, Math.min(attempt, 5)))
1829
+ retryTimer = setTimeout(connect, delay)
1830
+ }
1831
+ } catch { /* EventSource constructor failed — poll remains */ }
1832
+ }
1833
+ connect()
1834
+ return () => {
1835
+ closed = true
1836
+ if (retryTimer) clearTimeout(retryTimer)
1837
+ if (es) { try { es.close() } catch { /* already closed */ } }
1838
+ }
1839
+ }, 'dsh-tts: SSE audio stream')
1723
1840
  ctx.effect(() => {
1724
1841
  const timer = setInterval(pollPending, 1000)
1725
1842
  return () => clearInterval(timer)
1726
- }, 'dsh-tts: опрос готовых озвучек')
1843
+ }, 'dsh-tts: poll pending audio')
1727
1844
  }
1728
1845
  return module.exports
1729
1846
  },