@goodandready/dsh-tts 0.3.9 → 0.3.11
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 +214 -14
- package/lib/index.js +98 -8
- 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
|
@@ -4,13 +4,24 @@ window.__ModuleLoader__.load({
|
|
|
4
4
|
var module = { exports: {} }
|
|
5
5
|
var exports = module.exports
|
|
6
6
|
const React = require('react')
|
|
7
|
+
|
|
8
|
+
// Значок раскрытия — ядровый; свой SVG только как фолбэк для сборок
|
|
9
|
+
// без набора примитивов. Иконка всегда смотрит вниз, раскрытие
|
|
10
|
+
// поворачивает её классом.
|
|
11
|
+
let ChevronIcon = null
|
|
12
|
+
try {
|
|
13
|
+
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
14
|
+
ChevronIcon = primitives && primitives.IconChevronDownOutline14
|
|
15
|
+
} catch (noPrimitives) {
|
|
16
|
+
ChevronIcon = null
|
|
17
|
+
}
|
|
7
18
|
const NS = 'dsh-tts'
|
|
8
19
|
const PROVIDERS = [
|
|
9
20
|
'openai', 'elevenlabs', 'google', 'azure', 'groq', 'deepgram', 'openrouter',
|
|
10
21
|
'edge', 'piper', 'espeak',
|
|
11
22
|
]
|
|
12
23
|
const CLOUD = {
|
|
13
|
-
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,
|
|
14
25
|
}
|
|
15
26
|
const MODEL_HINT = {
|
|
16
27
|
openai: 'gpt-4o-mini-tts',
|
|
@@ -202,6 +213,13 @@ window.__ModuleLoader__.load({
|
|
|
202
213
|
playerChanged()
|
|
203
214
|
}
|
|
204
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
|
+
|
|
205
223
|
// Человек заговорил — замолкаем.
|
|
206
224
|
//
|
|
207
225
|
// Событие приходит из плагина голосового ввода, но никакой связи с ним
|
|
@@ -240,12 +258,55 @@ window.__ModuleLoader__.load({
|
|
|
240
258
|
// опроса, иначе порядок фраз рассыплется.
|
|
241
259
|
if (item.kind === 'reserved') break
|
|
242
260
|
player.after = item.id
|
|
261
|
+
if (item.text) recent.add(item.text)
|
|
243
262
|
player.queue.push(item)
|
|
244
263
|
}
|
|
245
264
|
if (player.queue.length) drainQueue()
|
|
246
265
|
} catch (hostRestarting) { /* хост перезапускается */ }
|
|
247
266
|
}
|
|
248
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
|
+
|
|
249
310
|
function credOf(map, provider) {
|
|
250
311
|
return (map && map[provider]) || { configured: false, writable: true, ref: '' }
|
|
251
312
|
}
|
|
@@ -291,6 +352,7 @@ window.__ModuleLoader__.load({
|
|
|
291
352
|
}),
|
|
292
353
|
React.createElement('button', { type: 'button', className: 'dts-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '\u2191'),
|
|
293
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'),
|
|
294
356
|
React.createElement('button', { type: 'button', className: 'dts-mini', title: t('remove'), disabled: !props.writable, onClick: () => remove(i) }, '\u00d7'),
|
|
295
357
|
),
|
|
296
358
|
cloud ? React.createElement('div', { className: 'dts-row' },
|
|
@@ -392,6 +454,25 @@ window.__ModuleLoader__.load({
|
|
|
392
454
|
'summaryModelHint': 'provider/model, empty = conversation default.',
|
|
393
455
|
'summarySentences': 'Retelling length',
|
|
394
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.',
|
|
395
476
|
}
|
|
396
477
|
const ru = {
|
|
397
478
|
'general': 'Общее',
|
|
@@ -465,6 +546,25 @@ window.__ModuleLoader__.load({
|
|
|
465
546
|
'summaryModelHint': 'provider/model, пусто — как у разговора.',
|
|
466
547
|
'summarySentences': 'Длина пересказа',
|
|
467
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а.',
|
|
468
568
|
}
|
|
469
569
|
|
|
470
570
|
function TtsSection(props) {
|
|
@@ -564,6 +664,19 @@ window.__ModuleLoader__.load({
|
|
|
564
664
|
clearCacheNow().catch((e) => setErr(String(e && e.message ? e.message : e)))
|
|
565
665
|
}
|
|
566
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
|
+
|
|
567
680
|
const boolField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
|
|
568
681
|
React.createElement('input', {
|
|
569
682
|
type: 'checkbox', checked: !!(draft && draft[key]), disabled: !writable,
|
|
@@ -614,11 +727,15 @@ window.__ModuleLoader__.load({
|
|
|
614
727
|
React.createElement('div', { className: 'dts-block' },
|
|
615
728
|
React.createElement('div', { className: 'dts-h' }, t('rolesTitle')),
|
|
616
729
|
React.createElement(RolesEditor, {
|
|
617
|
-
t: t, writable: writable,
|
|
730
|
+
t: t, writable: writable, onPreview: onPreview,
|
|
618
731
|
value: draft && draft.roles ? draft.roles : {},
|
|
619
732
|
onChange: (v) => setTop('roles', v),
|
|
620
733
|
}),
|
|
621
734
|
),
|
|
735
|
+
React.createElement('div', { className: 'dts-block' },
|
|
736
|
+
React.createElement('div', { className: 'dts-h' }, t('statsTitle')),
|
|
737
|
+
React.createElement(StatsPanel, { t: t }),
|
|
738
|
+
),
|
|
622
739
|
React.createElement('div', { className: 'dts-block' },
|
|
623
740
|
React.createElement('div', { className: 'dts-h' }, t('chainTitle')),
|
|
624
741
|
React.createElement('div', { className: 'dts-sub' },
|
|
@@ -631,6 +748,7 @@ window.__ModuleLoader__.load({
|
|
|
631
748
|
onDraft: setDraftKey,
|
|
632
749
|
onCommitKey: onCommitKey,
|
|
633
750
|
onClearKey: clearKey,
|
|
751
|
+
onPreview: onPreview,
|
|
634
752
|
}),
|
|
635
753
|
),
|
|
636
754
|
React.createElement('div', { className: 'dts-block' },
|
|
@@ -655,6 +773,20 @@ window.__ModuleLoader__.load({
|
|
|
655
773
|
React.createElement('span', { className: 'dts-sub' }, t('longReplyHint'))),
|
|
656
774
|
textField('summaryModel', t('summaryModel'), t('summaryModelHint')),
|
|
657
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')),
|
|
658
790
|
textField('approvalText', t('approvalText'), t('approvalHint')),
|
|
659
791
|
textField('questionText', t('questionText'), t('questionHint')),
|
|
660
792
|
textField('chime', t('chime'), t('chimeHint')),
|
|
@@ -749,10 +881,37 @@ window.__ModuleLoader__.load({
|
|
|
749
881
|
value: (value[r] && value[r].chime) || '', disabled: !props.writable,
|
|
750
882
|
onChange: (e) => set(r, { chime: e.target.value }),
|
|
751
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'),
|
|
752
890
|
)),
|
|
753
891
|
)
|
|
754
892
|
}
|
|
755
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
|
+
|
|
756
915
|
// Карточка во вкладке «Настройки плагинов» — разметка и стили по
|
|
757
916
|
// единому канону (issue #19): li в списке ядра, шапка со сбросом темы,
|
|
758
917
|
// тело с чертой-разделителем. Шеврон — наш, ядро его не даёт.
|
|
@@ -770,15 +929,17 @@ window.__ModuleLoader__.load({
|
|
|
770
929
|
React.createElement('div', { className: 'dts-title' }, t('title')),
|
|
771
930
|
React.createElement('div', { className: 'dts-sub' }, t('cardHint')),
|
|
772
931
|
),
|
|
773
|
-
|
|
774
|
-
className: 'dts-chev' + (open ? ' dts-chevOpen' : '')
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
932
|
+
ChevronIcon
|
|
933
|
+
? React.createElement(ChevronIcon, { className: 'dts-chev' + (open ? ' dts-chevOpen' : '') })
|
|
934
|
+
: React.createElement('svg', {
|
|
935
|
+
className: 'dts-chev' + (open ? ' dts-chevOpen' : ''),
|
|
936
|
+
width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none',
|
|
937
|
+
},
|
|
938
|
+
React.createElement('path', {
|
|
939
|
+
d: 'M4 6l4 4 4-4', stroke: 'currentColor', 'stroke-width': 1.5,
|
|
940
|
+
'stroke-linecap': 'round', 'stroke-linejoin': 'round',
|
|
941
|
+
}),
|
|
942
|
+
),
|
|
782
943
|
),
|
|
783
944
|
open ? React.createElement('div', { className: 'dts-body' },
|
|
784
945
|
React.createElement(TtsSection, Object.assign({}, props, { compact: true })),
|
|
@@ -786,12 +947,41 @@ window.__ModuleLoader__.load({
|
|
|
786
947
|
)
|
|
787
948
|
}
|
|
788
949
|
|
|
789
|
-
// Кнопка в строке ввода: видно, что сейчас читается,
|
|
950
|
+
// Кнопка в строке ввода: видно, что сейчас читается, можно остановить и
|
|
951
|
+
// открыть недавние озвучки для реплая.
|
|
790
952
|
function SpeakerControl() {
|
|
791
953
|
const p = usePlayer()
|
|
954
|
+
const [showList, setShowList] = React.useState(false)
|
|
792
955
|
const active = !!p.audio || p.queue.length > 0 || p.busy
|
|
793
|
-
|
|
794
|
-
|
|
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,
|
|
795
985
|
React.createElement('button', {
|
|
796
986
|
type: 'button',
|
|
797
987
|
className: 'dts-link',
|
|
@@ -874,6 +1064,16 @@ window.__ModuleLoader__.load({
|
|
|
874
1064
|
registerSettings(ctx)
|
|
875
1065
|
registerSpeaker(ctx)
|
|
876
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: горячие клавиши')
|
|
877
1077
|
ctx.effect(() => {
|
|
878
1078
|
const timer = setInterval(pollPending, 1000)
|
|
879
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(''),
|
|
@@ -56,7 +68,7 @@ export const Config = z.object({
|
|
|
56
68
|
})).default([])
|
|
57
69
|
.description('Say this instead of that. Applied top-down; /regex/ form allowed in from.'),
|
|
58
70
|
longReply: z.string().default('truncate')
|
|
59
|
-
.description('What to do when a reply exceeds the length limit.'),
|
|
71
|
+
.description('What to do when a reply exceeds the length limit.'), .description('What to do when a reply exceeds the length limit.'),
|
|
60
72
|
summaryModel: z.string().default('')
|
|
61
73
|
.description('provider/model for the spoken retelling; empty uses the conversation default.'),
|
|
62
74
|
summarySentences: z.number().default(3)
|
|
@@ -263,6 +275,11 @@ 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
|
+
|
|
281
|
+
const llm = ctx.llm
|
|
282
|
+
|
|
266
283
|
// Кэш синтеза живёт рядом с прочими данными харнесса.
|
|
267
284
|
const speechCache = createSpeechCache({
|
|
268
285
|
root: process.env.DSH_HOME || path.join(os.homedir(), '.dsh'),
|
|
@@ -280,7 +297,7 @@ export function apply(ctx, config) {
|
|
|
280
297
|
return { skipCode: cfg.skipCode !== false, phrases: speechPhrases(cfg.language) }
|
|
281
298
|
}
|
|
282
299
|
function cleanText(raw, cfg) {
|
|
283
|
-
return applyPronunciation(stripForSpeech(raw, cfg.maxChars, cleanOpts(cfg)), cfg.pronunciation, cfg.language)
|
|
300
|
+
return applyPronunciation(applyNarrationFilters(stripForSpeech(raw, cfg.maxChars, cleanOpts(cfg)), cfg), cfg.pronunciation, cfg.language)
|
|
284
301
|
}
|
|
285
302
|
|
|
286
303
|
// Пересказ быстрой моделью. Любая неудача — пустая строка, вызывающая
|
|
@@ -342,18 +359,28 @@ export function apply(ctx, config) {
|
|
|
342
359
|
const cacheAllowed = cfg.cache !== false && text.length <= CACHE_MAX_TEXT
|
|
343
360
|
if (cacheAllowed) {
|
|
344
361
|
for (const provider of order) {
|
|
345
|
-
const hit = await speechCache.get(cacheKey([text, provider, models[provider], voices[provider], role]))
|
|
346
|
-
if (hit)
|
|
362
|
+
const hit = await speechCache.get(cacheKey([text, provider, models[provider], voices[provider], role, ov.ssmlStyle]))
|
|
363
|
+
if (hit) {
|
|
364
|
+
stats.total++
|
|
365
|
+
stats.cacheHits++
|
|
366
|
+
const ps = stats.providers[provider] || (stats.providers[provider] = { n: 0, e: 0, ms: 0 })
|
|
367
|
+
ps.n++
|
|
368
|
+
return { provider, mime: hit.mime, audio: hit.audio, tookMs: 0, cached: true }
|
|
369
|
+
}
|
|
347
370
|
}
|
|
348
371
|
}
|
|
349
372
|
const providers = makeProviders(
|
|
350
373
|
{ resolveKey, fetchImpl: fetch, cfg },
|
|
351
|
-
{ text, lang: cfg.language, signal, models, voices },
|
|
374
|
+
{ text, lang: cfg.autoDetect ? detectLang(text) : cfg.language, signal, models, voices, role },
|
|
352
375
|
)
|
|
353
376
|
const out = await runChain(order, providers)
|
|
377
|
+
stats.total++
|
|
378
|
+
const ps = stats.providers[out.provider] || (stats.providers[out.provider] = { n: 0, e: 0, ms: 0 })
|
|
379
|
+
ps.n++
|
|
380
|
+
ps.ms += out.tookMs || 0
|
|
354
381
|
if (cacheAllowed) {
|
|
355
382
|
const p = out.provider
|
|
356
|
-
speechCache.put(cacheKey([text, p, models[p], voices[p], role]), out.mime, out.audio).catch(() => {})
|
|
383
|
+
speechCache.put(cacheKey([text, p, models[p], voices[p], role, ov.ssmlStyle]), out.mime, out.audio).catch(() => {})
|
|
357
384
|
}
|
|
358
385
|
return out
|
|
359
386
|
}
|
|
@@ -373,12 +400,16 @@ export function apply(ctx, config) {
|
|
|
373
400
|
id,
|
|
374
401
|
sessionId: sid,
|
|
375
402
|
kind: kind || 'speech',
|
|
403
|
+
text,
|
|
376
404
|
provider: out.provider,
|
|
377
405
|
mime: out.mime,
|
|
378
406
|
audioBase64: Buffer.from(out.audio).toString('base64'),
|
|
379
407
|
tookMs: out.tookMs,
|
|
380
408
|
}))
|
|
381
|
-
.catch((err) =>
|
|
409
|
+
.catch((err) => {
|
|
410
|
+
stats.errors++
|
|
411
|
+
settle(id, { id, sessionId: sid, kind: 'error', text, error: String(err && err.message || err) })
|
|
412
|
+
})
|
|
382
413
|
.finally(() => clearTimeout(timer))
|
|
383
414
|
}
|
|
384
415
|
|
|
@@ -519,6 +550,65 @@ export function apply(ctx, config) {
|
|
|
519
550
|
},
|
|
520
551
|
}), 'dsh-tts: /cache route')
|
|
521
552
|
|
|
553
|
+
ctx.effect(() => ctx.webServer.register({
|
|
554
|
+
kind: 'exact',
|
|
555
|
+
path: '/dsh-tts/preview',
|
|
556
|
+
handler: async (req, res) => {
|
|
557
|
+
if (req.method !== 'POST') {
|
|
558
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
562
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
let body = {}
|
|
566
|
+
try {
|
|
567
|
+
const chunks = []
|
|
568
|
+
for await (const c of req) chunks.push(c)
|
|
569
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
|
|
570
|
+
} catch (e) {
|
|
571
|
+
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
572
|
+
return
|
|
573
|
+
}
|
|
574
|
+
const cfg = live()
|
|
575
|
+
const phrase = String(cfg.language || '').toLowerCase().startsWith('ru') ? 'Проверка голоса.' : 'Voice check.'
|
|
576
|
+
try {
|
|
577
|
+
const probe = Object.assign({}, cfg, {
|
|
578
|
+
chain: [{ provider: body.provider || '', model: body.model || '', voice: body.voice || '' }],
|
|
579
|
+
})
|
|
580
|
+
const out = await synthesize(String(body.text || phrase), probe, null, 'reply')
|
|
581
|
+
writeJson(res, 200, { ok: true, mime: out.mime, audioBase64: Buffer.from(out.audio).toString('base64') })
|
|
582
|
+
} catch (e) {
|
|
583
|
+
writeJson(res, 502, { ok: false, error: { code: 'tts', message: String(e && e.message || e) } })
|
|
584
|
+
}
|
|
585
|
+
},
|
|
586
|
+
}), 'dsh-tts: /preview route')
|
|
587
|
+
|
|
588
|
+
ctx.effect(() => ctx.webServer.register({
|
|
589
|
+
kind: 'exact',
|
|
590
|
+
path: '/dsh-tts/stats',
|
|
591
|
+
handler: async (req, res) => {
|
|
592
|
+
if (req.method === 'GET') {
|
|
593
|
+
writeJson(res, 200, { ok: true, total: stats.total, cacheHits: stats.cacheHits, errors: stats.errors, providers: stats.providers })
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
if (req.method !== 'DELETE') {
|
|
597
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or DELETE' } })
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
601
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
602
|
+
return
|
|
603
|
+
}
|
|
604
|
+
stats.total = 0
|
|
605
|
+
stats.cacheHits = 0
|
|
606
|
+
stats.errors = 0
|
|
607
|
+
stats.providers = {}
|
|
608
|
+
writeJson(res, 200, { ok: true })
|
|
609
|
+
},
|
|
610
|
+
}), 'dsh-tts: /stats route')
|
|
611
|
+
|
|
522
612
|
ctx.effect(() => ctx.webServer.register({
|
|
523
613
|
kind: 'exact',
|
|
524
614
|
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.11",
|
|
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",
|