@goodandready/dsh-tts 0.4.8 → 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 +44 -1
- package/lib/client-src/20-player.js +64 -1
- package/lib/client-src/30-locales.js +3 -0
- package/lib/client-src/40-editors.js +57 -0
- package/lib/client.js +124 -1
- package/lib/index.js +5 -22
- package/lib/routes.js +4 -1
- package/lib/text.js +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
554
|
-
const
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
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
|
|
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
|
|
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.
|
|
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",
|