@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.
@@ -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 b64 = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData)?.inlineData?.data
165
- if (!b64) return { ok: false, provider: 'google', reason: 'no inline audio' }
166
- const audio = Buffer.from(b64, 'base64')
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 ssml = `<speak version="1.0" xml:lang="${lang || 'en-US'}">`
185
- + `<voice xml:lang="${lang || 'en-US'}" name="${voice}">`
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: 'ru-RU-SvetlanaNeural',
34
+ edge: 'en-US-AriaNeural',
35
35
  piper: '',
36
- espeak: 'ru',
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: 'ru-RU-SvetlanaNeural',
56
+ edge: 'en-US-AriaNeural',
57
57
  piper: '',
58
- espeak: 'ru',
58
+ espeak: 'en',
59
59
  }
60
60
 
61
61
 
@@ -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 || 'ru'
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 parentSignal.addEventListener('abort', () => controller.abort(parentSignal.reason), { once: true })
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: () => clearTimeout(timer),
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
- 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 (_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
- 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,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 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()
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 out = await synthesize(text, cfg, controller.signal)
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
- return true
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('close', onClose)
51
- res.on('error', onClose)
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
- return blocksToText(message.content)
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.27",
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",