@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 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
- if (!active) return null
807
- return React.createElement('div', { style: { display: 'inline-flex', gap: '4px', alignItems: 'center' } },
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) return { provider, mime: hit.mime, audio: hit.audio, tookMs: 0, cached: true }
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) => settle(id, { id, sessionId: sid, kind: kind || 'speech', text, error: String(err && err.message || 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
- + `${escapeXml(text)}</voice></speak>`
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.10",
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",