@goodandready/dsh-tts 0.3.6 → 0.3.8

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
@@ -228,6 +228,7 @@ window.__ModuleLoader__.load({
228
228
  player.enabled = !!(meta && meta.speakReplies)
229
229
  if (meta && typeof meta.rate === 'number') player.rate = meta.rate
230
230
  if (meta && meta.chime) player.chime = meta.chime
231
+ if (meta && meta.roles) player.roles = meta.roles
231
232
  if (meta && typeof meta.bargeIn === 'boolean') player.bargeIn = meta.bargeIn
232
233
  if (!player.enabled) return
233
234
  const res = await fetch('/dsh-tts/pending?after=' + encodeURIComponent(player.after || ''), { cache: 'no-store' })
@@ -368,6 +369,29 @@ window.__ModuleLoader__.load({
368
369
  'cardHint': 'Provider chain, voices and playback options.',
369
370
  'skipCode': 'Skip code blocks',
370
371
  'skipCodeHint': 'Say a short notice instead of reading code aloud.',
372
+ 'cache': 'Synthesis cache',
373
+ 'cacheHint': 'Reuse synthesized audio for repeated phrases.',
374
+ 'cacheMaxMb': 'Cache size limit, MB',
375
+ 'cacheMaxMbHint': 'Least recently used items are evicted first.',
376
+ 'clearCache': 'Clear cache',
377
+ 'pronTitle': 'Pronunciation',
378
+ 'pronHint': 'Rules run top-down. Wrap left side in /…/ for a regex; Lang filters the rule.',
379
+ 'pronFrom': 'Replace',
380
+ 'pronTo': 'With',
381
+ 'pronWhole': 'Whole word',
382
+ 'pronLang': 'Lang',
383
+ 'addRule': 'Add rule',
384
+ 'rolesTitle': 'Voice roles',
385
+ 'rolesHint': 'Empty fields inherit the main chain.',
386
+ 'roleReply': 'Reply',
387
+ 'roleApproval': 'Approval',
388
+ 'roleError': 'Error',
389
+ 'longReply': 'Long replies',
390
+ 'longReplyHint': 'truncate: cut at the limit; summarize: short retelling; full: read everything.',
391
+ 'summaryModel': 'Summary model',
392
+ 'summaryModelHint': 'provider/model, empty = conversation default.',
393
+ 'summarySentences': 'Retelling length',
394
+ 'summarySentencesHint': 'Sentences in the spoken retelling.',
371
395
  }
372
396
  const ru = {
373
397
  'general': 'Общее',
@@ -418,6 +442,29 @@ window.__ModuleLoader__.load({
418
442
  'cardHint': 'Цепочка провайдеров, голоса и параметры чтения.',
419
443
  'skipCode': 'Пропускать код',
420
444
  'skipCodeHint': 'Вместо чтения кода вслух — короткая пометка.',
445
+ 'cache': 'Кэш синтеза',
446
+ 'cacheHint': 'Повторяющиеся фразы не синтезируются заново.',
447
+ 'cacheMaxMb': 'Лимит кэша, МБ',
448
+ 'cacheMaxMbHint': 'Сначала вытесняется самое давнее по использованию.',
449
+ 'clearCache': 'Очистить кэш',
450
+ 'pronTitle': 'Произношение',
451
+ 'pronHint': 'Правила работают сверху вниз; /…/ в левой части — регулярка; Язк ограничивает правило.',
452
+ 'pronFrom': 'Заменить',
453
+ 'pronTo': 'На',
454
+ 'pronWhole': 'Целое слово',
455
+ 'pronLang': 'Язк',
456
+ 'addRule': 'Добавить правило',
457
+ 'rolesTitle': 'Голоса ролей',
458
+ 'rolesHint': 'Пустые поля наследуют основную цепочку.',
459
+ 'roleReply': 'Ответ',
460
+ 'roleApproval': 'Подтверждение',
461
+ 'roleError': 'Ошибка',
462
+ 'longReply': 'Длинные ответы',
463
+ 'longReplyHint': 'truncate: обрезать; summarize: короткий пересказ; full: читать целиком.',
464
+ 'summaryModel': 'Модель пересказа',
465
+ 'summaryModelHint': 'provider/model, пусто — как у разговора.',
466
+ 'summarySentences': 'Длина пересказа',
467
+ 'summarySentencesHint': 'Предложений в озвучиваемом пересказе.',
421
468
  }
422
469
 
423
470
  function TtsSection(props) {
@@ -507,6 +554,16 @@ window.__ModuleLoader__.load({
507
554
  commitKey(provider, raw).catch((e) => setErr(String(e && e.message ? e.message : e)))
508
555
  }
509
556
 
557
+ const clearCacheNow = async () => {
558
+ setErr('')
559
+ const res = await fetch('/dsh-tts/cache', { method: 'DELETE' })
560
+ if (!res.ok) throw new Error('HTTP ' + res.status)
561
+ setSaved(true); setTimeout(() => setSaved(false), 2000)
562
+ }
563
+ const onClearCache = () => {
564
+ clearCacheNow().catch((e) => setErr(String(e && e.message ? e.message : e)))
565
+ }
566
+
510
567
  const boolField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
511
568
  React.createElement('input', {
512
569
  type: 'checkbox', checked: !!(draft && draft[key]), disabled: !writable,
@@ -546,6 +603,22 @@ window.__ModuleLoader__.load({
546
603
  React.createElement('div', { className: 'dts-sub' },
547
604
  t('speakRepliesHint')),
548
605
  ),
606
+ React.createElement('div', { className: 'dts-block' },
607
+ React.createElement('div', { className: 'dts-h' }, t('pronTitle')),
608
+ React.createElement(PronEditor, {
609
+ t: t, writable: writable,
610
+ value: draft && draft.pronunciation ? draft.pronunciation : [],
611
+ onChange: (v) => setTop('pronunciation', v),
612
+ }),
613
+ ),
614
+ React.createElement('div', { className: 'dts-block' },
615
+ React.createElement('div', { className: 'dts-h' }, t('rolesTitle')),
616
+ React.createElement(RolesEditor, {
617
+ t: t, writable: writable,
618
+ value: draft && draft.roles ? draft.roles : {},
619
+ onChange: (v) => setTop('roles', v),
620
+ }),
621
+ ),
549
622
  React.createElement('div', { className: 'dts-block' },
550
623
  React.createElement('div', { className: 'dts-h' }, t('chainTitle')),
551
624
  React.createElement('div', { className: 'dts-sub' },
@@ -571,6 +644,17 @@ window.__ModuleLoader__.load({
571
644
  t('announceHint')),
572
645
  boolField('skipCode', t('skipCode'),
573
646
  t('skipCodeHint')),
647
+ boolField('cache', t('cache'),
648
+ t('cacheHint')),
649
+ numberField('cacheMaxMb', t('cacheMaxMb'), t('cacheMaxMbHint'), 10),
650
+ React.createElement('label', { className: 'dts-field' }, t('longReply'),
651
+ React.createElement('select', {
652
+ className: 'dts-input', value: (draft && draft.longReply) || 'truncate', disabled: !writable,
653
+ onChange: (e) => setTop('longReply', e.target.value),
654
+ }, ['truncate', 'summarize', 'full'].map((mode) => React.createElement('option', { key: mode, value: mode }, mode))),
655
+ React.createElement('span', { className: 'dts-sub' }, t('longReplyHint'))),
656
+ textField('summaryModel', t('summaryModel'), t('summaryModelHint')),
657
+ numberField('summarySentences', t('summarySentences'), t('summarySentencesHint'), 1),
574
658
  textField('approvalText', t('approvalText'), t('approvalHint')),
575
659
  textField('questionText', t('questionText'), t('questionHint')),
576
660
  textField('chime', t('chime'), t('chimeHint')),
@@ -582,6 +666,7 @@ window.__ModuleLoader__.load({
582
666
  textField('azureRegion', t('azureRegion'), t('azureHint')),
583
667
  ),
584
668
  React.createElement('div', { className: 'dts-foot' },
669
+ React.createElement('button', { type: 'button', className: 'dts-link', disabled: !writable, onClick: onClearCache }, t('clearCache')),
585
670
  React.createElement('button', { type: 'button', className: 'dts-save', disabled: !writable, onClick: save }, t('save')),
586
671
  saved ? React.createElement('span', { className: 'dts-ok' }, t('saved')) : null,
587
672
  err ? React.createElement('span', { className: 'dts-bad' }, err) : null,
@@ -590,6 +675,84 @@ window.__ModuleLoader__.load({
590
675
  }
591
676
 
592
677
 
678
+ // Редактор словаря произношения: таблица правил сверху вниз.
679
+ function PronEditor(props) {
680
+ const t = props.t || ((key) => key)
681
+ const rows = Array.isArray(props.value) ? props.value : []
682
+ const change = (i, patch) => {
683
+ props.onChange(rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r)))
684
+ }
685
+ const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
686
+ const add = () => props.onChange(rows.concat([{ from: '', to: '', whole: false, lang: '' }]))
687
+ return React.createElement('div', { className: 'dts-wrap-in' },
688
+ rows.map((rule, i) => React.createElement('div', { className: 'dts-row', key: i },
689
+ React.createElement('input', {
690
+ className: 'dts-input dts-grow', placeholder: t('pronFrom'),
691
+ value: rule.from || '', disabled: !props.writable,
692
+ onChange: (e) => change(i, { from: e.target.value }),
693
+ }),
694
+ React.createElement('input', {
695
+ className: 'dts-input dts-grow', placeholder: t('pronTo'),
696
+ value: rule.to || '', disabled: !props.writable,
697
+ onChange: (e) => change(i, { to: e.target.value }),
698
+ }),
699
+ React.createElement('label', { className: 'dts-sub', title: t('pronWhole') },
700
+ React.createElement('input', {
701
+ type: 'checkbox', checked: !!rule.whole, disabled: !props.writable,
702
+ onChange: (e) => change(i, { whole: e.target.checked }),
703
+ }),
704
+ ),
705
+ React.createElement('input', {
706
+ className: 'dts-input', style: { maxWidth: '70px' }, placeholder: t('pronLang'),
707
+ value: rule.lang || '', disabled: !props.writable,
708
+ onChange: (e) => change(i, { lang: e.target.value }),
709
+ }),
710
+ React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: () => remove(i) }, '\u00d7'),
711
+ )),
712
+ React.createElement('div', { className: 'dts-row' },
713
+ React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: add }, '+'),
714
+ React.createElement('span', { className: 'dts-sub' }, t('pronHint')),
715
+ ),
716
+ )
717
+ }
718
+
719
+ // Роли: переопределение провайдера/модели/голоса/сигнала, пусто — как у цепочки.
720
+ const ROLE_KEYS = ['reply', 'approval', 'error']
721
+
722
+ function RolesEditor(props) {
723
+ const t = props.t || ((key) => key)
724
+ const value = props.value || {}
725
+ const set = (r, patch) => props.onChange(Object.assign({}, value, { [r]: Object.assign({}, value[r], patch) }))
726
+ return React.createElement('div', { className: 'dts-block' },
727
+ React.createElement('span', { className: 'dts-sub' }, t('rolesHint')),
728
+ ROLE_KEYS.map((r) => React.createElement('div', { className: 'dts-row', key: r },
729
+ React.createElement('span', { className: 'dts-sub', style: { minWidth: '110px' } }, t('role_' + r)),
730
+ React.createElement('select', {
731
+ className: 'dts-input', value: (value[r] && value[r].provider) || '', disabled: !props.writable,
732
+ onChange: (e) => set(r, { provider: e.target.value }),
733
+ },
734
+ React.createElement('option', { value: '' }, '—'),
735
+ PROVIDERS.map((p) => React.createElement('option', { key: p, value: p }, p)),
736
+ ),
737
+ React.createElement('input', {
738
+ className: 'dts-input dts-grow', placeholder: 'model',
739
+ value: (value[r] && value[r].model) || '', disabled: !props.writable,
740
+ onChange: (e) => set(r, { model: e.target.value }),
741
+ }),
742
+ React.createElement('input', {
743
+ className: 'dts-input dts-grow', placeholder: 'voice',
744
+ value: (value[r] && value[r].voice) || '', disabled: !props.writable,
745
+ onChange: (e) => set(r, { voice: e.target.value }),
746
+ }),
747
+ React.createElement('input', {
748
+ className: 'dts-input', style: { maxWidth: '90px' }, placeholder: t('chime'),
749
+ value: (value[r] && value[r].chime) || '', disabled: !props.writable,
750
+ onChange: (e) => set(r, { chime: e.target.value }),
751
+ }),
752
+ )),
753
+ )
754
+ }
755
+
593
756
  // Карточка во вкладке «Настройки плагинов» — разметка и стили по
594
757
  // единому канону (issue #19): li в списке ядра, шапка со сбросом темы,
595
758
  // тело с чертой-разделителем. Шеврон — наш, ядро его не даёт.
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, speechPhrases, splitSentences, stripForSpeech } from './text.js'
6
+ import { applyPronunciation, 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,
@@ -15,7 +19,7 @@ import {
15
19
  } from './keys.js'
16
20
 
17
21
  export const name = 'dsh-tts'
18
- export const inject = ['tools', 'credentials', 'webServer', 'settings']
22
+ export const inject = ['tools', 'credentials', 'llm', 'webServer', 'settings']
19
23
 
20
24
  const NS = 'dsh-tts'
21
25
 
@@ -28,11 +32,41 @@ const ChainEntry = z.object({
28
32
  .description('Voice override. Empty means the provider default.'),
29
33
  })
30
34
 
35
+ const RoleOverride = z.object({
36
+ provider: z.string().default(''),
37
+ model: z.string().default(''),
38
+ voice: z.string().default(''),
39
+ chime: z.string().default(''),
40
+ })
41
+
31
42
  export const Config = z.object({
32
43
  speakReplies: z.boolean().default(false)
33
44
  .description('When on, speak each finished agent reply in the Web UI.'),
34
45
  skipCode: z.boolean().default(true)
35
46
  .description('Replace fenced code blocks with a short spoken notice instead of reading them aloud.'),
47
+ cache: z.boolean().default(true)
48
+ .description('Reuse synthesized audio for repeated phrases instead of paying and waiting again.'),
49
+ cacheMaxMb: z.number().default(100)
50
+ .description('Disk limit for the synthesis cache; least recently used items are evicted first.'),
51
+ pronunciation: z.array(z.object({
52
+ from: z.string().default(''),
53
+ to: z.string().default(''),
54
+ whole: z.boolean().default(false),
55
+ lang: z.string().default(''),
56
+ })).default([])
57
+ .description('Say this instead of that. Applied top-down; /regex/ form allowed in from.'),
58
+ longReply: z.enum(['truncate', 'summarize', 'full']).default('truncate')
59
+ .description('What to do when a reply exceeds the length limit.'),
60
+ summaryModel: z.string().default('')
61
+ .description('provider/model for the spoken retelling; empty uses the conversation default.'),
62
+ summarySentences: z.number().default(3)
63
+ .description('Sentences in the spoken retelling.'),
64
+ roles: z.object({
65
+ reply: RoleOverride.default({ provider: '', model: '', voice: '', chime: '' }),
66
+ approval: RoleOverride.default({ provider: '', model: '', voice: '', chime: '' }),
67
+ error: RoleOverride.default({ provider: '', model: '', voice: '', chime: '' }),
68
+ }).default({ reply: {}, approval: {}, error: {} })
69
+ .description('Per-role overrides; empty fields inherit the main chain.'),
36
70
  language: z.string().default('ru'),
37
71
  speakAsItGoes: z
38
72
  .boolean()
@@ -227,14 +261,66 @@ export function apply(ctx, config) {
227
261
  }
228
262
  }
229
263
 
264
+ const llm = ctx.llm
265
+
266
+ // Кэш синтеза живёт рядом с прочими данными харнесса.
267
+ const speechCache = createSpeechCache({
268
+ root: process.env.DSH_HOME || path.join(os.homedir(), '.dsh'),
269
+ maxBytes: () => {
270
+ const mb = Number(live().cacheMaxMb)
271
+ return mb > 0 ? mb * 1024 * 1024 : 100 * 1024 * 1024
272
+ },
273
+ })
274
+ // Порог осмысленности: уникальные длинные куски (чтение по ходу) в кэш
275
+ // не кладём — иначе он забьётся мусором, который никогда не повторится.
276
+ const CACHE_MAX_TEXT = 200
277
+
230
278
  // Настройки чистки текста считаем на каждый вызов: конфиг живой.
231
279
  function cleanOpts(cfg) {
232
280
  return { skipCode: cfg.skipCode !== false, phrases: speechPhrases(cfg.language) }
233
281
  }
282
+ function cleanText(raw, cfg) {
283
+ return applyPronunciation(stripForSpeech(raw, cfg.maxChars, cleanOpts(cfg)), cfg.pronunciation, cfg.language)
284
+ }
234
285
 
235
- async function synthesize(rawText, cfg, signal) {
236
- const text = stripForSpeech(rawText, cfg.maxChars, cleanOpts(cfg))
286
+ // Пересказ быстрой моделью. Любая неудача — пустая строка, вызывающая
287
+ // сторона тихо откатывается к обрезке.
288
+ async function summarizeReply(text, cfg, signal) {
289
+ const sentences = Number(cfg.summarySentences) > 0 ? Number(cfg.summarySentences) : 3
290
+ if (!llm || typeof llm.stream !== 'function') return ''
291
+ const opts = {
292
+ messages: [{ role: 'user', content: 'Перескажи текст в ' + sentences + ' предложениях на его языке. Только пересказ, без вступлений:\n\n' + text }],
293
+ signal,
294
+ }
295
+ const m = String(cfg.summaryModel || '')
296
+ if (m) {
297
+ const slash = m.indexOf('/')
298
+ if (slash > 0) { opts.provider = m.slice(0, slash); opts.model = m.slice(slash + 1) }
299
+ else opts.model = m
300
+ }
301
+ const parts = []
302
+ for await (const chunk of llm.stream(opts)) {
303
+ const piece = chunk && (chunk.text || (chunk.delta && chunk.delta.text)) || ''
304
+ if (piece) parts.push(piece)
305
+ }
306
+ return parts.join('').trim()
307
+ }
308
+
309
+ async function synthesize(rawText, cfg, signal, role = 'reply') {
310
+ let text = cleanText(rawText, cfg)
237
311
  if (!text) throw new Error('nothing to speak')
312
+ const maxChars = Number(cfg.maxChars) > 0 ? Number(cfg.maxChars) : 0
313
+ const overLimit = maxChars > 0 && text.length > maxChars
314
+ if (overLimit && cfg.longReply !== 'full') {
315
+ let cut = text.slice(0, maxChars)
316
+ if (cfg.longReply === 'summarize') {
317
+ try {
318
+ const retold = await summarizeReply(text.slice(0, 8000), cfg, signal)
319
+ if (retold) cut = speechPhrases(cfg.language).summaryIntro + ' ' + retold
320
+ } catch (llmDown) { /* тихий откат к обрезке */ }
321
+ }
322
+ text = cut
323
+ }
238
324
  const models = {}
239
325
  const voices = {}
240
326
  const order = []
@@ -244,11 +330,32 @@ export function apply(ctx, config) {
244
330
  models[entry.provider] = entry.model || DEFAULT_MODELS[entry.provider]
245
331
  voices[entry.provider] = entry.voice || DEFAULT_VOICES[entry.provider]
246
332
  }
333
+ // Ролевые переопределения: пустые поля наследуют основную цепочку.
334
+ const ov = (cfg.roles && cfg.roles[role]) || {}
335
+ if (ov.provider && PROVIDER_KEYS.includes(ov.provider)) order.unshift(ov.provider)
336
+ if (order.length) {
337
+ if (ov.model) models[order[0]] = ov.model
338
+ if (ov.voice) voices[order[0]] = ov.voice
339
+ }
340
+ // Скорость воспроизведения в ключ не входит: ею управляет браузер,
341
+ // на синтез она не влияет.
342
+ const cacheAllowed = cfg.cache !== false && text.length <= CACHE_MAX_TEXT
343
+ if (cacheAllowed) {
344
+ for (const provider of order) {
345
+ const hit = await speechCache.get(cacheKey([text, provider, models[provider], voices[provider], role]))
346
+ if (hit) return { provider, mime: hit.mime, audio: hit.audio, tookMs: 0, cached: true }
347
+ }
348
+ }
247
349
  const providers = makeProviders(
248
350
  { resolveKey, fetchImpl: fetch, cfg },
249
351
  { text, lang: cfg.language, signal, models, voices },
250
352
  )
251
- return runChain(order, providers)
353
+ const out = await runChain(order, providers)
354
+ if (cacheAllowed) {
355
+ const p = out.provider
356
+ speechCache.put(cacheKey([text, p, models[p], voices[p], role]), out.mime, out.audio).catch(() => {})
357
+ }
358
+ return out
252
359
  }
253
360
 
254
361
  // Синтез одной фразы с постановкой в очередь. Порядок сохраняется: номер
@@ -256,11 +363,12 @@ export function apply(ctx, config) {
256
363
  // обгоняли бы длинные.
257
364
  function speakPiece(sid, text, cfg, kind) {
258
365
  if (!text) return
366
+ const role = kind === 'notice' ? 'approval' : kind === 'error' ? 'error' : 'reply'
259
367
  const id = `u${++utteranceSeq}`
260
368
  const controller = new AbortController()
261
369
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
262
370
  reserve(id)
263
- synthesize(text, cfg, controller.signal)
371
+ synthesize(text, cfg, controller.signal, role)
264
372
  .then((out) => settle(id, {
265
373
  id,
266
374
  sessionId: sid,
@@ -276,9 +384,15 @@ export function apply(ctx, config) {
276
384
 
277
385
  // Объявление: сначала короткий сигнал, затем фраза. Сигнал — не файл, его
278
386
  // рисует сам браузер, поэтому ничего не качается и не хранится.
387
+ function roleChime(cfg, role) {
388
+ const ov = (cfg.roles && cfg.roles[role]) || {}
389
+ return ov.chime || cfg.chime
390
+ }
391
+
279
392
  function announce(sid, text, cfg) {
280
- if (cfg.chime && cfg.chime !== 'none') {
281
- enqueue({ id: `u${++utteranceSeq}`, sessionId: sid, kind: 'chime', chime: cfg.chime })
393
+ const chime = roleChime(cfg, 'approval')
394
+ if (chime && chime !== 'none') {
395
+ enqueue({ id: `u${++utteranceSeq}`, sessionId: sid, kind: 'chime', chime })
282
396
  }
283
397
  speakPiece(sid, text, cfg, 'notice')
284
398
  }
@@ -327,7 +441,7 @@ export function apply(ctx, config) {
327
441
  // предложениями, и очередь проигрывается по порядку. Ждать конца хода
328
442
  // значит молчать всё время, пока агент работает.
329
443
  if (cfg.speakAsItGoes) {
330
- const ready = stripForSpeech(text, cfg.maxChars, cleanOpts(cfg))
444
+ const ready = cleanText(text, cfg)
331
445
  for (const piece of splitSentences(ready, cfg.sentenceChars)) speakPiece(sid, piece, cfg)
332
446
  return
333
447
  }
@@ -342,7 +456,7 @@ export function apply(ctx, config) {
342
456
  const cur = collectors.get(sid)
343
457
  collectors.delete(sid)
344
458
  const raw = cur && Array.isArray(cur.parts) ? cur.parts.join('\n') : ''
345
- const text = stripForSpeech(raw, cfg.maxChars, cleanOpts(cfg))
459
+ const text = cleanText(raw, cfg)
346
460
  if (!text) return
347
461
  const id = `u${++utteranceSeq}`
348
462
  const controller = new AbortController()
@@ -382,11 +496,29 @@ export function apply(ctx, config) {
382
496
  bargeIn: cfg.bargeIn,
383
497
  language: cfg.language,
384
498
  chain: cfg.chain,
499
+ roles: cfg.roles,
385
500
  providers: PROVIDER_KEYS,
386
501
  })
387
502
  },
388
503
  }), 'dsh-tts: /status route')
389
504
 
505
+ ctx.effect(() => ctx.webServer.register({
506
+ kind: 'exact',
507
+ path: '/dsh-tts/cache',
508
+ handler: async (req, res) => {
509
+ if (req.method !== 'DELETE') {
510
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'DELETE only' } })
511
+ return
512
+ }
513
+ if (!isTrustedSettingsRequest(req)) {
514
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
515
+ return
516
+ }
517
+ const removed = await speechCache.clear()
518
+ writeJson(res, 200, { ok: true, removed })
519
+ },
520
+ }), 'dsh-tts: /cache route')
521
+
390
522
  ctx.effect(() => ctx.webServer.register({
391
523
  kind: 'exact',
392
524
  path: '/dsh-tts/config',
@@ -509,7 +641,7 @@ export function apply(ctx, config) {
509
641
  }
510
642
  let payload
511
643
  try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
512
- const text = stripForSpeech(payload.text, live().maxChars, cleanOpts(live()))
644
+ const text = cleanText(payload.text, live())
513
645
  if (!text) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
514
646
  const cfg = live()
515
647
  const controller = new AbortController()
package/lib/text.js CHANGED
@@ -14,8 +14,8 @@ export function blocksToText(content) {
14
14
  // Короткие пометки вместо выброшенной разметки. Формулировки выбираются по
15
15
  // языку озвучки из настроек; подстановки {n} заполняются числом строк.
16
16
  export const SPEECH_PHRASES = {
17
- en: { codeBlock: 'code block, {n} lines', table: 'table, {n} rows' },
18
- ru: { codeBlock: 'блок кода, {n} строк', table: 'таблица, {n} строк' },
17
+ en: { codeBlock: 'code block, {n} lines', table: 'table, {n} rows', summaryIntro: 'Summary of the reply.' },
18
+ ru: { codeBlock: 'блок кода, {n} строк', table: 'таблица, {n} строк', summaryIntro: 'Пересказ ответа.' },
19
19
  }
20
20
 
21
21
  export function speechPhrases(language) {
@@ -23,6 +23,34 @@ export function speechPhrases(language) {
23
23
  return lang.startsWith('ru') ? SPEECH_PHRASES.ru : SPEECH_PHRASES.en
24
24
  }
25
25
 
26
+ // Словарь произношения: правила применяются сверху вниз, каждое по одному
27
+ // разу ко всему тексту. Левая часть — обычный текст; запись вида /…/флаги
28
+ // трактуется как регулярное выражение для тех, кто умеет. whole=true требует
29
+ // совпадения целого слова (границы по буквам/цифрам Unicode).
30
+ function escapeRe(s) {
31
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
32
+ }
33
+
34
+ export function applyPronunciation(text, rules, language) {
35
+ let out = String(text || '')
36
+ const lang = String(language || '').toLowerCase()
37
+ const list = Array.isArray(rules) ? rules : []
38
+ for (const rule of list) {
39
+ if (!rule || typeof rule.from !== 'string' || !rule.from) continue
40
+ if (rule.lang && !lang.startsWith(String(rule.lang).toLowerCase())) continue
41
+ const to = typeof rule.to === 'string' ? rule.to : ''
42
+ try {
43
+ const rx = /^\/(.+)\/([a-z]*)$/s.exec(rule.from)
44
+ let re
45
+ if (rx) re = new RegExp(rx[1], rx[2].includes('g') ? rx[2] : rx[2] + 'g')
46
+ else if (rule.whole) re = new RegExp('(?<![\\p{L}\\p{N}])' + escapeRe(rule.from) + '(?![\\p{L}\\p{N}])', 'gu')
47
+ else re = new RegExp(escapeRe(rule.from), 'g')
48
+ out = out.replace(re, to)
49
+ } catch (badRule) { /* кривое правило не роняет озвучку */ }
50
+ }
51
+ return out
52
+ }
53
+
26
54
  function fill(template, n) {
27
55
  return String(template).replace('{n}', String(n))
28
56
  }
@@ -54,8 +82,9 @@ export function stripForSpeech(raw, maxChars, opts) {
54
82
  text = text.replace(/[#*_>]+/g, ' ')
55
83
  text = text.replace(/MEDIA:[^\s]+/g, ' ')
56
84
  text = text.replace(/\s+/g, ' ').trim()
57
- const limit = Number(maxChars) > 0 ? Number(maxChars) : 4000
58
- if (text.length > limit) text = text.slice(0, limit)
85
+ // maxChars === 0 отключает обрезку; не задан — дефолтные 4000.
86
+ const requested = Number(maxChars)
87
+ if (requested > 0 && text.length > requested) text = text.slice(0, requested)
59
88
  return text
60
89
  }
61
90
 
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.8",
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",