@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 +83 -0
- package/lib/client.js +163 -0
- package/lib/index.js +143 -11
- package/lib/text.js +33 -4
- 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
|
@@ -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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
58
|
-
|
|
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.
|
|
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",
|