@goodandready/dsh-tts 0.4.28 → 0.4.30

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/index.js CHANGED
@@ -163,9 +163,9 @@ export const Config = z.object({
163
163
  .description('Longer replies are truncated before synthesis.'),
164
164
  chain: z.array(ChainEntry).volatile()
165
165
  .default([
166
- { provider: 'edge', model: '', voice: 'ru-RU-SvetlanaNeural' },
166
+ { provider: 'edge', model: '', voice: 'en-US-JennyNeural' },
167
167
  { provider: 'piper', model: '', voice: '' },
168
- { provider: 'espeak', model: '', voice: 'ru' },
168
+ { provider: 'espeak', model: '', voice: 'en' },
169
169
  ])
170
170
  .description('Fallback chain. Order is the order of attempts. A provider without a key is skipped.'),
171
171
  openaiKeyEnv: z.string().volatile().default('OPENAI_API_KEY'),
@@ -198,23 +198,56 @@ export const Config = z.object({
198
198
  maxQueue: z.number().step(1).min(0).volatile().default(8),
199
199
  })
200
200
 
201
+ export function extractRevision(desc, targetNs = NS) {
202
+ if (!desc) return undefined
203
+ if (typeof desc.revision === 'number' || typeof desc.revision === 'string') {
204
+ return desc.revision
205
+ }
206
+ let list = null
207
+ if (Array.isArray(desc)) {
208
+ list = desc
209
+ } else if (Array.isArray(desc.namespaces)) {
210
+ list = desc.namespaces
211
+ } else if (Array.isArray(desc.entries)) {
212
+ list = desc.entries
213
+ }
214
+ if (list) {
215
+ const match = list.find((item) => {
216
+ if (!item || typeof item !== 'object') return false
217
+ const name = item.name || item.id || item.ns || item.namespace || item.key
218
+ return name === targetNs || name === 'goodandready/dsh-tts' || name === 'dsh-tts' || name === '@goodandready/dsh-tts'
219
+ })
220
+ if (match && (typeof match.revision === 'number' || typeof match.revision === 'string')) {
221
+ return match.revision
222
+ }
223
+ if (list.length === 1 && (typeof list[0].revision === 'number' || typeof list[0].revision === 'string')) {
224
+ return list[0].revision
225
+ }
226
+ }
227
+ if (typeof desc === 'object') {
228
+ for (const k of [targetNs, 'goodandready/dsh-tts', 'dsh-tts', '@goodandready/dsh-tts']) {
229
+ if (desc[k] && (typeof desc[k].revision === 'number' || typeof desc[k].revision === 'string')) {
230
+ return desc[k].revision
231
+ }
232
+ }
233
+ }
234
+ return undefined
235
+ }
236
+
201
237
  let utteranceSeq = 0
202
238
 
203
239
  export function apply(ctx, config) {
204
240
  const providerBreaker = createProviderBreaker()
205
241
 
206
- let savedConfig = null
207
242
  const live = () => {
208
243
  const fromBoxes = plainConfig(config)
209
- const merged = savedConfig ? { ...fromBoxes, ...savedConfig } : fromBoxes
210
- return plainConfig(Config(merged))
244
+ return plainConfig(Config(fromBoxes))
211
245
  }
212
246
 
213
247
  const settingsApi = {
214
248
  get: () => live(),
215
249
  replace: async (next, expectedRevision) => {
216
250
  const plain = plainConfig(next)
217
- const prevSaved = savedConfig
218
251
  const svc = ctx?.get?.('settings') ?? ctx?.settings ?? ctx?.get?.('settingsForms') ?? ctx?.settingsForms
219
252
  if (!svc) {
220
253
  throw new Error('Settings service is unavailable')
@@ -223,28 +256,33 @@ export function apply(ctx, config) {
223
256
  if (rev === undefined && typeof svc.describe === 'function') {
224
257
  try {
225
258
  const desc = await svc.describe(NS)
226
- if (desc && typeof desc.revision === 'number') {
227
- rev = desc.revision
228
- } else if (desc && Array.isArray(desc.namespaces)) {
229
- const entry = desc.namespaces.find((n) => n.ns === NS)
230
- if (entry && typeof entry.revision === 'number') rev = entry.revision
231
- }
232
- } catch {
259
+ rev = extractRevision(desc, NS)
260
+ } catch (_err) {
233
261
  // ignore describe error
234
262
  }
235
263
  }
236
- try {
237
- if (typeof svc.replace === 'function') {
238
- await svc.replace(NS, plain, rev)
239
- } else if (typeof svc.update === 'function') {
240
- await svc.update(NS, plain, rev)
241
- } else {
242
- throw new Error('Settings service does not support replace or update')
264
+ if (typeof svc.replace === 'function') {
265
+ await svc.replace(NS, plain, rev)
266
+ } else if (typeof svc.update === 'function') {
267
+ await svc.update(NS, plain, rev)
268
+ } else {
269
+ throw new Error('Settings service does not support replace or update')
270
+ }
271
+
272
+ // Mutate volatile boxes directly so runtime dynamically reflects new settings (#152)
273
+ if (config && typeof config === 'object') {
274
+ for (const [k, v] of Object.entries(plain)) {
275
+ if (config[k] && typeof config[k] === 'object') {
276
+ const sym = Object.getOwnPropertySymbols(config[k]).find((s) => String(s).includes('volatile.write'))
277
+ if (sym && typeof config[k][sym] === 'function') {
278
+ try { config[k][sym](v) } catch (_wErr) { /* best effort */ }
279
+ } else if ('value' in config[k]) {
280
+ config[k].value = v
281
+ }
282
+ } else if (config[k] !== undefined) {
283
+ config[k] = v
284
+ }
243
285
  }
244
- savedConfig = plain
245
- } catch (err) {
246
- savedConfig = prevSaved
247
- throw err
248
286
  }
249
287
  return plain
250
288
  },
@@ -265,7 +303,7 @@ export function apply(ctx, config) {
265
303
  try {
266
304
  const resolved = await ctx.credentials.resolve(credentialRef(ref))
267
305
  if (resolved && resolved.value) return resolved.value
268
- } catch { /* fall through to env */ }
306
+ } catch (_err) { /* fall through to env */ }
269
307
  return process.env[ref] || ''
270
308
  }
271
309
 
@@ -285,7 +323,7 @@ export function apply(ctx, config) {
285
323
  }
286
324
  const resolved = await ctx.credentials.resolve(credentialRef(ref))
287
325
  return { provider, ref, configured: !!(resolved && resolved.value), writable: true }
288
- } catch {
326
+ } catch (_err) {
289
327
  return base
290
328
  }
291
329
  }
@@ -327,10 +365,21 @@ export function apply(ctx, config) {
327
365
  }
328
366
 
329
367
  async function configResponse() {
368
+ let revision = null
369
+ const svc = ctx?.get?.('settings') ?? ctx?.settings ?? ctx?.get?.('settingsForms') ?? ctx?.settingsForms
370
+ if (svc && typeof svc.describe === 'function') {
371
+ try {
372
+ const desc = await svc.describe(NS)
373
+ revision = extractRevision(desc, NS) ?? null
374
+ } catch (_err) {
375
+ // best effort describe
376
+ }
377
+ }
330
378
  return {
331
379
  ok: true,
332
380
  config: publicConfig(live()),
333
381
  credentials: await credentialsView(),
382
+ revision,
334
383
  }
335
384
  }
336
385
 
@@ -381,7 +430,7 @@ export function apply(ctx, config) {
381
430
  if (retold) {
382
431
  return speechPhrases(cfg.language).summaryIntro + ' ' + retold
383
432
  }
384
- } catch { /* quiet fallback to truncation */ }
433
+ } catch (_err) { /* quiet fallback to truncation */ }
385
434
  }
386
435
  return text.slice(0, maxChars)
387
436
  }
@@ -389,7 +438,8 @@ export function apply(ctx, config) {
389
438
  }
390
439
 
391
440
  function synthesisFingerprint(text, provider, model, voice, role, ssmlStyle, cfg, lang) {
392
- const parts = [text, provider, model || '', voice || '', role || '', ssmlStyle || '', lang || '']
441
+ const credRef = needsApiKey(provider) ? (keyEnvName(cfg, provider) || '') : ''
442
+ const parts = [text, provider, model || '', voice || '', role || '', ssmlStyle || '', lang || '', credRef]
393
443
  if (provider === 'custom') parts.push(cfg.customBaseUrl || '')
394
444
  else if (provider === 'openai') parts.push(cfg.openaiBaseUrl || '')
395
445
  else if (provider === 'mimo') parts.push(cfg.mimoBaseUrl || '', cfg.mimoFormat || '')
@@ -402,12 +452,13 @@ export function apply(ctx, config) {
402
452
 
403
453
  function flightFingerprint(text, order, models, voices, role, ssmlStyle, cfg, lang) {
404
454
  const details = order.map((p) => {
455
+ const credRef = needsApiKey(p) ? (keyEnvName(cfg, p) || '') : ''
405
456
  const extra = p === 'custom' ? (cfg.customBaseUrl || '') :
406
457
  p === 'openai' ? (cfg.openaiBaseUrl || '') :
407
458
  p === 'mimo' ? `${cfg.mimoBaseUrl || ''}:${cfg.mimoFormat || ''}` :
408
459
  p === 'azure' ? (cfg.azureRegion || '') :
409
460
  p === 'piper' ? `${cfg.piperModel || ''}:${cfg.piperBin || ''}` : ''
410
- return `${p}:${models[p] || ''}:${voices[p] || ''}:${extra}`
461
+ return `${p}:${models[p] || ''}:${voices[p] || ''}:${credRef}:${extra}`
411
462
  }).join(';')
412
463
  return cacheKey([text, details, role, ssmlStyle, lang])
413
464
  }
@@ -656,7 +707,7 @@ export function apply(ctx, config) {
656
707
  if (p && p.id && activeReservations.has(p.id)) {
657
708
  const resv = activeReservations.get(p.id)
658
709
  resv.dropped = true
659
- try { resv.controller.abort() } catch {}
710
+ try { resv.controller.abort() } catch (_abortErr) { /* ignore aborted controller */ }
660
711
  clearTimeout(resv.timer)
661
712
  activeReservations.delete(p.id)
662
713
  }
@@ -671,7 +722,7 @@ export function apply(ctx, config) {
671
722
  const resv = activeReservations.get(dropped.id)
672
723
  if (resv) {
673
724
  resv.dropped = true
674
- try { resv.controller.abort() } catch {}
725
+ try { resv.controller.abort() } catch (_abortErr) { /* ignore aborted controller */ }
675
726
  clearTimeout(resv.timer)
676
727
  activeReservations.delete(dropped.id)
677
728
  }
@@ -720,8 +771,10 @@ export function apply(ctx, config) {
720
771
  if (!cfg.speakReplies) return
721
772
  const sid = session && session.id
722
773
  if (!sid) return
723
- const isMessenger = session && (session.kind === 'im' || session.kind === 'messenger' || session.channel === 'messenger' || session.type === 'im')
724
- if (isMessenger && cfg.messengerTtsEnabled === false) return
774
+ const isMessenger = session && (session.kind === 'im' || session.kind === 'messenger' || session.channel === 'messenger' || session.type === 'im' || session.source === 'messenger')
775
+ if (isMessenger && !cfg.messengerTtsEnabled) return
776
+ const isVoice = session && (session.kind === 'voice' || session.channel === 'voice' || session.type === 'voice' || session.mode === 'voice')
777
+ if (isVoice && !cfg.voiceDuplexEnabled) return
725
778
  // The agent is waiting for human approval — worth speaking aloud
726
779
  // so the user notices without watching the screen.
727
780
  if (event.type === 'approval/asked') {
@@ -737,7 +790,7 @@ export function apply(ctx, config) {
737
790
  }
738
791
 
739
792
  if (event.type === 'assistant/message') {
740
- const text = assistantText(event.data && event.data.message)
793
+ const text = assistantText(event.data && (event.data.message ?? event.data.text ?? event.data))
741
794
  const agentName = (event.data && (
742
795
  event.data.subagent ||
743
796
  event.data.agent ||
@@ -750,8 +803,12 @@ export function apply(ctx, config) {
750
803
  // in sentence pieces; waiting for turn end means silence while the agent works.
751
804
 
752
805
  if (cfg.speakAsItGoes) {
753
- const ready = cleanText(text, cfg, 0)
754
- for (const piece of splitSentences(ready, cfg.sentenceChars)) speakPiece(sid, piece, cfg, activeRole)
806
+ prepareSpeechText(text, cfg).then((ready) => {
807
+ if (!ready) return
808
+ for (const piece of splitSentences(ready, cfg.sentenceChars)) speakPiece(sid, piece, cfg, activeRole)
809
+ }).catch((_err) => {
810
+ // best-effort speech fallback
811
+ })
755
812
  return
756
813
  }
757
814
  const cur = collectors.get(sid) || { parts: [], role: activeRole }
@@ -816,6 +873,7 @@ export function apply(ctx, config) {
816
873
  additionalProperties: false,
817
874
  properties: {
818
875
  ok: { type: 'boolean' },
876
+ id: { type: 'string' },
819
877
  provider: { type: 'string' },
820
878
  mime: { type: 'string' },
821
879
  bytes: { type: 'number' },
@@ -192,7 +192,15 @@ export function makeCloudProviders(deps, req, withCloudTimeout) {
192
192
  const m = mimeType.match(/rate=(\d+)/i)
193
193
  if (m) sampleRate = parseInt(m[1], 10)
194
194
  const isWavAlready = raw.length >= 4 && raw.toString('ascii', 0, 4) === 'RIFF'
195
- const audio = isWavAlready ? raw : pcmToWav(raw, sampleRate, 1)
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
+ }
196
204
  return { ok: audio.length > 0, provider: 'google', audio, mime: 'audio/wav', reason: audio.length ? '' : 'empty audio' }
197
205
  } finally {
198
206
  cleanup()
package/lib/providers.js CHANGED
@@ -29,7 +29,7 @@ export function makeProviders(deps, req) {
29
29
  cleanup: () => {
30
30
  clearTimeout(timer)
31
31
  if (parentSignal && onParentAbort) {
32
- try { parentSignal.removeEventListener('abort', onParentAbort) } catch {}
32
+ try { parentSignal.removeEventListener('abort', onParentAbort) } catch (_err) { /* parent abort listener already removed */ }
33
33
  }
34
34
  },
35
35
  }
package/lib/routes.js CHANGED
@@ -119,7 +119,7 @@ export function registerHttpRoutes(ctx, api) {
119
119
  writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
120
120
  return
121
121
  }
122
- if (!MODEL_SPECS[engine]) {
122
+ if (!Object.hasOwn(MODEL_SPECS, engine)) {
123
123
  writeJson(res, 400, { ok: false, error: { code: 'param', message: 'unknown engine: ' + engine } })
124
124
  return
125
125
  }
@@ -160,7 +160,7 @@ export function registerHttpRoutes(ctx, api) {
160
160
  writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
161
161
  return
162
162
  }
163
- if (!MODEL_SPECS[engine]) {
163
+ if (!Object.hasOwn(MODEL_SPECS, engine)) {
164
164
  writeJson(res, 400, { ok: false, error: { code: 'param', message: 'unknown engine: ' + engine } })
165
165
  return
166
166
  }
@@ -187,7 +187,7 @@ export function registerHttpRoutes(ctx, api) {
187
187
  try {
188
188
  if (typeof ctx.get === 'function' && ctx.get(id)) return true
189
189
  if (ctx[id]) return true
190
- } catch {}
190
+ } catch (_err) { /* service check fallback */ }
191
191
  try {
192
192
  const reg = ctx.registry || ctx.root?.registry
193
193
  if (reg && typeof reg.entries === 'function') {
@@ -197,7 +197,7 @@ export function registerHttpRoutes(ctx, api) {
197
197
  if (id === 'messenger' && (name.includes('messenger') || name.includes('dsh-im'))) return true
198
198
  }
199
199
  }
200
- } catch {}
200
+ } catch (_err) { /* registry check fallback */ }
201
201
  try {
202
202
  if (ctx.webServer) {
203
203
  if (typeof ctx.webServer.hasRoute === 'function') {
@@ -207,7 +207,7 @@ export function registerHttpRoutes(ctx, api) {
207
207
  if (ctx.webServer.routes.some((r) => r && r.path && r.path.includes(id))) return true
208
208
  }
209
209
  }
210
- } catch {}
210
+ } catch (_err) { /* webServer route check fallback */ }
211
211
  return false
212
212
  }
213
213
 
@@ -467,6 +467,11 @@ export function registerHttpRoutes(ctx, api) {
467
467
  const rawText = String(payload.text || '')
468
468
  if (!rawText.trim()) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
469
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
+ }
470
475
  const controller = new AbortController()
471
476
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
472
477
  try {
@@ -490,15 +495,11 @@ export function registerHttpRoutes(ctx, api) {
490
495
  },
491
496
  }), 'dsh-tts: /speak route')
492
497
 
493
- ctx.effect(() => registerPluginUpdater(ctx, {
494
- endpoint: '/dsh-tts/updater',
495
- packageName: '@goodandready/dsh-tts',
496
- manifestUrl: new URL('../package.json', import.meta.url),
497
- }), 'dsh-tts: updater route /dsh-tts/updater')
498
-
498
+ const updaterMutex = { installing: false }
499
499
  ctx.effect(() => registerPluginUpdater(ctx, {
500
500
  endpoint: '/api/dsh-tts/update',
501
501
  packageName: '@goodandready/dsh-tts',
502
502
  manifestUrl: new URL('../package.json', import.meta.url),
503
+ mutex: updaterMutex,
503
504
  }), 'dsh-tts: updater route /api/dsh-tts/update')
504
505
  }
package/lib/stream-hub.js CHANGED
@@ -20,25 +20,22 @@ function detach(res) {
20
20
  }
21
21
 
22
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
- }
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
36
33
  }
37
34
 
38
35
  let writeOk = true
39
36
  try {
40
37
  writeOk = res.write(payload)
41
- } catch {
38
+ } catch (_err) {
42
39
  subscribers.delete(res)
43
40
  detach(res)
44
41
  return false
@@ -46,7 +43,13 @@ function safeWrite(res, payload) {
46
43
 
47
44
  if (writeOk === false) {
48
45
  res.__dtsBackpressured = true
49
- res.__dtsBufferedBytes = (res.__dtsBufferedBytes || 0) + Buffer.byteLength(payload)
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
+ }
50
53
  if (!res.__dtsOnDrain) {
51
54
  const onDrain = () => {
52
55
  res.__dtsBackpressured = false
@@ -55,7 +58,7 @@ function safeWrite(res, payload) {
55
58
  res.__dtsOnDrain = onDrain
56
59
  try {
57
60
  if (typeof res.on === 'function') res.on('drain', onDrain)
58
- } catch { /* non-eventemitter mock */ }
61
+ } catch (_err) { /* non-eventemitter mock */ }
59
62
  }
60
63
  } else if (res.__dtsBackpressured && (!res.writableLength || res.writableLength === 0)) {
61
64
  res.__dtsBackpressured = false
package/lib/text.js CHANGED
@@ -138,7 +138,13 @@ export function stripForSpeech(raw, maxChars, opts) {
138
138
 
139
139
  export function assistantText(message) {
140
140
  if (!message) return ''
141
- 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 ''
142
148
  }
143
149
 
144
150
  // Protect common English abbreviations from false sentence splits
package/lib/updater.js CHANGED
@@ -225,7 +225,6 @@ async function installExact(target, packageSpec, options) {
225
225
  '--profile',
226
226
  target.profileName,
227
227
  'add',
228
- '--config.minimumReleaseAge=0',
229
228
  packageSpec,
230
229
  `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
231
230
  ],
@@ -268,7 +267,7 @@ function json(response, statusCode, value) {
268
267
  }
269
268
 
270
269
  export function registerPluginUpdater(ctx, options) {
271
- let installing = false
270
+ const mutex = options?.mutex || { installing: false }
272
271
  if (typeof ctx?.webServer?.register !== 'function') {
273
272
  ctx?.logger?.warn?.('[dsh-tts] webServer service unavailable, updater skipped')
274
273
  return () => {}
@@ -298,11 +297,11 @@ export function registerPluginUpdater(ctx, options) {
298
297
  json(response, 403, { error: 'Rejected non-local or cross-origin update request.' })
299
298
  return
300
299
  }
301
- if (installing) {
300
+ if (mutex.installing) {
302
301
  json(response, 409, { error: 'This plugin is already updating.' })
303
302
  return
304
303
  }
305
- installing = true
304
+ mutex.installing = true
306
305
  try {
307
306
  const before = await status(options, target)
308
307
  if (before.latestVersion === undefined) {
@@ -322,7 +321,7 @@ export function registerPluginUpdater(ctx, options) {
322
321
  } catch (err) {
323
322
  json(response, 503, { error: `Plugin update failed: ${String(err?.message || err)}` })
324
323
  } finally {
325
- installing = false
324
+ mutex.installing = false
326
325
  }
327
326
  } catch (error) {
328
327
  ctx?.logger?.warn?.(`[dsh-tts] plugin updater failed: ${String(error)}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.4.28",
3
+ "version": "0.4.30",
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",