@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.
@@ -7,14 +7,19 @@
7
7
  const [saved, setSaved] = React.useState(false)
8
8
  const [err, setErr] = React.useState('')
9
9
  const [integrations, setIntegrations] = React.useState({ voice: { installed: false }, messenger: { installed: false } })
10
- const [telemetry, setTelemetry] = React.useState({ online: true, cacheHits: 0, sseOk: true, total: 0 })
10
+ const [telemetry, setTelemetry] = React.useState({ online: true, cacheHits: 0, sseOk: true, total: 0, sseClients: 0, breaker: [], provider: '', queue: 0 })
11
+ const [advancedOpen, setAdvancedOpen] = React.useState(() => {
12
+ try { return localStorage.getItem('dsh-tts/advancedOpen') === '1' } catch { /* localStorage unavailable */ return false }
13
+ })
14
+ const autoplay = useAutoplayGate()
15
+
11
16
  const ctx = props && props.ctx
12
17
  const scope = React.useMemo(() => {
13
18
  const s = (ctx && ctx.get && ctx.get('lanSettings')) || (ctx && ctx.settingsScope)
14
19
  if (!s || !s.bind) return null
15
20
  try {
16
21
  return s.bind({ namespace: NS })
17
- } catch (_) {
22
+ } catch (_) { /* settings scope binding failed */
18
23
  return null
19
24
  }
20
25
  }, [ctx])
@@ -24,7 +29,7 @@
24
29
  if (!scope || !scope.subscribe) return () => {}
25
30
  try {
26
31
  return scope.subscribe(cb) || (() => {})
27
- } catch (_) {
32
+ } catch (_) { /* settings scope binding failed */
28
33
  return () => {}
29
34
  }
30
35
  }
@@ -34,7 +39,7 @@
34
39
  if (!scope || !scope.getSnapshot) return SNAPSHOT_LOADING
35
40
  try {
36
41
  return scope.getSnapshot() || SNAPSHOT_LOADING
37
- } catch (_) {
42
+ } catch (_) { /* settings scope binding failed */
38
43
  return SNAPSHOT_LOADING
39
44
  }
40
45
  }, [scope])
@@ -71,18 +76,33 @@
71
76
  if (data && data.ok) setIntegrations(data)
72
77
  }).catch(() => {})
73
78
 
74
- fetch('/dsh-tts/stats', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
75
- if (!alive || !st) return
76
- setTelemetry((prev) => Object.assign({}, prev, {
77
- cacheHits: st.cacheHits || 0,
78
- total: st.total || 0,
79
- online: true,
80
- }))
81
- }).catch(() => {
82
- if (alive) setTelemetry((prev) => Object.assign({}, prev, { online: false }))
83
- })
79
+ const loadTelemetry = () => {
80
+ fetch('/dsh-tts/stats', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
81
+ if (!alive || !st) return
82
+ setTelemetry((prev) => Object.assign({}, prev, {
83
+ cacheHits: st.cacheHits || 0,
84
+ total: st.total || 0,
85
+ online: true,
86
+ breaker: st.breaker || prev.breaker || [],
87
+ }))
88
+ }).catch(() => {
89
+ if (alive) setTelemetry((prev) => Object.assign({}, prev, { online: false }))
90
+ })
91
+ fetch('/dsh-tts/status', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
92
+ if (!alive || !st) return
93
+ setTelemetry((prev) => Object.assign({}, prev, {
94
+ sseClients: st.sseClients || 0,
95
+ sseOk: (st.sseClients || 0) >= 0,
96
+ provider: (player.audio && player.provider) || (player.queue[0] && player.queue[0].provider) || '',
97
+ queue: player.queue.length,
98
+ }))
99
+ }).catch(() => {})
100
+ }
101
+ loadTelemetry()
102
+ const telTimer = setInterval(loadTelemetry, 3000)
103
+ // interval cleaned by effect return below
84
104
 
85
- return () => { alive = false }
105
+ return () => { alive = false; clearInterval(telTimer) }
86
106
  }, [])
