@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/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('
|
|
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: '
|
|
166
|
+
{ provider: 'edge', model: '', voice: 'en-US-JennyNeural' },
|
|
155
167
|
{ provider: 'piper', model: '', voice: '' },
|
|
156
|
-
{ provider: 'espeak', model: '', voice: '
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
await svc.
|
|
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,
|
|
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
|
|
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
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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
|
|
496
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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) {
|
|
518
|
-
|
|
519
|
-
|
|
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
|
|
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
|
-
|
|
557
|
-
|
|
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
|
-
|
|
577
|
-
|
|
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
|
|
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 || '
|
|
42
|
+
'-v', voice || 'en',
|
|
43
43
|
'--stdout',
|
|
44
44
|
'-s', '165',
|
|
45
45
|
], { stdinText: text, timeoutMs })
|