@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/LICENSE +1 -1
- package/README.md +192 -87
- package/README.ru.md +195 -0
- package/README.zh.md +190 -0
- package/lib/cache.js +6 -1
- package/lib/client/worklet.js +68 -1
- package/lib/client.js +427 -16
- package/lib/engines/f5.js +43 -2
- package/lib/engines/kokoro.js +68 -2
- package/lib/engines/manager.js +251 -0
- package/lib/engines/types.js +47 -2
- package/lib/index.js +230 -19
- package/lib/keys.js +9 -0
- package/lib/providers.js +34 -1
- package/lib/text.js +32 -3
- package/package.json +2 -2
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.
|
|
77
|
-
reply:
|
|
78
|
-
approval:
|
|
79
|
-
error:
|
|
80
|
-
})
|
|
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(
|
|
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 ||
|
|
432
|
-
while (pending.length > max)
|
|
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
|
-
|
|
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
|
|
566
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
}
|