@goodandready/dsh-tts 0.3.7 → 0.3.9
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/client.js +139 -0
- package/lib/index.js +93 -12
- package/lib/text.js +33 -4
- package/package.json +1 -1
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' })
|
|
@@ -373,6 +374,24 @@ window.__ModuleLoader__.load({
|
|
|
373
374
|
'cacheMaxMb': 'Cache size limit, MB',
|
|
374
375
|
'cacheMaxMbHint': 'Least recently used items are evicted first.',
|
|
375
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.',
|
|
376
395
|
}
|
|
377
396
|
const ru = {
|
|
378
397
|
'general': 'Общее',
|
|
@@ -428,6 +447,24 @@ window.__ModuleLoader__.load({
|
|
|
428
447
|
'cacheMaxMb': 'Лимит кэша, МБ',
|
|
429
448
|
'cacheMaxMbHint': 'Сначала вытесняется самое давнее по использованию.',
|
|
430
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': 'Предложений в озвучиваемом пересказе.',
|
|
431
468
|
}
|
|
432
469
|
|
|
433
470
|
function TtsSection(props) {
|
|
@@ -566,6 +603,22 @@ window.__ModuleLoader__.load({
|
|
|
566
603
|
React.createElement('div', { className: 'dts-sub' },
|
|
567
604
|
t('speakRepliesHint')),
|
|
568
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
|
+
),
|
|
569
622
|
React.createElement('div', { className: 'dts-block' },
|
|
570
623
|
React.createElement('div', { className: 'dts-h' }, t('chainTitle')),
|
|
571
624
|
React.createElement('div', { className: 'dts-sub' },
|
|
@@ -594,6 +647,14 @@ window.__ModuleLoader__.load({
|
|
|
594
647
|
boolField('cache', t('cache'),
|
|
595
648
|
t('cacheHint')),
|
|
596
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),
|
|
597
658
|
textField('approvalText', t('approvalText'), t('approvalHint')),
|
|
598
659
|
textField('questionText', t('questionText'), t('questionHint')),
|
|
599
660
|
textField('chime', t('chime'), t('chimeHint')),
|
|
@@ -614,6 +675,84 @@ window.__ModuleLoader__.load({
|
|
|
614
675
|
}
|
|
615
676
|
|
|
616
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
|
+
|
|
617
756
|
// Карточка во вкладке «Настройки плагинов» — разметка и стили по
|
|
618
757
|
// единому канону (issue #19): li в списке ядра, шапка со сбросом темы,
|
|
619
758
|
// тело с чертой-разделителем. Шеврон — наш, ядро его не даёт.
|
package/lib/index.js
CHANGED
|
@@ -3,7 +3,7 @@ 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
7
|
import { cacheKey, createSpeechCache } from './cache.js'
|
|
8
8
|
import fs from 'node:fs'
|
|
9
9
|
import os from 'node:os'
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
} from './keys.js'
|
|
20
20
|
|
|
21
21
|
export const name = 'dsh-tts'
|
|
22
|
-
export const inject = ['tools', 'credentials', 'webServer', 'settings']
|
|
22
|
+
export const inject = ['tools', 'credentials', 'llm', 'webServer', 'settings']
|
|
23
23
|
|
|
24
24
|
const NS = 'dsh-tts'
|
|
25
25
|
|
|
@@ -32,6 +32,13 @@ const ChainEntry = z.object({
|
|
|
32
32
|
.description('Voice override. Empty means the provider default.'),
|
|
33
33
|
})
|
|
34
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
|
+
|
|
35
42
|
export const Config = z.object({
|
|
36
43
|
speakReplies: z.boolean().default(false)
|
|
37
44
|
.description('When on, speak each finished agent reply in the Web UI.'),
|
|
@@ -41,6 +48,25 @@ export const Config = z.object({
|
|
|
41
48
|
.description('Reuse synthesized audio for repeated phrases instead of paying and waiting again.'),
|
|
42
49
|
cacheMaxMb: z.number().default(100)
|
|
43
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.string().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.'),
|
|
44
70
|
language: z.string().default('ru'),
|
|
45
71
|
speakAsItGoes: z
|
|
46
72
|
.boolean()
|
|
@@ -235,6 +261,8 @@ export function apply(ctx, config) {
|
|
|
235
261
|
}
|
|
236
262
|
}
|
|
237
263
|
|
|
264
|
+
const llm = ctx.llm
|
|
265
|
+
|
|
238
266
|
// Кэш синтеза живёт рядом с прочими данными харнесса.
|
|
239
267
|
const speechCache = createSpeechCache({
|
|
240
268
|
root: process.env.DSH_HOME || path.join(os.homedir(), '.dsh'),
|
|
@@ -251,10 +279,48 @@ export function apply(ctx, config) {
|
|
|
251
279
|
function cleanOpts(cfg) {
|
|
252
280
|
return { skipCode: cfg.skipCode !== false, phrases: speechPhrases(cfg.language) }
|
|
253
281
|
}
|
|
282
|
+
function cleanText(raw, cfg) {
|
|
283
|
+
return applyPronunciation(stripForSpeech(raw, cfg.maxChars, cleanOpts(cfg)), cfg.pronunciation, cfg.language)
|
|
284
|
+
}
|
|
285
|
+
|
|
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
|
+
}
|
|
254
308
|
|
|
255
|
-
async function synthesize(rawText, cfg, signal) {
|
|
256
|
-
|
|
309
|
+
async function synthesize(rawText, cfg, signal, role = 'reply') {
|
|
310
|
+
let text = cleanText(rawText, cfg)
|
|
257
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
|
+
}
|
|
258
324
|
const models = {}
|
|
259
325
|
const voices = {}
|
|
260
326
|
const order = []
|
|
@@ -264,12 +330,19 @@ export function apply(ctx, config) {
|
|
|
264
330
|
models[entry.provider] = entry.model || DEFAULT_MODELS[entry.provider]
|
|
265
331
|
voices[entry.provider] = entry.voice || DEFAULT_VOICES[entry.provider]
|
|
266
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
|
+
}
|
|
267
340
|
// Скорость воспроизведения в ключ не входит: ею управляет браузер,
|
|
268
341
|
// на синтез она не влияет.
|
|
269
342
|
const cacheAllowed = cfg.cache !== false && text.length <= CACHE_MAX_TEXT
|
|
270
343
|
if (cacheAllowed) {
|
|
271
344
|
for (const provider of order) {
|
|
272
|
-
const hit = await speechCache.get(cacheKey([text, provider, models[provider], voices[provider]]))
|
|
345
|
+
const hit = await speechCache.get(cacheKey([text, provider, models[provider], voices[provider], role]))
|
|
273
346
|
if (hit) return { provider, mime: hit.mime, audio: hit.audio, tookMs: 0, cached: true }
|
|
274
347
|
}
|
|
275
348
|
}
|
|
@@ -280,7 +353,7 @@ export function apply(ctx, config) {
|
|
|
280
353
|
const out = await runChain(order, providers)
|
|
281
354
|
if (cacheAllowed) {
|
|
282
355
|
const p = out.provider
|
|
283
|
-
speechCache.put(cacheKey([text, p, models[p], voices[p]]), out.mime, out.audio).catch(() => {})
|
|
356
|
+
speechCache.put(cacheKey([text, p, models[p], voices[p], role]), out.mime, out.audio).catch(() => {})
|
|
284
357
|
}
|
|
285
358
|
return out
|
|
286
359
|
}
|
|
@@ -290,11 +363,12 @@ export function apply(ctx, config) {
|
|
|
290
363
|
// обгоняли бы длинные.
|
|
291
364
|
function speakPiece(sid, text, cfg, kind) {
|
|
292
365
|
if (!text) return
|
|
366
|
+
const role = kind === 'notice' ? 'approval' : kind === 'error' ? 'error' : 'reply'
|
|
293
367
|
const id = `u${++utteranceSeq}`
|
|
294
368
|
const controller = new AbortController()
|
|
295
369
|
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
296
370
|
reserve(id)
|
|
297
|
-
synthesize(text, cfg, controller.signal)
|
|
371
|
+
synthesize(text, cfg, controller.signal, role)
|
|
298
372
|
.then((out) => settle(id, {
|
|
299
373
|
id,
|
|
300
374
|
sessionId: sid,
|
|
@@ -310,9 +384,15 @@ export function apply(ctx, config) {
|
|
|
310
384
|
|
|
311
385
|
// Объявление: сначала короткий сигнал, затем фраза. Сигнал — не файл, его
|
|
312
386
|
// рисует сам браузер, поэтому ничего не качается и не хранится.
|
|
387
|
+
function roleChime(cfg, role) {
|
|
388
|
+
const ov = (cfg.roles && cfg.roles[role]) || {}
|
|
389
|
+
return ov.chime || cfg.chime
|
|
390
|
+
}
|
|
391
|
+
|
|
313
392
|
function announce(sid, text, cfg) {
|
|
314
|
-
|
|
315
|
-
|
|
393
|
+
const chime = roleChime(cfg, 'approval')
|
|
394
|
+
if (chime && chime !== 'none') {
|
|
395
|
+
enqueue({ id: `u${++utteranceSeq}`, sessionId: sid, kind: 'chime', chime })
|
|
316
396
|
}
|
|
317
397
|
speakPiece(sid, text, cfg, 'notice')
|
|
318
398
|
}
|
|
@@ -361,7 +441,7 @@ export function apply(ctx, config) {
|
|
|
361
441
|
// предложениями, и очередь проигрывается по порядку. Ждать конца хода
|
|
362
442
|
// значит молчать всё время, пока агент работает.
|
|
363
443
|
if (cfg.speakAsItGoes) {
|
|
364
|
-
const ready =
|
|
444
|
+
const ready = cleanText(text, cfg)
|
|
365
445
|
for (const piece of splitSentences(ready, cfg.sentenceChars)) speakPiece(sid, piece, cfg)
|
|
366
446
|
return
|
|
367
447
|
}
|
|
@@ -376,7 +456,7 @@ export function apply(ctx, config) {
|
|
|
376
456
|
const cur = collectors.get(sid)
|
|
377
457
|
collectors.delete(sid)
|
|
378
458
|
const raw = cur && Array.isArray(cur.parts) ? cur.parts.join('\n') : ''
|
|
379
|
-
const text =
|
|
459
|
+
const text = cleanText(raw, cfg)
|
|
380
460
|
if (!text) return
|
|
381
461
|
const id = `u${++utteranceSeq}`
|
|
382
462
|
const controller = new AbortController()
|
|
@@ -416,6 +496,7 @@ export function apply(ctx, config) {
|
|
|
416
496
|
bargeIn: cfg.bargeIn,
|
|
417
497
|
language: cfg.language,
|
|
418
498
|
chain: cfg.chain,
|
|
499
|
+
roles: cfg.roles,
|
|
419
500
|
providers: PROVIDER_KEYS,
|
|
420
501
|
})
|
|
421
502
|
},
|
|
@@ -560,7 +641,7 @@ export function apply(ctx, config) {
|
|
|
560
641
|
}
|
|
561
642
|
let payload
|
|
562
643
|
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
|
|
563
|
-
const text =
|
|
644
|
+
const text = cleanText(payload.text, live())
|
|
564
645
|
if (!text) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
|
|
565
646
|
const cfg = live()
|
|
566
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.9",
|
|
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",
|