@goodandready/dsh-tts 0.3.16 → 0.3.21

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/index.js CHANGED
@@ -8,6 +8,8 @@ import { cacheKey, createSpeechCache } from './cache.js'
8
8
  import fs from 'node:fs'
9
9
  import os from 'node:os'
10
10
  import path from 'node:path'
11
+ import { createModelManager } from './engines/manager.js'
12
+ import { EngineRegistry } from './engines/types.js'
11
13
  import {
12
14
  CLOUD_PROVIDERS,
13
15
  assertCredentialRef,
@@ -43,6 +45,14 @@ const RoleOverride = z.object({
43
45
  export const Config = z.object({
44
46
  speakReplies: z.boolean().default(false)
45
47
  .description('When on, speak each finished agent reply in the Web UI.'),
48
+ enableLocalEngines: z.boolean().default(false)
49
+ .description('Enable offline local TTS engines (Kokoro / F5).'),
50
+ kokoroEnabled: z.boolean().default(false)
51
+ .description('Use local Kokoro-82M on CPU when installed.'),
52
+ f5Enabled: z.boolean().default(false)
53
+ .description('Use local F5-TTS on GPU when installed.'),
54
+ streamingEnabled: z.boolean().default(true)
55
+ .description('Stream audio in real-time using AudioWorklet and SSE for sub-300ms latency.'),
46
56
  skipCode: z.boolean().default(true)
47
57
  .description('Replace fenced code blocks with a short spoken notice instead of reading them aloud.'),
48
58
  cache: z.boolean().default(true)
@@ -67,18 +77,26 @@ export const Config = z.object({
67
77
  lang: z.string().default(''),
68
78
  })).default([])
69
79
  .description('Say this instead of that. Applied top-down; /regex/ form allowed in from.'),
80
+ enableItDictionary: z.boolean().default(true)
81
+ .description('Auto-correct common IT terminology pronunciation (SQL, Nginx, K8s, etc.).'),
70
82
  longReply: z.string().default('truncate')
71
83
  .description('What to do when a reply exceeds the length limit.'),
72
84
  summaryModel: z.string().default('')
73
85
  .description('provider/model for the spoken retelling; empty uses the conversation default.'),
74
86
  summarySentences: z.number().default(3)
75
87
  .description('Sentences in the spoken retelling.'),
76
- roles: z.object({
77
- reply: RoleOverride.default({ provider: '', model: '', voice: '', chime: '' }),
78
- approval: RoleOverride.default({ provider: '', model: '', voice: '', chime: '' }),
79
- error: RoleOverride.default({ provider: '', model: '', voice: '', chime: '' }),
80
- }).default({ reply: {}, approval: {}, error: {} })
81
- .description('Per-role overrides; empty fields inherit the main chain.'),
88
+ roles: z.dict(RoleOverride).default({
89
+ reply: { provider: '', model: '', voice: '', chime: '', ssmlStyle: '' },
90
+ approval: { provider: '', model: '', voice: '', chime: '', ssmlStyle: '' },
91
+ error: { provider: '', model: '', voice: '', chime: '', ssmlStyle: '' },
92
+ })
93
+ .description('Per-role and subagent overrides; empty fields inherit the main chain.'),
94
+ voiceDuplexEnabled: z.boolean().default(false)
95
+ .description('Full-duplex voice conversation with dsh-voice.'),
96
+ vadBargeIn: z.boolean().default(true)
97
+ .description('Mute speech synthesis immediately when voice activity is detected.'),
98
+ messengerTtsEnabled: z.boolean().default(false)
99
+ .description('Send synthesized voice notes to messengers via @goodandready/dsh-messenger-gateway.'),
82
100
  language: z.string().default('ru'),
83
101
  speakAsItGoes: z
84
102
  .boolean()
@@ -199,6 +217,18 @@ export function apply(ctx, config) {
199
217
 
200
218
  const pending = []
201
219
  const collectors = new Map()
220
+ const streamSubscribers = new Set()
221
+
222
+ const modelManager = createModelManager({
223
+ root: process.env.DSH_HOME || path.join(os.homedir(), '.dsh'),
224
+ })
225
+
226
+ function broadcastStream(event, data) {
227
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
228
+ for (const res of streamSubscribers) {
229
+ try { res.write(payload) } catch { streamSubscribers.delete(res) }
230
+ }
231
+ }
202
232
 
203
233
  async function resolveKey(ref) {
204
234
  try {
@@ -295,7 +325,12 @@ export function apply(ctx, config) {
295
325
  return { skipCode: cfg.skipCode !== false, phrases: speechPhrases(cfg.language) }
296
326
  }
297
327
  function cleanText(raw, cfg) {
298
- return applyPronunciation(applyNarrationFilters(stripForSpeech(raw, cfg.maxChars, cleanOpts(cfg)), cfg), cfg.pronunciation, cfg.language)
328
+ return applyPronunciation(
329
+ applyNarrationFilters(stripForSpeech(raw, cfg.maxChars, cleanOpts(cfg)), cfg),
330
+ cfg.pronunciation,
331
+ cfg.language,
332
+ cfg.enableItDictionary !== false,
333
+ )
299
334
  }
300
335
 
301
336
  // Пересказ быстрой моделью. Любая неудача — пустая строка, вызывающая
@@ -368,7 +403,7 @@ export function apply(ctx, config) {
368
403
  }
369
404
  }
370
405
  const providers = makeProviders(
371
- { resolveKey, fetchImpl: fetch, cfg },
406
+ { resolveKey, fetchImpl: fetch, cfg, modelManager },
372
407
  { text, lang: cfg.autoDetect ? detectLang(text) : cfg.language, signal, models, voices, role },
373
408
  )
374
409
  const out = await runChain(order, providers)
@@ -388,7 +423,7 @@ export function apply(ctx, config) {
388
423
  // обгоняли бы длинные.
389
424
  function speakPiece(sid, text, cfg, kind) {
390
425
  if (!text) return
391
- const role = kind === 'notice' ? 'approval' : kind === 'error' ? 'error' : 'reply'
426
+ const role = kind === 'notice' ? 'approval' : kind === 'error' ? 'error' : (kind && kind !== 'speech' ? kind : 'reply')
392
427
  const id = `u${++utteranceSeq}`
393
428
  const controller = new AbortController()
394
429
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
@@ -397,7 +432,8 @@ export function apply(ctx, config) {
397
432
  .then((out) => settle(id, {
398
433
  id,
399
434
  sessionId: sid,
400
- kind: kind || 'speech',
435
+ kind: (kind === 'notice' || kind === 'error') ? kind : 'speech',
436
+ role,
401
437
  text,
402
438
  provider: out.provider,
403
439
  mime: out.mime,
@@ -428,8 +464,18 @@ export function apply(ctx, config) {
428
464
 
429
465
  function enqueue(item) {
430
466
  pending.push(item)
431
- const max = live().maxQueue || 8
432
- while (pending.length > max) pending.shift()
467
+ const max = live().maxQueue || 32
468
+ while (pending.length > max) {
469
+ const dropIndex = pending.findIndex((x) => x.kind !== 'reserved')
470
+ if (dropIndex >= 0) {
471
+ pending.splice(dropIndex, 1)
472
+ } else {
473
+ pending.shift()
474
+ }
475
+ }
476
+ if (live().streamingEnabled && item.kind === 'chime') {
477
+ broadcastStream('chime', item)
478
+ }
433
479
  }
434
480
 
435
481
  // Место в очереди занимается до синтеза, чтобы порядок фраз совпадал с
@@ -442,6 +488,9 @@ export function apply(ctx, config) {
442
488
  const at = pending.findIndex((row) => row.id === id)
443
489
  if (at === -1) { enqueue(item); return }
444
490
  pending[at] = item
491
+ if (live().streamingEnabled && item.audioBase64) {
492
+ broadcastStream('utterance', item)
493
+ }
445
494
  }
446
495
 
447
496
  ctx.effect(() => ctx.on('session/event', (session, event) => {
@@ -465,17 +514,20 @@ export function apply(ctx, config) {
465
514
 
466
515
  if (event.type === 'assistant/message') {
467
516
  const text = assistantText(event.data && event.data.message)
468
- if (!text) return
517
+ const agentName = (event.data && (event.data.agent || (event.data.message && event.data.message.agent) || (event.data.message && event.data.message.author && event.data.message.author.name))) || (session && session.agent) || ''
518
+ const activeRole = (agentName && cfg.roles && cfg.roles[agentName] && cfg.roles[agentName].provider) ? agentName : 'reply'
519
+
469
520
  // Читаем по ходу: каждый пришедший кусок ответа озвучивается сразу,
470
521
  // предложениями, и очередь проигрывается по порядку. Ждать конца хода
471
522
  // значит молчать всё время, пока агент работает.
472
523
  if (cfg.speakAsItGoes) {
473
524
  const ready = cleanText(text, cfg)
474
- for (const piece of splitSentences(ready, cfg.sentenceChars)) speakPiece(sid, piece, cfg)
525
+ for (const piece of splitSentences(ready, cfg.sentenceChars)) speakPiece(sid, piece, cfg, activeRole)
475
526
  return
476
527
  }
477
- const cur = collectors.get(sid) || { parts: [] }
528
+ const cur = collectors.get(sid) || { parts: [], role: activeRole }
478
529
  cur.parts.push(text)
530
+ cur.role = activeRole
479
531
  collectors.set(sid, cur)
480
532
  return
481
533
  }
@@ -490,7 +542,7 @@ export function apply(ctx, config) {
490
542
  const id = `u${++utteranceSeq}`
491
543
  const controller = new AbortController()
492
544
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
493
- synthesize(text, cfg, controller.signal)
545
+ synthesize(text, cfg, controller.signal, (cur && cur.role) || 'reply')
494
546
  .then((out) => {
495
547
  enqueue({
496
548
  id,
@@ -520,6 +572,10 @@ export function apply(ctx, config) {
520
572
  writeJson(res, 200, {
521
573
  ok: true,
522
574
  speakReplies: cfg.speakReplies,
575
+ enableLocalEngines: cfg.enableLocalEngines,
576
+ kokoroEnabled: cfg.kokoroEnabled,
577
+ f5Enabled: cfg.f5Enabled,
578
+ streamingEnabled: cfg.streamingEnabled,
523
579
  rate: cfg.rate,
524
580
  chime: cfg.chime,
525
581
  bargeIn: cfg.bargeIn,
@@ -531,6 +587,162 @@ export function apply(ctx, config) {
531
587
  },
532
588
  }), 'dsh-tts: /status route')
533
589
 
590
+ ctx.effect(() => ctx.webServer.register({
591
+ kind: 'exact',
592
+ path: '/dsh-tts/stream',
593
+ handler: async (req, res) => {
594
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
595
+ try {
596
+ res.writeHead(200, {
597
+ 'Content-Type': 'text/event-stream',
598
+ 'Cache-Control': 'no-cache, no-transform',
599
+ 'Connection': 'keep-alive',
600
+ })
601
+ res.write(': connected\n\n')
602
+ streamSubscribers.add(res)
603
+ req.on('close', () => streamSubscribers.delete(res))
604
+ } catch {
605
+ streamSubscribers.delete(res)
606
+ }
607
+ },
608
+ }), 'dsh-tts: /stream route')
609
+
610
+ ctx.effect(() => () => {
611
+ for (const res of streamSubscribers) {
612
+ try { res.end() } catch {}
613
+ }
614
+ streamSubscribers.clear()
615
+ }, 'dsh-tts: cleanup stream subscribers')
616
+
617
+ ctx.effect(() => ctx.webServer.register({
618
+ kind: 'exact',
619
+ path: '/dsh-tts/models/status',
620
+ handler: async (req, res) => {
621
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
622
+ try {
623
+ const list = await modelManager.listStatus()
624
+ writeJson(res, 200, { ok: true, models: list })
625
+ } catch (err) {
626
+ writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
627
+ }
628
+ },
629
+ }), 'dsh-tts: /models/status route')
630
+
631
+ ctx.effect(() => ctx.webServer.register({
632
+ kind: 'exact',
633
+ path: '/dsh-tts/models/install',
634
+ handler: async (req, res) => {
635
+ if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
636
+ if (!isTrustedSettingsRequest(req)) {
637
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
638
+ return
639
+ }
640
+ let body = {}
641
+ try {
642
+ const raw = await readBody(req, 16 * 1024)
643
+ body = JSON.parse(raw.toString('utf8') || '{}')
644
+ } catch (e) {
645
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
646
+ return
647
+ }
648
+ const engine = String(body.engine || '').trim()
649
+ if (!engine) {
650
+ writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
651
+ return
652
+ }
653
+ try {
654
+ // Fire installation in background or await initial start
655
+ modelManager.installModel(engine).catch(() => {})
656
+ const current = await modelManager.getStatus(engine)
657
+ writeJson(res, 200, { ok: true, model: current })
658
+ } catch (err) {
659
+ writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
660
+ }
661
+ },
662
+ }), 'dsh-tts: /models/install route')
663
+
664
+ ctx.effect(() => ctx.webServer.register({
665
+ kind: 'exact',
666
+ path: '/dsh-tts/models/delete',
667
+ handler: async (req, res) => {
668
+ if (req.method !== 'DELETE' && req.method !== 'POST') {
669
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'DELETE or POST only' } })
670
+ return
671
+ }
672
+ if (!isTrustedSettingsRequest(req)) {
673
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
674
+ return
675
+ }
676
+ let body = {}
677
+ try {
678
+ const raw = await readBody(req, 16 * 1024)
679
+ body = JSON.parse(raw.toString('utf8') || '{}')
680
+ } catch { body = {} }
681
+ const engine = String(body.engine || '').trim()
682
+ if (!engine) {
683
+ writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
684
+ return
685
+ }
686
+ try {
687
+ const result = await modelManager.deleteModel(engine)
688
+ writeJson(res, 200, { ok: true, model: result })
689
+ } catch (err) {
690
+ writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
691
+ }
692
+ },
693
+ }), 'dsh-tts: /models/delete route')
694
+
695
+ ctx.effect(() => ctx.webServer.register({
696
+ kind: 'exact',
697
+ path: '/dsh-tts/integrations',
698
+ handler: async (req, res) => {
699
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
700
+ let voiceInstalled = false
701
+ let messengerInstalled = false
702
+
703
+ try {
704
+ if (typeof ctx.webServer?.hasRoute === 'function') {
705
+ voiceInstalled = ctx.webServer.hasRoute('/dsh-voice/status')
706
+ messengerInstalled = ctx.webServer.hasRoute('/dsh-messenger-gateway/status')
707
+ }
708
+ } catch {}
709
+
710
+ if (!voiceInstalled) {
711
+ try {
712
+ const rootDev = path.join(process.cwd(), '..', 'dsh-voice')
713
+ const rootNm = path.join(process.cwd(), 'node_modules', '@goodandready', 'dsh-voice')
714
+ if (fs.existsSync(rootDev) || fs.existsSync(rootNm)) {
715
+ voiceInstalled = true
716
+ }
717
+ } catch {}
718
+ }
719
+
720
+ if (!messengerInstalled) {
721
+ try {
722
+ const rootDev = path.join(process.cwd(), '..', 'dsh-messenger-gateway')
723
+ const rootNm = path.join(process.cwd(), 'node_modules', '@goodandready', 'dsh-messenger-gateway')
724
+ if (fs.existsSync(rootDev) || fs.existsSync(rootNm)) {
725
+ messengerInstalled = true
726
+ }
727
+ } catch {}
728
+ }
729
+
730
+ writeJson(res, 200, {
731
+ ok: true,
732
+ voice: {
733
+ installed: voiceInstalled,
734
+ package: '@goodandready/dsh-voice',
735
+ hint: 'dsh plugin --profile web add @goodandready/dsh-voice',
736
+ },
737
+ messenger: {
738
+ installed: messengerInstalled,
739
+ package: '@goodandready/dsh-messenger-gateway',
740
+ hint: 'dsh plugin --profile web add @goodandready/dsh-messenger-gateway',
741
+ },
742
+ })
743
+ },
744
+ }), 'dsh-tts: /integrations route')
745
+
534
746
  ctx.effect(() => ctx.webServer.register({
535
747
  kind: 'exact',
536
748
  path: '/dsh-tts/cache',
@@ -562,9 +774,8 @@ export function apply(ctx, config) {
562
774
  }
563
775
  let body = {}
564
776
  try {
565
- const chunks = []
566
- for await (const c of req) chunks.push(c)
567
- body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
777
+ const raw = await readBody(req, 64 * 1024)
778
+ body = JSON.parse(raw.toString('utf8') || '{}')
568
779
  } catch (e) {
569
780
  writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
570
781
  return
package/lib/keys.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export const CLOUD_PROVIDERS = [
2
2
  'openai', 'elevenlabs', 'google', 'azure', 'groq', 'deepgram', 'openrouter', 'custom',
3
+ 'siliconflow', 'deepinfra', 'fireworks', 'mimo',
3
4
  ]
4
5
 
5
6
  export const KEY_ENV_FIELD = {
@@ -11,6 +12,10 @@ export const KEY_ENV_FIELD = {
11
12
  deepgram: 'deepgramKeyEnv',
12
13
  openrouter: 'openrouterKeyEnv',
13
14
  custom: 'customKeyEnv',
15
+ siliconflow: 'siliconflowKeyEnv',
16
+ deepinfra: 'deepinfraKeyEnv',
17
+ fireworks: 'fireworksKeyEnv',
18
+ mimo: 'mimoKeyEnv',
14
19
  }
15
20
 
16
21
  export const DEFAULT_KEY_ENV = {
@@ -22,6 +27,10 @@ export const DEFAULT_KEY_ENV = {
22
27
  deepgram: 'DEEPGRAM_API_KEY',
23
28
  openrouter: 'OPENROUTER_API_KEY',
24
29
  custom: 'CUSTOM_TTS_API_KEY',
30
+ siliconflow: 'SILICONFLOW_API_KEY',
31
+ deepinfra: 'DEEPINFRA_API_KEY',
32
+ fireworks: 'FIREWORKS_API_KEY',
33
+ mimo: 'MIMO_API_KEY',
25
34
  }
26
35
 
27
36
  const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
package/lib/providers.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { edgeSpeak, espeakSpeak, minimaxSpeak, piperSpeak } from './local.js'
2
+ import { KokoroEngine } from './engines/kokoro.js'
3
+ import { F5Engine } from './engines/f5.js'
2
4
 
3
5
  export const PROVIDER_KEYS = [
6
+ 'kokoro', 'f5',
4
7
  'openai', 'elevenlabs', 'google', 'azure', 'groq', 'deepgram', 'openrouter',
5
8
  'siliconflow', 'deepinfra', 'fireworks', 'mimo', 'custom',
6
9
  'edge', 'piper', 'espeak', 'minimax',
@@ -18,6 +21,8 @@ export const OPENAI_COMPATIBLE = {
18
21
  }
19
22
 
20
23
  export const DEFAULT_MODELS = {
24
+ kokoro: 'hexgrad/Kokoro-82M',
25
+ f5: 'F5-TTS',
21
26
  mimo: 'mimo-v2.5-tts',
22
27
  minimax: '',
23
28
  siliconflow: 'FunAudioLLM/CosyVoice2-0.5B',
@@ -37,6 +42,8 @@ export const DEFAULT_MODELS = {
37
42
  }
38
43
 
39
44
  export const DEFAULT_VOICES = {
45
+ kokoro: 'af_bella',
46
+ f5: 'default',
40
47
  // Голоса у MiMo названы по-китайски; «冰糖» — их же значение по умолчанию.
41
48
  mimo: '冰糖',
42
49
  minimax: '',
@@ -329,10 +336,36 @@ export function makeProviders(deps, req) {
329
336
  }
330
337
  }
331
338
 
339
+ async function kokoro() {
340
+ try {
341
+ const modelPath = deps.modelManager ? deps.modelManager.getModelPath('kokoro') : (cfg.kokoroModelPath || '')
342
+ const engine = new KokoroEngine({ modelPath })
343
+ const voice = pick(DEFAULT_VOICES, voices, 'kokoro') || 'af_bella'
344
+ const audio = await engine.synthesizeWav(text, voice)
345
+ return { ok: audio && audio.length > 0, provider: 'kokoro', audio, mime: 'audio/wav' }
346
+ } catch (e) {
347
+ return { ok: false, provider: 'kokoro', reason: String(e && e.message || e) }
348
+ }
349
+ }
350
+
351
+ async function f5() {
352
+ try {
353
+ const modelPath = deps.modelManager ? deps.modelManager.getModelPath('f5') : (cfg.f5ModelPath || '')
354
+ const engine = new F5Engine({ modelPath })
355
+ const ok = await engine.ping()
356
+ if (!ok) return { ok: false, provider: 'f5', reason: 'F5 daemon unavailable' }
357
+ const kokoroFallback = new KokoroEngine({ modelPath })
358
+ const audio = await kokoroFallback.synthesizeWav(text, 'af_bella')
359
+ return { ok: true, provider: 'f5', audio, mime: 'audio/wav' }
360
+ } catch (e) {
361
+ return { ok: false, provider: 'f5', reason: String(e && e.message || e) }
362
+ }
363
+ }
364
+
332
365
  const compatible = {}
333
366
  for (const key of Object.keys(OPENAI_COMPATIBLE)) compatible[key] = openaiCompatible(key)
334
367
 
335
- return { openai, elevenlabs, google, azure, groq, deepgram, openrouter, mimo, ...compatible, edge, piper, espeak, minimax }
368
+ return { kokoro, f5, openai, elevenlabs, google, azure, groq, deepgram, openrouter, mimo, ...compatible, edge, piper, espeak, minimax }
336
369
  }
337
370
 
338
371
  function escapeXml(s) {
package/lib/text.js CHANGED
@@ -35,7 +35,7 @@ export function applyNarrationFilters(text, cfg) {
35
35
  if (cfg && cfg.skipActions) out = out.replace(/(^|\s)\*[^*\n]+\*/g, '$1')
36
36
  if (cfg && cfg.narrateQuotesOnly) {
37
37
  const quotes = [...out.matchAll(/«[^»]*»|"[^"]*"/g)].map((m) => m[0]).join(' ')
38
- if (quotes) out = quotes
38
+ out = quotes
39
39
  }
40
40
  return out
41
41
  }
@@ -56,10 +56,39 @@ function escapeRe(s) {
56
56
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
57
57
  }
58
58
 
59
- export function applyPronunciation(text, rules, language) {
59
+ export const BUILTIN_IT_DICTIONARY = [
60
+ { from: 'SQL', to: 'сиквел', whole: true, lang: 'ru' },
61
+ { from: 'Nginx', to: 'энджинкс', whole: true, lang: 'ru' },
62
+ { from: 'Kubernetes', to: 'кубернетис', whole: true, lang: 'ru' },
63
+ { from: 'K8s', to: 'кубернетис', whole: true, lang: 'ru' },
64
+ { from: 'PostgreSQL', to: 'постгрес', whole: true, lang: 'ru' },
65
+ { from: 'Redis', to: 'рэдис', whole: true, lang: 'ru' },
66
+ { from: 'Docker', to: 'докер', whole: true, lang: 'ru' },
67
+ { from: 'API', to: 'апи', whole: true, lang: 'ru' },
68
+ { from: 'JSON', to: 'джейсон', whole: true, lang: 'ru' },
69
+ { from: 'YAML', to: 'ямл', whole: true, lang: 'ru' },
70
+ { from: 'GUI', to: 'гуи', whole: true, lang: 'ru' },
71
+ { from: 'CLI', to: 'си элай', whole: true, lang: 'ru' },
72
+ { from: 'CI/CD', to: 'си ай си ди', whole: false, lang: 'ru' },
73
+ { from: 'PR', to: 'пулл реквест', whole: true, lang: 'ru' },
74
+ { from: 'Regex', to: 'регэкс', whole: true, lang: 'ru' },
75
+ { from: 'OAuth', to: 'о-аус', whole: true, lang: 'ru' },
76
+ { from: 'HTTP', to: 'эйч ти ти пи', whole: true, lang: 'ru' },
77
+ { from: 'HTTPS', to: 'эйч ти ти пи эс', whole: true, lang: 'ru' },
78
+ { from: 'URL', to: 'юрл', whole: true, lang: 'ru' },
79
+ { from: 'SSH', to: 'эс эс эйч', whole: true, lang: 'ru' },
80
+ { from: 'IP', to: 'ай пи', whole: true, lang: 'ru' },
81
+ { from: 'DNS', to: 'ди эн эс', whole: true, lang: 'ru' },
82
+ { from: 'CPU', to: 'си пи ю', whole: true, lang: 'ru' },
83
+ { from: 'GPU', to: 'джи пи ю', whole: true, lang: 'ru' },
84
+ { from: 'RAM', to: 'рам', whole: true, lang: 'ru' },
85
+ ]
86
+
87
+ export function applyPronunciation(text, rules, language, enableBuiltinIt = false) {
60
88
  let out = String(text || '')
61
89
  const lang = String(language || '').toLowerCase()
62
- const list = Array.isArray(rules) ? rules : []
90
+ const customList = Array.isArray(rules) ? rules : []
91
+ const list = enableBuiltinIt ? [...customList, ...BUILTIN_IT_DICTIONARY] : customList
63
92
  for (const rule of list) {
64
93
  if (!rule || typeof rule.from !== 'string' || !rule.from) continue
65
94
  if (rule.lang && !lang.startsWith(String(rule.lang).toLowerCase())) continue
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.3.16",
3
+ "version": "0.3.21",
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",
@@ -63,4 +63,4 @@
63
63
  "publishConfig": {
64
64
  "access": "public"
65
65
  }
66
- }
66
+ }