87
107
 
88
108
  if (!draft) return React.createElement('div', { className: 'dts-wrap' }, t('loading'))
@@ -99,7 +119,7 @@
99
119
  headers: { 'Content-Type': 'application/json' },
100
120
  body: JSON.stringify({ provider: provider, value: value }),
101
121
  })
102
- const data = await res.json().catch(() => ({}))
122
+ const data = await res.json().catch(() => ({ /* parse error */ }))
103
123
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
104
124
  if (data && data.credentials) setCredentials(data.credentials)
105
125
  setKeyDrafts((d) => Object.assign({}, d, { [provider]: '' }))
@@ -113,7 +133,7 @@
113
133
  headers: { 'Content-Type': 'application/json' },
114
134
  body: JSON.stringify({ provider: provider }),
115
135
  })
116
- const data = await res.json().catch(() => ({}))
136
+ const data = await res.json().catch(() => ({ /* parse error */ }))
117
137
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
118
138
  if (data && data.credentials) setCredentials(data.credentials)
119
139
  setKeyDrafts((d) => Object.assign({}, d, { [provider]: '' }))
@@ -134,7 +154,7 @@
134
154
  headers: { 'Content-Type': 'application/json' },
135
155
  body: JSON.stringify(Object.assign({}, draft, { keys: keys })),
136
156
  })
137
- const data = await res.json().catch(() => ({}))
157
+ const data = await res.json().catch(() => ({ /* parse error */ }))
138
158
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
139
159
  applyPayload(data)
140
160
  setKeyDrafts({})
@@ -153,6 +173,9 @@
153
173
  setSaved(true); setTimeout(() => setSaved(false), 2000)
154
174
  }
155
175
  const onClearCache = () => {
176
+ if (typeof window !== 'undefined' && window.confirm) {
177
+ if (!window.confirm(t('confirmClearCache'))) return
178
+ }
156
179
  clearCacheNow().catch((e) => setErr(String(e && e.message ? e.message : e)))
157
180
  }
158
181
 
@@ -164,7 +187,7 @@
164
187
  .then((r) => r.json())
165
188
  .then((d) => {
166
189
  if (!d.ok) throw new Error((d.error && d.error.message) || 'HTTP error')
167
- new Audio('data:' + d.mime + ';base64,' + d.audioBase64).play().catch(() => {})
190
+ new Audio('data:' + d.mime + ';base64,' + d.audioBase64).play().catch(() => { /* autoplay blocked */ })
168
191
  })
169
192
  .catch((e) => setErr(String(e && e.message ? e.message : e)))
170
193
  }
@@ -199,16 +222,31 @@
199
222
  }),
200
223
  React.createElement('span', { className: 'dts-sub' }, hint))
201
224
 
202
- const telemetryBadges = React.createElement('div', { className: 'dts-row', style: { marginBottom: '8px' } },
225
+ const autoplayBanner = autoplay.blocked
226
+ ? React.createElement('div', { className: 'dts-unlock' },
227
+ React.createElement('span', null, t('autoplayBlocked')),
228
+ React.createElement('button', {
229
+ type: 'button', className: 'dts-unlock-btn',
230
+ onClick: () => player.unlockAudio && player.unlockAudio(),
231
+ }, t('enableSound')),
232
+ )
233
+ : null
234
+ const breakerBadges = (telemetry.breaker || []).filter((b) => b && b.open).slice(0, 2)
235
+ .map((b) => React.createElement('span', { key: b.id, className: 'dts-badge dts-badge-bad' }, t('breakerOpen') + ': ' + b.id))
236
+ const telemetryBadges = React.createElement('div', { className: 'dts-row', style: { marginBottom: '8px' }, 'data-dsh-tts-telemetry': '1' },
203
237
  React.createElement('span', {
204
238
  className: 'dts-badge ' + (telemetry.online ? 'dts-badge-on' : 'dts-badge-bad'),
205
- }, telemetry.online ? '🟢 ' + (t('statusOnline') || 'Host online') : '🔴 ' + (t('statusOffline') || 'Host offline')),
206
- React.createElement('span', {
207
- className: 'dts-badge dts-badge-on',
208
- }, '⚡ SSE Active'),
239
+ }, telemetry.online ? t('statusOnline') : t('statusOffline')),
209
240
  React.createElement('span', {
210
- className: 'dts-badge',
211
- }, '💾 ' + (t('cacheStats') || 'Cache') + ': ' + telemetry.cacheHits + ' / ' + telemetry.total),
241
+ className: 'dts-badge ' + (telemetry.sseClients > 0 ? 'dts-badge-on' : ''),
242
+ }, t('sseState') + ': ' + (telemetry.sseClients || 0)),
243
+ React.createElement('span', { className: 'dts-badge' },
244
+ t('queueLen') + ': ' + (telemetry.queue || player.queue.length || 0)),
245
+ telemetry.provider ? React.createElement('span', { className: 'dts-badge' },
246
+ t('activeProvider') + ': ' + telemetry.provider) : null,
247
+ React.createElement('span', { className: 'dts-badge' },
248
+ t('cacheStats') + ': ' + telemetry.cacheHits + ' / ' + telemetry.total),
249
+ ...breakerBadges,
212
250
  )
