@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.
- package/LICENSE +1 -1
- package/README.md +192 -87
- package/README.ru.md +195 -0
- package/README.zh.md +190 -0
- package/lib/client/worklet.js +68 -1
- package/lib/client.js +382 -8
- package/lib/engines/f5.js +43 -2
- package/lib/engines/kokoro.js +29 -2
- package/lib/engines/manager.js +235 -0
- package/lib/engines/types.js +47 -2
- package/lib/index.js +211 -13
- package/lib/text.js +31 -2
- package/package.json +4 -4
|
@@ -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
|
+
}
|
package/lib/engines/types.js
CHANGED
|
@@ -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
|
|
3
|
-
|
|
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.
|
|
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
|
// Пересказ быстрой моделью. Любая неудача — пустая строка, вызывающая
|
|
@@ -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
|
-
|
|
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
|
|
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.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-
|
|
51
|
-
"@deepseek-ai/dsh-client-
|
|
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
|
+
}
|