@goodandready/dsh-tts 0.3.7 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -228,6 +228,7 @@ window.__ModuleLoader__.load({
228
228
  player.enabled = !!(meta && meta.speakReplies)
229
229
  if (meta && typeof meta.rate === 'number') player.rate = meta.rate
230
230
  if (meta && meta.chime) player.chime = meta.chime
231
+ if (meta && meta.roles) player.roles = meta.roles
231
232
  if (meta && typeof meta.bargeIn === 'boolean') player.bargeIn = meta.bargeIn
232
233
  if (!player.enabled) return
233
234
  const res = await fetch('/dsh-tts/pending?after=' + encodeURIComponent(player.after || ''), { cache: 'no-store' })
@@ -373,6 +374,24 @@ window.__ModuleLoader__.load({
373
374
  'cacheMaxMb': 'Cache size limit, MB',
374
375
  'cacheMaxMbHint': 'Least recently used items are evicted first.',
375
376
  'clearCache': 'Clear cache',
377
+ 'pronTitle': 'Pronunciation',
378
+ 'pronHint': 'Rules run top-down. Wrap left side in /…/ for a regex; Lang filters the rule.',
379
+ 'pronFrom': 'Replace',
380
+ 'pronTo': 'With',
381
+ 'pronWhole': 'Whole word',
382
+ 'pronLang': 'Lang',
383
+ 'addRule': 'Add rule',
384
+ 'rolesTitle': 'Voice roles',
385
+ 'rolesHint': 'Empty fields inherit the main chain.',
386
+ 'roleReply': 'Reply',
387
+ 'roleApproval': 'Approval',
388
+ 'roleError': 'Error',
389
+ 'longReply': 'Long replies',
390
+ 'longReplyHint': 'truncate: cut at the limit; summarize: short retelling; full: read everything.',
391
+ 'summaryModel': 'Summary model',
392
+ 'summaryModelHint': 'provider/model, empty = conversation default.',
393
+ 'summarySentences': 'Retelling length',
394
+ 'summarySentencesHint': 'Sentences in the spoken retelling.',
376
395
  }
377
396
  const ru = {
378
397
  'general': 'Общее',
@@ -428,6 +447,24 @@ window.__ModuleLoader__.load({
428
447
  'cacheMaxMb': 'Лимит кэша, МБ',
429
448
  'cacheMaxMbHint': 'Сначала вытесняется самое давнее по использованию.',
430
449
  'clearCache': 'Очистить кэш',
450
+ 'pronTitle': 'Произношение',
451
+ 'pronHint': 'Правила работают сверху вниз; /…/ в левой части — регулярка; Язк ограничивает правило.',
452
+ 'pronFrom': 'Заменить',
453
+ 'pronTo': 'На',
454
+ 'pronWhole': 'Целое слово',
455
+ 'pronLang': 'Язк',
456
+ 'addRule': 'Добавить правило',
457
+ 'rolesTitle': 'Голоса ролей',
458
+ 'rolesHint': 'Пустые поля наследуют основную цепочку.',
459
+ 'roleReply': 'Ответ',
460
+ 'roleApproval': 'Подтверждение',
461
+ 'roleError': 'Ошибка',
462
+ 'longReply': 'Длинные ответы',
463
+ 'longReplyHint': 'truncate: обрезать; summarize: короткий пересказ; full: читать целиком.',
464
+ 'summaryModel': 'Модель пересказа',
465
+ 'summaryModelHint': 'provider/model, пусто — как у разговора.',
466
+ 'summarySentences': 'Длина пересказа',
467
+ 'summarySentencesHint': 'Предложений в озвучиваемом пересказе.',
431
468
  }
432
469
 
433
470
  function TtsSection(props) {
@@ -566,6 +603,22 @@ window.__ModuleLoader__.load({
566
603
  React.createElement('div', { className: 'dts-sub' },
567
604
  t('speakRepliesHint')),
568
605
  ),
606
+ React.createElement('div', { className: 'dts-block' },
607
+ React.createElement('div', { className: 'dts-h' }, t('pronTitle')),
608
+ React.createElement(PronEditor, {
609
+ t: t, writable: writable,
610
+ value: draft && draft.pronunciation ? draft.pronunciation : [],
611
+ onChange: (v) => setTop('pronunciation', v),
612
+ }),
613
+ ),
614
+ React.createElement('div', { className: 'dts-block' },
615
+ React.createElement('div', { className: 'dts-h' }, t('rolesTitle')),
616
+ React.createElement(RolesEditor, {
617
+ t: t, writable: writable,
618
+ value: draft && draft.roles ? draft.roles : {},
619
+ onChange: (v) => setTop('roles', v),
620
+ }),
621
+ ),
569
622
  React.createElement('div', { className: 'dts-block' },
570
623
  React.createElement('div', { className: 'dts-h' }, t('chainTitle')),
571
624
  React.createElement('div', { className: 'dts-sub' },
@@ -594,6 +647,14 @@ window.__ModuleLoader__.load({
594
647
  boolField('cache', t('cache'),
595
648
  t('cacheHint')),
596
649
  numberField('cacheMaxMb', t('cacheMaxMb'), t('cacheMaxMbHint'), 10),
650
+ React.createElement('label', { className: 'dts-field' }, t('longReply'),
651
+ React.createElement('select', {
652
+ className: 'dts-input', value: (draft && draft.longReply) || 'truncate', disabled: !writable,
653
+ onChange: (e) => setTop('longReply', e.target.value),
654
+ }, ['truncate', 'summarize', 'full'].map((mode) => React.createElement('option', { key: mode, value: mode }, mode))),
655
+ React.createElement('span', { className: 'dts-sub' }, t('longReplyHint'))),
656
+ textField('summaryModel', t('summaryModel'), t('summaryModelHint')),
657
+ numberField('summarySentences', t('summarySentences'), t('summarySentencesHint'), 1),
597
658
  textField('approvalText', t('approvalText'), t('approvalHint')),
598
659
  textField('questionText', t('questionText'), t('questionHint')),
599
660
  textField('chime', t('chime'), t('chimeHint')),
@@ -614,6 +675,84 @@ window.__ModuleLoader__.load({
614
675
  }
615
676
 
616
677
 
678
+ // Редактор словаря произношения: таблица правил сверху вниз.
679
+ function PronEditor(props) {
680
+ const t = props.t || ((key) => key)
681
+ const rows = Array.isArray(props.value) ? props.value : []
682
+ const change = (i, patch) => {
683
+ props.onChange(rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r)))
684
+ }
685
+ const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
686
+ const add = () => props.onChange(rows.concat([{ from: '', to: '', whole: false, lang: '' }]))
687
+ return React.createElement('div', { className: 'dts-wrap-in' },
688
+ rows.map((rule, i) => React.createElement('div', { className: 'dts-row', key: i },
689
+ React.createElement('input', {
690
+ className: 'dts-input dts-grow', placeholder: t('pronFrom'),
691
+ value: rule.from || '', disabled: !props.writable,
692
+ onChange: (e) => change(i, { from: e.target.value }),
693
+ }),
694
+ React.createElement('input', {
695
+ className: 'dts-input dts-grow', placeholder: t('pronTo'),
696
+ value: rule.to || '', disabled: !props.writable,
697
+ onChange: (e) => change(i, { to: e.target.value }),
698
+ }),
699
+ React.createElement('label', { className: 'dts-sub', title: t('pronWhole') },
700
+ React.createElement('input', {
701
+ type: 'checkbox', checked: !!rule.whole, disabled: !props.writable,
702
+ onChange: (e) => change(i, { whole: e.target.checked }),
703
+ }),
704
+ ),
705
+ React.createElement('input', {
706
+ className: 'dts-input', style: { maxWidth: '70px' }, placeholder: t('pronLang'),
707
+ value: rule.lang || '', disabled: !props.writable,
708
+ onChange: (e) => change(i, { lang: e.target.value }),
709
+ }),
710
+ React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: () => remove(i) }, '\u00d7'),
711
+ )),
712
+ React.createElement('div', { className: 'dts-row' },
713
+ React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: add }, '+'),
714
+ React.createElement('span', { className: 'dts-sub' }, t('pronHint')),
715
+ ),
716
+ )
717
+ }
718
+
719
+ // Роли: переопределение провайдера/модели/голоса/сигнала, пусто — как у цепочки.
720
+ const ROLE_KEYS = ['reply', 'approval', 'error']
721
+
722
+ function RolesEditor(props) {
723
+ const t = props.t || ((key) => key)
724
+ const value = props.value || {}
725
+ const set = (r, patch) => props.onChange(Object.assign({}, value, { [r]: Object.assign({}, value[r], patch) }))
726
+ return React.createElement('div', { className: 'dts-block' },
727
+ React.createElement('span', { className: 'dts-sub' }, t('rolesHint')),
728
+ ROLE_KEYS.map((r) => React.createElement('div', { className: 'dts-row', key: r },
729
+ React.createElement('span', { className: 'dts-sub', style: { minWidth: '110px' } }, t('role_' + r)),
730
+ React.createElement('select', {
731
+ className: 'dts-input', value: (value[r] && value[r].provider) || '', disabled: !props.writable,
732
+ onChange: (e) => set(r, { provider: e.target.value }),
733
+ },
734
+ React.createElement('option', { value: '' }, '—'),
735
+ PROVIDERS.map((p) => React.createElement('option', { key: p, value: p }, p)),
736
+ ),
737
+ React.createElement('input', {
738
+ className: 'dts-input dts-grow', placeholder: 'model',
739
+ value: (value[r] && value[r].model) || '', disabled: !props.writable,
740
+ onChange: (e) => set(r, { model: e.target.value }),
741
+ }),
742
+ React.createElement('input', {
743
+ className: 'dts-input dts-grow', placeholder: 'voice',
744
+ value: (value[r] && value[r].voice) || '', disabled: !props.writable,
745
+ onChange: (e) => set(r, { voice: e.target.value }),
746
+ }),
747
+ React.createElement('input', {
748
+ className: 'dts-input', style: { maxWidth: '90px' }, placeholder: t('chime'),
749
+ value: (value[r] && value[r].chime) || '', disabled: !props.writable,
750
+ onChange: (e) => set(r, { chime: e.target.value }),
751
+ }),
752
+ )),
753
+ )
754
+ }
755
+
617
756
  // Карточка во вкладке «Настройки плагинов» — разметка и стили по
618
757
  // единому канону (issue #19): li в списке ядра, шапка со сбросом темы,
619
758
  // тело с чертой-разделителем. Шеврон — наш, ядро его не даёт.
package/lib/index.js CHANGED
@@ -3,7 +3,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
3
3
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
4
4
  import { runChain } from './chain.js'
5
5
  import { makeProviders, PROVIDER_KEYS, DEFAULT_MODELS, DEFAULT_VOICES } from './providers.js'
6
- import { assistantText, speechPhrases, splitSentences, stripForSpeech } from './text.js'
6
+ import { applyPronunciation, assistantText, speechPhrases, splitSentences, stripForSpeech } from './text.js'
7
7
  import { cacheKey, createSpeechCache } from './cache.js'
8
8
  import fs from 'node:fs'
9
9
  import os from 'node:os'
@@ -19,7 +19,7 @@ import {
19
19
  } from './keys.js'
20
20
 
21
21
  export const name = 'dsh-tts'
22
- export const inject = ['tools', 'credentials', 'webServer', 'settings']
22
+ export const inject = ['tools', 'credentials', 'llm', 'webServer', 'settings']
23
23
 
24
24
  const NS = 'dsh-tts'
25
25
 
@@ -32,6 +32,13 @@ const ChainEntry = z.object({
32
32
  .description('Voice override. Empty means the provider default.'),
33
33
  })
34
34
 
35
+ const RoleOverride = z.object({
36
+ provider: z.string().default(''),
37
+ model: z.string().default(''),
38
+ voice: z.string().default(''),
39
+ chime: z.string().default(''),
40
+ })
41
+
35
42
  export const Config = z.object({
36
43
  speakReplies: z.boolean().default(false)
37
44
  .description('When on, speak each finished agent reply in the Web UI.'),
@@ -41,6 +48,25 @@ export const Config = z.object({
41
48
  .description('Reuse synthesized audio for repeated phrases instead of paying and waiting again.'),
42
49
  cacheMaxMb: z.number().default(100)
43
50
  .description('Disk limit for the synthesis cache; least recently used items are evicted first.'),
51
+ pronunciation: z.array(z.object({
52
+ from: z.string().default(''),
53
+ to: z.string().default(''),
54
+ whole: z.boolean().default(false),
55
+ lang: z.string().default(''),
56
+ })).default([])
57
+ .description('Say this instead of that. Applied top-down; /regex/ form allowed in from.'),
58
+ longReply: z.enum(['truncate', 'summarize', 'full']).default('truncate')
59
+ .description('What to do when a reply exceeds the length limit.'),
60
+ summaryModel: z.string().default('')
61
+ .description('provider/model for the spoken retelling; empty uses the conversation default.'),
62
+ summarySentences: z.number().default(3)
63
+ .description('Sentences in the spoken retelling.'),
64
+ roles: z.object({
65
+ reply: RoleOverride.default({ provider: '', model: '', voice: '', chime: '' }),
66
+ approval: RoleOverride.default({ provider: '', model: '', voice: '', chime: '' }),
67
+ error: RoleOverride.default({ provider: '', model: '', voice: '', chime: '' }),
68
+ }).default({ reply: {}, approval: {}, error: {} })
69
+ .description('Per-role overrides; empty fields inherit the main chain.'),
44
70
  language: z.string().default('ru'),
45
71
  speakAsItGoes: z
46
72
  .boolean()
@@ -235,6 +261,8 @@ export function apply(ctx, config) {
235
261
  }
236
262
  }
237
263
 
264
+ const llm = ctx.llm
265
+
238
266
  // Кэш синтеза живёт рядом с прочими данными харнесса.
239
267
  const speechCache = createSpeechCache({
240
268
  root: process.env.DSH_HOME || path.join(os.homedir(), '.dsh'),
@@ -251,10 +279,48 @@ export function apply(ctx, config) {
251
279
  function cleanOpts(cfg) {
252
280
  return { skipCode: cfg.skipCode !== false, phrases: speechPhrases(cfg.language) }
253
281
  }
282
+ function cleanText(raw, cfg) {
283
+ return applyPronunciation(stripForSpeech(raw, cfg.maxChars, cleanOpts(cfg)), cfg.pronunciation, cfg.language)
284
+ }
285
+
286
+ // Пересказ быстрой моделью. Любая неудача — пустая строка, вызывающая
287
+ // сторона тихо откатывается к обрезке.
288
+ async function summarizeReply(text, cfg, signal) {
289
+ const sentences = Number(cfg.summarySentences) > 0 ? Number(cfg.summarySentences) : 3
290
+ if (!llm || typeof llm.stream !== 'function') return ''
291
+ const opts = {
292
+ messages: [{ role: 'user', content: 'Перескажи текст в ' + sentences + ' предложениях на его языке. Только пересказ, без вступлений:\n\n' + text }],
293
+ signal,
294
+ }
295
+ const m = String(cfg.summaryModel || '')
296
+ if (m) {
297
+ const slash = m.indexOf('/')
298
+ if (slash > 0) { opts.provider = m.slice(0, slash); opts.model = m.slice(slash + 1) }
299
+ else opts.model = m
300
+ }
301
+ const parts = []
302
+ for await (const chunk of llm.stream(opts)) {
303
+ const piece = chunk && (chunk.text || (chunk.delta && chunk.delta.text)) || ''
304
+ if (piece) parts.push(piece)
305
+ }
306
+ return parts.join('').trim()
307
+ }
254
308
 
255
- async function synthesize(rawText, cfg, signal) {
256
- const text = stripForSpeech(rawText, cfg.maxChars, cleanOpts(cfg))
309
+ async function synthesize(rawText, cfg, signal, role = 'reply') {
310
+ let text = cleanText(rawText, cfg)
257
311
  if (!text) throw new Error('nothing to speak')
312
+ const maxChars = Number(cfg.maxChars) > 0 ? Number(cfg.maxChars) : 0
313
+ const overLimit = maxChars > 0 && text.length > maxChars
314
+ if (overLimit && cfg.longReply !== 'full') {
315
+ let cut = text.slice(0, maxChars)
316
+ if (cfg.longReply === 'summarize') {
317
+ try {
318
+ const retold = await summarizeReply(text.slice(0, 8000), cfg, signal)
319
+ if (retold) cut = speechPhrases(cfg.language).summaryIntro + ' ' + retold
320
+ } catch (llmDown) { /* тихий откат к обрезке */ }
321
+ }
322
+ text = cut
323
+ }
258
324
  const models = {}
259
325
  const voices = {}
260
326
  const order = []
@@ -264,12 +330,19 @@ export function apply(ctx, config) {
264
330
  models[entry.provider] = entry.model || DEFAULT_MODELS[entry.provider]
265
331
  voices[entry.provider] = entry.voice || DEFAULT_VOICES[entry.provider]
266
332
  }
333
+ // Ролевые переопределения: пустые поля наследуют основную цепочку.
334
+ const ov = (cfg.roles && cfg.roles[role]) || {}
335
+ if (ov.provider && PROVIDER_KEYS.includes(ov.provider)) order.unshift(ov.provider)
336
+ if (order.length) {
337
+ if (ov.model) models[order[0]] = ov.model
338
+ if (ov.voice) voices[order[0]] = ov.voice
339
+ }
267
340
  // Скорость воспроизведения в ключ не входит: ею управляет браузер,
268
341
  // на синтез она не влияет.
269
342
  const cacheAllowed = cfg.cache !== false && text.length <= CACHE_MAX_TEXT
270
343
  if (cacheAllowed) {
271
344
  for (const provider of order) {
272
- const hit = await speechCache.get(cacheKey([text, provider, models[provider], voices[provider]]))
345
+ const hit = await speechCache.get(cacheKey([text, provider, models[provider], voices[provider], role]))
273
346
  if (hit) return { provider, mime: hit.mime, audio: hit.audio, tookMs: 0, cached: true }
274
347
  }
275
348
  }
@@ -280,7 +353,7 @@ export function apply(ctx, config) {
280
353
  const out = await runChain(order, providers)
281
354
  if (cacheAllowed) {
282
355
  const p = out.provider
283
- speechCache.put(cacheKey([text, p, models[p], voices[p]]), out.mime, out.audio).catch(() => {})
356
+ speechCache.put(cacheKey([text, p, models[p], voices[p], role]), out.mime, out.audio).catch(() => {})
284
357
  }
285
358
  return out
286
359
  }
@@ -290,11 +363,12 @@ export function apply(ctx, config) {
290
363
  // обгоняли бы длинные.
291
364
  function speakPiece(sid, text, cfg, kind) {
292
365
  if (!text) return
366
+ const role = kind === 'notice' ? 'approval' : kind === 'error' ? 'error' : 'reply'
293
367
  const id = `u${++utteranceSeq}`
294
368
  const controller = new AbortController()
295
369
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
296
370
  reserve(id)
297
- synthesize(text, cfg, controller.signal)
371
+ synthesize(text, cfg, controller.signal, role)
298
372
  .then((out) => settle(id, {
299
373
  id,
300
374
  sessionId: sid,
@@ -310,9 +384,15 @@ export function apply(ctx, config) {
310
384
 
311
385
  // Объявление: сначала короткий сигнал, затем фраза. Сигнал — не файл, его
312
386
  // рисует сам браузер, поэтому ничего не качается и не хранится.
387
+ function roleChime(cfg, role) {
388
+ const ov = (cfg.roles && cfg.roles[role]) || {}
389
+ return ov.chime || cfg.chime
390
+ }
391
+
313
392
  function announce(sid, text, cfg) {
314
- if (cfg.chime && cfg.chime !== 'none') {
315
- enqueue({ id: `u${++utteranceSeq}`, sessionId: sid, kind: 'chime', chime: cfg.chime })
393
+ const chime = roleChime(cfg, 'approval')
394
+ if (chime && chime !== 'none') {
395
+ enqueue({ id: `u${++utteranceSeq}`, sessionId: sid, kind: 'chime', chime })
316
396
  }
317
397
  speakPiece(sid, text, cfg, 'notice')
318
398
  }
@@ -361,7 +441,7 @@ export function apply(ctx, config) {
361
441
  // предложениями, и очередь проигрывается по порядку. Ждать конца хода
362
442
  // значит молчать всё время, пока агент работает.
363
443
  if (cfg.speakAsItGoes) {
364
- const ready = stripForSpeech(text, cfg.maxChars, cleanOpts(cfg))
444
+ const ready = cleanText(text, cfg)
365
445
  for (const piece of splitSentences(ready, cfg.sentenceChars)) speakPiece(sid, piece, cfg)
366
446
  return
367
447
  }
@@ -376,7 +456,7 @@ export function apply(ctx, config) {
376
456
  const cur = collectors.get(sid)
377
457
  collectors.delete(sid)
378
458
  const raw = cur && Array.isArray(cur.parts) ? cur.parts.join('\n') : ''
379
- const text = stripForSpeech(raw, cfg.maxChars, cleanOpts(cfg))
459
+ const text = cleanText(raw, cfg)
380
460
  if (!text) return
381
461
  const id = `u${++utteranceSeq}`
382
462
  const controller = new AbortController()
@@ -416,6 +496,7 @@ export function apply(ctx, config) {
416
496
  bargeIn: cfg.bargeIn,
417
497
  language: cfg.language,
418
498
  chain: cfg.chain,
499
+ roles: cfg.roles,
419
500
  providers: PROVIDER_KEYS,
420
501
  })
421
502
  },
@@ -560,7 +641,7 @@ export function apply(ctx, config) {
560
641
  }
561
642
  let payload
562
643
  try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
563
- const text = stripForSpeech(payload.text, live().maxChars, cleanOpts(live()))
644
+ const text = cleanText(payload.text, live())
564
645
  if (!text) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
565
646
  const cfg = live()
566
647
  const controller = new AbortController()
package/lib/text.js CHANGED
@@ -14,8 +14,8 @@ export function blocksToText(content) {
14
14
  // Короткие пометки вместо выброшенной разметки. Формулировки выбираются по
15
15
  // языку озвучки из настроек; подстановки {n} заполняются числом строк.
16
16
  export const SPEECH_PHRASES = {
17
- en: { codeBlock: 'code block, {n} lines', table: 'table, {n} rows' },
18
- ru: { codeBlock: 'блок кода, {n} строк', table: 'таблица, {n} строк' },
17
+ en: { codeBlock: 'code block, {n} lines', table: 'table, {n} rows', summaryIntro: 'Summary of the reply.' },
18
+ ru: { codeBlock: 'блок кода, {n} строк', table: 'таблица, {n} строк', summaryIntro: 'Пересказ ответа.' },
19
19
  }
20
20
 
21
21
  export function speechPhrases(language) {
@@ -23,6 +23,34 @@ export function speechPhrases(language) {
23
23
  return lang.startsWith('ru') ? SPEECH_PHRASES.ru : SPEECH_PHRASES.en
24
24
  }
25
25
 
26
+ // Словарь произношения: правила применяются сверху вниз, каждое по одному
27
+ // разу ко всему тексту. Левая часть — обычный текст; запись вида /…/флаги
28
+ // трактуется как регулярное выражение для тех, кто умеет. whole=true требует
29
+ // совпадения целого слова (границы по буквам/цифрам Unicode).
30
+ function escapeRe(s) {
31
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
32
+ }
33
+
34
+ export function applyPronunciation(text, rules, language) {
35
+ let out = String(text || '')
36
+ const lang = String(language || '').toLowerCase()
37
+ const list = Array.isArray(rules) ? rules : []
38
+ for (const rule of list) {
39
+ if (!rule || typeof rule.from !== 'string' || !rule.from) continue
40
+ if (rule.lang && !lang.startsWith(String(rule.lang).toLowerCase())) continue
41
+ const to = typeof rule.to === 'string' ? rule.to : ''
42
+ try {
43
+ const rx = /^\/(.+)\/([a-z]*)$/s.exec(rule.from)
44
+ let re
45
+ if (rx) re = new RegExp(rx[1], rx[2].includes('g') ? rx[2] : rx[2] + 'g')
46
+ else if (rule.whole) re = new RegExp('(?<![\\p{L}\\p{N}])' + escapeRe(rule.from) + '(?![\\p{L}\\p{N}])', 'gu')
47
+ else re = new RegExp(escapeRe(rule.from), 'g')
48
+ out = out.replace(re, to)
49
+ } catch (badRule) { /* кривое правило не роняет озвучку */ }
50
+ }
51
+ return out
52
+ }
53
+
26
54
  function fill(template, n) {
27
55
  return String(template).replace('{n}', String(n))
28
56
  }
@@ -54,8 +82,9 @@ export function stripForSpeech(raw, maxChars, opts) {
54
82
  text = text.replace(/[#*_>]+/g, ' ')
55
83
  text = text.replace(/MEDIA:[^\s]+/g, ' ')
56
84
  text = text.replace(/\s+/g, ' ').trim()
57
- const limit = Number(maxChars) > 0 ? Number(maxChars) : 4000
58
- if (text.length > limit) text = text.slice(0, limit)
85
+ // maxChars === 0 отключает обрезку; не задан — дефолтные 4000.
86
+ const requested = Number(maxChars)
87
+ if (requested > 0 && text.length > requested) text = text.slice(0, requested)
59
88
  return text
60
89
  }
61
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "Text-to-speech for DeepSeek Harness: speak agent replies in the Web UI with a provider fallback chain (OpenAI, ElevenLabs, Google, Azure, Groq, Deepgram, OpenRouter, Edge, Piper, eSpeak).",
5
5
  "license": "MIT",
6
6
  "type": "module",