@goodandready/dsh-tts 0.3.5 → 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
@@ -366,6 +366,13 @@ window.__ModuleLoader__.load({
366
366
  'save': 'Save',
367
367
  'saved': 'Saved',
368
368
  'cardHint': 'Provider chain, voices and playback options.',
369
+ 'skipCode': 'Skip code blocks',
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',
369
376
  }
370
377
  const ru = {
371
378
  'general': 'Общее',
@@ -414,6 +421,13 @@ window.__ModuleLoader__.load({
414
421
  'save': 'Сохранить',
415
422
  'saved': 'Сохранено',
416
423
  'cardHint': 'Цепочка провайдеров, голоса и параметры чтения.',
424
+ 'skipCode': 'Пропускать код',
425
+ 'skipCodeHint': 'Вместо чтения кода вслух — короткая пометка.',
426
+ 'cache': 'Кэш синтеза',
427
+ 'cacheHint': 'Повторяющиеся фразы не синтезируются заново.',
428
+ 'cacheMaxMb': 'Лимит кэша, МБ',
429
+ 'cacheMaxMbHint': 'Сначала вытесняется самое давнее по использованию.',
430
+ 'clearCache': 'Очистить кэш',
417
431
  }
418
432
 
419
433
  function TtsSection(props) {
@@ -503,6 +517,16 @@ window.__ModuleLoader__.load({
503
517
  commitKey(provider, raw).catch((e) => setErr(String(e && e.message ? e.message : e)))
504
518
  }
505
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
+
506
530
  const boolField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
507
531
  React.createElement('input', {
508
532
  type: 'checkbox', checked: !!(draft && draft[key]), disabled: !writable,
@@ -565,6 +589,11 @@ window.__ModuleLoader__.load({
565
589
  t('bargeInHint')),
566
590
  boolField('announceApproval', t('announce'),
567
591
  t('announceHint')),
592
+ boolField('skipCode', t('skipCode'),
593
+ t('skipCodeHint')),
594
+ boolField('cache', t('cache'),
595
+ t('cacheHint')),
596
+ numberField('cacheMaxMb', t('cacheMaxMb'), t('cacheMaxMbHint'), 10),
568
597
  textField('approvalText', t('approvalText'), t('approvalHint')),
569
598
  textField('questionText', t('questionText'), t('questionHint')),
570
599
  textField('chime', t('chime'), t('chimeHint')),
@@ -576,6 +605,7 @@ window.__ModuleLoader__.load({
576
605
  textField('azureRegion', t('azureRegion'), t('azureHint')),
577
606
  ),
578
607
  React.createElement('div', { className: 'dts-foot' },
608
+ React.createElement('button', { type: 'button', className: 'dts-link', disabled: !writable, onClick: onClearCache }, t('clearCache')),
579
609
  React.createElement('button', { type: 'button', className: 'dts-save', disabled: !writable, onClick: save }, t('save')),
580
610
  saved ? React.createElement('span', { className: 'dts-ok' }, t('saved')) : null,
581
611
  err ? React.createElement('span', { className: 'dts-bad' }, err) : null,
package/lib/index.js CHANGED
@@ -3,7 +3,11 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
3
3
  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
- import { assistantText, stripForSpeech, splitSentences } from './text.js'
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,
@@ -31,6 +35,12 @@ const ChainEntry = z.object({
31
35
  export const Config = z.object({
32
36
  speakReplies: z.boolean().default(false)
33
37
  .description('When on, speak each finished agent reply in the Web UI.'),
38
+ skipCode: z.boolean().default(true)
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.'),
34
44
  language: z.string().default('ru'),
35
45
  speakAsItGoes: z
36
46
  .boolean()
@@ -225,8 +235,25 @@ export function apply(ctx, config) {
225
235
  }
226
236
  }
227
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
+
250
+ // Настройки чистки текста считаем на каждый вызов: конфиг живой.
251
+ function cleanOpts(cfg) {
252
+ return { skipCode: cfg.skipCode !== false, phrases: speechPhrases(cfg.language) }
253
+ }
254
+
228
255
  async function synthesize(rawText, cfg, signal) {
229
- const text = stripForSpeech(rawText, cfg.maxChars)
256
+ const text = stripForSpeech(rawText, cfg.maxChars, cleanOpts(cfg))
230
257
  if (!text) throw new Error('nothing to speak')
231
258
  const models = {}
232
259
  const voices = {}
@@ -237,11 +264,25 @@ export function apply(ctx, config) {
237
264
  models[entry.provider] = entry.model || DEFAULT_MODELS[entry.provider]
238
265
  voices[entry.provider] = entry.voice || DEFAULT_VOICES[entry.provider]
239
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
+ }
240
276
  const providers = makeProviders(
241
277
  { resolveKey, fetchImpl: fetch, cfg },
242
278
  { text, lang: cfg.language, signal, models, voices },
243
279
  )
244
- 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
245
286
  }
246
287
 
247
288
  // Синтез одной фразы с постановкой в очередь. Порядок сохраняется: номер
@@ -320,7 +361,7 @@ export function apply(ctx, config) {
320
361
  // предложениями, и очередь проигрывается по порядку. Ждать конца хода
321
362
  // значит молчать всё время, пока агент работает.
322
363
  if (cfg.speakAsItGoes) {
323
- const ready = stripForSpeech(text, cfg.maxChars)
364
+ const ready = stripForSpeech(text, cfg.maxChars, cleanOpts(cfg))
324
365
  for (const piece of splitSentences(ready, cfg.sentenceChars)) speakPiece(sid, piece, cfg)
325
366
  return
326
367
  }
@@ -335,7 +376,7 @@ export function apply(ctx, config) {
335
376
  const cur = collectors.get(sid)
336
377
  collectors.delete(sid)
337
378
  const raw = cur && Array.isArray(cur.parts) ? cur.parts.join('\n') : ''
338
- const text = stripForSpeech(raw, cfg.maxChars)
379
+ const text = stripForSpeech(raw, cfg.maxChars, cleanOpts(cfg))
339
380
  if (!text) return
340
381
  const id = `u${++utteranceSeq}`
341
382
  const controller = new AbortController()
@@ -380,6 +421,23 @@ export function apply(ctx, config) {
380
421
  },
381
422
  }), 'dsh-tts: /status route')
382
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
+
383
441
  ctx.effect(() => ctx.webServer.register({
384
442
  kind: 'exact',
385
443
  path: '/dsh-tts/config',
@@ -502,7 +560,7 @@ export function apply(ctx, config) {
502
560
  }
503
561
  let payload
504
562
  try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
505
- const text = stripForSpeech(payload.text, live().maxChars)
563
+ const text = stripForSpeech(payload.text, live().maxChars, cleanOpts(live()))
506
564
  if (!text) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
507
565
  const cfg = live()
508
566
  const controller = new AbortController()
package/lib/text.js CHANGED
@@ -11,10 +11,44 @@ export function blocksToText(content) {
11
11
  return parts.join('\n')
12
12
  }
13
13
 
14
- export function stripForSpeech(raw, maxChars) {
14
+ // Короткие пометки вместо выброшенной разметки. Формулировки выбираются по
15
+ // языку озвучки из настроек; подстановки {n} заполняются числом строк.
16
+ export const SPEECH_PHRASES = {
17
+ en: { codeBlock: 'code block, {n} lines', table: 'table, {n} rows' },
18
+ ru: { codeBlock: 'блок кода, {n} строк', table: 'таблица, {n} строк' },
19
+ }
20
+
21
+ export function speechPhrases(language) {
22
+ const lang = String(language || '').toLowerCase()
23
+ return lang.startsWith('ru') ? SPEECH_PHRASES.ru : SPEECH_PHRASES.en
24
+ }
25
+
26
+ function fill(template, n) {
27
+ return String(template).replace('{n}', String(n))
28
+ }
29
+
30
+ export function stripForSpeech(raw, maxChars, opts) {
31
+ const options = opts || {}
32
+ const skipCode = options.skipCode !== false
33
+ const phrases = options.phrases || SPEECH_PHRASES.en
15
34
  let text = String(raw || '')
16
- text = text.replace(/```[\s\S]*?```/g, ' ')
17
- text = text.replace(/`[^`]+`/g, ' ')
35
+ // Чистка обязана идти до нарезки на предложения, иначе половина блока
36
+ // уедет в первый кусок и будет озвучена.
37
+ if (skipCode) {
38
+ text = text.replace(/```[\s\S]*?```/g, (block) => {
39
+ const inner = block.replace(/^```[^\n]*\n?/, '').replace(/\n?```\s*$/, '')
40
+ const lines = inner ? inner.split('\n').length : 1
41
+ return ' ' + fill(phrases.codeBlock, lines) + ' '
42
+ })
43
+ text = text.replace(/`[^`\n]+`/g, ' ')
44
+ }
45
+ // Таблицы Markdown: подряд идущие строки, начинающиеся с |; разделительная
46
+ // шапка |---| в счёт строк не идёт.
47
+ text = text.replace(/(?:^[ \t]*\|.*\|[ \t]*$\n?)+/gm, (run) => {
48
+ const rows = run.trim().split('\n')
49
+ .filter((line) => !/^\s*\|[\s:|-]+\|\s*$/.test(line)).length
50
+ return fill(phrases.table, Math.max(1, rows)) + '\n'
51
+ })
18
52
  text = text.replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
19
53
  text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
20
54
  text = text.replace(/[#*_>]+/g, ' ')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.3.5",
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",