@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/lib/index.js CHANGED
@@ -87,7 +87,7 @@ export const Config = z.object({
87
87
  to: z.string().default(''),
88
88
  whole: z.boolean().default(false),
89
89
  lang: z.string().default(''),
90
- })).default([])
90
+ })).volatile().default([])
91
91
  .description('Say this instead of that. Applied top-down; /regex/ form allowed in from.'),
92
92
  enableItDictionary: z.boolean().volatile().default(true)
93
93
  .description('Auto-correct common IT terminology pronunciation (SQL, Nginx, K8s, etc.).'),
@@ -111,49 +111,61 @@ export const Config = z.object({
111
111
  .description('Send synthesized voice notes to messengers via @goodandready/dsh-messenger-gateway.'),
112
112
  autoDetectSubagent: z.boolean().volatile().default(true)
113
113
  .description('Auto-detect subagent roles in multi-agent sessions and route to dedicated voice profiles.'),
114
- language: z.string().volatile().default('ru'),
114
+ language: z.string().volatile().default('en'),
115
115
  speakAsItGoes: z
116
116
  .boolean()
117
+ .volatile()
117
118
  .description('Speak each reply as it lands instead of waiting for the whole turn to finish. '
118
119
  + 'Long answers start sounding almost at once because they are synthesized sentence by sentence.')
119
120
  .default(true),
120
121
  sentenceChars: z
121
122
  .number()
123
+ .step(1)
124
+ .min(10)
125
+ .volatile()
122
126
  .description('Upper bound of one spoken piece when speaking as it goes.')
123
127
  .default(320),
124
128
  rate: z
125
129
  .number()
130
+ .min(0.1)
131
+ .max(5)
132
+ .volatile()
126
133
  .description('Playback speed, 0.5 to 2. Synthesis is untouched; the browser plays faster or slower.')
127
134
  .default(1),
128
135
  bargeIn: z
129
136
  .boolean()
137
+ .volatile()
130
138
  .description('Fall silent the moment the microphone opens. Listening to a reply and talking over it '
131
139
  + 'at the same time does not work, and the reply would be recorded along with the voice.')
132
140
  .default(true),
133
141
  announceApproval: z
134
142
  .boolean()
143
+ .volatile()
135
144
  .description('Say out loud when the agent stops and waits for an approval, and play a short chime. '
136
145
  + 'Useful when you walk away from a long run.')
137
146
  .default(true),
138
147
  approvalText: z
139
148
  .string()
149
+ .volatile()
140
150
  .description('What to say when an approval is asked for. The tool name is appended.')
141
151
  .default('Approval required'),
142
152
  questionText: z
143
153
  .string()
154
+ .volatile()
144
155
  .description('What to say when the agent asks a question. Empty disables it.')
145
156
  .default('Agent asked a question'),
146
157
  chime: z
147
158
  .string()
159
+ .volatile()
148
160
  .description('Short sound before an announcement: ding, beep or none.')
149
161
  .default('ding'),
150
- maxChars: z.number().volatile().default(4000)
162
+ maxChars: z.number().step(1).min(0).volatile().default(4000)
151
163
  .description('Longer replies are truncated before synthesis.'),
152
164
  chain: z.array(ChainEntry).volatile()
153
165
  .default([
154
- { provider: 'edge', model: '', voice: 'ru-RU-SvetlanaNeural' },
166
+ { provider: 'edge', model: '', voice: 'en-US-JennyNeural' },
155
167
  { provider: 'piper', model: '', voice: '' },
156
- { provider: 'espeak', model: '', voice: 'ru' },
168
+ { provider: 'espeak', model: '', voice: 'en' },
157
169
  ])
158
170
  .description('Fallback chain. Order is the order of attempts. A provider without a key is skipped.'),
159
171
  openaiKeyEnv: z.string().volatile().default('OPENAI_API_KEY'),
@@ -182,31 +194,94 @@ export const Config = z.object({
182
194
  piperModel: z.string().volatile().default('')
183
195
  .description('Path to a Piper ONNX model. The piper provider is skipped while this is empty.'),
184
196
  espeakBin: z.string().volatile().default('espeak-ng'),
185
- timeoutMs: z.number().volatile().default(60000),
186
- maxQueue: z.number().volatile().default(8),
197
+ timeoutMs: z.number().step(1).min(1000).volatile().default(60000),
198
+ maxQueue: z.number().step(1).min(0).volatile().default(8),
187
199
  })
188
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
+
189
237
  let utteranceSeq = 0
190
238
 
191
239
  export function apply(ctx, config) {
192
240
  const providerBreaker = createProviderBreaker()
193
241
 
194
- let currentConfig = plainConfig(config)
195
- let getConfig = () => currentConfig
196
- const live = () => plainConfig(Config(structuredClone(getConfig() ?? {})) ?? config)
242
+ const live = () => {
243
+ const fromBoxes = plainConfig(config)
244
+ return plainConfig(Config(fromBoxes))
245
+ }
197
246
 
198
247
  const settingsApi = {
199
248
  get: () => live(),
200
- replace: async (next) => {
249
+ replace: async (next, expectedRevision) => {
201
250
  const plain = plainConfig(next)
202
- currentConfig = plain
203
- const svc = ctx?.get?.('settings')
204
- if (svc) {
205
- const rev = svc.getSnapshot?.()?.revision ?? svc.getSnapshot?.()?.entries?.[NS]?.revision
206
- if (typeof svc.replace === 'function') {
207
- await svc.replace(NS, plain, rev)
208
- } else if (typeof svc.update === 'function') {
209
- await svc.update(NS, plain, rev)
251
+ const svc = ctx?.get?.('settings') ?? ctx?.settings ?? ctx?.get?.('settingsForms') ?? ctx?.settingsForms
252
+ if (!svc) {
253
+ throw new Error('Settings service is unavailable')
254
+ }
255
+ let rev = expectedRevision
256
+ if (rev === undefined && typeof svc.describe === 'function') {
257
+ try {
258
+ const desc = await svc.describe(NS)
259
+ rev = extractRevision(desc, NS)
260
+ } catch (_err) {
261
+ // ignore describe error
262
+ }
263
+ }
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
+ }
210
285
  }
211
286
  }
212
287
  return plain
@@ -228,7 +303,7 @@ export function apply(ctx, config) {
228
303
  try {
229
304
  const resolved = await ctx.credentials.resolve(credentialRef(ref))
230
305
  if (resolved && resolved.value) return resolved.value
231
- } catch { /* fall through to env */ }
306
+ } catch (_err) { /* fall through to env */ }
232
307
  return process.env[ref] || ''
233
308
  }
234
309
 
@@ -248,7 +323,7 @@ export function apply(ctx, config) {
248
323
  }
249
324
  const resolved = await ctx.credentials.resolve(credentialRef(ref))
250
325
  return { provider, ref, configured: !!(resolved && resolved.value), writable: true }
251
- } catch {
326
+ } catch (_err) {
252
327
  return base
253
328
  }
254
329
  }
@@ -290,10 +365,21 @@ export function apply(ctx, config) {
290
365
  }
291
366
 
292
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
+ }
293
378
  return {
294
379
  ok: true,
295
380
  config: publicConfig(live()),
296
381
  credentials: await credentialsView(),
382
+ revision,
297
383
  }
298
384
  }
