@goodandready/dsh-tts 0.3.6 → 0.3.7
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 +83 -0
- package/lib/client.js +24 -0
- package/lib/index.js +52 -1
- package/package.json +1 -1
package/lib/cache.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Кэш синтеза на диске: ключ — хеш (текст, провайдер, модель, голос),
|
|
2
|
+
// значение — ответ провайдера как есть (байты + MIME). Вытеснение самых
|
|
3
|
+
// давних по времени последнего обращения; ограничение задаётся настройкой
|
|
4
|
+
// cacheMaxMb и может меняться на живую, поэтому приходит функцией.
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
export function cacheKey(parts) {
|
|
10
|
+
return createHash('sha1').update(JSON.stringify(parts)).digest('hex')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const AUDIO_EXT = '.audio'
|
|
14
|
+
|
|
15
|
+
export function createSpeechCache({ root, maxBytes }) {
|
|
16
|
+
const dir = path.join(String(root), 'data', 'dsh-tts', 'cache')
|
|
17
|
+
let ready = null
|
|
18
|
+
const ensure = () => {
|
|
19
|
+
if (!ready) ready = fs.promises.mkdir(dir, { recursive: true })
|
|
20
|
+
return ready
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function listEntries() {
|
|
24
|
+
await ensure()
|
|
25
|
+
const out = []
|
|
26
|
+
for (const name of await fs.promises.readdir(dir)) {
|
|
27
|
+
if (!name.endsWith(AUDIO_EXT)) continue
|
|
28
|
+
const full = path.join(dir, name)
|
|
29
|
+
const st = await fs.promises.stat(full)
|
|
30
|
+
let at = st.mtimeMs
|
|
31
|
+
try {
|
|
32
|
+
const meta = JSON.parse(await fs.promises.readFile(path.join(dir, name.slice(0, -AUDIO_EXT.length) + '.json'), 'utf8'))
|
|
33
|
+
if (meta && typeof meta.at === 'number') at = meta.at
|
|
34
|
+
} catch (noMeta) { /* старьё без меты — вытесним первым */ }
|
|
35
|
+
out.push({ full, meta: name.slice(0, -AUDIO_EXT.length) + '.json', size: st.size, at })
|
|
36
|
+
}
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function evict() {
|
|
41
|
+
const limit = typeof maxBytes === 'function' ? maxBytes() : Number(maxBytes)
|
|
42
|
+
if (!(limit > 0)) return
|
|
43
|
+
const entries = (await listEntries()).sort((a, b) => a.at - b.at)
|
|
44
|
+
let total = entries.reduce((acc, e) => acc + e.size, 0)
|
|
45
|
+
for (const e of entries) {
|
|
46
|
+
if (total <= limit) break
|
|
47
|
+
await fs.promises.rm(e.full, { force: true })
|
|
48
|
+
await fs.promises.rm(path.join(dir, e.meta), { force: true }).catch(() => {})
|
|
49
|
+
total -= e.size
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
async put(key, mime, audio) {
|
|
55
|
+
await ensure()
|
|
56
|
+
const buf = Buffer.isBuffer(audio) ? audio : Buffer.from(audio)
|
|
57
|
+
await fs.promises.writeFile(path.join(dir, key + AUDIO_EXT), buf)
|
|
58
|
+
await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime, at: Date.now() }))
|
|
59
|
+
await evict()
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
async get(key) {
|
|
63
|
+
try {
|
|
64
|
+
const meta = JSON.parse(await fs.promises.readFile(path.join(dir, key + '.json'), 'utf8'))
|
|
65
|
+
const audio = await fs.promises.readFile(path.join(dir, key + AUDIO_EXT))
|
|
66
|
+
// Последнее обращение двигаем асинхронно: чтение тормозить нельзя.
|
|
67
|
+
fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: meta.mime, at: Date.now() })).catch(() => {})
|
|
68
|
+
return { mime: meta.mime || 'audio/mpeg', audio }
|
|
69
|
+
} catch (miss) {
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async clear() {
|
|
75
|
+
const entries = await listEntries()
|
|
76
|
+
for (const e of entries) {
|
|
77
|
+
await fs.promises.rm(e.full, { force: true })
|
|
78
|
+
await fs.promises.rm(path.join(dir, e.meta), { force: true }).catch(() => {})
|
|
79
|
+
}
|
|
80
|
+
return entries.length
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -368,6 +368,11 @@ window.__ModuleLoader__.load({
|
|
|
368
368
|
'cardHint': 'Provider chain, voices and playback options.',
|
|
369
369
|
'skipCode': 'Skip code blocks',
|
|
370
370
|
'skipCodeHint': 'Say a short notice instead of reading code aloud.',
|
|
371
|
+
'cache': 'Synthesis cache',
|
|
372
|
+
'cacheHint': 'Reuse synthesized audio for repeated phrases.',
|
|
373
|
+
'cacheMaxMb': 'Cache size limit, MB',
|
|
374
|
+
'cacheMaxMbHint': 'Least recently used items are evicted first.',
|
|
375
|
+
'clearCache': 'Clear cache',
|
|
371
376
|
}
|
|
372
377
|
const ru = {
|
|
373
378
|
'general': 'Общее',
|
|
@@ -418,6 +423,11 @@ window.__ModuleLoader__.load({
|
|
|
418
423
|
'cardHint': 'Цепочка провайдеров, голоса и параметры чтения.',
|
|
419
424
|
'skipCode': 'Пропускать код',
|
|
420
425
|
'skipCodeHint': 'Вместо чтения кода вслух — короткая пометка.',
|
|
426
|
+
'cache': 'Кэш синтеза',
|
|
427
|
+
'cacheHint': 'Повторяющиеся фразы не синтезируются заново.',
|
|
428
|
+
'cacheMaxMb': 'Лимит кэша, МБ',
|
|
429
|
+
'cacheMaxMbHint': 'Сначала вытесняется самое давнее по использованию.',
|
|
430
|
+
'clearCache': 'Очистить кэш',
|
|
421
431
|
}
|
|
422
432
|
|
|
423
433
|
function TtsSection(props) {
|
|
@@ -507,6 +517,16 @@ window.__ModuleLoader__.load({
|
|
|
507
517
|
commitKey(provider, raw).catch((e) => setErr(String(e && e.message ? e.message : e)))
|
|
508
518
|
}
|
|
509
519
|
|
|
520
|
+
const clearCacheNow = async () => {
|
|
521
|
+
setErr('')
|
|
522
|
+
const res = await fetch('/dsh-tts/cache', { method: 'DELETE' })
|
|
523
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
524
|
+
setSaved(true); setTimeout(() => setSaved(false), 2000)
|
|
525
|
+
}
|
|
526
|
+
const onClearCache = () => {
|
|
527
|
+
clearCacheNow().catch((e) => setErr(String(e && e.message ? e.message : e)))
|
|
528
|
+
}
|
|
529
|
+
|
|
510
530
|
const boolField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
|
|
511
531
|
React.createElement('input', {
|
|
512
532
|
type: 'checkbox', checked: !!(draft && draft[key]), disabled: !writable,
|
|
@@ -571,6 +591,9 @@ window.__ModuleLoader__.load({
|
|
|
571
591
|
t('announceHint')),
|
|
572
592
|
boolField('skipCode', t('skipCode'),
|
|
573
593
|
t('skipCodeHint')),
|
|
594
|
+
boolField('cache', t('cache'),
|
|
595
|
+
t('cacheHint')),
|
|
596
|
+
numberField('cacheMaxMb', t('cacheMaxMb'), t('cacheMaxMbHint'), 10),
|
|
574
597
|
textField('approvalText', t('approvalText'), t('approvalHint')),
|
|
575
598
|
textField('questionText', t('questionText'), t('questionHint')),
|
|
576
599
|
textField('chime', t('chime'), t('chimeHint')),
|
|
@@ -582,6 +605,7 @@ window.__ModuleLoader__.load({
|
|
|
582
605
|
textField('azureRegion', t('azureRegion'), t('azureHint')),
|
|
583
606
|
),
|
|
584
607
|
React.createElement('div', { className: 'dts-foot' },
|
|
608
|
+
React.createElement('button', { type: 'button', className: 'dts-link', disabled: !writable, onClick: onClearCache }, t('clearCache')),
|
|
585
609
|
React.createElement('button', { type: 'button', className: 'dts-save', disabled: !writable, onClick: save }, t('save')),
|
|
586
610
|
saved ? React.createElement('span', { className: 'dts-ok' }, t('saved')) : null,
|
|
587
611
|
err ? React.createElement('span', { className: 'dts-bad' }, err) : null,
|
package/lib/index.js
CHANGED
|
@@ -4,6 +4,10 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
|
4
4
|
import { runChain } from './chain.js'
|
|
5
5
|
import { makeProviders, PROVIDER_KEYS, DEFAULT_MODELS, DEFAULT_VOICES } from './providers.js'
|
|
6
6
|
import { assistantText, speechPhrases, splitSentences, stripForSpeech } from './text.js'
|
|
7
|
+
import { cacheKey, createSpeechCache } from './cache.js'
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import os from 'node:os'
|
|
10
|
+
import path from 'node:path'
|
|
7
11
|
import {
|
|
8
12
|
CLOUD_PROVIDERS,
|
|
9
13
|
assertCredentialRef,
|
|
@@ -33,6 +37,10 @@ export const Config = z.object({
|
|
|
33
37
|
.description('When on, speak each finished agent reply in the Web UI.'),
|
|
34
38
|
skipCode: z.boolean().default(true)
|
|
35
39
|
.description('Replace fenced code blocks with a short spoken notice instead of reading them aloud.'),
|
|
40
|
+
cache: z.boolean().default(true)
|
|
41
|
+
.description('Reuse synthesized audio for repeated phrases instead of paying and waiting again.'),
|
|
42
|
+
cacheMaxMb: z.number().default(100)
|
|
43
|
+
.description('Disk limit for the synthesis cache; least recently used items are evicted first.'),
|
|
36
44
|
language: z.string().default('ru'),
|
|
37
45
|
speakAsItGoes: z
|
|
38
46
|
.boolean()
|
|
@@ -227,6 +235,18 @@ export function apply(ctx, config) {
|
|
|
227
235
|
}
|
|
228
236
|
}
|
|
229
237
|
|
|
238
|
+
// Кэш синтеза живёт рядом с прочими данными харнесса.
|
|
239
|
+
const speechCache = createSpeechCache({
|
|
240
|
+
root: process.env.DSH_HOME || path.join(os.homedir(), '.dsh'),
|
|
241
|
+
maxBytes: () => {
|
|
242
|
+
const mb = Number(live().cacheMaxMb)
|
|
243
|
+
return mb > 0 ? mb * 1024 * 1024 : 100 * 1024 * 1024
|
|
244
|
+
},
|
|
245
|
+
})
|
|
246
|
+
// Порог осмысленности: уникальные длинные куски (чтение по ходу) в кэш
|
|
247
|
+
// не кладём — иначе он забьётся мусором, который никогда не повторится.
|
|
248
|
+
const CACHE_MAX_TEXT = 200
|
|
249
|
+
|
|
230
250
|
// Настройки чистки текста считаем на каждый вызов: конфиг живой.
|
|
231
251
|
function cleanOpts(cfg) {
|
|
232
252
|
return { skipCode: cfg.skipCode !== false, phrases: speechPhrases(cfg.language) }
|
|
@@ -244,11 +264,25 @@ export function apply(ctx, config) {
|
|
|
244
264
|
models[entry.provider] = entry.model || DEFAULT_MODELS[entry.provider]
|
|
245
265
|
voices[entry.provider] = entry.voice || DEFAULT_VOICES[entry.provider]
|
|
246
266
|
}
|
|
267
|
+
// Скорость воспроизведения в ключ не входит: ею управляет браузер,
|
|
268
|
+
// на синтез она не влияет.
|
|
269
|
+
const cacheAllowed = cfg.cache !== false && text.length <= CACHE_MAX_TEXT
|
|
270
|
+
if (cacheAllowed) {
|
|
271
|
+
for (const provider of order) {
|
|
272
|
+
const hit = await speechCache.get(cacheKey([text, provider, models[provider], voices[provider]]))
|
|
273
|
+
if (hit) return { provider, mime: hit.mime, audio: hit.audio, tookMs: 0, cached: true }
|
|
274
|
+
}
|
|
275
|
+
}
|
|
247
276
|
const providers = makeProviders(
|
|
248
277
|
{ resolveKey, fetchImpl: fetch, cfg },
|
|
249
278
|
{ text, lang: cfg.language, signal, models, voices },
|
|
250
279
|
)
|
|
251
|
-
|
|
280
|
+
const out = await runChain(order, providers)
|
|
281
|
+
if (cacheAllowed) {
|
|
282
|
+
const p = out.provider
|
|
283
|
+
speechCache.put(cacheKey([text, p, models[p], voices[p]]), out.mime, out.audio).catch(() => {})
|
|
284
|
+
}
|
|
285
|
+
return out
|
|
252
286
|
}
|
|
253
287
|
|
|
254
288
|
// Синтез одной фразы с постановкой в очередь. Порядок сохраняется: номер
|
|
@@ -387,6 +421,23 @@ export function apply(ctx, config) {
|
|
|
387
421
|
},
|
|
388
422
|
}), 'dsh-tts: /status route')
|
|
389
423
|
|
|
424
|
+
ctx.effect(() => ctx.webServer.register({
|
|
425
|
+
kind: 'exact',
|
|
426
|
+
path: '/dsh-tts/cache',
|
|
427
|
+
handler: async (req, res) => {
|
|
428
|
+
if (req.method !== 'DELETE') {
|
|
429
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'DELETE only' } })
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
433
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
const removed = await speechCache.clear()
|
|
437
|
+
writeJson(res, 200, { ok: true, removed })
|
|
438
|
+
},
|
|
439
|
+
}), 'dsh-tts: /cache route')
|
|
440
|
+
|
|
390
441
|
ctx.effect(() => ctx.webServer.register({
|
|
391
442
|
kind: 'exact',
|
|
392
443
|
path: '/dsh-tts/config',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-tts",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
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",
|