@goodandready/dsh-tts 0.3.16 → 0.3.20

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.
@@ -0,0 +1,235 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+
5
+ export const MODEL_SPECS = {
6
+ kokoro: {
7
+ id: 'kokoro',
8
+ name: 'Kokoro-82M (CPU)',
9
+ approxMb: 350,
10
+ requiresGpu: false,
11
+ files: [
12
+ {
13
+ name: 'kokoro-v0_19.onnx',
14
+ url: 'https://huggingface.co/hexgrad/Kokoro-82M/resolve/main/kokoro-v0_19.onnx',
15
+ },
16
+ {
17
+ name: 'voices.json',
18
+ url: 'https://huggingface.co/hexgrad/Kokoro-82M/resolve/main/voices.json',
19
+ },
20
+ ],
21
+ },
22
+ f5: {
23
+ id: 'f5',
24
+ name: 'F5-TTS (GPU)',
25
+ approxMb: 2100,
26
+ requiresGpu: true,
27
+ files: [
28
+ {
29
+ name: 'model_1200000.safetensors',
30
+ url: 'https://huggingface.co/SWivid/F5-TTS/resolve/main/F5TTS_Base/model_1200000.safetensors',
31
+ },
32
+ ],
33
+ },
34
+ }
35
+
36
+ export function createModelManager({ root = process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), fetchImpl = fetch } = {}) {
37
+ const baseDir = path.join(String(root), 'data', 'dsh-tts', 'models')
38
+ const activeDownloads = new Map()
39
+
40
+ function getModelPath(engineId) {
41
+ return path.join(baseDir, engineId)
42
+ }
43
+
44
+ async function checkFileExists(filePath) {
45
+ try {
46
+ const st = await fs.promises.stat(filePath)
47
+ return st.isFile() && st.size > 0
48
+ } catch {
49
+ return false
50
+ }
51
+ }
52
+
53
+ async function getDirSize(dirPath) {
54
+ try {
55
+ let total = 0
56
+ const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
57
+ for (const entry of entries) {
58
+ const full = path.join(dirPath, entry.name)
59
+ if (entry.isFile()) {
60
+ const st = await fs.promises.stat(full)
61
+ total += st.size
62
+ }
63
+ }
64
+ return total
65
+ } catch {
66
+ return 0
67
+ }
68
+ }
69
+
70
+ async function getStatus(engineId) {
71
+ const spec = MODEL_SPECS[engineId]
72
+ if (!spec) {
73
+ return { id: engineId, installed: false, error: 'unknown engine' }
74
+ }
75
+
76
+ const dir = getModelPath(engineId)
77
+ const active = activeDownloads.get(engineId)
78
+
79
+ if (active) {
80
+ return {
81
+ id: engineId,
82
+ name: spec.name,
83
+ approxMb: spec.approxMb,
84
+ requiresGpu: spec.requiresGpu,
85
+ installed: false,
86
+ downloading: true,
87
+ progress: active.progress || 0,
88
+ downloadedBytes: active.downloadedBytes || 0,
89
+ totalBytes: active.totalBytes || 0,
90
+ path: dir,
91
+ }
92
+ }
93
+
94
+ let allFilesExist = true
95
+ for (const f of spec.files) {
96
+ const p = path.join(dir, f.name)
97
+ if (!(await checkFileExists(p))) {
98
+ allFilesExist = false
99
+ break
100
+ }
101
+ }
102
+
103
+ const sizeBytes = allFilesExist ? await getDirSize(dir) : 0
104
+
105
+ return {
106
+ id: engineId,
107
+ name: spec.name,
108
+ approxMb: spec.approxMb,
109
+ requiresGpu: spec.requiresGpu,
110
+ installed: allFilesExist,
111
+ downloading: false,
112
+ progress: allFilesExist ? 100 : 0,
113
+ sizeBytes,
114
+ path: dir,
115
+ }
116
+ }
117
+
118
+ async function listStatus() {
119
+ const out = {}
120
+ for (const id of Object.keys(MODEL_SPECS)) {
121
+ out[id] = await getStatus(id)
122
+ }
123
+ return out
124
+ }
125
+
126
+ async function installModel(engineId, options = {}) {
127
+ const spec = MODEL_SPECS[engineId]
128
+ if (!spec) throw new Error(`Unknown engine: ${engineId}`)
129
+
130
+ if (activeDownloads.has(engineId)) {
131
+ throw new Error(`Download already in progress for ${engineId}`)
132
+ }
133
+
134
+ const customFetch = options.fetchImpl || fetchImpl
135
+ const dir = getModelPath(engineId)
136
+ await fs.promises.mkdir(dir, { recursive: true })
137
+
138
+ const controller = new AbortController()
139
+ const downloadState = {
140
+ progress: 0,
141
+ downloadedBytes: 0,
142
+ totalBytes: spec.approxMb * 1024 * 1024,
143
+ controller,
144
+ }
145
+ activeDownloads.set(engineId, downloadState)
146
+
147
+ try {
148
+ for (const f of spec.files) {
149
+ if (options.signal?.aborted || controller.signal.aborted) {
150
+ throw new Error('Download aborted')
151
+ }
152
+
153
+ const targetFile = path.join(dir, f.name)
154
+ const tmpFile = path.join(dir, `${f.name}.tmp`)
155
+
156
+ const res = await customFetch(f.url, { signal: controller.signal })
157
+ if (!res.ok) {
158
+ throw new Error(`Failed to download ${f.name}: HTTP ${res.status}`)
159
+ }
160
+
161
+ const contentLength = Number(res.headers?.get?.('content-length') || 0)
162
+ if (contentLength > 0) {
163
+ downloadState.totalBytes = contentLength
164
+ }
165
+
166
+ if (res.body && typeof res.body.getReader === 'function') {
167
+ const reader = res.body.getReader()
168
+ const chunks = []
169
+ let loaded = 0
170
+
171
+ while (true) {
172
+ const { done, value } = await reader.read()
173
+ if (done) break
174
+ chunks.push(value)
175
+ loaded += value.length
176
+ downloadState.downloadedBytes = loaded
177
+ downloadState.progress = Math.min(100, Math.round((loaded / (downloadState.totalBytes || 1)) * 100))
178
+ if (typeof options.onProgress === 'function') {
179
+ options.onProgress(downloadState)
180
+ }
181
+ }
182
+ await fs.promises.writeFile(tmpFile, Buffer.concat(chunks))
183
+ } else if (res.arrayBuffer) {
184
+ const buf = Buffer.from(await res.arrayBuffer())
185
+ downloadState.downloadedBytes = buf.length
186
+ downloadState.progress = 100
187
+ await fs.promises.writeFile(tmpFile, buf)
188
+ } else if (typeof res.text === 'function') {
189
+ const txt = await res.text()
190
+ await fs.promises.writeFile(tmpFile, Buffer.from(txt, 'utf8'))
191
+ }
192
+
193
+ await fs.promises.rename(tmpFile, targetFile)
194
+ }
195
+
196
+ activeDownloads.delete(engineId)
197
+ return await getStatus(engineId)
198
+ } catch (err) {
199
+ // Clean up tmp files
200
+ for (const f of spec.files) {
201
+ const tmpFile = path.join(dir, `${f.name}.tmp`)
202
+ await fs.promises.rm(tmpFile, { force: true }).catch(() => {})
203
+ }
204
+ throw err
205
+ } finally {
206
+ activeDownloads.delete(engineId)
207
+ }
208
+ }
209
+
210
+ async function cancelDownload(engineId) {
211
+ const active = activeDownloads.get(engineId)
212
+ if (active && active.controller) {
213
+ active.controller.abort()
214
+ activeDownloads.delete(engineId)
215
+ return true
216
+ }
217
+ return false
218
+ }
219
+
220
+ async function deleteModel(engineId) {
221
+ await cancelDownload(engineId)
222
+ const dir = getModelPath(engineId)
223
+ await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {})
224
+ return await getStatus(engineId)
225
+ }
226
+
227
+ return {
228
+ getModelPath,
229
+ getStatus,
230
+ listStatus,
231
+ installModel,
232
+ cancelDownload,
233
+ deleteModel,
234
+ }
235
+ }
@@ -1,4 +1,49 @@
1
+ import { execSync } from 'node:child_process'
2
+
3
+ export function detectGPU() {
4
+ try {
5
+ const out = execSync('nvidia-smi --query-gpu=name --format=csv,noheader', {
6
+ stdio: ['ignore', 'pipe', 'ignore'],
7
+ timeout: 2000,
8
+ encoding: 'utf8',
9
+ })
10
+ return !!(out && out.trim())
11
+ } catch {
12
+ return false
13
+ }
14
+ }
15
+
1
16
  export class EngineRegistry {
2
- constructor({gpu, installed}) { this.gpu=gpu; this.installed=installed }
3
- list(){ return this.installed.map(id=>({id, supports:()=>true})) }
17
+ constructor({ gpu, installed, manager } = {}) {
18
+ this.gpu = typeof gpu === 'boolean' ? gpu : detectGPU()
19
+ this.installed = Array.isArray(installed) ? installed : []
20
+ this.manager = manager || null
21
+ }
22
+
23
+ isInstalled(engineId) {
24
+ return this.installed.includes(engineId)
25
+ }
26
+
27
+ list() {
28
+ const list = []
29
+ // Kokoro works on CPU everywhere
30
+ if (this.installed.includes('kokoro')) {
31
+ list.push({
32
+ id: 'kokoro',
33
+ name: 'Kokoro-82M',
34
+ type: 'local-cpu',
35
+ supports: (lang) => !lang || ['en', 'ru', 'ja', 'zh'].includes(String(lang).slice(0, 2).toLowerCase()),
36
+ })
37
+ }
38
+ // F5-TTS requires GPU
39
+ if (this.gpu && this.installed.includes('f5')) {
40
+ list.push({
41
+ id: 'f5',
42
+ name: 'F5-TTS',
43
+ type: 'local-gpu',
44
+ supports: () => true,
45
+ })
46
+ }
47
+ return list
48
+ }
4
49
  }
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
  // Пересказ быстрой моделью. Любая неудача — пустая строка, вызывающая
