@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 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
- return runChain(order, providers)
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.6",
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",