@goodandready/dsh-tts 0.4.26 → 0.4.28

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/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 (!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 (!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
- let voiceInstalled = false
171
- let messengerInstalled = false
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 {}
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 {}
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 {}
211
+ return false
212
+ }
172
213
 
173
- try {
174
- if (typeof ctx.webServer?.hasRoute === 'function') {
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
- voice: {
186
- installed: voiceInstalled,
187
- package: '@goodandready/dsh-voice',
188
- hint: 'dsh plugin --profile web add @goodandready/dsh-voice',
189
- },
190
- messenger: {
191
- installed: messengerInstalled,
192
- package: '@goodandready/dsh-messenger-gateway',
193
- hint: 'dsh plugin --profile web add @goodandready/dsh-messenger-gateway',
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
- writeJson(res, 500, { ok: false, error: { code: 'save', message: String(e && e.message || e) } })
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,17 @@ 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 text = cleanText(payload.text, live())
406
- if (!text) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
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()
408
470
  const controller = new AbortController()
409
471
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
410
472
  try {
411
- const out = await synthesize(text, cfg, controller.signal)
473
+ const text = typeof prepareSpeechText === 'function'
474
+ ? await prepareSpeechText(rawText, cfg, controller.signal)
475
+ : cleanText(rawText, cfg, 0)
476
+ if (!text) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
477
+ const out = await synthesize(text, cfg, controller.signal, 'reply', { skipClean: true })
412
478
  writeJson(res, 200, {
413
479
  ok: true,
414
480
  provider: out.provider,
@@ -424,15 +490,15 @@ export function registerHttpRoutes(ctx, api) {
424
490
  },
425
491
  }), 'dsh-tts: /speak route')
426
492
 
427
- registerPluginUpdater(ctx, {
493
+ ctx.effect(() => registerPluginUpdater(ctx, {
428
494
  endpoint: '/dsh-tts/updater',
429
495
  packageName: '@goodandready/dsh-tts',
430
496
  manifestUrl: new URL('../package.json', import.meta.url),
431
- })
497
+ }), 'dsh-tts: updater route /dsh-tts/updater')
432
498
 
433
- registerPluginUpdater(ctx, {
499
+ ctx.effect(() => registerPluginUpdater(ctx, {
434
500
  endpoint: '/api/dsh-tts/update',
435
501
  packageName: '@goodandready/dsh-tts',
436
502
  manifestUrl: new URL('../package.json', import.meta.url),
437
- })
503
+ }), 'dsh-tts: updater route /api/dsh-tts/update')
438
504
  }
package/lib/stream-hub.js CHANGED
@@ -3,24 +3,66 @@
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
+ // If subscriber is already backpressured, check buffered limit
24
+ if (res.__dtsBackpressured) {
25
+ const currentBuffered = typeof res.writableLength === 'number'
26
+ ? res.writableLength
27
+ : (res.__dtsBufferedBytes || 0)
28
+ const payloadBytes = Buffer.byteLength(payload)
29
+ if (currentBuffered + payloadBytes > MAX_SUBSCRIBER_BUFFER) {
30
+ // Disconnect slow subscriber exceeding memory buffer limit (#186)
31
+ subscribers.delete(res)
32
+ detach(res)
33
+ try { res.end() } catch {}
34
+ return false
35
+ }
36
+ }
37
+
38
+ let writeOk = true
16
39
  try {
17
- res.write(payload)
18
- return true
40
+ writeOk = res.write(payload)
19
41
  } catch {
20
42
  subscribers.delete(res)
21
43
  detach(res)
22
44
  return false
23
45
  }
46
+
47
+ if (writeOk === false) {
48
+ res.__dtsBackpressured = true
49
+ res.__dtsBufferedBytes = (res.__dtsBufferedBytes || 0) + Buffer.byteLength(payload)
50
+ if (!res.__dtsOnDrain) {
51
+ const onDrain = () => {
52
+ res.__dtsBackpressured = false
53
+ res.__dtsBufferedBytes = 0
54
+ }
55
+ res.__dtsOnDrain = onDrain
56
+ try {
57
+ if (typeof res.on === 'function') res.on('drain', onDrain)
58
+ } catch { /* non-eventemitter mock */ }
59
+ }
60
+ } else if (res.__dtsBackpressured && (!res.writableLength || res.writableLength === 0)) {
61
+ res.__dtsBackpressured = false
62
+ res.__dtsBufferedBytes = 0
63
+ }
64
+
65
+ return writeOk
24
66
  }
25
67
 
26
68
  function ensureHeartbeat() {
@@ -47,8 +89,10 @@ export function addStreamSubscriber(res) {
47
89
  res.__dtsOnClose = onClose
48
90
  res.__dtsOnError = onClose
49
91
  try {
50
- res.on('close', onClose)
51
- res.on('error', onClose)
92
+ if (typeof res.on === 'function') {
93
+ res.on('close', onClose)
94
+ res.on('error', onClose)
95
+ }
52
96
  } catch { /* non-node response object in tests */ }
53
97
  ensureHeartbeat()
54
98
  }
@@ -80,4 +124,4 @@ export function clearStreamSubscribers() {
80
124
  clearInterval(heartbeatTimer)
81
125
  heartbeatTimer = null
82
126
  }
83
- }
127
+ }
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 || '')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.4.26",
3
+ "version": "0.4.28",
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",