299
385
 
@@ -321,15 +407,62 @@ export function apply(ctx, config) {
321
407
  function cleanOpts(cfg) {
322
408
  return { skipCode: cfg.skipCode !== false, phrases: speechPhrases(cfg.language) }
323
409
  }
324
- function cleanText(raw, cfg) {
410
+ function cleanText(raw, cfg, maxChars = 0) {
325
411
  return applyPronunciation(
326
- applyNarrationFilters(stripForSpeech(raw, cfg.maxChars, cleanOpts(cfg)), cfg),
412
+ applyNarrationFilters(stripForSpeech(raw, maxChars, cleanOpts(cfg)), cfg),
327
413
  cfg.pronunciation,
328
414
  cfg.language,
329
415
  cfg.enableItDictionary !== false,
330
416
  )
331
417
  }
332
418
 
419
+ // Canonical text preparation (#156, #157): clean once without premature truncation,
420
+ // then apply longReply strategy (full / summarize / truncate).
421
+ async function prepareSpeechText(raw, cfg, signal) {
422
+ let text = cleanText(raw, cfg, 0)
423
+ if (!text) return ''
424
+ const maxChars = Number(cfg.maxChars) > 0 ? Number(cfg.maxChars) : 0
425
+ const overLimit = maxChars > 0 && text.length > maxChars
426
+ if (overLimit && cfg.longReply !== 'full') {
427
+ if (cfg.longReply === 'summarize') {
428
+ try {
429
+ const retold = await summarizeReply(text.slice(0, 8000), cfg, signal)
430
+ if (retold) {
431
+ return speechPhrases(cfg.language).summaryIntro + ' ' + retold
432
+ }
433
+ } catch (_err) { /* quiet fallback to truncation */ }
434
+ }
435
+ return text.slice(0, maxChars)
436
+ }
437
+ return text
438
+ }
439
+
440
+ function synthesisFingerprint(text, provider, model, voice, role, ssmlStyle, cfg, lang) {
441
+ const credRef = needsApiKey(provider) ? (keyEnvName(cfg, provider) || '') : ''
442
+ const parts = [text, provider, model || '', voice || '', role || '', ssmlStyle || '', lang || '', credRef]
443
+ if (provider === 'custom') parts.push(cfg.customBaseUrl || '')
444
+ else if (provider === 'openai') parts.push(cfg.openaiBaseUrl || '')
445
+ else if (provider === 'mimo') parts.push(cfg.mimoBaseUrl || '', cfg.mimoFormat || '')
446
+ else if (provider === 'azure') parts.push(cfg.azureRegion || '')
447
+ else if (provider === 'piper') parts.push(cfg.piperModel || '', cfg.piperBin || '')
448
+ else if (provider === 'edge') parts.push(cfg.edgeBin || '')
449
+ else if (provider === 'espeak') parts.push(cfg.espeakBin || '')
450
+ return cacheKey(parts)
451
+ }
452
+
453
+ function flightFingerprint(text, order, models, voices, role, ssmlStyle, cfg, lang) {
454
+ const details = order.map((p) => {
455
+ const credRef = needsApiKey(p) ? (keyEnvName(cfg, p) || '') : ''
456
+ const extra = p === 'custom' ? (cfg.customBaseUrl || '') :
457
+ p === 'openai' ? (cfg.openaiBaseUrl || '') :
458
+ p === 'mimo' ? `${cfg.mimoBaseUrl || ''}:${cfg.mimoFormat || ''}` :
459
+ p === 'azure' ? (cfg.azureRegion || '') :
460
+ p === 'piper' ? `${cfg.piperModel || ''}:${cfg.piperBin || ''}` : ''
461
+ return `${p}:${models[p] || ''}:${voices[p] || ''}:${credRef}:${extra}`
462
+ }).join(';')
463
+ return cacheKey([text, details, role, ssmlStyle, lang])
464
+ }
465
+
333
466
  // Fast-model summary. Any failure returns an empty string and the caller
334
467
  // silently falls back to truncation.
335
468
  async function summarizeReply(text, cfg, signal) {
@@ -353,21 +486,10 @@ export function apply(ctx, config) {
353
486
  return parts.join('').trim()
354
487
  }
355
488
 
356
- async function synthesize(rawText, cfg, signal, role = 'reply') {
357
- let text = cleanText(rawText, cfg)
489
+ async function synthesize(rawText, cfg, signal, role = 'reply', { skipClean = false } = {}) {
490
+ let text = skipClean ? String(rawText || '') : cleanText(rawText, cfg, 0)
358
491
  if (!text) throw new Error('nothing to speak')
359
- const maxChars = Number(cfg.maxChars) > 0 ? Number(cfg.maxChars) : 0
360
- const overLimit = maxChars > 0 && text.length > maxChars
361
- if (overLimit && cfg.longReply !== 'full') {
362
- let cut = text.slice(0, maxChars)
363
- if (cfg.longReply === 'summarize') {
364
- try {
365
- const retold = await summarizeReply(text.slice(0, 8000), cfg, signal)
366
- if (retold) cut = speechPhrases(cfg.language).summaryIntro + ' ' + retold
367
- } catch (llmDown) { /* quiet fallback to truncation */ }
368
- }
369
- text = cut
370
- }
492
+ const effectiveLang = cfg.autoDetect ? detectLang(text) : cfg.language
371
493
  const models = {}
372
494
  const voices = {}
373
495
  const order = []
@@ -389,7 +511,8 @@ export function apply(ctx, config) {
389
511
  const cacheAllowed = cfg.cache !== false && text.length <= CACHE_MAX_TEXT
390
512
  if (cacheAllowed) {
391
513
  for (const provider of order) {
392
- const hit = await speechCache.get(cacheKey([text, provider, models[provider], voices[provider], role, ov.ssmlStyle]))
514
+ const fp = synthesisFingerprint(text, provider, models[provider], voices[provider], role, ov.ssmlStyle, cfg, effectiveLang)
515
+ const hit = await speechCache.get(fp)
393
516
  if (hit) {
394
517
  stats.total++
395
518
  stats.cacheHits++
@@ -399,7 +522,7 @@ export function apply(ctx, config) {
399
522
  }
400
523
  }
401
524
  }
402
- const flightKey = cacheKey([text, order.join(','), JSON.stringify(models), JSON.stringify(voices), role, ov.ssmlStyle])
525
+ const flightKey = flightFingerprint(text, order, models, voices, role, ov.ssmlStyle, cfg, effectiveLang)
403
526
  if (inFlightSyntheses.has(flightKey)) {
404
527
  return inFlightSyntheses.get(flightKey)
405
528
  }
@@ -408,7 +531,7 @@ export function apply(ctx, config) {
408
531
  try {
409
532
  const baseProviders = makeProviders(
410
533
  { resolveKey, fetchImpl: fetch, cfg, modelManager },
411
- { text, lang: cfg.autoDetect ? detectLang(text) : cfg.language, signal, models, voices, role },
534
+ { text, lang: effectiveLang, signal, models, voices, role },
412
535
  )
413
536
  const providers = {}
414
537
  for (const key of Object.keys(baseProviders)) {
@@ -435,7 +558,8 @@ export function apply(ctx, config) {
435
558
  ps.ms += out.tookMs || 0
436
559
  if (cacheAllowed) {
437
560
  const p = out.provider
438
- speechCache.put(cacheKey([text, p, models[p], voices[p], role, ov.ssmlStyle]), out.mime, out.audio).catch(() => {})
561
+ const fp = synthesisFingerprint(text, p, models[p], voices[p], role, ov.ssmlStyle, cfg, effectiveLang)
562
+ speechCache.put(fp, out.mime, out.audio).catch(() => {})
439
563
  }
440
564
  return out
441
565
  } finally {
@@ -447,16 +571,93 @@ export function apply(ctx, config) {
447
571
  return synthPromise
448
572
  }
449
573
 
574
+ // Active reservations map and concurrency throttler (#174)
575
+ const activeReservations = new Map() // id -> { controller, timer, dropped }
576
+ let activeSyntheses = 0
577
+ const synthesisQueue = []
578
+
579
+ function getEffectiveConcurrency(cfg) {
580
+ const maxQ = typeof cfg.maxQueue === 'number' && cfg.maxQueue > 0 ? cfg.maxQueue : 8
581
+ return Math.max(1, Math.min(maxQ, 4))
582
+ }
583
+
584
+ function runThrottledSynthesis(id, signal, fn) {
585
+ if (signal && signal.aborted) {
586
+ return Promise.reject(new Error('aborted'))
587
+ }
588
+ return new Promise((resolve, reject) => {
589
+ let isQueued = true
590
+ const onAbort = () => {
591
+ if (isQueued) {
592
+ const idx = synthesisQueue.findIndex((item) => item.id === id)
593
+ if (idx >= 0) synthesisQueue.splice(idx, 1)
594
+ reject(new Error('aborted'))
595
+ }
596
+ }
597
+ if (signal) {
598
+ signal.addEventListener('abort', onAbort, { once: true })
599
+ }
600
+
601
+ const execute = async () => {
602
+ isQueued = false
603
+ if (signal) signal.removeEventListener('abort', onAbort)
604
+ if (signal && signal.aborted) {
605
+ reject(new Error('aborted'))
606
+ pumpSynthesisQueue()
607
+ return
608
+ }
609
+ const resv = activeReservations.get(id)
610
+ if (resv && resv.dropped) {
611
+ reject(new Error('dropped from queue'))
612
+ pumpSynthesisQueue()
613
+ return
614
+ }
615
+ activeSyntheses++
616
+ try {
617
+ const result = await fn()
618
+ resolve(result)
619
+ } catch (err) {
620
+ reject(err)
621
+ } finally {
622
+ activeSyntheses--
623
+ pumpSynthesisQueue()
624
+ }
625
+ }
626
+
627
+ const maxLimit = getEffectiveConcurrency(live())
628
+ if (activeSyntheses < maxLimit) {
629
+ execute()
630
+ } else {
631
+ synthesisQueue.push({ id, run: execute })
632
+ }
633
+ })
634
+ }
635
+
636
+ function pumpSynthesisQueue() {
637
+ const maxLimit = getEffectiveConcurrency(live())
638
+ while (activeSyntheses < maxLimit && synthesisQueue.length > 0) {
639
+ const next = synthesisQueue.shift()
640
+ if (next) next.run()
641
+ }
642
+ }
643
+
450
644
  // Synthesize one phrase with queue reservation. The id is issued immediately,
451
645
  // not when the provider answers, so short phrases cannot overtake long ones.
452
646
  function speakPiece(sid, text, cfg, kind) {
453
647
  if (!text) return
648
+ const maxQ = Math.max(0, typeof cfg.maxQueue === 'number' ? cfg.maxQueue : 32)
649
+ if (maxQ === 0) return
650
+
454
651
  const role = kind === 'notice' ? 'approval' : kind === 'error' ? 'error' : (kind && kind !== 'speech' ? kind : 'reply')
455
652
  const id = `u${++utteranceSeq}`
456
653
  const controller = new AbortController()
457
654
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
655
+ activeReservations.set(id, { controller, timer, dropped: false })
458
656
  reserve(id)
459
- synthesize(text, cfg, controller.signal, role)
657
+
658
+ runThrottledSynthesis(id, controller.signal, () =>
659
+ synthesize(text, cfg, controller.signal, role, { skipClean: true })
660
+ )
460
661
  .then((out) => settle(id, {
461
662
  id,
462
663
  sessionId: sid,
@@ -469,10 +670,18 @@ export function apply(ctx, config) {
469
670
  tookMs: out.tookMs,
470
671
  }))
471
672
  .catch((err) => {
673
+ const resv = activeReservations.get(id)
674
+ if (resv && resv.dropped) {
675
+ activeReservations.delete(id)
676
+ return
677
+ }
472
678
  stats.errors++
473
679
  settle(id, { id, sessionId: sid, kind: 'error', text, error: String(err && err.message || err) })
474
680
  })
475
- .finally(() => clearTimeout(timer))
681
+ .finally(() => {
682
+ clearTimeout(timer)
683
+ activeReservations.delete(id)
684
+ })
476
685
  }
477
686
 
478
687
  // Announcement: short chime then phrase. The chime is drawn by the browser,
@@ -492,16 +701,35 @@ export function apply(ctx, config) {
492
701
 
493
702
  function enqueue(item) {
494
703
  pending.push(item)
495
- const max = live().maxQueue || 32
496
- while (pending.length > max) {
704
+ const max = Math.max(0, typeof live().maxQueue === 'number' ? live().maxQueue : 32)
705
+ if (max === 0) {
706
+ for (const p of pending) {
707
+ if (p && p.id && activeReservations.has(p.id)) {
708
+ const resv = activeReservations.get(p.id)
709
+ resv.dropped = true
710
+ try { resv.controller.abort() } catch (_abortErr) { /* ignore aborted controller */ }
711
+ clearTimeout(resv.timer)
712
+ activeReservations.delete(p.id)
713
+ }
714
+ }
715
+ pending.length = 0
716
+ return
717
+ }
718
+ while (pending.length > max && pending.length > 0) {
497
719
  const dropIndex = pending.findIndex((x) => x.kind !== 'reserved')
498
- if (dropIndex >= 0) {
499
- pending.splice(dropIndex, 1)
500
- } else {
501
- pending.shift()
720
+ const dropped = dropIndex >= 0 ? pending.splice(dropIndex, 1)[0] : pending.shift()
721
+ if (dropped && dropped.id && activeReservations.has(dropped.id)) {
722
+ const resv = activeReservations.get(dropped.id)
723
+ if (resv) {
724
+ resv.dropped = true
725
+ try { resv.controller.abort() } catch (_abortErr) { /* ignore aborted controller */ }
726
+ clearTimeout(resv.timer)
727
+ activeReservations.delete(dropped.id)
728
+ }
502
729
  }
503
730
  }
504
731
  if (live().streamingEnabled && item.kind === 'chime') {
732
+ item._streamEmitted = true
505
733
  broadcastStream('chime', item)
506
734
  }
507
735
  }
@@ -512,13 +740,30 @@ export function apply(ctx, config) {
512
740
  enqueue({ id, kind: 'reserved' })
513
741
  }
514
742
 
743
+ function flushReadyStream() {
744
+ if (!live().streamingEnabled) return
745
+ for (const item of pending) {
746
+ if (item.kind === 'reserved') {
747
+ break // Stop at the first unsettled reservation to preserve sequence order
748
+ }
749
+ if (!item._streamEmitted) {
750
+ item._streamEmitted = true
751
+ if (item.audioBase64) {
752
+ broadcastStream('utterance', item)
753
+ }
754
+ }
755
+ }
756
+ }
757
+
515
758
  function settle(id, item) {
759
+ activeReservations.delete(id)
516
760
  const at = pending.findIndex((row) => row.id === id)
517
- if (at === -1) { enqueue(item); return }
518
- pending[at] = item
519
- if (live().streamingEnabled && item.audioBase64) {
520
- broadcastStream('utterance', item)
761
+ if (at === -1) {
762
+ // Dropped reservation: do NOT re-enqueue (#174)
763
+ return
521
764
  }
765
+ pending[at] = item
766
+ flushReadyStream()
522
767
  }
523
768
 
524
769
  ctx.effect(() => ctx.on('session/event', (session, event) => {
@@ -526,6 +771,10 @@ export function apply(ctx, config) {
526
771
  if (!cfg.speakReplies) return
527
772
  const sid = session && session.id
528
773
  if (!sid) 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
529
778
  // The agent is waiting for human approval — worth speaking aloud
530
779
  // so the user notices without watching the screen.
531
780
  if (event.type === 'approval/asked') {
@@ -541,20 +790,25 @@ export function apply(ctx, config) {
541
790
  }
542
791
 
543
792
  if (event.type === 'assistant/message') {
544
- const text = assistantText(event.data && event.data.message)
793
+ const text = assistantText(event.data && (event.data.message ?? event.data.text ?? event.data))
545
794
  const agentName = (event.data && (
546
795
  event.data.subagent ||
547
796
  event.data.agent ||
548
797
  (event.data.message && (event.data.message.subagent || event.data.message.agent || event.data.message.sender || (event.data.message.author && event.data.message.author.name)))
549
798
  )) || (session && (session.subagent || session.agent)) || ''
550
- const activeRole = (agentName && cfg.roles && cfg.roles[agentName] && (cfg.roles[agentName].provider || cfg.roles[agentName].voice)) ? agentName : 'reply'
799
+ const allowSubagent = cfg.autoDetectSubagent !== false
800
+ const activeRole = (allowSubagent && agentName && cfg.roles && cfg.roles[agentName] && (cfg.roles[agentName].provider || cfg.roles[agentName].voice)) ? agentName : 'reply'
551
801
 
552
802
  // Speak as it goes: each incoming chunk is spoken immediately
553
803
  // in sentence pieces; waiting for turn end means silence while the agent works.
554
804
 
555
805
  if (cfg.speakAsItGoes) {
556
- const ready = cleanText(text, cfg)
557
- 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
+ })
558
812
  return
559
813
  }
560
814
  const cur = collectors.get(sid) || { parts: [], role: activeRole }
@@ -569,13 +823,14 @@ export function apply(ctx, config) {
569
823
  const cur = collectors.get(sid)
570
824
  collectors.delete(sid)
571
825
  const raw = cur && Array.isArray(cur.parts) ? cur.parts.join('\n') : ''
572
- const text = cleanText(raw, cfg)
573
- if (!text) return
574
- const pieces = splitSentences(text, cfg.sentenceChars)
575
826
  const activeRole = (cur && cur.role) || 'reply'
576
- for (const piece of pieces) {
577
- speakPiece(sid, piece, cfg, activeRole)
578
- }
827
+ prepareSpeechText(raw, cfg).then((text) => {
828
+ if (!text) return
829
+ const pieces = splitSentences(text, cfg.sentenceChars)
830
+ for (const piece of pieces) {
831
+ speakPiece(sid, piece, cfg, activeRole)
832
+ }
833
+ }).catch(() => {})
579
834
  }), 'dsh-tts: speak finished replies')
580
835
 
581
836
 
@@ -587,6 +842,7 @@ export function apply(ctx, config) {
587
842
  stats,
588
843
  speechCache,
589
844
  cleanText,
845
+ prepareSpeechText,
590
846
  synthesize,
591
847
  credentialsView,
592
848
  configResponse,
@@ -617,6 +873,7 @@ export function apply(ctx, config) {
617
873
  additionalProperties: false,
618
874
  properties: {
619
875
  ok: { type: 'boolean' },
876
+ id: { type: 'string' },
620
877
  provider: { type: 'string' },
621
878
  mime: { type: 'string' },
622
879
  bytes: { type: 'number' },
@@ -633,16 +890,40 @@ export function apply(ctx, config) {
633
890
  },
634
891
  execute: async (args, exec) => {
635
892
  const cfg = live()
893
+ const rawText = String(args.text || '').trim()
894
+ if (!rawText) return { ok: false, error: 'empty text' }
895
+ const sid = exec?.session?.id || 'tool'
896
+ const id = `u${++utteranceSeq}`
897
+ reserve(id)
636
898
  try {
637
- const out = await synthesize(String(args.text || ''), cfg, exec.signal)
899
+ const text = await prepareSpeechText(rawText, cfg, exec.signal)
900
+ if (!text) {
901
+ settle(id, { id, sessionId: sid, kind: 'error', text: rawText, error: 'nothing to speak' })
902
+ return { ok: false, error: 'nothing to speak' }
903
+ }
904
+ const out = await synthesize(text, cfg, exec.signal, 'reply', { skipClean: true })
905
+ const item = {
906
+ id,
907
+ sessionId: sid,
908
+ kind: 'speech',
909
+ role: 'reply',
910
+ text,
911
+ provider: out.provider,
912
+ mime: out.mime,
913
+ audioBase64: Buffer.from(out.audio).toString('base64'),
914
+ tookMs: out.tookMs,
915
+ }
916
+ settle(id, item)
638
917
  return {
639
918
  ok: true,
919
+ id,
640
920
  provider: out.provider,
641
921
  mime: out.mime,
642
922
  bytes: out.audio.length,
643
923
  tookMs: out.tookMs,
644
924
  }
645
925
  } catch (e) {
926
+ settle(id, { id, sessionId: sid, kind: 'error', text: rawText, error: String(e && e.message || e) })
646
927
  return { ok: false, error: String(e && e.message || e) }
647
928
  }
648
929
  },
package/lib/local.js CHANGED
@@ -39,7 +39,7 @@ function spawnCollect(bin, args, { stdinText, timeoutMs } = {}) {
39
39
 
40
40
  export async function espeakSpeak(text, { bin, voice, timeoutMs }) {
41
41
  const bytes = await spawnCollect(bin || 'espeak-ng', [
42
- '-v', voice || 'ru',
42
+ '-v', voice || 'en',
43
43
  '--stdout',
44
44
  '-s', '165',
45
45
  ], { stdinText: text, timeoutMs })