@goodandready/dsh-tts 0.3.10 → 0.3.12
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 +192 -5
- package/lib/index.js +95 -7
- package/lib/keys.js +3 -1
- package/lib/providers.js +13 -2
- package/lib/text.js +25 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -21,7 +21,7 @@ window.__ModuleLoader__.load({
|
|
|
21
21
|
'edge', 'piper', 'espeak',
|
|
22
22
|
]
|
|
23
23
|
const CLOUD = {
|
|
24
|
-
openai: 1, elevenlabs: 1, google: 1, azure: 1, groq: 1, deepgram: 1, openrouter: 1,
|
|
24
|
+
openai: 1, elevenlabs: 1, google: 1, azure: 1, groq: 1, deepgram: 1, openrouter: 1, custom: 1,
|
|
25
25
|
}
|
|
26
26
|
const MODEL_HINT = {
|
|
27
27
|
openai: 'gpt-4o-mini-tts',
|
|
@@ -213,6 +213,13 @@ window.__ModuleLoader__.load({
|
|
|
213
213
|
playerChanged()
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
function skipCurrent() {
|
|
217
|
+
if (player.audio) { try { player.audio.pause() } catch (already) { /* стоит */ } }
|
|
218
|
+
player.audio = null
|
|
219
|
+
playerChanged()
|
|
220
|
+
if (player.queue.length) drainQueue()
|
|
221
|
+
}
|
|
222
|
+
|
|
216
223
|
// Человек заговорил — замолкаем.
|
|
217
224
|
//
|
|
218
225
|
// Событие приходит из плагина голосового ввода, но никакой связи с ним
|
|
@@ -251,12 +258,55 @@ window.__ModuleLoader__.load({
|
|
|
251
258
|
// опроса, иначе порядок фраз рассыплется.
|
|
252
259
|
if (item.kind === 'reserved') break
|
|
253
260
|
player.after = item.id
|
|
261
|
+
if (item.text) recent.add(item.text)
|
|
254
262
|
player.queue.push(item)
|
|
255
263
|
}
|
|
256
264
|
if (player.queue.length) drainQueue()
|
|
257
265
|
} catch (hostRestarting) { /* хост перезапускается */ }
|
|
258
266
|
}
|
|
259
267
|
|
|
268
|
+
// Недавние озвучки: тексты последних реплик и избранное (localStorage).
|
|
269
|
+
function recentStore() {
|
|
270
|
+
const KEY = 'dsh-tts/recent'
|
|
271
|
+
let st
|
|
272
|
+
try {
|
|
273
|
+
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null
|
|
274
|
+
st = JSON.parse(raw) || { items: [], favs: [] }
|
|
275
|
+
} catch (broken) { st = { items: [], favs: [] } }
|
|
276
|
+
const persist = () => { try { if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(st)) } catch (full) { /* приватный режим */ } }
|
|
277
|
+
return {
|
|
278
|
+
add(text) {
|
|
279
|
+
if (!text || text.length < 2) return
|
|
280
|
+
st.items = [text, ...st.items.filter((x) => x !== text)].slice(0, 30)
|
|
281
|
+
persist()
|
|
282
|
+
},
|
|
283
|
+
toggleFav(text) {
|
|
284
|
+
st.favs = st.favs.includes(text)
|
|
285
|
+
? st.favs.filter((x) => x !== text)
|
|
286
|
+
: [text, ...st.favs].slice(0, 20)
|
|
287
|
+
persist()
|
|
288
|
+
},
|
|
289
|
+
list() { return { items: st.items, favs: st.favs } },
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const recent = recentStore()
|
|
293
|
+
|
|
294
|
+
// Разовое проигрывание вне очереди: реплай из списка недавних.
|
|
295
|
+
function playStandalone(text) {
|
|
296
|
+
fetch('/dsh-tts/speak', {
|
|
297
|
+
method: 'POST',
|
|
298
|
+
headers: { 'Content-Type': 'application/json' },
|
|
299
|
+
body: JSON.stringify({ text }),
|
|
300
|
+
})
|
|
301
|
+
.then((r) => r.json())
|
|
302
|
+
.then((d) => {
|
|
303
|
+
if (!d || !d.ok) return
|
|
304
|
+
const audio = new Audio('data:' + (d.mime || 'audio/mpeg') + ';base64,' + d.audioBase64)
|
|
305
|
+
audio.play().catch(() => {})
|
|
306
|
+
})
|
|
307
|
+
.catch(() => {})
|
|
308
|
+
}
|
|
309
|
+
|
|
260
310
|
function credOf(map, provider) {
|
|
261
311
|
return (map && map[provider]) || { configured: false, writable: true, ref: '' }
|
|
262
312
|
}
|
|
@@ -302,6 +352,7 @@ window.__ModuleLoader__.load({
|
|
|
302
352
|
}),
|
|
303
353
|
React.createElement('button', { type: 'button', className: 'dts-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '\u2191'),
|
|
304
354
|
React.createElement('button', { type: 'button', className: 'dts-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, '\u2193'),
|
|
355
|
+
React.createElement('button', { type: 'button', className: 'dts-mini', title: t('preview'), disabled: !props.writable, onClick: () => props.onPreview(row.provider, row.model, row.voice) }, '\u25b6'),
|
|
305
356
|
React.createElement('button', { type: 'button', className: 'dts-mini', title: t('remove'), disabled: !props.writable, onClick: () => remove(i) }, '\u00d7'),
|
|
306
357
|
),
|
|
307
358
|
cloud ? React.createElement('div', { className: 'dts-row' },
|
|
@@ -403,6 +454,25 @@ window.__ModuleLoader__.load({
|
|
|
403
454
|
'summaryModelHint': 'provider/model, empty = conversation default.',
|
|
404
455
|
'summarySentences': 'Retelling length',
|
|
405
456
|
'summarySentencesHint': 'Sentences in the spoken retelling.',
|
|
457
|
+
'preview': 'Preview',
|
|
458
|
+
'statsTitle': 'Statistics',
|
|
459
|
+
'statTotal': 'Total',
|
|
460
|
+
'statHits': 'Cache hits',
|
|
461
|
+
'statErrors': 'Errors',
|
|
462
|
+
'statReset': 'Reset stats',
|
|
463
|
+
|
|
464
|
+
'autoDetect': 'Auto language per piece',
|
|
465
|
+
'autoDetectHint': 'Guess ru/en per spoken piece instead of the language setting.',
|
|
466
|
+
'narrateQuotesOnly': 'Only quotes',
|
|
467
|
+
'narrateQuotesHint': 'Read only «quoted» fragments of a reply.',
|
|
468
|
+
'skipActions': 'Skip *actions*',
|
|
469
|
+
'skipActionsHint': 'Drop asterisk action blocks.',
|
|
470
|
+
'removeRegex': 'Remove regex',
|
|
471
|
+
'removeRegexHint': 'Global regex; matches are removed before synthesis.',
|
|
472
|
+
'customBaseUrl': 'Custom TTS endpoint',
|
|
473
|
+
'customBaseUrlHint': 'OpenAI-compatible origin, e.g. http://localhost:8880/v1; empty = off.',
|
|
474
|
+
'customKeyEnv': 'Custom key env',
|
|
475
|
+
'customKeyEnvHint': 'Credential name for the custom endpoint bearer key.',
|
|
406
476
|
}
|
|
407
477
|
const ru = {
|
|
408
478
|
'general': 'Общее',
|
|
@@ -476,6 +546,25 @@ window.__ModuleLoader__.load({
|
|
|
476
546
|
'summaryModelHint': 'provider/model, пусто — как у разговора.',
|
|
477
547
|
'summarySentences': 'Длина пересказа',
|
|
478
548
|
'summarySentencesHint': 'Предложений в озвучиваемом пересказе.',
|
|
549
|
+
'preview': 'Проба',
|
|
550
|
+
'statsTitle': 'Статистика',
|
|
551
|
+
'statTotal': 'Всего',
|
|
552
|
+
'statHits': 'Кэш-хиты',
|
|
553
|
+
'statErrors': 'Ошибки',
|
|
554
|
+
'statReset': 'Сбросить',
|
|
555
|
+
|
|
556
|
+
'autoDetect': 'Автоязык по куску',
|
|
557
|
+
'autoDetectHint': 'Подсказывать провайдеру ru/en по каждому куску вместо общей настройки.',
|
|
558
|
+
'narrateQuotesOnly': 'Только цитаты',
|
|
559
|
+
'narrateQuotesHint': 'Читать только фрагменты в кавычках.',
|
|
560
|
+
'skipActions': 'Пропускать *действия*',
|
|
561
|
+
'skipActionsHint': 'Вырезать блоки в звёздочках.',
|
|
562
|
+
'removeRegex': 'Регулярка вырезания',
|
|
563
|
+
'removeRegexHint': 'Глобальная регулярка: совпадения удаляются перед синтезом.',
|
|
564
|
+
'customBaseUrl': 'Свой TTS endpoint',
|
|
565
|
+
'customBaseUrlHint': 'OpenAI-совместимый адрес, например http://localhost:8880/v1; пусто = выключен.',
|
|
566
|
+
'customKeyEnv': 'Имя ключа для endpoint',
|
|
567
|
+
'customKeyEnvHint': 'Имя credential для Bearer-ключа своего endpointа.',
|
|
479
568
|
}
|
|
480
569
|
|
|
481
570
|
function TtsSection(props) {
|
|
@@ -575,6 +664,19 @@ window.__ModuleLoader__.load({
|
|
|
575
664
|
clearCacheNow().catch((e) => setErr(String(e && e.message ? e.message : e)))
|
|
576
665
|
}
|
|
577
666
|
|
|
667
|
+
const onPreview = (provider, model, voice) => {
|
|
668
|
+
fetch('/dsh-tts/preview', {
|
|
669
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
670
|
+
body: JSON.stringify({ provider, model, voice }),
|
|
671
|
+
})
|
|
672
|
+
.then((r) => r.json())
|
|
673
|
+
.then((d) => {
|
|
674
|
+
if (!d.ok) throw new Error((d.error && d.error.message) || 'HTTP error')
|
|
675
|
+
new Audio('data:' + d.mime + ';base64,' + d.audioBase64).play().catch(() => {})
|
|
676
|
+
})
|
|
677
|
+
.catch((e) => setErr(String(e && e.message ? e.message : e)))
|
|
678
|
+
}
|
|
679
|
+
|
|
578
680
|
const boolField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
|
|
579
681
|
React.createElement('input', {
|
|
580
682
|
type: 'checkbox', checked: !!(draft && draft[key]), disabled: !writable,
|
|
@@ -625,11 +727,15 @@ window.__ModuleLoader__.load({
|
|
|
625
727
|
React.createElement('div', { className: 'dts-block' },
|
|
626
728
|
React.createElement('div', { className: 'dts-h' }, t('rolesTitle')),
|
|
627
729
|
React.createElement(RolesEditor, {
|
|
628
|
-
t: t, writable: writable,
|
|
730
|
+
t: t, writable: writable, onPreview: onPreview,
|
|
629
731
|
value: draft && draft.roles ? draft.roles : {},
|
|
630
732
|
onChange: (v) => setTop('roles', v),
|
|
631
733
|
}),
|
|
632
734
|
),
|
|
735
|
+
React.createElement('div', { className: 'dts-block' },
|
|
736
|
+
React.createElement('div', { className: 'dts-h' }, t('statsTitle')),
|
|
737
|
+
React.createElement(StatsPanel, { t: t }),
|
|
738
|
+
),
|
|
633
739
|
React.createElement('div', { className: 'dts-block' },
|
|
634
740
|
React.createElement('div', { className: 'dts-h' }, t('chainTitle')),
|
|
635
741
|
React.createElement('div', { className: 'dts-sub' },
|
|
@@ -642,6 +748,7 @@ window.__ModuleLoader__.load({
|
|
|
642
748
|
onDraft: setDraftKey,
|
|
643
749
|
onCommitKey: onCommitKey,
|
|
644
750
|
onClearKey: clearKey,
|
|
751
|
+
onPreview: onPreview,
|
|
645
752
|
}),
|
|
646
753
|
),
|
|
647
754
|
React.createElement('div', { className: 'dts-block' },
|
|
@@ -666,6 +773,20 @@ window.__ModuleLoader__.load({
|
|
|
666
773
|
React.createElement('span', { className: 'dts-sub' }, t('longReplyHint'))),
|
|
667
774
|
textField('summaryModel', t('summaryModel'), t('summaryModelHint')),
|
|
668
775
|
numberField('summarySentences', t('summarySentences'), t('summarySentencesHint'), 1),
|
|
776
|
+
React.createElement('label', { className: 'dts-field' }, t('longReply'),
|
|
777
|
+
React.createElement('select', {
|
|
778
|
+
className: 'dts-input', value: (draft && draft.longReply) || 'truncate', disabled: !writable,
|
|
779
|
+
onChange: (e) => setTop('longReply', e.target.value),
|
|
780
|
+
}, ['truncate', 'summarize', 'full'].map((mode) => React.createElement('option', { key: mode, value: mode }, mode))),
|
|
781
|
+
React.createElement('span', { className: 'dts-sub' }, t('longReplyHint'))),
|
|
782
|
+
textField('summaryModel', t('summaryModel'), t('summaryModelHint')),
|
|
783
|
+
numberField('summarySentences', t('summarySentences'), t('summarySentencesHint'), 1),
|
|
784
|
+
boolField('autoDetect', t('autoDetect'), t('autoDetectHint')),
|
|
785
|
+
boolField('narrateQuotesOnly', t('narrateQuotesOnly'), t('narrateQuotesHint')),
|
|
786
|
+
boolField('skipActions', t('skipActions'), t('skipActionsHint')),
|
|
787
|
+
textField('removeRegex', t('removeRegex'), t('removeRegexHint')),
|
|
788
|
+
textField('customBaseUrl', t('customBaseUrl'), t('customBaseUrlHint')),
|
|
789
|
+
textField('customKeyEnv', t('customKeyEnv'), t('customKeyEnvHint')),
|
|
669
790
|
textField('approvalText', t('approvalText'), t('approvalHint')),
|
|
670
791
|
textField('questionText', t('questionText'), t('questionHint')),
|
|
671
792
|
textField('chime', t('chime'), t('chimeHint')),
|
|
@@ -760,10 +881,37 @@ window.__ModuleLoader__.load({
|
|
|
760
881
|
value: (value[r] && value[r].chime) || '', disabled: !props.writable,
|
|
761
882
|
onChange: (e) => set(r, { chime: e.target.value }),
|
|
762
883
|
}),
|
|
884
|
+
React.createElement('input', {
|
|
885
|
+
className: 'dts-input dts-grow', placeholder: 'ssml',
|
|
886
|
+
value: (value[r] && value[r].ssmlStyle) || '', disabled: !props.writable,
|
|
887
|
+
onChange: (e) => set(r, { ssmlStyle: e.target.value }),
|
|
888
|
+
}),
|
|
889
|
+
React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: () => props.onPreview((value[r] && value[r].provider) || '', (value[r] && value[r].model) || '', (value[r] && value[r].voice) || '') }, '\u25b6'),
|
|
763
890
|
)),
|
|
764
891
|
)
|
|
765
892
|
}
|
|
766
893
|
|
|
894
|
+
// Панель статистики синтеза: живые счётчики хоста.
|
|
895
|
+
function StatsPanel(props) {
|
|
896
|
+
const t = props.t || ((key) => key)
|
|
897
|
+
const [data, setData] = React.useState(null)
|
|
898
|
+
const loadStats = () => {
|
|
899
|
+
fetch('/dsh-tts/stats', { cache: 'no-store' }).then((r) => r.json()).then(setData).catch(() => {})
|
|
900
|
+
}
|
|
901
|
+
React.useEffect(() => { loadStats() }, [])
|
|
902
|
+
const reset = () => {
|
|
903
|
+
fetch('/dsh-tts/stats', { method: 'DELETE' }).then(loadStats).catch(() => {})
|
|
904
|
+
}
|
|
905
|
+
return React.createElement('div', { className: 'dts-block' },
|
|
906
|
+
React.createElement('span', { className: 'dts-sub' }, data
|
|
907
|
+
? t('statTotal') + ': ' + data.total + ' · ' + t('statHits') + ': ' + data.cacheHits + ' · ' + t('statErrors') + ': ' + data.errors
|
|
908
|
+
: t('loading')),
|
|
909
|
+
data && Object.keys(data.providers || {}).map((p) => React.createElement('span', { className: 'dts-sub', key: p },
|
|
910
|
+
p + ': ' + data.providers[p].n + ' / ' + data.providers[p].ms + 'ms')),
|
|
911
|
+
React.createElement('button', { type: 'button', className: 'dts-link', onClick: reset }, t('statReset')),
|
|
912
|
+
)
|
|
913
|
+
}
|
|
914
|
+
|
|
767
915
|
// Карточка во вкладке «Настройки плагинов» — разметка и стили по
|
|
768
916
|
// единому канону (issue #19): li в списке ядра, шапка со сбросом темы,
|
|
769
917
|
// тело с чертой-разделителем. Шеврон — наш, ядро его не даёт.
|
|
@@ -799,12 +947,41 @@ window.__ModuleLoader__.load({
|
|
|
799
947
|
)
|
|
800
948
|
}
|
|
801
949
|
|
|
802
|
-
// Кнопка в строке ввода: видно, что сейчас читается,
|
|
950
|
+
// Кнопка в строке ввода: видно, что сейчас читается, можно остановить и
|
|
951
|
+
// открыть недавние озвучки для реплая.
|
|
803
952
|
function SpeakerControl() {
|
|
804
953
|
const p = usePlayer()
|
|
954
|
+
const [showList, setShowList] = React.useState(false)
|
|
805
955
|
const active = !!p.audio || p.queue.length > 0 || p.busy
|
|
806
|
-
|
|
807
|
-
|
|
956
|
+
const [, force] = React.useReducer((n) => n + 1, 0)
|
|
957
|
+
if (!active && !showList) return null
|
|
958
|
+
const lists = recent.list()
|
|
959
|
+
const row = (text, starred) => React.createElement('div', { className: 'dts-row', key: text.slice(0, 24) + starred },
|
|
960
|
+
React.createElement('button', {
|
|
961
|
+
type: 'button', className: 'dts-link', style: { flex: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
962
|
+
title: text,
|
|
963
|
+
onClick: () => playStandalone(text),
|
|
964
|
+
}, text.slice(0, 80)),
|
|
965
|
+
React.createElement('button', {
|
|
966
|
+
type: 'button', className: 'dts-link', title: '★',
|
|
967
|
+
onClick: () => { recent.toggleFav(text); force() },
|
|
968
|
+
}, starred ? '★' : '☆'),
|
|
969
|
+
)
|
|
970
|
+
return React.createElement('div', { style: { position: 'relative', display: 'inline-flex', gap: '4px', alignItems: 'center' } },
|
|
971
|
+
React.createElement('button', {
|
|
972
|
+
type: 'button', className: 'dts-link', title: 'Недавние',
|
|
973
|
+
onClick: () => setShowList((v) => !v),
|
|
974
|
+
}, '☰'),
|
|
975
|
+
showList ? React.createElement('div', {
|
|
976
|
+
className: 'dts-wrap',
|
|
977
|
+
style: { position: 'absolute', bottom: '36px', right: 0, zIndex: 30, maxHeight: '320px', overflowY: 'auto', background: 'var(--dsw-alias-bg-layer-2)', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '12px', padding: '10px 12px', minWidth: '260px', flexDirection: 'column', gap: '6px', display: 'flex' },
|
|
978
|
+
},
|
|
979
|
+
lists.favs.length ? React.createElement('div', { className: 'dts-h' }, '★') : null,
|
|
980
|
+
lists.favs.map((text) => row(text, true)),
|
|
981
|
+
React.createElement('div', { className: 'dts-h' }, '⟳'),
|
|
982
|
+
lists.items.filter((x) => !lists.favs.includes(x)).map((text) => row(text, false)),
|
|
983
|
+
!lists.items.length ? React.createElement('span', { className: 'dts-sub' }, '—') : null,
|
|
984
|
+
) : null,
|
|
808
985
|
React.createElement('button', {
|
|
809
986
|
type: 'button',
|
|
810
987
|
className: 'dts-link',
|
|
@@ -887,6 +1064,16 @@ window.__ModuleLoader__.load({
|
|
|
887
1064
|
registerSettings(ctx)
|
|
888
1065
|
registerSpeaker(ctx)
|
|
889
1066
|
ctx.effect(() => listenForVoice(), 'dsh-tts: замолкать, когда человек заговорил')
|
|
1067
|
+
ctx.effect(() => {
|
|
1068
|
+
if (typeof window === 'undefined') return () => {}
|
|
1069
|
+
const onKey = (e) => {
|
|
1070
|
+
if (e.ctrlKey && e.key === 'Escape') { togglePause(); e.preventDefault() }
|
|
1071
|
+
else if (e.altKey && (e.key === 's' || e.key === 'S' || e.key === 'ы')) { stopPlayback(); e.preventDefault() }
|
|
1072
|
+
else if (e.altKey && e.key === 'ArrowRight') { skipCurrent(); e.preventDefault() }
|
|
1073
|
+
}
|
|
1074
|
+
window.addEventListener('keydown', onKey)
|
|
1075
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
1076
|
+
}, 'dsh-tts: горячие клавиши')
|
|
890
1077
|
ctx.effect(() => {
|
|
891
1078
|
const timer = setInterval(pollPending, 1000)
|
|
892
1079
|
return () => clearInterval(timer)
|
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 { applyPronunciation, assistantText, speechPhrases, splitSentences, stripForSpeech } from './text.js'
|
|
6
|
+
import { applyNarrationFilters, applyPronunciation, assistantText, detectLang, 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'
|
|
@@ -37,6 +37,7 @@ const RoleOverride = z.object({
|
|
|
37
37
|
model: z.string().default(''),
|
|
38
38
|
voice: z.string().default(''),
|
|
39
39
|
chime: z.string().default(''),
|
|
40
|
+
ssmlStyle: z.string().default(''),
|
|
40
41
|
})
|
|
41
42
|
|
|
42
43
|
export const Config = z.object({
|
|
@@ -48,6 +49,17 @@ export const Config = z.object({
|
|
|
48
49
|
.description('Reuse synthesized audio for repeated phrases instead of paying and waiting again.'),
|
|
49
50
|
cacheMaxMb: z.number().default(100)
|
|
50
51
|
.description('Disk limit for the synthesis cache; least recently used items are evicted first.'),
|
|
52
|
+
narrateQuotesOnly: z.boolean().default(false)
|
|
53
|
+
.description('Speak only quoted fragments.'),
|
|
54
|
+
skipActions: z.boolean().default(false)
|
|
55
|
+
.description('Drop *asterisk action* blocks instead of reading them.'),
|
|
56
|
+
removeRegex: z.string().default('')
|
|
57
|
+
.description('Custom global regex whose matches are removed before synthesis.'),
|
|
58
|
+
autoDetect: z.boolean().default(false)
|
|
59
|
+
.description('Guess ru/en per spoken piece instead of using the language setting.'),
|
|
60
|
+
customBaseUrl: z.string().default('')
|
|
61
|
+
.description('Origin of an OpenAI-compatible /audio/speech endpoint; empty skips it.'),
|
|
62
|
+
customKeyEnv: z.string().default('CUSTOM_TTS_API_KEY'),
|
|
51
63
|
pronunciation: z.array(z.object({
|
|
52
64
|
from: z.string().default(''),
|
|
53
65
|
to: z.string().default(''),
|
|
@@ -263,6 +275,9 @@ export function apply(ctx, config) {
|
|
|
263
275
|
|
|
264
276
|
const llm = ctx.llm
|
|
265
277
|
|
|
278
|
+
// Счётчики синтеза: в памяти, обнуляются рестартом и DELETE /stats.
|
|
279
|
+
const stats = { total: 0, cacheHits: 0, errors: 0, providers: {} }
|
|
280
|
+
|
|
266
281
|
// Кэш синтеза живёт рядом с прочими данными харнесса.
|
|
267
282
|
const speechCache = createSpeechCache({
|
|
268
283
|
root: process.env.DSH_HOME || path.join(os.homedir(), '.dsh'),
|
|
@@ -280,7 +295,7 @@ export function apply(ctx, config) {
|
|
|
280
295
|
return { skipCode: cfg.skipCode !== false, phrases: speechPhrases(cfg.language) }
|
|
281
296
|
}
|
|
282
297
|
function cleanText(raw, cfg) {
|
|
283
|
-
return applyPronunciation(stripForSpeech(raw, cfg.maxChars, cleanOpts(cfg)), cfg.pronunciation, cfg.language)
|
|
298
|
+
return applyPronunciation(applyNarrationFilters(stripForSpeech(raw, cfg.maxChars, cleanOpts(cfg)), cfg), cfg.pronunciation, cfg.language)
|
|
284
299
|
}
|
|
285
300
|
|
|
286
301
|
// Пересказ быстрой моделью. Любая неудача — пустая строка, вызывающая
|
|
@@ -342,18 +357,28 @@ export function apply(ctx, config) {
|
|
|
342
357
|
const cacheAllowed = cfg.cache !== false && text.length <= CACHE_MAX_TEXT
|
|
343
358
|
if (cacheAllowed) {
|
|
344
359
|
for (const provider of order) {
|
|
345
|
-
const hit = await speechCache.get(cacheKey([text, provider, models[provider], voices[provider], role]))
|
|
346
|
-
if (hit)
|
|
360
|
+
const hit = await speechCache.get(cacheKey([text, provider, models[provider], voices[provider], role, ov.ssmlStyle]))
|
|
361
|
+
if (hit) {
|
|
362
|
+
stats.total++
|
|
363
|
+
stats.cacheHits++
|
|
364
|
+
const ps = stats.providers[provider] || (stats.providers[provider] = { n: 0, e: 0, ms: 0 })
|
|
365
|
+
ps.n++
|
|
366
|
+
return { provider, mime: hit.mime, audio: hit.audio, tookMs: 0, cached: true }
|
|
367
|
+
}
|
|
347
368
|
}
|
|
348
369
|
}
|
|
349
370
|
const providers = makeProviders(
|
|
350
371
|
{ resolveKey, fetchImpl: fetch, cfg },
|
|
351
|
-
{ text, lang: cfg.language, signal, models, voices },
|
|
372
|
+
{ text, lang: cfg.autoDetect ? detectLang(text) : cfg.language, signal, models, voices, role },
|
|
352
373
|
)
|
|
353
374
|
const out = await runChain(order, providers)
|
|
375
|
+
stats.total++
|
|
376
|
+
const ps = stats.providers[out.provider] || (stats.providers[out.provider] = { n: 0, e: 0, ms: 0 })
|
|
377
|
+
ps.n++
|
|
378
|
+
ps.ms += out.tookMs || 0
|
|
354
379
|
if (cacheAllowed) {
|
|
355
380
|
const p = out.provider
|
|
356
|
-
speechCache.put(cacheKey([text, p, models[p], voices[p], role]), out.mime, out.audio).catch(() => {})
|
|
381
|
+
speechCache.put(cacheKey([text, p, models[p], voices[p], role, ov.ssmlStyle]), out.mime, out.audio).catch(() => {})
|
|
357
382
|
}
|
|
358
383
|
return out
|
|
359
384
|
}
|
|
@@ -373,12 +398,16 @@ export function apply(ctx, config) {
|
|
|
373
398
|
id,
|
|
374
399
|
sessionId: sid,
|
|
375
400
|
kind: kind || 'speech',
|
|
401
|
+
text,
|
|
376
402
|
provider: out.provider,
|
|
377
403
|
mime: out.mime,
|
|
378
404
|
audioBase64: Buffer.from(out.audio).toString('base64'),
|
|
379
405
|
tookMs: out.tookMs,
|
|
380
406
|
}))
|
|
381
|
-
.catch((err) =>
|
|
407
|
+
.catch((err) => {
|
|
408
|
+
stats.errors++
|
|
409
|
+
settle(id, { id, sessionId: sid, kind: 'error', text, error: String(err && err.message || err) })
|
|
410
|
+
})
|
|
382
411
|
.finally(() => clearTimeout(timer))
|
|
383
412
|
}
|
|
384
413
|
|
|
@@ -519,6 +548,65 @@ export function apply(ctx, config) {
|
|
|
519
548
|
},
|
|
520
549
|
}), 'dsh-tts: /cache route')
|
|
521
550
|
|
|
551
|
+
ctx.effect(() => ctx.webServer.register({
|
|
552
|
+
kind: 'exact',
|
|
553
|
+
path: '/dsh-tts/preview',
|
|
554
|
+
handler: async (req, res) => {
|
|
555
|
+
if (req.method !== 'POST') {
|
|
556
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
557
|
+
return
|
|
558
|
+
}
|
|
559
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
560
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
561
|
+
return
|
|
562
|
+
}
|
|
563
|
+
let body = {}
|
|
564
|
+
try {
|
|
565
|
+
const chunks = []
|
|
566
|
+
for await (const c of req) chunks.push(c)
|
|
567
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
|
|
568
|
+
} catch (e) {
|
|
569
|
+
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
const cfg = live()
|
|
573
|
+
const phrase = String(cfg.language || '').toLowerCase().startsWith('ru') ? 'Проверка голоса.' : 'Voice check.'
|
|
574
|
+
try {
|
|
575
|
+
const probe = Object.assign({}, cfg, {
|
|
576
|
+
chain: [{ provider: body.provider || '', model: body.model || '', voice: body.voice || '' }],
|
|
577
|
+
})
|
|
578
|
+
const out = await synthesize(String(body.text || phrase), probe, null, 'reply')
|
|
579
|
+
writeJson(res, 200, { ok: true, mime: out.mime, audioBase64: Buffer.from(out.audio).toString('base64') })
|
|
580
|
+
} catch (e) {
|
|
581
|
+
writeJson(res, 502, { ok: false, error: { code: 'tts', message: String(e && e.message || e) } })
|
|
582
|
+
}
|
|
583
|
+
},
|
|
584
|
+
}), 'dsh-tts: /preview route')
|
|
585
|
+
|
|
586
|
+
ctx.effect(() => ctx.webServer.register({
|
|
587
|
+
kind: 'exact',
|
|
588
|
+
path: '/dsh-tts/stats',
|
|
589
|
+
handler: async (req, res) => {
|
|
590
|
+
if (req.method === 'GET') {
|
|
591
|
+
writeJson(res, 200, { ok: true, total: stats.total, cacheHits: stats.cacheHits, errors: stats.errors, providers: stats.providers })
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
if (req.method !== 'DELETE') {
|
|
595
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or DELETE' } })
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
599
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
stats.total = 0
|
|
603
|
+
stats.cacheHits = 0
|
|
604
|
+
stats.errors = 0
|
|
605
|
+
stats.providers = {}
|
|
606
|
+
writeJson(res, 200, { ok: true })
|
|
607
|
+
},
|
|
608
|
+
}), 'dsh-tts: /stats route')
|
|
609
|
+
|
|
522
610
|
ctx.effect(() => ctx.webServer.register({
|
|
523
611
|
kind: 'exact',
|
|
524
612
|
path: '/dsh-tts/config',
|
package/lib/keys.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const CLOUD_PROVIDERS = [
|
|
2
|
-
'openai', 'elevenlabs', 'google', 'azure', 'groq', 'deepgram', 'openrouter',
|
|
2
|
+
'openai', 'elevenlabs', 'google', 'azure', 'groq', 'deepgram', 'openrouter', 'custom',
|
|
3
3
|
]
|
|
4
4
|
|
|
5
5
|
export const KEY_ENV_FIELD = {
|
|
@@ -10,6 +10,7 @@ export const KEY_ENV_FIELD = {
|
|
|
10
10
|
groq: 'groqKeyEnv',
|
|
11
11
|
deepgram: 'deepgramKeyEnv',
|
|
12
12
|
openrouter: 'openrouterKeyEnv',
|
|
13
|
+
custom: 'customKeyEnv',
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export const DEFAULT_KEY_ENV = {
|
|
@@ -20,6 +21,7 @@ export const DEFAULT_KEY_ENV = {
|
|
|
20
21
|
groq: 'GROQ_API_KEY',
|
|
21
22
|
deepgram: 'DEEPGRAM_API_KEY',
|
|
22
23
|
openrouter: 'OPENROUTER_API_KEY',
|
|
24
|
+
custom: 'CUSTOM_TTS_API_KEY',
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
package/lib/providers.js
CHANGED
|
@@ -2,7 +2,7 @@ import { edgeSpeak, espeakSpeak, minimaxSpeak, piperSpeak } from './local.js'
|
|
|
2
2
|
|
|
3
3
|
export const PROVIDER_KEYS = [
|
|
4
4
|
'openai', 'elevenlabs', 'google', 'azure', 'groq', 'deepgram', 'openrouter',
|
|
5
|
-
'siliconflow', 'deepinfra', 'fireworks', 'mimo',
|
|
5
|
+
'siliconflow', 'deepinfra', 'fireworks', 'mimo', 'custom',
|
|
6
6
|
'edge', 'piper', 'espeak', 'minimax',
|
|
7
7
|
]
|
|
8
8
|
|
|
@@ -13,6 +13,8 @@ export const OPENAI_COMPATIBLE = {
|
|
|
13
13
|
siliconflow: { baseUrl: 'https://api.siliconflow.cn/v1', keyEnv: 'SILICONFLOW_API_KEY' },
|
|
14
14
|
deepinfra: { baseUrl: 'https://api.deepinfra.com/v1/openai', keyEnv: 'DEEPINFRA_API_KEY' },
|
|
15
15
|
fireworks: { baseUrl: 'https://api.fireworks.ai/inference/v1', keyEnv: 'FIREWORKS_API_KEY' },
|
|
16
|
+
// Пользовательский endpoint: адрес и имя ключа приходят из настроек.
|
|
17
|
+
custom: { baseUrl: '', keyEnv: 'CUSTOM_TTS_API_KEY' },
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export const DEFAULT_MODELS = {
|
|
@@ -28,6 +30,7 @@ export const DEFAULT_MODELS = {
|
|
|
28
30
|
groq: 'playai-tts',
|
|
29
31
|
deepgram: 'aura-asteria-en',
|
|
30
32
|
openrouter: 'openai/gpt-4o-mini-tts-2025-12-15',
|
|
33
|
+
custom: '',
|
|
31
34
|
edge: 'ru-RU-SvetlanaNeural',
|
|
32
35
|
piper: '',
|
|
33
36
|
espeak: 'ru',
|
|
@@ -47,6 +50,7 @@ export const DEFAULT_VOICES = {
|
|
|
47
50
|
groq: 'Fritz-PlayAI',
|
|
48
51
|
deepgram: '',
|
|
49
52
|
openrouter: 'alloy',
|
|
53
|
+
custom: '',
|
|
50
54
|
edge: 'ru-RU-SvetlanaNeural',
|
|
51
55
|
piper: '',
|
|
52
56
|
espeak: 'ru',
|
|
@@ -94,6 +98,7 @@ export function makeProviders(deps, req) {
|
|
|
94
98
|
const token = await resolveKey(keyEnv)
|
|
95
99
|
if (!token) return { ok: false, provider: key, reason: `no ${keyEnv}` }
|
|
96
100
|
const base = cfg[key + 'BaseUrl'] || spec.baseUrl
|
|
101
|
+
if (!base) return { ok: false, provider: key, reason: 'no base url configured' }
|
|
97
102
|
const res = await fetchImpl(base + '/audio/speech', {
|
|
98
103
|
method: 'POST',
|
|
99
104
|
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
@@ -217,9 +222,15 @@ export function makeProviders(deps, req) {
|
|
|
217
222
|
const region = (cfg.azureRegion || '').trim()
|
|
218
223
|
if (!region) return { ok: false, provider: 'azure', reason: 'azureRegion is empty' }
|
|
219
224
|
const voice = pick(DEFAULT_VOICES, voices, 'azure')
|
|
225
|
+
const roleCfg = req.role && cfg.roles && cfg.roles[req.role]
|
|
226
|
+
const style = roleCfg && roleCfg.ssmlStyle
|
|
227
|
+
const safeStyle = style ? String(style).replace(/["<>&]/g, '') : ''
|
|
228
|
+
const inner = safeStyle
|
|
229
|
+
? `<mstts:express-as style="${safeStyle}">${escapeXml(text)}</mstts:express-as>`
|
|
230
|
+
: escapeXml(text)
|
|
220
231
|
const ssml = `<speak version="1.0" xml:lang="${lang || 'en-US'}">`
|
|
221
232
|
+ `<voice xml:lang="${lang || 'en-US'}" name="${voice}">`
|
|
222
|
-
+ `${
|
|
233
|
+
+ `${inner}</voice></speak>`
|
|
223
234
|
const res = await fetchImpl(`https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`, {
|
|
224
235
|
method: 'POST',
|
|
225
236
|
headers: {
|
package/lib/text.js
CHANGED
|
@@ -23,6 +23,31 @@ export function speechPhrases(language) {
|
|
|
23
23
|
return lang.startsWith('ru') ? SPEECH_PHRASES.ru : SPEECH_PHRASES.en
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// Фильтры наррации: вырезания поверх общей чистки, до словаря произношения.
|
|
27
|
+
// Порядок: своя регулярка -> блоки *действий* -> режим «только цитаты».
|
|
28
|
+
export function applyNarrationFilters(text, cfg) {
|
|
29
|
+
let out = String(text || '')
|
|
30
|
+
if (cfg && cfg.removeRegex) {
|
|
31
|
+
try {
|
|
32
|
+
out = out.replace(new RegExp(cfg.removeRegex, 'g'), ' ')
|
|
33
|
+
} catch (badUserRegex) { /* кривая регулярка не роняет озвучку */ }
|
|
34
|
+
}
|
|
35
|
+
if (cfg && cfg.skipActions) out = out.replace(/(^|\s)\*[^*\n]+\*/g, '$1')
|
|
36
|
+
if (cfg && cfg.narrateQuotesOnly) {
|
|
37
|
+
const quotes = [...out.matchAll(/«[^»]*»|"[^"]*"/g)].map((m) => m[0]).join(' ')
|
|
38
|
+
if (quotes) out = quotes
|
|
39
|
+
}
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Грубая эвристика языка куска: где букв больше, тот язык и подсказываем.
|
|
44
|
+
export function detectLang(text) {
|
|
45
|
+
const s = String(text || '')
|
|
46
|
+
const cyr = (s.match(/[\u0400-\u04FF]/g) || []).length
|
|
47
|
+
const lat = (s.match(/[A-Za-z]/g) || []).length
|
|
48
|
+
return cyr > lat ? 'ru' : 'en'
|
|
49
|
+
}
|
|
50
|
+
|
|
26
51
|
// Словарь произношения: правила применяются сверху вниз, каждое по одному
|
|
27
52
|
// разу ко всему тексту. Левая часть — обычный текст; запись вида /…/флаги
|
|
28
53
|
// трактуется как регулярное выражение для тех, кто умеет. whole=true требует
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-tts",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
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",
|