@goodandready/dsh-tts 0.4.7 → 0.4.9

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/cache.js CHANGED
@@ -19,6 +19,28 @@ export function createSpeechCache({ root, maxBytes }) {
19
19
  return ready
20
20
  }
21
21
 
22
+ // L1 In-Memory RAM Cache (max 50 entries or 10MB total buffer size)
23
+ const l1Map = new Map()
24
+ const L1_MAX_ITEMS = 50
25
+ const L1_MAX_BYTES = 10 * 1024 * 1024
26
+
27
+ function putL1(key, mime, audio) {
28
+ const buf = Buffer.isBuffer(audio) ? audio : Buffer.from(audio)
29
+ l1Map.delete(key) // reset insertion order
30
+ l1Map.set(key, { mime, audio: buf, at: Date.now(), size: buf.length })
31
+
32
+ let totalSize = 0
33
+ for (const item of l1Map.values()) totalSize += item.size
34
+
35
+ while (l1Map.size > L1_MAX_ITEMS || totalSize > L1_MAX_BYTES) {
36
+ const oldestKey = l1Map.keys().next().value
37
+ if (!oldestKey) break
38
+ const oldest = l1Map.get(oldestKey)
39
+ if (oldest) totalSize -= oldest.size
40
+ l1Map.delete(oldestKey)
41
+ }
42
+ }
43
+
22
44
  async function listEntries() {
23
45
  await ensure()
24
46
  const out = []
@@ -44,12 +66,19 @@ export function createSpeechCache({ root, maxBytes }) {
44
66
  async function evict() {
45
67
  const limit = typeof maxBytes === 'function' ? maxBytes() : Number(maxBytes)
46
68
  if (!(limit > 0)) return
47
- const entries = (await listEntries()).sort((a, b) => a.at - b.at)
69
+ const rawEntries = await listEntries()
70
+ const entries = rawEntries.map((e) => {
71
+ const k = path.basename(e.full, AUDIO_EXT)
72
+ const l1 = l1Map.get(k)
73
+ return { ...e, key: k, at: (l1 && typeof l1.at === 'number') ? l1.at : e.at }
74
+ }).sort((a, b) => a.at - b.at)
75
+
48
76
  let total = entries.reduce((acc, e) => acc + e.size, 0)
49
77
  for (const e of entries) {
50
78
  if (total <= limit) break
51
79
  await fs.promises.rm(e.full, { force: true })
52
80
  await fs.promises.rm(path.join(dir, e.meta), { force: true }).catch(() => {})
81
+ l1Map.delete(e.key)
53
82
  total -= e.size
54
83
  }
55
84
  }
@@ -58,15 +87,28 @@ export function createSpeechCache({ root, maxBytes }) {
58
87
  async put(key, mime, audio) {
59
88
  await ensure()
60
89
  const buf = Buffer.isBuffer(audio) ? audio : Buffer.from(audio)
90
+ putL1(key, mime, buf)
61
91
  await fs.promises.writeFile(path.join(dir, key + AUDIO_EXT), buf)
62
92
  await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime, at: Date.now() }))
63
93
  await evict()
64
94
  },
65
95
 
66
96
  async get(key) {
97
+ // 1. L1 RAM Hit
98
+ if (l1Map.has(key)) {
99
+ const hit = l1Map.get(key)
100
+ const now = Date.now()
101
+ l1Map.delete(key) // refresh LRU order
102
+ l1Map.set(key, { ...hit, at: now })
103
+ fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: hit.mime, at: now })).catch(() => {})
104
+ return { mime: hit.mime || 'audio/mpeg', audio: hit.audio }
105
+ }
106
+
107
+ // 2. L2 Disk Hit
67
108
  try {
68
109
  const meta = JSON.parse(await fs.promises.readFile(path.join(dir, key + '.json'), 'utf8'))
69
110
  const audio = await fs.promises.readFile(path.join(dir, key + AUDIO_EXT))
111
+ putL1(key, meta.mime, audio)
70
112
  // Touch LRU asynchronously so reads are never blocked.
71
113
  fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: meta.mime, at: Date.now() })).catch(() => {})
72
114
  return { mime: meta.mime || 'audio/mpeg', audio }
@@ -76,6 +118,7 @@ export function createSpeechCache({ root, maxBytes }) {
76
118
  },
77
119
 
