@goodandready/dsh-tts 0.4.27 → 0.4.29
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/CHANGELOG.md +68 -0
- package/README.md +18 -13
- package/README.ru.md +11 -10
- package/README.zh.md +5 -5
- package/lib/cache.js +70 -40
- package/lib/client.js +338 -166
- package/lib/engines/manager.js +137 -75
- package/lib/index.js +345 -64
- package/lib/local.js +1 -1
- package/lib/providers/cloud.js +44 -5
- package/lib/providers/constants.js +4 -4
- package/lib/providers/local.js +1 -1
- package/lib/providers.js +11 -2
- package/lib/routes.js +107 -36
- package/lib/stream-hub.js +55 -8
- package/lib/text.js +25 -2
- package/package.json +1 -1
package/lib/providers/cloud.js
CHANGED
|
@@ -18,6 +18,29 @@ function escapeXml(s) {
|
|
|
18
18
|
.replace(/"/g, '"')
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function pcmToWav(pcmBuffer, sampleRate = 24000, channels = 1) {
|
|
22
|
+
if (pcmBuffer.length >= 4 && pcmBuffer.toString('ascii', 0, 4) === 'RIFF') {
|
|
23
|
+
return pcmBuffer
|
|
24
|
+
}
|
|
25
|
+
const header = Buffer.alloc(44)
|
|
26
|
+
const byteRate = sampleRate * channels * 2
|
|
27
|
+
const blockAlign = channels * 2
|
|
28
|
+
header.write('RIFF', 0)
|
|
29
|
+
header.writeUInt32LE(36 + pcmBuffer.length, 4)
|
|
30
|
+
header.write('WAVE', 8)
|
|
31
|
+
header.write('fmt ', 12)
|
|
32
|
+
header.writeUInt32LE(16, 16)
|
|
33
|
+
header.writeUInt16LE(1, 20)
|
|
34
|
+
header.writeUInt16LE(channels, 22)
|
|
35
|
+
header.writeUInt32LE(sampleRate, 24)
|
|
36
|
+
header.writeUInt32LE(byteRate, 28)
|
|
37
|
+
header.writeUInt16LE(blockAlign, 32)
|
|
38
|
+
header.writeUInt16LE(16, 34)
|
|
39
|
+
header.write('data', 36)
|
|
40
|
+
header.writeUInt32LE(pcmBuffer.length, 40)
|
|
41
|
+
return Buffer.concat([header, pcmBuffer])
|
|
42
|
+
}
|
|
43
|
+
|
|
21
44
|
export function makeCloudProviders(deps, req, withCloudTimeout) {
|
|
22
45
|
const { resolveKey, fetchImpl, cfg } = deps
|
|
23
46
|
const { text, lang, models, voices, role } = req
|
|
@@ -161,9 +184,23 @@ export function makeCloudProviders(deps, req, withCloudTimeout) {
|
|
|
161
184
|
)
|
|
162
185
|
if (!res.ok) throw new Error(`Google HTTP ${res.status}`)
|
|
163
186
|
const data = await res.json()
|
|
164
|
-
const
|
|
165
|
-
if (!
|
|
166
|
-
const
|
|
187
|
+
const inline = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData)?.inlineData
|
|
188
|
+
if (!inline?.data) return { ok: false, provider: 'google', reason: 'no inline audio' }
|
|
189
|
+
const raw = Buffer.from(inline.data, 'base64')
|
|
190
|
+
const mimeType = String(inline.mimeType || '')
|
|
191
|
+
let sampleRate = 24000
|
|
192
|
+
const m = mimeType.match(/rate=(\d+)/i)
|
|
193
|
+
if (m) sampleRate = parseInt(m[1], 10)
|
|
194
|
+
const isWavAlready = raw.length >= 4 && raw.toString('ascii', 0, 4) === 'RIFF'
|
|
195
|
+
let audio = null
|
|
196
|
+
const lowerMime = mimeType.toLowerCase()
|
|
197
|
+
if (isWavAlready || lowerMime.includes('wav')) {
|
|
198
|
+
audio = raw
|
|
199
|
+
} else if (!lowerMime || lowerMime.includes('pcm') || lowerMime.includes('raw') || lowerMime.includes('l16')) {
|
|
200
|
+
audio = pcmToWav(raw, sampleRate, 1)
|
|
201
|
+
} else {
|
|
202
|
+
return { ok: false, provider: 'google', reason: `unsupported Google TTS MIME: ${mimeType}` }
|
|
203
|
+
}
|
|
167
204
|
return { ok: audio.length > 0, provider: 'google', audio, mime: 'audio/wav', reason: audio.length ? '' : 'empty audio' }
|
|
168
205
|
} finally {
|
|
169
206
|
cleanup()
|
|
@@ -181,8 +218,10 @@ export function makeCloudProviders(deps, req, withCloudTimeout) {
|
|
|
181
218
|
const inner = safeStyle
|
|
182
219
|
? `<mstts:express-as style="${safeStyle}">${escapeXml(text)}</mstts:express-as>`
|
|
183
220
|
: escapeXml(text)
|
|
184
|
-
const
|
|
185
|
-
|
|
221
|
+
const safeLang = escapeXml(lang || 'en-US')
|
|
222
|
+
const safeVoice = escapeXml(voice)
|
|
223
|
+
const ssml = `<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="https://www.w3.org/2001/mstts" xml:lang="${safeLang}">`
|
|
224
|
+
+ `<voice xml:lang="${safeLang}" name="${safeVoice}">`
|
|
186
225
|
+ `${inner}</voice></speak>`
|
|
187
226
|
const { signal, cleanup } = withCloudTimeout()
|
|
188
227
|
try {
|
|
@@ -31,9 +31,9 @@ export const DEFAULT_MODELS = {
|
|
|
31
31
|
deepgram: 'aura-asteria-en',
|
|
32
32
|
openrouter: 'openai/gpt-4o-mini-tts-2025-12-15',
|
|
33
33
|
custom: '',
|
|
34
|
-
edge: '
|
|
34
|
+
edge: 'en-US-AriaNeural',
|
|
35
35
|
piper: '',
|
|
36
|
-
espeak: '
|
|
36
|
+
espeak: 'en',
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export const DEFAULT_VOICES = {
|
|
@@ -53,9 +53,9 @@ export const DEFAULT_VOICES = {
|
|
|
53
53
|
deepgram: '',
|
|
54
54
|
openrouter: 'alloy',
|
|
55
55
|
custom: '',
|
|
56
|
-
edge: '
|
|
56
|
+
edge: 'en-US-AriaNeural',
|
|
57
57
|
piper: '',
|
|
58
|
-
espeak: '
|
|
58
|
+
espeak: 'en',
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
|
package/lib/providers/local.js
CHANGED
|
@@ -32,7 +32,7 @@ export function makeLocalProviders(deps, req) {
|
|
|
32
32
|
}
|
|
33
33
|
async function espeak() {
|
|
34
34
|
try {
|
|
35
|
-
const voice = pick(DEFAULT_VOICES, voices, 'espeak') || lang || '
|
|
35
|
+
const voice = pick(DEFAULT_VOICES, voices, 'espeak') || lang || 'en'
|
|
36
36
|
const out = await espeakSpeak(text, { bin: cfg.espeakBin, voice, timeoutMs: cfg.timeoutMs })
|
|
37
37
|
return { ok: true, provider: 'espeak', audio: out.audio, mime: out.mime }
|
|
38
38
|
} catch (e) {
|
package/lib/providers.js
CHANGED
|
@@ -16,13 +16,22 @@ export function makeProviders(deps, req) {
|
|
|
16
16
|
timedOut = true
|
|
17
17
|
controller.abort(new Error())
|
|
18
18
|
}, cloudTimeoutMs)
|
|
19
|
+
let onParentAbort = null
|
|
19
20
|
if (parentSignal) {
|
|
20
21
|
if (parentSignal.aborted) controller.abort(parentSignal.reason)
|
|
21
|
-
else
|
|
22
|
+
else {
|
|
23
|
+
onParentAbort = () => controller.abort(parentSignal.reason)
|
|
24
|
+
parentSignal.addEventListener('abort', onParentAbort, { once: true })
|
|
25
|
+
}
|
|
22
26
|
}
|
|
23
27
|
return {
|
|
24
28
|
signal: controller.signal,
|
|
25
|
-
cleanup: () =>
|
|
29
|
+
cleanup: () => {
|
|
30
|
+
clearTimeout(timer)
|
|
31
|
+
if (parentSignal && onParentAbort) {
|
|
32
|
+
try { parentSignal.removeEventListener('abort', onParentAbort) } catch (_err) { /* parent abort listener already removed */ }
|
|
33
|
+
}
|
|
34
|
+
},
|
|
26
35
|
}
|
|
27
36
|
}
|
|
28
37
|
|
package/lib/routes.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { MODEL_SPECS } from './engines/manager.js'
|
|
1
2
|
import { resolve } from 'node:path'
|
|
2
3
|
import { registerPluginUpdater } from './updater.js'
|
|
3
4
|
import { writeJson, readBody, isTrustedSettingsRequest } from './http-util.js'
|
|
@@ -17,6 +18,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
17
18
|
stats,
|
|
18
19
|
speechCache,
|
|
19
20
|
cleanText,
|
|
21
|
+
prepareSpeechText,
|
|
20
22
|
synthesize,
|
|
21
23
|
credentialsView,
|
|
22
24
|
configResponse,
|
|
@@ -32,6 +34,10 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
32
34
|
path: '/dsh-tts/status',
|
|
33
35
|
handler: async (req, res) => {
|
|
34
36
|
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
37
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
38
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
39
|
+
return
|
|
40
|
+
}
|
|
35
41
|
const cfg = live()
|
|
36
42
|
writeJson(res, 200, {
|
|
37
43
|
ok: true,
|
|
@@ -42,7 +48,9 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
42
48
|
streamingEnabled: cfg.streamingEnabled,
|
|
43
49
|
rate: cfg.rate,
|
|
44
50
|
chime: cfg.chime,
|
|
45
|
-
bargeIn: cfg.bargeIn,
|
|
51
|
+
bargeIn: cfg.bargeIn && cfg.vadBargeIn !== false,
|
|
52
|
+
voiceDuplexEnabled: cfg.voiceDuplexEnabled !== false,
|
|
53
|
+
messengerTtsEnabled: cfg.messengerTtsEnabled !== false,
|
|
46
54
|
language: cfg.language,
|
|
47
55
|
chain: cfg.chain,
|
|
48
56
|
roles: cfg.roles,
|
|
@@ -74,7 +82,6 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
74
82
|
},
|
|
75
83
|
}), 'dsh-tts: /stream route')
|
|
76
84
|
|
|
77
|
-
|
|
78
85
|
ctx.effect(() => ctx.webServer.register({
|
|
79
86
|
kind: 'exact',
|
|
80
87
|
path: '/dsh-tts/models/status',
|
|
@@ -112,6 +119,10 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
112
119
|
writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
|
|
113
120
|
return
|
|
114
121
|
}
|
|
122
|
+
if (!Object.hasOwn(MODEL_SPECS, engine)) {
|
|
123
|
+
writeJson(res, 400, { ok: false, error: { code: 'param', message: 'unknown engine: ' + engine } })
|
|
124
|
+
return
|
|
125
|
+
}
|
|
115
126
|
try {
|
|
116
127
|
// Fire installation in background or await initial start
|
|
117
128
|
modelManager.installModel(engine).catch(() => { /* background install error tracked in manager state */ })
|
|
@@ -149,6 +160,10 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
149
160
|
writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
|
|
150
161
|
return
|
|
151
162
|
}
|
|
163
|
+
if (!Object.hasOwn(MODEL_SPECS, engine)) {
|
|
164
|
+
writeJson(res, 400, { ok: false, error: { code: 'param', message: 'unknown engine: ' + engine } })
|
|
165
|
+
return
|
|
166
|
+
}
|
|
152
167
|
try {
|
|
153
168
|
const result = await modelManager.deleteModel(engine)
|
|
154
169
|
writeJson(res, 200, { ok: true, model: result })
|
|
@@ -167,30 +182,56 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
167
182
|
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
168
183
|
return
|
|
169
184
|
}
|
|
170
|
-
|
|
171
|
-
|
|
185
|
+
function isPluginOrServicePresent(id) {
|
|
186
|
+
if (!ctx) return false
|
|
187
|
+
try {
|
|
188
|
+
if (typeof ctx.get === 'function' && ctx.get(id)) return true
|
|
189
|
+
if (ctx[id]) return true
|
|
190
|
+
} catch (_err) { /* service check fallback */ }
|
|
191
|
+
try {
|
|
192
|
+
const reg = ctx.registry || ctx.root?.registry
|
|
193
|
+
if (reg && typeof reg.entries === 'function') {
|
|
194
|
+
for (const [k, v] of reg.entries()) {
|
|
195
|
+
const name = String(v?.name || k?.name || '')
|
|
196
|
+
if (name === id || name === `dsh-${id}` || name === `@goodandready/dsh-${id}`) return true
|
|
197
|
+
if (id === 'messenger' && (name.includes('messenger') || name.includes('dsh-im'))) return true
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} catch (_err) { /* registry check fallback */ }
|
|
201
|
+
try {
|
|
202
|
+
if (ctx.webServer) {
|
|
203
|
+
if (typeof ctx.webServer.hasRoute === 'function') {
|
|
204
|
+
if (ctx.webServer.hasRoute(`/dsh-${id}/status`)) return true
|
|
205
|
+
}
|
|
206
|
+
if (Array.isArray(ctx.webServer.routes)) {
|
|
207
|
+
if (ctx.webServer.routes.some((r) => r && r.path && r.path.includes(id))) return true
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch (_err) { /* webServer route check fallback */ }
|
|
211
|
+
return false
|
|
212
|
+
}
|
|
172
213
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
voiceInstalled = ctx.webServer.hasRoute('/dsh-voice/status')
|
|
176
|
-
messengerInstalled = ctx.webServer.hasRoute('/dsh-messenger-gateway/status')
|
|
177
|
-
}
|
|
178
|
-
} catch { /* ignore malformed client frame */ }
|
|
214
|
+
const voiceInstalled = isPluginOrServicePresent('voice')
|
|
215
|
+
const messengerInstalled = isPluginOrServicePresent('messenger')
|
|
179
216
|
|
|
180
217
|
// Runtime routes are the only reliable signal. Do not probe ../ siblings
|
|
181
218
|
// or node_modules layouts — they lie outside a normal npm install.
|
|
182
|
-
|
|
183
219
|
writeJson(res, 200, {
|
|
184
220
|
ok: true,
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
221
|
+
voiceInstalled,
|
|
222
|
+
messengerInstalled,
|
|
223
|
+
note: 'State reflects active routes in the running harness.',
|
|
224
|
+
integrations: {
|
|
225
|
+
voice: {
|
|
226
|
+
installed: voiceInstalled,
|
|
227
|
+
package: '@goodandready/dsh-voice',
|
|
228
|
+
hint: 'dsh plugin --profile web add @goodandready/dsh-voice',
|
|
229
|
+
},
|
|
230
|
+
messenger: {
|
|
231
|
+
installed: messengerInstalled,
|
|
232
|
+
package: '@goodandready/dsh-messenger-gateway',
|
|
233
|
+
hint: 'dsh plugin --profile web add @goodandready/dsh-messenger-gateway',
|
|
234
|
+
},
|
|
194
235
|
},
|
|
195
236
|
})
|
|
196
237
|
},
|
|
@@ -200,7 +241,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
200
241
|
kind: 'exact',
|
|
201
242
|
path: '/dsh-tts/cache',
|
|
202
243
|
handler: async (req, res) => {
|
|
203
|
-
if (req.method !== 'DELETE') {
|
|
244
|
+
if (req.method !== 'DELETE' && req.method !== 'POST') {
|
|
204
245
|
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'DELETE only' } })
|
|
205
246
|
return
|
|
206
247
|
}
|
|
@@ -209,10 +250,27 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
209
250
|
return
|
|
210
251
|
}
|
|
211
252
|
const removed = await speechCache.clear()
|
|
212
|
-
writeJson(res, 200, { ok: true, removed })
|
|
253
|
+
writeJson(res, 200, { ok: true, removed, cleared: removed })
|
|
213
254
|
},
|
|
214
255
|
}), 'dsh-tts: /cache route')
|
|
215
256
|
|
|
257
|
+
ctx.effect(() => ctx.webServer.register({
|
|
258
|
+
kind: 'exact',
|
|
259
|
+
path: '/dsh-tts/cache/clear',
|
|
260
|
+
handler: async (req, res) => {
|
|
261
|
+
if (req.method !== 'POST' && req.method !== 'DELETE') {
|
|
262
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
266
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
const removed = await speechCache.clear()
|
|
270
|
+
writeJson(res, 200, { ok: true, removed, cleared: removed })
|
|
271
|
+
},
|
|
272
|
+
}), 'dsh-tts: /cache/clear route')
|
|
273
|
+
|
|
216
274
|
ctx.effect(() => ctx.webServer.register({
|
|
217
275
|
kind: 'exact',
|
|
218
276
|
path: '/dsh-tts/preview',
|
|
@@ -281,6 +339,10 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
281
339
|
kind: 'exact',
|
|
282
340
|
path: '/dsh-tts/config',
|
|
283
341
|
handler: async (req, res) => {
|
|
342
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
343
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-tts: settings writes are same-origin only' } })
|
|
344
|
+
return
|
|
345
|
+
}
|
|
284
346
|
if (req.method === 'GET') {
|
|
285
347
|
writeJson(res, 200, await configResponse())
|
|
286
348
|
return
|
|
@@ -289,10 +351,6 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
289
351
|
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or PUT' } })
|
|
290
352
|
return
|
|
291
353
|
}
|
|
292
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
293
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-tts: settings writes are same-origin only' } })
|
|
294
|
-
return
|
|
295
|
-
}
|
|
296
354
|
const settingsApi = typeof getSettingsApi === 'function' ? getSettingsApi() : null
|
|
297
355
|
if (!settingsApi) {
|
|
298
356
|
writeJson(res, 503, { ok: false, error: { code: 'settings', message: 'settings not ready' } })
|
|
@@ -308,8 +366,9 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
308
366
|
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
309
367
|
return
|
|
310
368
|
}
|
|
369
|
+
const revision = payload?.revision ?? (typeof payload?.config === 'object' ? payload.config?.revision : undefined)
|
|
311
370
|
if (payload && typeof payload.config === 'object') {
|
|
312
|
-
payload = { ...payload.config, keys: payload.keys }
|
|
371
|
+
payload = { ...payload.config, keys: payload.keys, revision: payload.revision ?? payload.config.revision }
|
|
313
372
|
}
|
|
314
373
|
const keys = pendingKeyWrites(payload && payload.keys)
|
|
315
374
|
const stripped = stripSecretsFromConfig(payload)
|
|
@@ -322,10 +381,13 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
322
381
|
for (const item of keys) {
|
|
323
382
|
await storeProviderKey(item.provider, item.value)
|
|
324
383
|
}
|
|
325
|
-
await settingsApi.replace(parsed)
|
|
384
|
+
await settingsApi.replace(parsed, revision)
|
|
326
385
|
writeJson(res, 200, await configResponse())
|
|
327
386
|
} catch (e) {
|
|
328
|
-
|
|
387
|
+
const isConflict = e?.code === 'conflict' || e?.name === 'SettingsConflictError' || /conflict|revision/i.test(e?.message || '')
|
|
388
|
+
const statusCode = isConflict ? 409 : 500
|
|
389
|
+
const code = isConflict ? 'conflict' : 'save'
|
|
390
|
+
writeJson(res, statusCode, { ok: false, error: { code, message: String(e && e.message || e) } })
|
|
329
391
|
}
|
|
330
392
|
},
|
|
331
393
|
}), 'dsh-tts: /config route')
|
|
@@ -402,13 +464,22 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
402
464
|
}
|
|
403
465
|
let payload
|
|
404
466
|
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* invalid JSON treated as empty */ /* invalid JSON treated as empty */ payload = {} }
|
|
405
|
-
const
|
|
406
|
-
if (!
|
|
467
|
+
const rawText = String(payload.text || '')
|
|
468
|
+
if (!rawText.trim()) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
|
|
407
469
|
const cfg = live()
|
|
470
|
+
const isMessenger = payload.channel === 'messenger' || payload.source === 'messenger' || req.headers['x-dsh-channel'] === 'messenger'
|
|
471
|
+
if (isMessenger && !cfg.messengerTtsEnabled) {
|
|
472
|
+
writeJson(res, 403, { ok: false, error: { code: 'disabled', message: 'messenger tts is disabled' } })
|
|
473
|
+
return
|
|
474
|
+
}
|
|
408
475
|
const controller = new AbortController()
|
|
409
476
|
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
410
477
|
try {
|
|
411
|
-
const
|
|
478
|
+
const text = typeof prepareSpeechText === 'function'
|
|
479
|
+
? await prepareSpeechText(rawText, cfg, controller.signal)
|
|
480
|
+
: cleanText(rawText, cfg, 0)
|
|
481
|
+
if (!text) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
|
|
482
|
+
const out = await synthesize(text, cfg, controller.signal, 'reply', { skipClean: true })
|
|
412
483
|
writeJson(res, 200, {
|
|
413
484
|
ok: true,
|
|
414
485
|
provider: out.provider,
|
|
@@ -424,15 +495,15 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
424
495
|
},
|
|
425
496
|
}), 'dsh-tts: /speak route')
|
|
426
497
|
|
|
427
|
-
registerPluginUpdater(ctx, {
|
|
498
|
+
ctx.effect(() => registerPluginUpdater(ctx, {
|
|
428
499
|
endpoint: '/dsh-tts/updater',
|
|
429
500
|
packageName: '@goodandready/dsh-tts',
|
|
430
501
|
manifestUrl: new URL('../package.json', import.meta.url),
|
|
431
|
-
})
|
|
502
|
+
}), 'dsh-tts: updater route /dsh-tts/updater')
|
|
432
503
|
|
|
433
|
-
registerPluginUpdater(ctx, {
|
|
504
|
+
ctx.effect(() => registerPluginUpdater(ctx, {
|
|
434
505
|
endpoint: '/api/dsh-tts/update',
|
|
435
506
|
packageName: '@goodandready/dsh-tts',
|
|
436
507
|
manifestUrl: new URL('../package.json', import.meta.url),
|
|
437
|
-
})
|
|
508
|
+
}), 'dsh-tts: updater route /api/dsh-tts/update')
|
|
438
509
|
}
|
package/lib/stream-hub.js
CHANGED
|
@@ -3,24 +3,69 @@
|
|
|
3
3
|
const subscribers = new Set()
|
|
4
4
|
let heartbeatTimer = null
|
|
5
5
|
|
|
6
|
+
// Max buffer size for a slow subscriber before disconnecting (512 KB)
|
|
7
|
+
export const MAX_SUBSCRIBER_BUFFER = 512 * 1024
|
|
8
|
+
|
|
6
9
|
function detach(res) {
|
|
7
10
|
try {
|
|
8
|
-
if (res.__dtsOnClose) res.off('close', res.__dtsOnClose)
|
|
9
|
-
if (res.__dtsOnError) res.off('error', res.__dtsOnError)
|
|
11
|
+
if (res.__dtsOnClose && typeof res.off === 'function') res.off('close', res.__dtsOnClose)
|
|
12
|
+
if (res.__dtsOnError && typeof res.off === 'function') res.off('error', res.__dtsOnError)
|
|
13
|
+
if (res.__dtsOnDrain && typeof res.off === 'function') res.off('drain', res.__dtsOnDrain)
|
|
10
14
|
} catch { /* listeners may already be gone */ }
|
|
11
15
|
res.__dtsOnClose = null
|
|
12
16
|
res.__dtsOnError = null
|
|
17
|
+
res.__dtsOnDrain = null
|
|
18
|
+
res.__dtsBackpressured = false
|
|
19
|
+
res.__dtsBufferedBytes = 0
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
function safeWrite(res, payload) {
|
|
23
|
+
const payloadBytes = Buffer.byteLength(payload)
|
|
24
|
+
const currentBuffered = typeof res.writableLength === 'number'
|
|
25
|
+
? res.writableLength
|
|
26
|
+
: (res.__dtsBufferedBytes || 0)
|
|
27
|
+
|
|
28
|
+
if (res.__dtsBackpressured && currentBuffered + payloadBytes > MAX_SUBSCRIBER_BUFFER) {
|
|
29
|
+
subscribers.delete(res)
|
|
30
|
+
detach(res)
|
|
31
|
+
try { res.end() } catch (_err) { /* already ended */ }
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let writeOk = true
|
|
16
36
|
try {
|
|
17
|
-
res.write(payload)
|
|
18
|
-
|
|
19
|
-
} catch {
|
|
37
|
+
writeOk = res.write(payload)
|
|
38
|
+
} catch (_err) {
|
|
20
39
|
subscribers.delete(res)
|
|
21
40
|
detach(res)
|
|
22
41
|
return false
|
|
23
42
|
}
|
|
43
|
+
|
|
44
|
+
if (writeOk === false) {
|
|
45
|
+
res.__dtsBackpressured = true
|
|
46
|
+
res.__dtsBufferedBytes = (res.__dtsBufferedBytes || 0) + payloadBytes
|
|
47
|
+
if (res.__dtsBufferedBytes > MAX_SUBSCRIBER_BUFFER) {
|
|
48
|
+
subscribers.delete(res)
|
|
49
|
+
detach(res)
|
|
50
|
+
try { res.end() } catch (_err) { /* already ended */ }
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
if (!res.__dtsOnDrain) {
|
|
54
|
+
const onDrain = () => {
|
|
55
|
+
res.__dtsBackpressured = false
|
|
56
|
+
res.__dtsBufferedBytes = 0
|
|
57
|
+
}
|
|
58
|
+
res.__dtsOnDrain = onDrain
|
|
59
|
+
try {
|
|
60
|
+
if (typeof res.on === 'function') res.on('drain', onDrain)
|
|
61
|
+
} catch (_err) { /* non-eventemitter mock */ }
|
|
62
|
+
}
|
|
63
|
+
} else if (res.__dtsBackpressured && (!res.writableLength || res.writableLength === 0)) {
|
|
64
|
+
res.__dtsBackpressured = false
|
|
65
|
+
res.__dtsBufferedBytes = 0
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return writeOk
|
|
24
69
|
}
|
|
25
70
|
|
|
26
71
|
function ensureHeartbeat() {
|
|
@@ -47,8 +92,10 @@ export function addStreamSubscriber(res) {
|
|
|
47
92
|
res.__dtsOnClose = onClose
|
|
48
93
|
res.__dtsOnError = onClose
|
|
49
94
|
try {
|
|
50
|
-
res.on
|
|
51
|
-
|
|
95
|
+
if (typeof res.on === 'function') {
|
|
96
|
+
res.on('close', onClose)
|
|
97
|
+
res.on('error', onClose)
|
|
98
|
+
}
|
|
52
99
|
} catch { /* non-node response object in tests */ }
|
|
53
100
|
ensureHeartbeat()
|
|
54
101
|
}
|
|
@@ -80,4 +127,4 @@ export function clearStreamSubscribers() {
|
|
|
80
127
|
clearInterval(heartbeatTimer)
|
|
81
128
|
heartbeatTimer = null
|
|
82
129
|
}
|
|
83
|
-
}
|
|
130
|
+
}
|
package/lib/text.js
CHANGED
|
@@ -59,7 +59,24 @@ function escapeRe(s) {
|
|
|
59
59
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
export const BUILTIN_IT_DICTIONARY = [
|
|
62
|
+
export const BUILTIN_IT_DICTIONARY = [
|
|
63
|
+
{ from: 'API', to: 'A P I', whole: true, lang: '' },
|
|
64
|
+
{ from: 'CLI', to: 'C L I', whole: true, lang: '' },
|
|
65
|
+
{ from: 'CPU', to: 'C P U', whole: true, lang: '' },
|
|
66
|
+
{ from: 'GPU', to: 'G P U', whole: true, lang: '' },
|
|
67
|
+
{ from: 'RAM', to: 'ram', whole: true, lang: '' },
|
|
68
|
+
{ from: 'SQL', to: 'sequel', whole: true, lang: '' },
|
|
69
|
+
{ from: 'UI', to: 'U I', whole: true, lang: '' },
|
|
70
|
+
{ from: 'URL', to: 'U R L', whole: true, lang: '' },
|
|
71
|
+
{ from: 'UUID', to: 'U U I D', whole: true, lang: '' },
|
|
72
|
+
{ from: 'JSON', to: 'j-son', whole: true, lang: '' },
|
|
73
|
+
{ from: 'YAML', to: 'yam-el', whole: true, lang: '' },
|
|
74
|
+
{ from: 'HTML', to: 'H T M L', whole: true, lang: '' },
|
|
75
|
+
{ from: 'CSS', to: 'C S S', whole: true, lang: '' },
|
|
76
|
+
{ from: 'SSH', to: 'S S H', whole: true, lang: '' },
|
|
77
|
+
{ from: 'DNS', to: 'D N S', whole: true, lang: '' },
|
|
78
|
+
{ from: 'IP', to: 'I P', whole: true, lang: '' },
|
|
79
|
+
]
|
|
63
80
|
|
|
64
81
|
export function applyPronunciation(text, rules, language, enableBuiltinIt = false) {
|
|
65
82
|
let out = String(text || '')
|
|
@@ -121,7 +138,13 @@ export function stripForSpeech(raw, maxChars, opts) {
|
|
|
121
138
|
|
|
122
139
|
export function assistantText(message) {
|
|
123
140
|
if (!message) return ''
|
|
124
|
-
|
|
141
|
+
if (typeof message === 'string') return message
|
|
142
|
+
if (typeof message === 'object') {
|
|
143
|
+
if (message.content !== undefined) return blocksToText(message.content)
|
|
144
|
+
if (typeof message.text === 'string') return message.text
|
|
145
|
+
if (typeof message.message === 'string') return message.message
|
|
146
|
+
}
|
|
147
|
+
return ''
|
|
125
148
|
}
|
|
126
149
|
|
|
127
150
|
// Protect common English abbreviations from false sentence splits
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-tts",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.29",
|
|
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",
|