@@ -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,
@@ -430,6 +466,9 @@ export function apply(ctx, config) {
430
466
  pending.push(item)
431
467
  const max = live().maxQueue || 8
432
468
  while (pending.length > max) pending.shift()
469
+ if (live().streamingEnabled && item.kind === 'chime') {
470
+ broadcastStream('chime', item)
471
+ }
433
472
  }
434
473
 
435
474
  // Место в очереди занимается до синтеза, чтобы порядок фраз совпадал с
@@ -442,6 +481,9 @@ export function apply(ctx, config) {
442
481
  const at = pending.findIndex((row) => row.id === id)
443
482
  if (at === -1) { enqueue(item); return }
444
483
  pending[at] = item
484
+ if (live().streamingEnabled && item.audioBase64) {
485
+ broadcastStream('utterance', item)
486
+ }
445
487
  }
446
488
 
447
489
  ctx.effect(() => ctx.on('session/event', (session, event) => {
@@ -465,17 +507,20 @@ export function apply(ctx, config) {
465
507
 
466
508
  if (event.type === 'assistant/message') {
467
509
  const text = assistantText(event.data && event.data.message)
468
- if (!text) return
510
+ 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) || ''
511
+ const activeRole = (agentName && cfg.roles && cfg.roles[agentName] && cfg.roles[agentName].provider) ? agentName : 'reply'
512
+
469
513
  // Читаем по ходу: каждый пришедший кусок ответа озвучивается сразу,
470
514
  // предложениями, и очередь проигрывается по порядку. Ждать конца хода
471
515
  // значит молчать всё время, пока агент работает.
472
516
  if (cfg.speakAsItGoes) {
473
517
  const ready = cleanText(text, cfg)
474
- for (const piece of splitSentences(ready, cfg.sentenceChars)) speakPiece(sid, piece, cfg)
518
+ for (const piece of splitSentences(ready, cfg.sentenceChars)) speakPiece(sid, piece, cfg, activeRole)
475
519
  return
476
520
  }
477
- const cur = collectors.get(sid) || { parts: [] }
521
+ const cur = collectors.get(sid) || { parts: [], role: activeRole }
478
522
  cur.parts.push(text)
523
+ cur.role = activeRole
479
524
  collectors.set(sid, cur)
480
525
  return
481
526
  }
@@ -490,7 +535,7 @@ export function apply(ctx, config) {
490
535
  const id = `u${++utteranceSeq}`
491
536
  const controller = new AbortController()
492
537
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
493
- synthesize(text, cfg, controller.signal)
538
+ synthesize(text, cfg, controller.signal, (cur && cur.role) || 'reply')
494
539
  .then((out) => {
495
540
  enqueue({
496
541
  id,
@@ -520,6 +565,10 @@ export function apply(ctx, config) {
520
565
  writeJson(res, 200, {
521
566
  ok: true,
522
567
  speakReplies: cfg.speakReplies,
568
+ enableLocalEngines: cfg.enableLocalEngines,
569
+ kokoroEnabled: cfg.kokoroEnabled,
570
+ f5Enabled: cfg.f5Enabled,
571
+ streamingEnabled: cfg.streamingEnabled,
523
572
  rate: cfg.rate,
524
573
  chime: cfg.chime,
525
574
  bargeIn: cfg.bargeIn,
@@ -531,6 +580,155 @@ export function apply(ctx, config) {
531
580
  },
532
581
  }), 'dsh-tts: /status route')
533
582
 
583
+ ctx.effect(() => ctx.webServer.register({
584
+ kind: 'exact',
585
+ path: '/dsh-tts/stream',
586
+ handler: async (req, res) => {
587
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
588
+ try {
589
+ res.writeHead(200, {
590
+ 'Content-Type': 'text/event-stream',
591
+ 'Cache-Control': 'no-cache, no-transform',
592
+ 'Connection': 'keep-alive',
593
+ })
594
+ res.write(': connected\n\n')
595
+ streamSubscribers.add(res)
596
+ req.on('close', () => streamSubscribers.delete(res))
597
+ } catch {
598
+ streamSubscribers.delete(res)
599
+ }
600
+ },
601
+ }), 'dsh-tts: /stream route')
602
+
603
+ ctx.effect(() => ctx.webServer.register({
604
+ kind: 'exact',
605
+ path: '/dsh-tts/models/status',
606
+ handler: async (req, res) => {
607
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
608
+ try {
609
+ const list = await modelManager.listStatus()
610
+ writeJson(res, 200, { ok: true, models: list })
611
+ } catch (err) {
612
+ writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
613
+ }
614
+ },
615
+ }), 'dsh-tts: /models/status route')
616
+
617
+ ctx.effect(() => ctx.webServer.register({
618
+ kind: 'exact',
619
+ path: '/dsh-tts/models/install',
620
+ handler: async (req, res) => {
621
+ if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
622
+ if (!isTrustedSettingsRequest(req)) {
623
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
624
+ return
625
+ }
626
+ let body = {}
627
+ try {
628
+ const raw = await readBody(req, 16 * 1024)
629
+ body = JSON.parse(raw.toString('utf8') || '{}')
630
+ } catch (e) {
631
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
632
+ return
633
+ }
634
+ const engine = String(body.engine || '').trim()
635
+ if (!engine) {
636
+ writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
637
+ return
638
+ }
639
+ try {
640
+ // Fire installation in background or await initial start
641
+ modelManager.installModel(engine).catch(() => {})
642
+ const current = await modelManager.getStatus(engine)
643
+ writeJson(res, 200, { ok: true, model: current })
644
+ } catch (err) {
645
+ writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
646
+ }
647
+ },
648
+ }), 'dsh-tts: /models/install route')
649
+
650
+ ctx.effect(() => ctx.webServer.register({
651
+ kind: 'exact',
652
+ path: '/dsh-tts/models/delete',
653
+ handler: async (req, res) => {
654
+ if (req.method !== 'DELETE' && req.method !== 'POST') {
655
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'DELETE or POST only' } })
656
+ return
657
+ }
658
+ if (!isTrustedSettingsRequest(req)) {
659
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
660
+ return
661
+ }
662
+ let body = {}
663
+ try {
664
+ const raw = await readBody(req, 16 * 1024)
665
+ body = JSON.parse(raw.toString('utf8') || '{}')
666
+ } catch { body = {} }
667
+ const engine = String(body.engine || '').trim()
668
+ if (!engine) {
669
+ writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
670
+ return
671
+ }
672
+ try {
673
+ const result = await modelManager.deleteModel(engine)
674
+ writeJson(res, 200, { ok: true, model: result })
675
+ } catch (err) {
676
+ writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
677
+ }
678
+ },
679
+ }), 'dsh-tts: /models/delete route')
680
+
681
+ ctx.effect(() => ctx.webServer.register({
682
+ kind: 'exact',
683
+ path: '/dsh-tts/integrations',
684
+ handler: async (req, res) => {
685
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
686
+ let voiceInstalled = false
687
+ let messengerInstalled = false
688
+
689
+ try {
690
+ if (typeof ctx.webServer?.hasRoute === 'function') {
691
+ voiceInstalled = ctx.webServer.hasRoute('/dsh-voice/status')
692
+ messengerInstalled = ctx.webServer.hasRoute('/dsh-messenger-gateway/status')
693
+ }
694
+ } catch {}
695
+
696
+ if (!voiceInstalled) {
697
+ try {
698
+ const rootDev = path.join(process.cwd(), '..', 'dsh-voice')
699
+ const rootExt = path.resolve('B:/Project/DEV/dhsplugins/dsh-voice')
700
+ if (fs.existsSync(rootDev) || fs.existsSync(rootExt)) {
701
+ voiceInstalled = true
702
+ }
703
+ } catch {}
704
+ }
705
+
706
+ if (!messengerInstalled) {
707
+ try {
708
+ const rootDev = path.join(process.cwd(), '..', 'dsh-messenger-gateway')
709
+ const rootExt = path.resolve('B:/Project/DEV/dhsplugins/dsh-messenger-gateway')
710
+ if (fs.existsSync(rootDev) || fs.existsSync(rootExt)) {
711
+ messengerInstalled = true
712
+ }
713
+ } catch {}
714
+ }
715
+
716
+ writeJson(res, 200, {
717
+ ok: true,
718
+ voice: {
719
+ installed: voiceInstalled,
720
+ package: '@goodandready/dsh-voice',
721
+ hint: 'dsh plugin --profile web add @goodandready/dsh-voice',
722
+ },
723
+ messenger: {
724
+ installed: messengerInstalled,
725
+ package: '@goodandready/dsh-messenger-gateway',
726
+ hint: 'dsh plugin --profile web add @goodandready/dsh-messenger-gateway',
727
+ },
728
+ })
729
+ },
730
+ }), 'dsh-tts: /integrations route')
731
+
534
732
  ctx.effect(() => ctx.webServer.register({
535
733
  kind: 'exact',
536
734
  path: '/dsh-tts/cache',
package/lib/text.js CHANGED
@@ -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.20",
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",
@@ -47,8 +47,8 @@
47
47
  "client": {
48
48
  "platform": "web",
49
49
  "inject": [
50
- "@deepseek-ai/dsh-client-locale",
51
- "@deepseek-ai/dsh-client-connection"
50
+ "@deepseek-ai/dsh-client-runtime",
51
+ "@deepseek-ai/dsh-client-ui-slots"
52
52
  ]
53
53
  }
54
54
  },
@@ -63,4 +63,4 @@
63
63
  "publishConfig": {
64
64
  "access": "public"
65
65
  }
66
- }
66
+ }