78
120
  async clear() {
121
+ l1Map.clear()
79
122
  const entries = await listEntries()
80
123
  for (const e of entries) {
81
124
  await fs.promises.rm(e.full, { force: true })
@@ -248,7 +248,70 @@
248
248
  return () => window.removeEventListener('dsh-voice:speaking', onSpeaking)
249
249
  }
250
250
 
251
- async function pollPending() {
251
+ // SSE Realtime Stream Connection with Exponential Backoff & Jitter
252
+ let sseAttempt = 0
253
+ let sseTimer = null
254
+ let sseSource = null
255
+
256
+ function connectSseStream() {
257
+ if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return
258
+ if (sseSource) {
259
+ try { sseSource.close() } catch {}
260
+ sseSource = null
261
+ }
262
+ try {
263
+ sseSource = new EventSource('/dsh-tts/stream')
264
+ sseSource.onopen = () => {
265
+ sseAttempt = 0
266
+ }
267
+ sseSource.addEventListener('utterance', (e) => {
268
+ sseAttempt = 0
269
+ try {
270
+ const item = JSON.parse(e.data)
271
+ if (item && item.id) {
272
+ player.after = item.id
273
+ if (!seenIds.has(item.id)) {
274
+ seenIds.add(item.id)
275
+ if (seenIds.size > 500) {
276
+ const first = seenIds.values().next().value
277
+ seenIds.delete(first)
278
+ }
279
+ if (item.text) recent.add(item.text)
280
+ player.queue.push(item)
281
+ drainQueue()
282
+ }
283
+ }
284
+ } catch {}
285
+ })
286
+ sseSource.addEventListener('chime', (e) => {
287
+ sseAttempt = 0
288
+ try {
289
+ const item = JSON.parse(e.data)
290
+ if (item) {
291
+ player.queue.push(item)
292
+ drainQueue()
293
+ }
294
+ } catch {}
295
+ })
296
+ sseSource.onerror = () => {
297
+ if (sseSource) {
298
+ try { sseSource.close() } catch {}
299
+ sseSource = null
300
+ }
301
+ sseAttempt++
302
+ const baseDelay = Math.min(10000, 500 * Math.pow(2, sseAttempt - 1))
303
+ const jitter = baseDelay * (Math.random() * 0.6 - 0.3)
304
+ const delay = Math.max(200, Math.floor(baseDelay + jitter))
305
+ if (sseTimer) clearTimeout(sseTimer)
306
+ sseTimer = setTimeout(() => {
307
+ connectSseStream()
308
+ }, delay)
309
+ }
310
+ } catch {}
311
+ }
312
+
313
+ async function pollPending() {
314
+ connectSseStream()
252
315
  try {
253
316
  const st = await fetch('/dsh-tts/status', { cache: 'no-store' })
254
317
  if (!st.ok) return
@@ -361,6 +361,9 @@
361
361
  'enableItDictionary': '内置 IT 术语发音校正',
362
362
  'enableItDictionaryHint': '自动纠正 SQL、Nginx、K8s、Docker、API、JSON、YAML 等专有名词发音。',
363
363
  'loadItDictionary': '填入常用 IT 术语',
364
+ 'exportJson': '📥 导出 JSON',
365
+ 'importJson': '📤 导入 JSON',
366
+ 'invalidJsonFormat': '无效的字典 JSON 格式',
364
367
  'previewRule': '试听发音',
365
368
  'messengerIntegrationTitle': '即时通讯联动 (Telegram, Discord)',
366
369
  'messengerIntegrationHint': '通过 @goodandready/dsh-messenger-gateway 向聊天群组发送语音回复。',
@@ -164,6 +164,8 @@
164
164
  function PronEditor(props) {
165
165
  const t = props.t || ((key) => key)
166
166
  const rows = Array.isArray(props.value) ? props.value : []
167
+ const fileInputRef = React.useRef(null)
168
+
167
169
  const change = (i, patch) => {
168
170
  props.onChange(rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r)))
169
171
  }
@@ -175,7 +177,60 @@
175
177
  props.onChange(rows.concat(toAdd))
176
178
  }
177
179
 
180
+ const exportJson = () => {
181
+ const data = JSON.stringify(rows, null, 2)
182
+ const blob = new Blob([data], { type: 'application/json' })
183
+ const url = URL.createObjectURL(blob)
184
+ const a = document.createElement('a')
185
+ a.href = url
186
+ a.download = 'pronunciation-dictionary.json'
187
+ document.body.appendChild(a)
188
+ a.click()
189
+ document.body.removeChild(a)
190
+ setTimeout(() => { try { URL.revokeObjectURL(url) } catch {} }, 1000)
191
+ }
192
+
193
+ const triggerImport = () => {
194
+ if (fileInputRef.current) fileInputRef.current.click()
195
+ }
196
+
197
+ const handleImportFile = (e) => {
198
+ const file = e.target.files && e.target.files[0]
199
+ if (!file) return
200
+ const reader = new FileReader()
201
+ reader.onload = (event) => {
202
+ try {
203
+ const parsed = JSON.parse(event.target.result)
204
+ if (!Array.isArray(parsed)) {
205
+ if (typeof window !== 'undefined' && window.alert) window.alert(t('invalidJsonFormat'))
206
+ return
207
+ }
208
+ const importedRules = parsed.map((item) => ({
209
+ from: String(item.from || ''),
210
+ to: String(item.to || ''),
211
+ whole: !!item.whole,
212
+ lang: String(item.lang || ''),
213
+ })).filter((r) => r.from || r.to)
214
+
215
+ const existingKeys = new Set(rows.map((r) => String(r.from).toLowerCase()))
216
+ const newItems = importedRules.filter((r) => !existingKeys.has(String(r.from).toLowerCase()))
217
+ props.onChange(rows.concat(newItems))
218
+ } catch (err) {
219
+ if (typeof window !== 'undefined' && window.alert) window.alert(t('invalidJsonFormat'))
220
+ }
221
+ e.target.value = ''
222
+ }
223
+ reader.readAsText(file)
224
+ }
225
+
178
226
  return React.createElement('div', { className: 'dts-wrap-in' },
227
+ React.createElement('input', {
228
+ type: 'file',
229
+ ref: fileInputRef,
230
+ accept: '.json',
231
+ style: { display: 'none' },
232
+ onChange: handleImportFile,
233
+ }),
179
234
  rows.map((rule, i) => React.createElement('div', { className: 'dts-row', key: i },
180
235
  React.createElement('input', {
181
236
  className: 'dts-input dts-grow', placeholder: t('pronFrom'),
@@ -209,6 +264,8 @@
209
264
  React.createElement('div', { className: 'dts-row' },
210
265
  React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: add }, '+'),
211
266
  React.createElement('button', { type: 'button', className: 'dts-link', disabled: !props.writable, onClick: loadIt }, t('loadItDictionary')),
267
+ React.createElement('button', { type: 'button', className: 'dts-link', disabled: !props.writable, onClick: exportJson }, t('exportJson')),
268
+ React.createElement('button', { type: 'button', className: 'dts-link', disabled: !props.writable, onClick: triggerImport }, t('importJson')),
212
269
  React.createElement('span', { className: 'dts-sub' }, t('pronHint')),
213
270
  ),
214
271
  )
package/lib/client.js CHANGED
@@ -387,7 +387,70 @@ window.__ModuleLoader__.load({
387
387
  return () => window.removeEventListener('dsh-voice:speaking', onSpeaking)
388
388
  }
389
389
 
390
- async function pollPending() {
390
+ // SSE Realtime Stream Connection with Exponential Backoff & Jitter
391
+ let sseAttempt = 0
392
+ let sseTimer = null
393
+ let sseSource = null
394
+
395
+ function connectSseStream() {
396
+ if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return
397
+ if (sseSource) {
398
+ try { sseSource.close() } catch {}
399
+ sseSource = null
400
+ }
401
+ try {
402
+ sseSource = new EventSource('/dsh-tts/stream')
403
+ sseSource.onopen = () => {
404
+ sseAttempt = 0
405
+ }
406
+ sseSource.addEventListener('utterance', (e) => {
407
+ sseAttempt = 0
408
+ try {
409
+ const item = JSON.parse(e.data)
410
+ if (item && item.id) {
411
+ player.after = item.id
412
+ if (!seenIds.has(item.id)) {
413
+ seenIds.add(item.id)
414
+ if (seenIds.size > 500) {
415
+ const first = seenIds.values().next().value
416
+ seenIds.delete(first)
417
+ }
418
+ if (item.text) recent.add(item.text)
419
+ player.queue.push(item)
420
+ drainQueue()
421
+ }
422
+ }
423
+ } catch {}
424
+ })
425
+ sseSource.addEventListener('chime', (e) => {
426
+ sseAttempt = 0
427
+ try {
428
+ const item = JSON.parse(e.data)
429
+ if (item) {
430
+ player.queue.push(item)
431
+ drainQueue()
432
+ }
433
+ } catch {}
434
+ })
435
+ sseSource.onerror = () => {
436
+ if (sseSource) {
437
+ try { sseSource.close() } catch {}
438
+ sseSource = null
439
+ }
440
+ sseAttempt++
441
+ const baseDelay = Math.min(10000, 500 * Math.pow(2, sseAttempt - 1))
442
+ const jitter = baseDelay * (Math.random() * 0.6 - 0.3)
443
+ const delay = Math.max(200, Math.floor(baseDelay + jitter))
444
+ if (sseTimer) clearTimeout(sseTimer)
445
+ sseTimer = setTimeout(() => {
446
+ connectSseStream()
447
+ }, delay)
448
+ }
449
+ } catch {}
450
+ }
451
+
452
+ async function pollPending() {
453
+ connectSseStream()
391
454
  try {
392
455
  const st = await fetch('/dsh-tts/status', { cache: 'no-store' })
393
456
  if (!st.ok) return
@@ -861,6 +924,9 @@ window.__ModuleLoader__.load({
861
924
  'enableItDictionary': '内置 IT 术语发音校正',
862
925
  'enableItDictionaryHint': '自动纠正 SQL、Nginx、K8s、Docker、API、JSON、YAML 等专有名词发音。',
863
926
  'loadItDictionary': '填入常用 IT 术语',
927
+ 'exportJson': '📥 导出 JSON',
928
+ 'importJson': '📤 导入 JSON',
929
+ 'invalidJsonFormat': '无效的字典 JSON 格式',
864
930
  'previewRule': '试听发音',
865
931
  'messengerIntegrationTitle': '即时通讯联动 (Telegram, Discord)',
866
932
  'messengerIntegrationHint': '通过 @goodandready/dsh-messenger-gateway 向聊天群组发送语音回复。',
@@ -1035,6 +1101,8 @@ window.__ModuleLoader__.load({
1035
1101
  function PronEditor(props) {
1036
1102
  const t = props.t || ((key) => key)
1037
1103
  const rows = Array.isArray(props.value) ? props.value : []
1104
+ const fileInputRef = React.useRef(null)
1105
+
1038
1106
  const change = (i, patch) => {
1039
1107
  props.onChange(rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r)))
1040
1108
  }
@@ -1046,7 +1114,60 @@ window.__ModuleLoader__.load({
1046
1114
  props.onChange(rows.concat(toAdd))
1047
1115
  }
1048
1116
 
1117
+ const exportJson = () => {
1118
+ const data = JSON.stringify(rows, null, 2)
1119
+ const blob = new Blob([data], { type: 'application/json' })
1120
+ const url = URL.createObjectURL(blob)
1121
+ const a = document.createElement('a')
1122
+ a.href = url
1123
+ a.download = 'pronunciation-dictionary.json'
1124
+ document.body.appendChild(a)
1125
+ a.click()
1126
+ document.body.removeChild(a)
1127
+ setTimeout(() => { try { URL.revokeObjectURL(url) } catch {} }, 1000)
1128
+ }
1129
+
1130
+ const triggerImport = () => {
1131
+ if (fileInputRef.current) fileInputRef.current.click()
1132
+ }
1133
+
1134
+ const handleImportFile = (e) => {
1135
+ const file = e.target.files && e.target.files[0]
1136
+ if (!file) return
1137
+ const reader = new FileReader()
1138
+ reader.onload = (event) => {
1139
+ try {
1140
+ const parsed = JSON.parse(event.target.result)
1141
+ if (!Array.isArray(parsed)) {
1142
+ if (typeof window !== 'undefined' && window.alert) window.alert(t('invalidJsonFormat'))
1143
+ return
1144
+ }
1145
+ const importedRules = parsed.map((item) => ({
1146
+ from: String(item.from || ''),
1147
+ to: String(item.to || ''),
1148
+ whole: !!item.whole,
1149
+ lang: String(item.lang || ''),
1150
+ })).filter((r) => r.from || r.to)
1151
+
1152
+ const existingKeys = new Set(rows.map((r) => String(r.from).toLowerCase()))
1153
+ const newItems = importedRules.filter((r) => !existingKeys.has(String(r.from).toLowerCase()))
1154
+ props.onChange(rows.concat(newItems))
1155
+ } catch (err) {
1156
+ if (typeof window !== 'undefined' && window.alert) window.alert(t('invalidJsonFormat'))
1157
+ }
1158
+ e.target.value = ''
1159
+ }
1160
+ reader.readAsText(file)
1161
+ }
1162
+
1049
1163
  return React.createElement('div', { className: 'dts-wrap-in' },
1164
+ React.createElement('input', {
1165
+ type: 'file',
1166
+ ref: fileInputRef,
1167
+ accept: '.json',
1168
+ style: { display: 'none' },
1169
+ onChange: handleImportFile,
1170
+ }),
1050
1171
  rows.map((rule, i) => React.createElement('div', { className: 'dts-row', key: i },
1051
1172
  React.createElement('input', {
1052
1173
  className: 'dts-input dts-grow', placeholder: t('pronFrom'),
@@ -1080,6 +1201,8 @@ window.__ModuleLoader__.load({
1080
1201
  React.createElement('div', { className: 'dts-row' },
1081
1202
  React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: add }, '+'),
1082
1203
  React.createElement('button', { type: 'button', className: 'dts-link', disabled: !props.writable, onClick: loadIt }, t('loadItDictionary')),
1204
+ React.createElement('button', { type: 'button', className: 'dts-link', disabled: !props.writable, onClick: exportJson }, t('exportJson')),
1205
+ React.createElement('button', { type: 'button', className: 'dts-link', disabled: !props.writable, onClick: triggerImport }, t('importJson')),
1083
1206
  React.createElement('span', { className: 'dts-sub' }, t('pronHint')),
1084
1207
  ),
1085
1208
  )
package/lib/index.js CHANGED
@@ -550,28 +550,11 @@ export function apply(ctx, config) {
550
550
  const raw = cur && Array.isArray(cur.parts) ? cur.parts.join('\n') : ''
551
551
  const text = cleanText(raw, cfg)
552
552
  if (!text) return
553
- const id = `u${++utteranceSeq}`
554
- const controller = new AbortController()
555
- const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
556
- synthesize(text, cfg, controller.signal, (cur && cur.role) || 'reply')
557
- .then((out) => {
558
- enqueue({
559
- id,
560
- sessionId: sid,
561
- provider: out.provider,
562
- mime: out.mime,
563
- audioBase64: Buffer.from(out.audio).toString('base64'),
564
- tookMs: out.tookMs,
565
- })
566
- })
567
- .catch((err) => {
568
- enqueue({
569
- id,
570
- sessionId: sid,
571
- error: String(err && err.message || err),
572
- })
573
- })
574
- .finally(() => clearTimeout(timer))
553
+ const pieces = splitSentences(text, cfg.sentenceChars)
554
+ const activeRole = (cur && cur.role) || 'reply'
555
+ for (const piece of pieces) {
556
+ speakPiece(sid, piece, cfg, activeRole)
557
+ }
575
558
  }), 'dsh-tts: speak finished replies')
576
559
 
577
560
 
package/lib/routes.js CHANGED
@@ -226,7 +226,10 @@ export function registerHttpRoutes(ctx, api) {
226
226
  return
227
227
  }
228
228
  const cfg = live()
229
- const phrase = String(cfg.language || '').toLowerCase().startsWith('ru') ? 'Проверка голоса.' : 'Voice check.'
229
+ const lang = String(cfg.language || '').toLowerCase()
230
+ let phrase = 'Voice check.'
231
+ if (lang.startsWith('zh')) phrase = '语音合成测试。'
232
+ else if (lang.startsWith('ru')) phrase = 'Проверка голоса.'
230
233
  try {
231
234
  const probe = Object.assign({}, cfg, {
232
235
  chain: [{ provider: body.provider || '', model: body.model || '', voice: body.voice || '' }],
package/lib/text.js CHANGED
@@ -170,7 +170,7 @@ export function protectAbbreviations(text) {
170
170
  const rx = new RegExp('(?<=^|[\\s"«(])' + escaped + '(?=[\\s"»)\\]]|$)', 'gi')
171
171
  res = res.replace(rx, (m) => m.replace(/\./g, '\u2024'))
172
172
  }
173
- // Decimal numbers and versions: e.g. 3.14, v0.4.6, 192.168.1.111
173
+ // Decimal numbers and versions: e.g. 3.14, v0.4.6, 192.168.0.1
174
174
  res = res.replace(/(?<=[vV]?\d+)\.(?=\d+)/g, '\u2024')
175
175
  // List numbering at line/word starts: 1. item, 2. item
176
176
  res = res.replace(/(?<=^|[\s\n])(\d+)\.(?=\s+[A-Za-zА-Яа-я\u4E00-\u9FFF])/g, '$1\u2024')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "Text-to-speech for DeepSeek Harness: speak agent replies in the Web UI with a provider fallback chain (OpenAI, ElevenLabs, Google, Azure, Groq, Deepgram, OpenRouter, Edge, Piper, eSpeak).",
5
5
  "license": "MIT",
6
6
  "type": "module",