213
251
  const titleLine = props && props.compact ? null : React.createElement('div', { className: 'dts-header' },
214
252
  React.createElement('div', { className: 'dts-page-title' }, '🔊 ' + t('title')),
@@ -216,6 +254,7 @@
216
254
  telemetryBadges,
217
255
  )
218
256
  return React.createElement('div', { className: 'dts-wrap' },
257
+ autoplayBanner,
219
258
  React.createElement('div', { className: 'dts-block' },
220
259
  titleLine,
221
260
  React.createElement('label', { className: 'dts-row' },
@@ -269,6 +308,7 @@
269
308
  React.createElement('div', { className: 'dts-sub' },
270
309
  t('chainHintCard')),
271
310
  React.createElement(ChainEditor, {
311
+ breaker: telemetry.breaker,
272
312
  t: t,
273
313
  value: draft && draft.chain ? draft.chain : [], writable: writable,
274
314
  credentials: credentials, keyDrafts: keyDrafts,
@@ -318,16 +358,33 @@
318
358
  textField('azureRegion', t('azureRegion'), t('azureHint')),
319
359
  ),
320
360
  React.createElement('div', { className: 'dts-block' },
321
- React.createElement('div', { className: 'dts-h' }, t('advancedTitle')),
322
- React.createElement('div', { className: 'dts-sub' }, t('advancedHint')),
323
- numberField('maxChars', t('maxChars'), t('maxCharsHint'), 100),
324
- numberField('sentenceChars', t('sentenceChars'), t('sentenceCharsHint'), 20),
325
- numberField('timeoutMs', t('timeoutMs'), t('timeoutMsHint'), 1000),
326
- numberField('maxQueue', t('maxQueue'), t('maxQueueHint'), 1),
327
- textField('openaiBaseUrl', t('openaiBaseUrl'), t('openaiBaseUrlHint')),
328
- textField('mimoBaseUrl', t('mimoBaseUrl'), t('mimoBaseUrlHint')),
329
- textField('mimoFormat', t('mimoFormat'), t('mimoFormatHint')),
330
- textField('minimaxBin', t('minimaxBin'), t('minimaxBinHint')),
361
+ React.createElement('button', {
362
+ type: 'button',
363
+ className: 'dts-head',
364
+ 'aria-expanded': advancedOpen,
365
+ onClick: () => {
366
+ setAdvancedOpen((v) => {
367
+ const next = !v
368
+ try { localStorage.setItem('dsh-tts/advancedOpen', next ? '1' : '0') } catch { /* private mode */ }
369
+ return next
370
+ })
371
+ },
372
+ },
373
+ React.createElement('div', { className: 'dts-headText' },
374
+ React.createElement('div', { className: 'dts-title' }, t('advancedTitle')),
375
+ React.createElement('div', { className: 'dts-sub' }, t('advancedHint')),
376
+ ),
377
+ ),
378
+ advancedOpen ? React.createElement('div', { className: 'dts-body' },
379
+ numberField('maxChars', t('maxChars'), t('maxCharsHint'), 100),
380
+ numberField('sentenceChars', t('sentenceChars'), t('sentenceCharsHint'), 20),
381
+ numberField('timeoutMs', t('timeoutMs'), t('timeoutMsHint'), 1000),
382
+ numberField('maxQueue', t('maxQueue'), t('maxQueueHint'), 1),
383
+ textField('openaiBaseUrl', t('openaiBaseUrl'), t('openaiBaseUrlHint')),
384
+ textField('mimoBaseUrl', t('mimoBaseUrl'), t('mimoBaseUrlHint')),
385
+ textField('mimoFormat', t('mimoFormat'), t('mimoFormatHint')),
386
+ textField('minimaxBin', t('minimaxBin'), t('minimaxBinHint')),
387
+ ) : null,
331
388
  ),
332
389
  React.createElement('div', { className: 'dts-foot' },
333
390
  React.createElement('button', { type: 'button', className: 'dts-link', disabled: !writable, onClick: onClearCache }, t('clearCache')),
@@ -47,7 +47,7 @@
47
47
  React.createElement('button', {
48
48
  type: 'button', className: 'dts-link', style: { flex: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
49
49
  title: text,
50
- onClick: () => playStandalone(text),
50
+ onClick: () => playStandalone(text), title: text,
51
51
  }, text.slice(0, 80)),
52
52
  React.createElement('button', {
53
53
  type: 'button', className: 'dts-link', title: '★',
@@ -56,7 +56,7 @@
56
56
  )
57
57
  return React.createElement('div', { style: { position: 'relative', display: 'inline-flex', gap: '4px', alignItems: 'center' } },
58
58
  React.createElement('button', {
59
- type: 'button', className: 'dts-link', title: 'Недавние',
59
+ type: 'button', className: 'dts-link', title: t('recent') || 'Recent',
60
60
  onClick: () => setShowList((v) => !v),
61
61
  }, '☰'),
62
62
  showList ? React.createElement('div', {
@@ -67,7 +67,7 @@
67
67
  lists.favs.map((text) => row(text, true)),
68
68
  React.createElement('div', { className: 'dts-h' }, '⟳'),
69
69
  lists.items.filter((x) => !lists.favs.includes(x)).map((text) => row(text, false)),
70
- !lists.items.length ? React.createElement('span', { className: 'dts-sub' }, '—') : null,
70
+ !lists.items.length ? React.createElement('span', { className: 'dts-sub' }, t('recentEmpty') || 'No recent speech yet') : null,
71
71
  ) : null,
72
72
  React.createElement('button', {
73
73
  type: 'button',
@@ -85,11 +85,10 @@
85
85
  }
86
86
 
87
87
  /**
88
- * Запасной перевод дока.
88
+ * Fallback translator for the dock.
89
89
  *
90
- * Ставится при регистрации и живёт до конца работы страницы. Нужен
91
- * потому, что подпись слота рисует ядро: `props.t` туда не доходит в
92
- * принципе, и привязка — единственный способ дать ей язык.
90
+ * Bound at registration for the page lifetime. The core draws the slot label;
91
+ * props.t never reaches it, so the binder is the only way to give it a language.
93
92
  */
94
93
  let fallbackDockText = (key) => key
95
94
 
@@ -124,7 +123,7 @@
124
123
  ctx.effect(() => {
125
124
  const undo = [addLocale('en', en), addLocale('ru', ru)]
126
125
  return () => { for (const off of undo) off() }
127
- }, 'dsh-tts: словари')
126
+ }, 'dsh-tts: locales')
128
127
  // The sidebar draws the section label, not our component: props.t never
129
128
  // reaches it, so bind the translator to the namespace ourselves.
130
129
  const t = ctx.locale.bind(NS)
@@ -168,7 +167,7 @@
168
167
  exports.apply = function apply(ctx) {
169
168
  registerSettings(ctx)
170
169
  registerSpeaker(ctx)
171
- ctx.effect(() => listenForVoice(), 'dsh-tts: замолкать, когда человек заговорил')
170
+ ctx.effect(() => listenForVoice(), 'dsh-tts: barge-in on user speech')
172
171
  ctx.effect(() => {
173
172
  if (typeof window === 'undefined') return () => {}
174
173
  const onKey = (e) => {
@@ -178,47 +177,54 @@
178
177
  }
179
178
  window.addEventListener('keydown', onKey)
180
179
  return () => window.removeEventListener('keydown', onKey)
181
- }, 'dsh-tts: горячие клавиши')
180
+ }, 'dsh-tts: hotkeys')
182
181
  ctx.effect(() => {
183
182
  if (typeof window === 'undefined' || typeof EventSource === 'undefined') return () => {}
184
183
  let es = null
185
- try {
186
- es = new EventSource('/dsh-tts/stream')
187
- es.addEventListener('utterance', (e) => {
188
- try {
189
- const item = JSON.parse(e.data)
190
- if (!item || !item.id) return
191
- if (seenIds.has(item.id)) return
192
- seenIds.add(item.id)
193
- if (seenIds.size > 500) {
194
- const first = seenIds.values().next().value
195
- seenIds.delete(first)
196
- }
197
- player.queue.push(item)
198
- drainQueue()
199
- } catch {}
200
- })
201
- es.addEventListener('chime', (e) => {
202
- try {
203
- const item = JSON.parse(e.data)
204
- if (!item || !item.id) return
205
- if (seenIds.has(item.id)) return
206
- seenIds.add(item.id)
207
- if (seenIds.size > 500) {
208
- const first = seenIds.values().next().value
209
- seenIds.delete(first)
210
- }
211
- player.queue.push(item)
212
- drainQueue()
213
- } catch {}
214
- })
215
- } catch {}
216
- return () => { if (es) es.close() }
217
- }, 'dsh-tts: потоковое получение звука по SSE')
184
+ let closed = false
185
+ let attempt = 0
186
+ let retryTimer = null
187
+ const onItem = (e) => {
188
+ try {
189
+ const item = JSON.parse(e.data)
190
+ if (!item || !item.id) return
191
+ if (seenIds.has(item.id)) return
192
+ seenIds.add(item.id)
193
+ if (seenIds.size > 500) {
194
+ const first = seenIds.values().next().value
195
+ seenIds.delete(first)
196
+ }
197
+ player.queue.push(item)
198
+ drainQueue()
199
+ } catch { /* ignore malformed SSE frame */ }
200
+ }
201
+ const connect = () => {
202
+ if (closed) return
203
+ try {
204
+ es = new EventSource('/dsh-tts/stream')
205
+ es.addEventListener('open', () => { attempt = 0 })
206
+ es.addEventListener('utterance', onItem)
207
+ es.addEventListener('chime', onItem)
208
+ es.onerror = () => {
209
+ try { es.close() } catch { /* already closed */ }
210
+ if (closed) return
211
+ attempt += 1
212
+ const delay = Math.min(15000, 500 * Math.pow(2, Math.min(attempt, 5)))
213
+ retryTimer = setTimeout(connect, delay)
214
+ }
215
+ } catch { /* EventSource constructor failed — poll remains */ }
216
+ }
217
+ connect()
218
+ return () => {
219
+ closed = true
220
+ if (retryTimer) clearTimeout(retryTimer)
221
+ if (es) { try { es.close() } catch { /* already closed */ } }
222
+ }
223
+ }, 'dsh-tts: SSE audio stream')
218
224
  ctx.effect(() => {
219
225
  const timer = setInterval(pollPending, 1000)
220
226
  return () => clearInterval(timer)
221
- }, 'dsh-tts: опрос готовых озвучек')
227
+ }, 'dsh-tts: poll pending audio')
222
228
  }
223
229
  return module.exports
224
230
  },