@goodandready/dsh-tts 0.3.22 → 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 +105 -88
- 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 +70 -467
- package/lib/providers.js +105 -25
- package/lib/routes.js +400 -0
- package/lib/stream-hub.js +31 -0
- package/lib/text.js +46 -19
- package/package.json +6 -3
- package/scripts/f5_daemon.py +40 -0
- package/lib/client/worklet.js +0 -71
- package/lib/router.js +0 -4
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,6 +147,32 @@ export function assistantText(message) {
|
|
|
147
147
|
return blocksToText(message.content)
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
// Protect common abbreviations from false sentence splits
|
|
151
|
+
const PROTECTED_ABBREVIATIONS = [
|
|
152
|
+
// Russian abbreviations
|
|
153
|
+
'т.е.', 'т.д.', 'т.п.', 'т.к.', 'руб.', 'коп.', 'ул.', 'пер.', 'пр.', 'просп.',
|
|
154
|
+
'д.', 'корп.', 'стр.', 'кв.', 'г.', 'гг.', 'в.', 'вв.', 'см.', 'ср.', 'напр.',
|
|
155
|
+
'рис.', 'табл.', 'им.', 'акад.', 'проф.', 'доц.', 'млн.', 'млрд.', 'тыс.',
|
|
156
|
+
// English abbreviations
|
|
157
|
+
'e.g.', 'i.e.', 'vs.', 'etc.', 'dr.', 'mr.', 'mrs.', 'ms.', 'prof.',
|
|
158
|
+
'approx.', 'dept.', 'est.', 'min.', 'max.', 'no.', 'vol.', 'p.m.', 'a.m.'
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
export function protectAbbreviations(text) {
|
|
162
|
+
let res = String(text || '')
|
|
163
|
+
for (const abbr of PROTECTED_ABBREVIATIONS) {
|
|
164
|
+
const escaped = abbr.replace(/\./g, '\\.')
|
|
165
|
+
const rx = new RegExp('(?<=^|[\\s"«(])' + escaped + '(?=[\\s"»)\\]]|$)', 'gi')
|
|
166
|
+
res = res.replace(rx, (m) => m.replace(/\./g, '\u2024'))
|
|
167
|
+
}
|
|
168
|
+
res = res.replace(/(?<=[vV]?\d+)\.(?=\d+)/g, '\u2024')
|
|
169
|
+
return res
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function restoreAbbreviations(text) {
|
|
173
|
+
return String(text || '').replace(/\u2024/g, '.')
|
|
174
|
+
}
|
|
175
|
+
|
|
150
176
|
/**
|
|
151
177
|
* Режет текст на куски по границам предложений.
|
|
152
178
|
*
|
|
@@ -161,18 +187,19 @@ export function assistantText(message) {
|
|
|
161
187
|
export function splitSentences(text, maxChars) {
|
|
162
188
|
const limit = Number(maxChars) > 0 ? Number(maxChars) : 320
|
|
163
189
|
const min = Math.min(60, Math.floor(limit / 3))
|
|
164
|
-
const
|
|
165
|
-
if (!
|
|
190
|
+
const raw = String(text || '').trim()
|
|
191
|
+
if (!raw) return []
|
|
166
192
|
|
|
193
|
+
const source = protectAbbreviations(raw)
|
|
167
194
|
const pieces = []
|
|
168
195
|
let current = ''
|
|
169
|
-
//
|
|
196
|
+
// Boundaries are period, question, exclamation, ellipsis, and newline.
|
|
170
197
|
for (const part of source.split(/(?<=[.!?…])\s+|\n+/)) {
|
|
171
198
|
const chunk = String(part || '').trim()
|
|
172
199
|
if (!chunk) continue
|
|
173
200
|
if (!current) { current = chunk } else if (current.length < min) { current += ' ' + chunk }
|
|
174
201
|
else { pieces.push(current); current = chunk }
|
|
175
|
-
//
|
|
202
|
+
// Oversized pieces are split on words: providers choke on walls of text.
|
|
176
203
|
while (current.length > limit) {
|
|
177
204
|
let cut = current.lastIndexOf(' ', limit)
|
|
178
205
|
if (cut < min) cut = limit
|
|
@@ -181,5 +208,5 @@ export function splitSentences(text, maxChars) {
|
|
|
181
208
|
}
|
|
182
209
|
}
|
|
183
210
|
if (current) pieces.push(current)
|
|
184
|
-
return pieces.filter(Boolean)
|
|
211
|
+
return pieces.filter(Boolean).map(restoreAbbreviations)
|
|
185
212
|
}
|
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",
|
|
@@ -63,4 +66,4 @@
|
|
|
63
66
|
"publishConfig": {
|
|
64
67
|
"access": "public"
|
|
65
68
|
}
|
|
66
|
-
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""F5-TTS control stub.
|
|
3
|
+
|
|
4
|
+
Honest status only: ping answers when this script can run. It does not
|
|
5
|
+
synthesize audio. Real F5 inference is out of scope for this package until a
|
|
6
|
+
supported external runtime is documented and wired.
|
|
7
|
+
"""
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main():
|
|
13
|
+
if len(sys.argv) > 1 and sys.argv[1] == '--ping':
|
|
14
|
+
print(json.dumps({"ok": True, "status": "ready", "synthesize": False}))
|
|
15
|
+
sys.exit(0)
|
|
16
|
+
|
|
17
|
+
for line in sys.stdin:
|
|
18
|
+
line = line.strip()
|
|
19
|
+
if not line:
|
|
20
|
+
continue
|
|
21
|
+
try:
|
|
22
|
+
req = json.loads(line)
|
|
23
|
+
cmd = req.get("cmd")
|
|
24
|
+
if cmd == "ping":
|
|
25
|
+
print(json.dumps({"ok": True, "status": "pong", "synthesize": False}))
|
|
26
|
+
elif cmd == "synthesize":
|
|
27
|
+
print(json.dumps({
|
|
28
|
+
"ok": False,
|
|
29
|
+
"error": "F5 synthesis is not bundled; use Edge/Piper/eSpeak offline providers",
|
|
30
|
+
}))
|
|
31
|
+
else:
|
|
32
|
+
print(json.dumps({"ok": False, "error": f"unknown cmd: {cmd}"}))
|
|
33
|
+
sys.stdout.flush()
|
|
34
|
+
except Exception as e:
|
|
35
|
+
print(json.dumps({"ok": False, "error": str(e)}))
|
|
36
|
+
sys.stdout.flush()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
main()
|