@goodandready/dsh-tts 0.3.23 → 0.4.0
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/README.md +12 -12
- package/README.ru.md +13 -10
- package/README.zh.md +10 -10
- package/lib/cache.js +4 -5
- package/lib/client.js +69 -81
- package/lib/engines/f5.js +45 -33
- package/lib/engines/kokoro.js +21 -29
- package/lib/engines/types.js +26 -13
- package/lib/http-util.js +27 -0
- package/lib/index.js +41 -455
- package/lib/providers.js +29 -18
- package/lib/routes.js +400 -0
- package/lib/stream-hub.js +31 -0
- package/lib/text.js +19 -19
- package/package.json +5 -2
- package/scripts/f5_daemon.py +40 -0
package/lib/providers.js
CHANGED
|
@@ -9,14 +9,13 @@ export const PROVIDER_KEYS = [
|
|
|
9
9
|
'edge', 'piper', 'espeak', 'minimax',
|
|
10
10
|
]
|
|
11
11
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// ключа — все отвечают 401, то есть путь существует и ждёт ключ.
|
|
12
|
+
// OpenAI-compatible providers share /audio/speech; they differ by base URL,
|
|
13
|
+
// model, and key name. Each URL was probed without a key and returned 401.
|
|
15
14
|
export const OPENAI_COMPATIBLE = {
|
|
16
15
|
siliconflow: { baseUrl: 'https://api.siliconflow.cn/v1', keyEnv: 'SILICONFLOW_API_KEY' },
|
|
17
16
|
deepinfra: { baseUrl: 'https://api.deepinfra.com/v1/openai', keyEnv: 'DEEPINFRA_API_KEY' },
|
|
18
17
|
fireworks: { baseUrl: 'https://api.fireworks.ai/inference/v1', keyEnv: 'FIREWORKS_API_KEY' },
|
|
19
|
-
//
|
|
18
|
+
// Custom endpoint: base URL and key name come from settings.
|
|
20
19
|
custom: { baseUrl: '', keyEnv: 'CUSTOM_TTS_API_KEY' },
|
|
21
20
|
}
|
|
22
21
|
|
|
@@ -44,7 +43,7 @@ export const DEFAULT_MODELS = {
|
|
|
44
43
|
export const DEFAULT_VOICES = {
|
|
45
44
|
kokoro: 'af_bella',
|
|
46
45
|
f5: 'default',
|
|
47
|
-
//
|
|
46
|
+
// MiMo voices use Chinese names; 冰糖 is their default.
|
|
48
47
|
mimo: '冰糖',
|
|
49
48
|
minimax: '',
|
|
50
49
|
siliconflow: 'FunAudioLLM/CosyVoice2-0.5B:alex',
|
|
@@ -119,8 +118,8 @@ export function makeProviders(deps, req) {
|
|
|
119
118
|
}
|
|
120
119
|
}
|
|
121
120
|
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
// One implementation for all OpenAI-compatible providers: base URL and key
|
|
122
|
+
// name come from the table; everything else is identical.
|
|
124
123
|
function openaiCompatible(key) {
|
|
125
124
|
const spec = OPENAI_COMPATIBLE[key]
|
|
126
125
|
return async function speak() {
|
|
@@ -151,8 +150,8 @@ export function makeProviders(deps, req) {
|
|
|
151
150
|
}
|
|
152
151
|
}
|
|
153
152
|
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
// Xiaomi MiMo. Synthesis goes through chat: text as a message, voice/format
|
|
154
|
+
// as a separate field; audio returns base64 in the response.
|
|
156
155
|
async function mimo() {
|
|
157
156
|
const key = await resolveKey(cfg.mimoKeyEnv)
|
|
158
157
|
if (!key) return { ok: false, provider: 'mimo', reason: `no ${cfg.mimoKeyEnv}` }
|
|
@@ -195,7 +194,7 @@ export function makeProviders(deps, req) {
|
|
|
195
194
|
}
|
|
196
195
|
}
|
|
197
196
|
|
|
198
|
-
// MiniMax
|
|
197
|
+
// MiniMax uses their CLI on purpose; no custom protocol here.
|
|
199
198
|
async function minimax() {
|
|
200
199
|
try {
|
|
201
200
|
const out = await minimaxSpeak(text, {
|
|
@@ -404,7 +403,14 @@ export function makeProviders(deps, req) {
|
|
|
404
403
|
const modelPath = deps.modelManager ? deps.modelManager.getModelPath('kokoro') : (cfg.kokoroModelPath || '')
|
|
405
404
|
const engine = new KokoroEngine({ modelPath })
|
|
406
405
|
if (!engine.isInstalled()) {
|
|
407
|
-
return { ok: false, provider: 'kokoro', reason: 'Kokoro ONNX model not installed. Use Piper or eSpeak for offline TTS.' }
|
|
406
|
+
return { ok: false, provider: 'kokoro', reason: 'Kokoro ONNX model not installed. Use Edge, Piper, or eSpeak for offline TTS.' }
|
|
407
|
+
}
|
|
408
|
+
if (!engine.isRuntimeAvailable()) {
|
|
409
|
+
return {
|
|
410
|
+
ok: false,
|
|
411
|
+
provider: 'kokoro',
|
|
412
|
+
reason: 'Kokoro ONNX runtime is not bundled. Weights alone do not produce speech. Use Edge, Piper, or eSpeak for offline TTS.',
|
|
413
|
+
}
|
|
408
414
|
}
|
|
409
415
|
const voice = pick(DEFAULT_VOICES, voices, 'kokoro') || 'af_bella'
|
|
410
416
|
const audio = await engine.synthesizeWav(text, voice)
|
|
@@ -416,16 +422,21 @@ export function makeProviders(deps, req) {
|
|
|
416
422
|
|
|
417
423
|
async function f5() {
|
|
418
424
|
try {
|
|
419
|
-
const modelPath = deps.modelManager ? deps.modelManager.getModelPath('f5') :
|
|
425
|
+
const modelPath = deps.modelManager ? deps.modelManager.getModelPath('f5') : ''
|
|
420
426
|
const engine = new F5Engine({ modelPath })
|
|
421
427
|
const ok = await engine.ping()
|
|
422
|
-
if (!ok)
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
428
|
+
if (!ok) {
|
|
429
|
+
return { ok: false, provider: 'f5', reason: 'F5 daemon unavailable. Use Edge, Piper, or eSpeak for offline TTS.' }
|
|
430
|
+
}
|
|
431
|
+
if (!engine.isRuntimeAvailable()) {
|
|
432
|
+
return {
|
|
433
|
+
ok: false,
|
|
434
|
+
provider: 'f5',
|
|
435
|
+
reason: 'F5-TTS synthesis is not bundled. Use Edge, Piper, or eSpeak for offline TTS.',
|
|
436
|
+
}
|
|
426
437
|
}
|
|
427
|
-
const audio = await
|
|
428
|
-
return { ok:
|
|
438
|
+
const audio = await engine.synthesize(text)
|
|
439
|
+
return { ok: !!(audio && audio.length), provider: 'f5', audio, mime: 'audio/wav' }
|
|
429
440
|
} catch (e) {
|
|
430
441
|
return { ok: false, provider: 'f5', reason: String(e && e.message || e) }
|
|
431
442
|
}
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { writeJson, readBody, isTrustedSettingsRequest } from './http-util.js'
|
|
2
|
+
import { addStreamSubscriber, removeStreamSubscriber } from './stream-hub.js'
|
|
3
|
+
import { pendingKeyWrites, stripSecretsFromConfig, needsApiKey } from './keys.js'
|
|
4
|
+
import { PROVIDER_KEYS } from './providers.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Register all /dsh-tts HTTP routes.
|
|
8
|
+
* @param {object} ctx cordis context
|
|
9
|
+
* @param {object} api live bindings from apply()
|
|
10
|
+
*/
|
|
11
|
+
export function registerHttpRoutes(ctx, api) {
|
|
12
|
+
const {
|
|
13
|
+
live,
|
|
14
|
+
modelManager,
|
|
15
|
+
stats,
|
|
16
|
+
speechCache,
|
|
17
|
+
cleanText,
|
|
18
|
+
synthesize,
|
|
19
|
+
credentialsView,
|
|
20
|
+
configResponse,
|
|
21
|
+
storeProviderKey,
|
|
22
|
+
clearProviderKey,
|
|
23
|
+
pending,
|
|
24
|
+
} = api
|
|
25
|
+
ctx.effect(() => ctx.webServer.register({
|
|
26
|
+
kind: 'exact',
|
|
27
|
+
path: '/dsh-tts/status',
|
|
28
|
+
handler: async (req, res) => {
|
|
29
|
+
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
30
|
+
const cfg = live()
|
|
31
|
+
writeJson(res, 200, {
|
|
32
|
+
ok: true,
|
|
33
|
+
speakReplies: cfg.speakReplies,
|
|
34
|
+
enableLocalEngines: cfg.enableLocalEngines,
|
|
35
|
+
kokoroEnabled: cfg.kokoroEnabled,
|
|
36
|
+
f5Enabled: cfg.f5Enabled,
|
|
37
|
+
streamingEnabled: cfg.streamingEnabled,
|
|
38
|
+
rate: cfg.rate,
|
|
39
|
+
chime: cfg.chime,
|
|
40
|
+
bargeIn: cfg.bargeIn,
|
|
41
|
+
language: cfg.language,
|
|
42
|
+
chain: cfg.chain,
|
|
43
|
+
roles: cfg.roles,
|
|
44
|
+
providers: PROVIDER_KEYS,
|
|
45
|
+
})
|
|
46
|
+
},
|
|
47
|
+
}), 'dsh-tts: /status route')
|
|
48
|
+
|
|
49
|
+
ctx.effect(() => ctx.webServer.register({
|
|
50
|
+
kind: 'exact',
|
|
51
|
+
path: '/dsh-tts/stream',
|
|
52
|
+
handler: async (req, res) => {
|
|
53
|
+
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
54
|
+
try {
|
|
55
|
+
res.writeHead(200, {
|
|
56
|
+
'Content-Type': 'text/event-stream',
|
|
57
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
58
|
+
'Connection': 'keep-alive',
|
|
59
|
+
})
|
|
60
|
+
res.write(': connected\n\n')
|
|
61
|
+
addStreamSubscriber(res)
|
|
62
|
+
req.on('close', () => removeStreamSubscriber(res))
|
|
63
|
+
} catch {
|
|
64
|
+
removeStreamSubscriber(res)
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
}), 'dsh-tts: /stream route')
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
ctx.effect(() => ctx.webServer.register({
|
|
71
|
+
kind: 'exact',
|
|
72
|
+
path: '/dsh-tts/models/status',
|
|
73
|
+
handler: async (req, res) => {
|
|
74
|
+
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
75
|
+
try {
|
|
76
|
+
const list = await modelManager.listStatus()
|
|
77
|
+
writeJson(res, 200, { ok: true, models: list })
|
|
78
|
+
} catch (err) {
|
|
79
|
+
writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
}), 'dsh-tts: /models/status route')
|
|
83
|
+
|
|
84
|
+
ctx.effect(() => ctx.webServer.register({
|
|
85
|
+
kind: 'exact',
|
|
86
|
+
path: '/dsh-tts/models/install',
|
|
87
|
+
handler: async (req, res) => {
|
|
88
|
+
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
89
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
90
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
let body = {}
|
|
94
|
+
try {
|
|
95
|
+
const raw = await readBody(req, 16 * 1024)
|
|
96
|
+
body = JSON.parse(raw.toString('utf8') || '{}')
|
|
97
|
+
} catch (e) {
|
|
98
|
+
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
const engine = String(body.engine || '').trim()
|
|
102
|
+
if (!engine) {
|
|
103
|
+
writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
// Fire installation in background or await initial start
|
|
108
|
+
modelManager.installModel(engine).catch(() => {})
|
|
109
|
+
const current = await modelManager.getStatus(engine)
|
|
110
|
+
writeJson(res, 200, { ok: true, model: current })
|
|
111
|
+
} catch (err) {
|
|
112
|
+
writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
}), 'dsh-tts: /models/install route')
|
|
116
|
+
|
|
117
|
+
ctx.effect(() => ctx.webServer.register({
|
|
118
|
+
kind: 'exact',
|
|
119
|
+
path: '/dsh-tts/models/delete',
|
|
120
|
+
handler: async (req, res) => {
|
|
121
|
+
if (req.method !== 'DELETE' && req.method !== 'POST') {
|
|
122
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'DELETE or POST only' } })
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
126
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
let body = {}
|
|
130
|
+
try {
|
|
131
|
+
const raw = await readBody(req, 16 * 1024)
|
|
132
|
+
body = JSON.parse(raw.toString('utf8') || '{}')
|
|
133
|
+
} catch { body = {} }
|
|
134
|
+
const engine = String(body.engine || '').trim()
|
|
135
|
+
if (!engine) {
|
|
136
|
+
writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const result = await modelManager.deleteModel(engine)
|
|
141
|
+
writeJson(res, 200, { ok: true, model: result })
|
|
142
|
+
} catch (err) {
|
|
143
|
+
writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
}), 'dsh-tts: /models/delete route')
|
|
147
|
+
|
|
148
|
+
ctx.effect(() => ctx.webServer.register({
|
|
149
|
+
kind: 'exact',
|
|
150
|
+
path: '/dsh-tts/integrations',
|
|
151
|
+
handler: async (req, res) => {
|
|
152
|
+
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
153
|
+
let voiceInstalled = false
|
|
154
|
+
let messengerInstalled = false
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
if (typeof ctx.webServer?.hasRoute === 'function') {
|
|
158
|
+
voiceInstalled = ctx.webServer.hasRoute('/dsh-voice/status')
|
|
159
|
+
messengerInstalled = ctx.webServer.hasRoute('/dsh-messenger-gateway/status')
|
|
160
|
+
}
|
|
161
|
+
} catch {}
|
|
162
|
+
|
|
163
|
+
// Runtime routes are the only reliable signal. Do not probe ../ siblings
|
|
164
|
+
// or node_modules layouts — they lie outside a normal npm install.
|
|
165
|
+
|
|
166
|
+
writeJson(res, 200, {
|
|
167
|
+
ok: true,
|
|
168
|
+
voice: {
|
|
169
|
+
installed: voiceInstalled,
|
|
170
|
+
package: '@goodandready/dsh-voice',
|
|
171
|
+
hint: 'dsh plugin --profile web add @goodandready/dsh-voice',
|
|
172
|
+
},
|
|
173
|
+
messenger: {
|
|
174
|
+
installed: messengerInstalled,
|
|
175
|
+
package: '@goodandready/dsh-messenger-gateway',
|
|
176
|
+
hint: 'dsh plugin --profile web add @goodandready/dsh-messenger-gateway',
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
},
|
|
180
|
+
}), 'dsh-tts: /integrations route')
|
|
181
|
+
|
|
182
|
+
ctx.effect(() => ctx.webServer.register({
|
|
183
|
+
kind: 'exact',
|
|
184
|
+
path: '/dsh-tts/cache',
|
|
185
|
+
handler: async (req, res) => {
|
|
186
|
+
if (req.method !== 'DELETE') {
|
|
187
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'DELETE only' } })
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
191
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
const removed = await speechCache.clear()
|
|
195
|
+
writeJson(res, 200, { ok: true, removed })
|
|
196
|
+
},
|
|
197
|
+
}), 'dsh-tts: /cache route')
|
|
198
|
+
|
|
199
|
+
ctx.effect(() => ctx.webServer.register({
|
|
200
|
+
kind: 'exact',
|
|
201
|
+
path: '/dsh-tts/preview',
|
|
202
|
+
handler: async (req, res) => {
|
|
203
|
+
if (req.method !== 'POST') {
|
|
204
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
208
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
let body = {}
|
|
212
|
+
try {
|
|
213
|
+
const raw = await readBody(req, 64 * 1024)
|
|
214
|
+
body = JSON.parse(raw.toString('utf8') || '{}')
|
|
215
|
+
} catch (e) {
|
|
216
|
+
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
const cfg = live()
|
|
220
|
+
const phrase = String(cfg.language || '').toLowerCase().startsWith('ru') ? 'Проверка голоса.' : 'Voice check.'
|
|
221
|
+
try {
|
|
222
|
+
const probe = Object.assign({}, cfg, {
|
|
223
|
+
chain: [{ provider: body.provider || '', model: body.model || '', voice: body.voice || '' }],
|
|
224
|
+
})
|
|
225
|
+
const out = await synthesize(String(body.text || phrase), probe, null, 'reply')
|
|
226
|
+
writeJson(res, 200, { ok: true, mime: out.mime, audioBase64: Buffer.from(out.audio).toString('base64') })
|
|
227
|
+
} catch (e) {
|
|
228
|
+
writeJson(res, 502, { ok: false, error: { code: 'tts', message: String(e && e.message || e) } })
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
}), 'dsh-tts: /preview route')
|
|
232
|
+
|
|
233
|
+
ctx.effect(() => ctx.webServer.register({
|
|
234
|
+
kind: 'exact',
|
|
235
|
+
path: '/dsh-tts/stats',
|
|
236
|
+
handler: async (req, res) => {
|
|
237
|
+
if (req.method === 'GET') {
|
|
238
|
+
writeJson(res, 200, { ok: true, total: stats.total, cacheHits: stats.cacheHits, errors: stats.errors, providers: stats.providers })
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
if (req.method !== 'DELETE') {
|
|
242
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or DELETE' } })
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
246
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
stats.total = 0
|
|
250
|
+
stats.cacheHits = 0
|
|
251
|
+
stats.errors = 0
|
|
252
|
+
stats.providers = {}
|
|
253
|
+
writeJson(res, 200, { ok: true })
|
|
254
|
+
},
|
|
255
|
+
}), 'dsh-tts: /stats route')
|
|
256
|
+
|
|
257
|
+
ctx.effect(() => ctx.webServer.register({
|
|
258
|
+
kind: 'exact',
|
|
259
|
+
path: '/dsh-tts/config',
|
|
260
|
+
handler: async (req, res) => {
|
|
261
|
+
if (req.method === 'GET') {
|
|
262
|
+
writeJson(res, 200, await configResponse())
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
if (req.method !== 'PUT' && req.method !== 'POST') {
|
|
266
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or PUT' } })
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
270
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-tts: settings writes are same-origin only' } })
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
if (!settingsApi) {
|
|
274
|
+
writeJson(res, 503, { ok: false, error: { code: 'settings', message: 'settings not ready' } })
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
let raw
|
|
278
|
+
try { raw = await readBody(req, 256 * 1024) } catch (e) {
|
|
279
|
+
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
let payload
|
|
283
|
+
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch {
|
|
284
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
if (payload && typeof payload.config === 'object') {
|
|
288
|
+
payload = { ...payload.config, keys: payload.keys }
|
|
289
|
+
}
|
|
290
|
+
const keys = pendingKeyWrites(payload && payload.keys)
|
|
291
|
+
const stripped = stripSecretsFromConfig(payload)
|
|
292
|
+
let parsed
|
|
293
|
+
try { parsed = Config(stripped) } catch (e) {
|
|
294
|
+
writeJson(res, 400, { ok: false, error: { code: 'schema', message: String(e && e.message || e) } })
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
for (const item of keys) {
|
|
299
|
+
await storeProviderKey(item.provider, item.value)
|
|
300
|
+
}
|
|
301
|
+
await settingsApi.replace(parsed)
|
|
302
|
+
writeJson(res, 200, await configResponse())
|
|
303
|
+
} catch (e) {
|
|
304
|
+
writeJson(res, 500, { ok: false, error: { code: 'save', message: String(e && e.message || e) } })
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
}), 'dsh-tts: /config route')
|
|
308
|
+
|
|
309
|
+
ctx.effect(() => ctx.webServer.register({
|
|
310
|
+
kind: 'exact',
|
|
311
|
+
path: '/dsh-tts/credential',
|
|
312
|
+
handler: async (req, res) => {
|
|
313
|
+
if (req.method !== 'PUT' && req.method !== 'DELETE') {
|
|
314
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'PUT or DELETE only' } })
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
318
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-tts: keys are same-origin only' } })
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
let payload = {}
|
|
322
|
+
if (req.method === 'PUT' || (req.headers['content-length'] && Number(req.headers['content-length']) > 0)) {
|
|
323
|
+
let raw
|
|
324
|
+
try { raw = await readBody(req, 16 * 1024) } catch (e) {
|
|
325
|
+
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch {
|
|
329
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const provider = typeof payload.provider === 'string' ? payload.provider.trim() : ''
|
|
334
|
+
if (!needsApiKey(provider)) {
|
|
335
|
+
writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown cloud provider' } })
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
if (req.method === 'DELETE') {
|
|
340
|
+
const ref = await clearProviderKey(provider)
|
|
341
|
+
writeJson(res, 200, { ok: true, ref, credentials: await credentialsView() })
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
const ref = await storeProviderKey(provider, payload.value)
|
|
345
|
+
writeJson(res, 200, { ok: true, ref, credentials: await credentialsView() })
|
|
346
|
+
} catch (e) {
|
|
347
|
+
writeJson(res, 400, { ok: false, error: { code: 'credential', message: String(e && e.message || e) } })
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
}), 'dsh-tts: /credential route')
|
|
351
|
+
|
|
352
|
+
ctx.effect(() => ctx.webServer.register({
|
|
353
|
+
kind: 'exact',
|
|
354
|
+
path: '/dsh-tts/pending',
|
|
355
|
+
handler: async (req, res) => {
|
|
356
|
+
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
357
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1')
|
|
358
|
+
const after = url.searchParams.get('after') || ''
|
|
359
|
+
let items = pending.slice()
|
|
360
|
+
if (after) {
|
|
361
|
+
const idx = items.findIndex((x) => x.id === after)
|
|
362
|
+
items = idx >= 0 ? items.slice(idx + 1) : items
|
|
363
|
+
}
|
|
364
|
+
writeJson(res, 200, { ok: true, items })
|
|
365
|
+
},
|
|
366
|
+
}), 'dsh-tts: /pending route')
|
|
367
|
+
|
|
368
|
+
ctx.effect(() => ctx.webServer.register({
|
|
369
|
+
kind: 'exact',
|
|
370
|
+
path: '/dsh-tts/speak',
|
|
371
|
+
handler: async (req, res) => {
|
|
372
|
+
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
373
|
+
let raw
|
|
374
|
+
try { raw = await readBody(req, 256 * 1024) } catch (e) {
|
|
375
|
+
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
|
|
376
|
+
}
|
|
377
|
+
let payload
|
|
378
|
+
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
|
|
379
|
+
const text = cleanText(payload.text, live())
|
|
380
|
+
if (!text) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
|
|
381
|
+
const cfg = live()
|
|
382
|
+
const controller = new AbortController()
|
|
383
|
+
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
384
|
+
try {
|
|
385
|
+
const out = await synthesize(text, cfg, controller.signal)
|
|
386
|
+
writeJson(res, 200, {
|
|
387
|
+
ok: true,
|
|
388
|
+
provider: out.provider,
|
|
389
|
+
mime: out.mime,
|
|
390
|
+
audioBase64: Buffer.from(out.audio).toString('base64'),
|
|
391
|
+
tookMs: out.tookMs,
|
|
392
|
+
})
|
|
393
|
+
} catch (e) {
|
|
394
|
+
writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
|
|
395
|
+
} finally {
|
|
396
|
+
clearTimeout(timer)
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
}), 'dsh-tts: /speak route')
|
|
400
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// SSE subscriber hub for /dsh-tts/stream.
|
|
2
|
+
|
|
3
|
+
const subscribers = new Set()
|
|
4
|
+
|
|
5
|
+
export function addStreamSubscriber(res) {
|
|
6
|
+
subscribers.add(res)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function removeStreamSubscriber(res) {
|
|
10
|
+
subscribers.delete(res)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function broadcastStream(type, item) {
|
|
14
|
+
const payload = `event: ${type}\ndata: ${JSON.stringify(item)}\n\n`
|
|
15
|
+
for (const res of subscribers) {
|
|
16
|
+
try {
|
|
17
|
+
res.write(payload)
|
|
18
|
+
} catch {
|
|
19
|
+
subscribers.delete(res)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function clearStreamSubscribers() {
|
|
25
|
+
for (const res of subscribers) {
|
|
26
|
+
try {
|
|
27
|
+
res.end()
|
|
28
|
+
} catch { /* already closed */ }
|
|
29
|
+
}
|
|
30
|
+
subscribers.clear()
|
|
31
|
+
}
|
package/lib/text.js
CHANGED
|
@@ -11,8 +11,8 @@ export function blocksToText(content) {
|
|
|
11
11
|
return parts.join('\n')
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
//
|
|
15
|
-
//
|
|
14
|
+
// Short spoken notices replace dropped markup. Wording follows
|
|
15
|
+
// the configured speech language; {n} is filled with a line/row count.
|
|
16
16
|
export const SPEECH_PHRASES = {
|
|
17
17
|
en: { codeBlock: 'code block, {n} lines', table: 'table, {n} rows', summaryIntro: 'Summary of the reply.' },
|
|
18
18
|
ru: { codeBlock: 'блок кода, {n} строк', table: 'таблица, {n} строк', summaryIntro: 'Пересказ ответа.' },
|
|
@@ -23,8 +23,8 @@ export function speechPhrases(language) {
|
|
|
23
23
|
return lang.startsWith('ru') ? SPEECH_PHRASES.ru : SPEECH_PHRASES.en
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
//
|
|
27
|
-
//
|
|
26
|
+
// Narration filters run on top of general scrubbing, before pronunciation.
|
|
27
|
+
// Order: custom regex -> *action* blocks -> quotes-only mode.
|
|
28
28
|
export function applyNarrationFilters(text, cfg) {
|
|
29
29
|
let out = String(text || '')
|
|
30
30
|
if (cfg && cfg.removeRegex) {
|
|
@@ -40,7 +40,7 @@ export function applyNarrationFilters(text, cfg) {
|
|
|
40
40
|
return out
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
//
|
|
43
|
+
// Rough language heuristic: whichever script has more letters wins.
|
|
44
44
|
export function detectLang(text) {
|
|
45
45
|
const s = String(text || '')
|
|
46
46
|
const cyr = (s.match(/[\u0400-\u04FF]/g) || []).length
|
|
@@ -48,10 +48,10 @@ export function detectLang(text) {
|
|
|
48
48
|
return cyr > lat ? 'ru' : 'en'
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
51
|
+
// Pronunciation rules apply top-down, once each over the whole text.
|
|
52
|
+
// Left side is plain text; /…/flags form is treated as a regular expression.
|
|
53
|
+
// whole=true requires a full word match (Unicode letter/digit boundaries).
|
|
54
|
+
|
|
55
55
|
function escapeRe(s) {
|
|
56
56
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
57
57
|
}
|
|
@@ -114,8 +114,8 @@ export function stripForSpeech(raw, maxChars, opts) {
|
|
|
114
114
|
const skipCode = options.skipCode !== false
|
|
115
115
|
const phrases = options.phrases || SPEECH_PHRASES.en
|
|
116
116
|
let text = String(raw || '')
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
// Scrubbing must run before sentence splitting or half a fenced block ends
|
|
118
|
+
// up in the first chunk and is spoken.
|
|
119
119
|
if (skipCode) {
|
|
120
120
|
text = text.replace(/```[\s\S]*?```/g, (block) => {
|
|
121
121
|
const inner = block.replace(/^```[^\n]*\n?/, '').replace(/\n?```\s*$/, '')
|
|
@@ -124,8 +124,8 @@ export function stripForSpeech(raw, maxChars, opts) {
|
|
|
124
124
|
})
|
|
125
125
|
text = text.replace(/`[^`\n]+`/g, ' ')
|
|
126
126
|
}
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
// Markdown tables: consecutive lines starting with |; the |---| separator
|
|
128
|
+
// row is not counted as a data row.
|
|
129
129
|
text = text.replace(/(?:^[ \t]*\|.*\|[ \t]*$\n?)+/gm, (run) => {
|
|
130
130
|
const rows = run.trim().split('\n')
|
|
131
131
|
.filter((line) => !/^\s*\|[\s:|-]+\|\s*$/.test(line)).length
|
|
@@ -136,7 +136,7 @@ export function stripForSpeech(raw, maxChars, opts) {
|
|
|
136
136
|
text = text.replace(/[#*_>]+/g, ' ')
|
|
137
137
|
text = text.replace(/MEDIA:[^\s]+/g, ' ')
|
|
138
138
|
text = text.replace(/\s+/g, ' ').trim()
|
|
139
|
-
// maxChars === 0
|
|
139
|
+
// maxChars === 0 disables truncation; unset means the 4000 default.
|
|
140
140
|
const requested = Number(maxChars)
|
|
141
141
|
if (requested > 0 && text.length > requested) text = text.slice(0, requested)
|
|
142
142
|
return text
|
|
@@ -147,13 +147,13 @@ export function assistantText(message) {
|
|
|
147
147
|
return blocksToText(message.content)
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
//
|
|
150
|
+
// Protect common abbreviations from false sentence splits
|
|
151
151
|
const PROTECTED_ABBREVIATIONS = [
|
|
152
|
-
//
|
|
152
|
+
// Russian abbreviations
|
|
153
153
|
'т.е.', 'т.д.', 'т.п.', 'т.к.', 'руб.', 'коп.', 'ул.', 'пер.', 'пр.', 'просп.',
|
|
154
154
|
'д.', 'корп.', 'стр.', 'кв.', 'г.', 'гг.', 'в.', 'вв.', 'см.', 'ср.', 'напр.',
|
|
155
155
|
'рис.', 'табл.', 'им.', 'акад.', 'проф.', 'доц.', 'млн.', 'млрд.', 'тыс.',
|
|
156
|
-
//
|
|
156
|
+
// English abbreviations
|
|
157
157
|
'e.g.', 'i.e.', 'vs.', 'etc.', 'dr.', 'mr.', 'mrs.', 'ms.', 'prof.',
|
|
158
158
|
'approx.', 'dept.', 'est.', 'min.', 'max.', 'no.', 'vol.', 'p.m.', 'a.m.'
|
|
159
159
|
]
|
|
@@ -193,13 +193,13 @@ export function splitSentences(text, maxChars) {
|
|
|
193
193
|
const source = protectAbbreviations(raw)
|
|
194
194
|
const pieces = []
|
|
195
195
|
let current = ''
|
|
196
|
-
//
|
|
196
|
+
// Boundaries are period, question, exclamation, ellipsis, and newline.
|
|
197
197
|
for (const part of source.split(/(?<=[.!?…])\s+|\n+/)) {
|
|
198
198
|
const chunk = String(part || '').trim()
|
|
199
199
|
if (!chunk) continue
|
|
200
200
|
if (!current) { current = chunk } else if (current.length < min) { current += ' ' + chunk }
|
|
201
201
|
else { pieces.push(current); current = chunk }
|
|
202
|
-
//
|
|
202
|
+
// Oversized pieces are split on words: providers choke on walls of text.
|
|
203
203
|
while (current.length > limit) {
|
|
204
204
|
let cut = current.lastIndexOf(' ', limit)
|
|
205
205
|
if (cut < min) cut = limit
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-tts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"lib/",
|
|
16
|
+
"scripts/",
|
|
16
17
|
"cordis.patch.yml",
|
|
17
18
|
"README.md",
|
|
18
|
-
"LICENSE"
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"README.ru.md",
|
|
21
|
+
"README.zh.md"
|
|
19
22
|
],
|
|
20
23
|
"keywords": [
|
|
21
24
|
"dsh",
|