@goodandready/dsh-tts 0.4.1 → 0.4.5

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/providers.js CHANGED
@@ -1,80 +1,12 @@
1
- import { edgeSpeak, espeakSpeak, minimaxSpeak, piperSpeak } from './local.js'
2
- import { KokoroEngine } from './engines/kokoro.js'
3
- import { F5Engine } from './engines/f5.js'
1
+ import { PROVIDER_KEYS, OPENAI_COMPATIBLE, DEFAULT_MODELS, DEFAULT_VOICES } from "./providers/constants.js"
2
+ import { makeCloudProviders } from "./providers/cloud.js"
3
+ import { makeLocalProviders } from "./providers/local.js"
4
4
 
5
- export const PROVIDER_KEYS = [
6
- 'kokoro', 'f5',
7
- 'openai', 'elevenlabs', 'google', 'azure', 'groq', 'deepgram', 'openrouter',
8
- 'siliconflow', 'deepinfra', 'fireworks', 'mimo', 'custom',
9
- 'edge', 'piper', 'espeak', 'minimax',
10
- ]
11
-
12
- // OpenAI-compatible providers share /audio/speech; they differ by base URL,
13
- // model, and key name. Each URL was probed without a key and returned 401.
14
- export const OPENAI_COMPATIBLE = {
15
- siliconflow: { baseUrl: 'https://api.siliconflow.cn/v1', keyEnv: 'SILICONFLOW_API_KEY' },
16
- deepinfra: { baseUrl: 'https://api.deepinfra.com/v1/openai', keyEnv: 'DEEPINFRA_API_KEY' },
17
- fireworks: { baseUrl: 'https://api.fireworks.ai/inference/v1', keyEnv: 'FIREWORKS_API_KEY' },
18
- // Custom endpoint: base URL and key name come from settings.
19
- custom: { baseUrl: '', keyEnv: 'CUSTOM_TTS_API_KEY' },
20
- }
21
-
22
- export const DEFAULT_MODELS = {
23
- kokoro: 'hexgrad/Kokoro-82M',
24
- f5: 'F5-TTS',
25
- mimo: 'mimo-v2.5-tts',
26
- minimax: '',
27
- siliconflow: 'FunAudioLLM/CosyVoice2-0.5B',
28
- deepinfra: 'hexgrad/Kokoro-82M',
29
- fireworks: 'kokoro',
30
- openai: 'gpt-4o-mini-tts',
31
- elevenlabs: 'eleven_multilingual_v2',
32
- google: 'gemini-2.5-flash-preview-tts',
33
- azure: 'en-US-JennyNeural',
34
- groq: 'playai-tts',
35
- deepgram: 'aura-asteria-en',
36
- openrouter: 'openai/gpt-4o-mini-tts-2025-12-15',
37
- custom: '',
38
- edge: 'ru-RU-SvetlanaNeural',
39
- piper: '',
40
- espeak: 'ru',
41
- }
42
-
43
- export const DEFAULT_VOICES = {
44
- kokoro: 'af_bella',
45
- f5: 'default',
46
- // MiMo voices use Chinese names; 冰糖 is their default.
47
- mimo: '冰糖',
48
- minimax: '',
49
- siliconflow: 'FunAudioLLM/CosyVoice2-0.5B:alex',
50
- deepinfra: 'af_bella',
51
- fireworks: 'af_bella',
52
- openai: 'alloy',
53
- elevenlabs: '21m00Tcm4TlvDq8ikWAM',
54
- google: 'Kore',
55
- azure: 'en-US-JennyNeural',
56
- groq: 'Fritz-PlayAI',
57
- deepgram: '',
58
- openrouter: 'alloy',
59
- custom: '',
60
- edge: 'ru-RU-SvetlanaNeural',
61
- piper: '',
62
- espeak: 'ru',
63
- }
64
-
65
- function pick(map, models, key) {
66
- const chosen = models && typeof models[key] === 'string' ? models[key].trim() : ''
67
- return chosen || map[key]
68
- }
69
-
70
- function asBuffer(body) {
71
- if (Buffer.isBuffer(body)) return body
72
- return Buffer.from(body)
73
- }
5
+ export { PROVIDER_KEYS, OPENAI_COMPATIBLE, DEFAULT_MODELS, DEFAULT_VOICES }
74
6
 
75
7
  export function makeProviders(deps, req) {
76
- const { resolveKey, fetchImpl, cfg } = deps
77
- const { text, lang, signal: parentSignal, models, voices } = req
8
+ const { cfg } = deps
9
+ const { signal: parentSignal } = req
78
10
  const cloudTimeoutMs = Number(cfg.cloudTimeoutMs) > 0 ? Number(cfg.cloudTimeoutMs) : 10000
79
11
 
80
12
  function withCloudTimeout() {
@@ -82,11 +14,11 @@ export function makeProviders(deps, req) {
82
14
  let timedOut = false
83
15
  const timer = setTimeout(() => {
84
16
  timedOut = true
85
- controller.abort(new Error(`Cloud TTS timeout after ${cloudTimeoutMs}ms`))
17
+ controller.abort(new Error())
86
18
  }, cloudTimeoutMs)
87
19
  if (parentSignal) {
88
20
  if (parentSignal.aborted) controller.abort(parentSignal.reason)
89
- else parentSignal.addEventListener('abort', () => controller.abort(parentSignal.reason), { once: true })
21
+ else parentSignal.addEventListener(abort, () => controller.abort(parentSignal.reason), { once: true })
90
22
  }
91
23
  return {
92
24
  signal: controller.signal,
@@ -94,364 +26,8 @@ export function makeProviders(deps, req) {
94
26
  }
95
27
  }
96
28
 
97
- async function openai() {
98
- const key = await resolveKey(cfg.openaiKeyEnv)
99
- if (!key) return { ok: false, provider: 'openai', reason: `no ${cfg.openaiKeyEnv}` }
100
- const { signal, cleanup } = withCloudTimeout()
101
- try {
102
- const res = await fetchImpl((cfg.openaiBaseUrl || 'https://api.openai.com/v1') + '/audio/speech', {
103
- method: 'POST',
104
- headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
105
- body: JSON.stringify({
106
- model: pick(DEFAULT_MODELS, models, 'openai'),
107
- input: text,
108
- voice: pick(DEFAULT_VOICES, voices, 'openai'),
109
- response_format: 'mp3',
110
- }),
111
- signal,
112
- })
113
- if (!res.ok) throw new Error(`OpenAI HTTP ${res.status}`)
114
- const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
115
- return { ok: audio.length > 0, provider: 'openai', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
116
- } finally {
117
- cleanup()
118
- }
119
- }
120
-
121
- // One implementation for all OpenAI-compatible providers: base URL and key
122
- // name come from the table; everything else is identical.
123
- function openaiCompatible(key) {
124
- const spec = OPENAI_COMPATIBLE[key]
125
- return async function speak() {
126
- const keyEnv = (cfg[key + 'KeyEnv'] || spec.keyEnv)
127
- const token = await resolveKey(keyEnv)
128
- if (!token) return { ok: false, provider: key, reason: `no ${keyEnv}` }
129
- const base = cfg[key + 'BaseUrl'] || spec.baseUrl
130
- if (!base) return { ok: false, provider: key, reason: 'no base url configured' }
131
- const { signal, cleanup } = withCloudTimeout()
132
- try {
133
- const res = await fetchImpl(base + '/audio/speech', {
134
- method: 'POST',
135
- headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
136
- body: JSON.stringify({
137
- model: pick(DEFAULT_MODELS, models, key),
138
- input: text,
139
- voice: pick(DEFAULT_VOICES, voices, key),
140
- response_format: 'mp3',
141
- }),
142
- signal,
143
- })
144
- if (!res.ok) throw new Error(`${key} HTTP ${res.status}`)
145
- const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
146
- return { ok: audio.length > 0, provider: key, audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
147
- } finally {
148
- cleanup()
149
- }
150
- }
151
- }
152
-
153
- // Xiaomi MiMo. Synthesis goes through chat: text as a message, voice/format
154
- // as a separate field; audio returns base64 in the response.
155
- async function mimo() {
156
- const key = await resolveKey(cfg.mimoKeyEnv)
157
- if (!key) return { ok: false, provider: 'mimo', reason: `no ${cfg.mimoKeyEnv}` }
158
- const base = (cfg.mimoBaseUrl || 'https://api.xiaomimimo.com/v1').replace(/\/+$/, '')
159
- const format = cfg.mimoFormat === 'wav' ? 'wav' : 'mp3'
160
- const { signal, cleanup } = withCloudTimeout()
161
- try {
162
- const res = await fetchImpl(base + '/chat/completions', {
163
- method: 'POST',
164
- headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json', accept: 'application/json' },
165
- body: JSON.stringify({
166
- model: pick(DEFAULT_MODELS, models, 'mimo'),
167
- messages: [{ role: 'assistant', content: text }],
168
- audio: { format, voice: pick(DEFAULT_VOICES, voices, 'mimo') },
169
- stream: false,
170
- }),
171
- signal,
172
- })
173
- const data = await res.json().catch(() => ({}))
174
- if (!res.ok) {
175
- const detail = data && data.error && data.error.message
176
- throw new Error(`MiMo HTTP ${res.status}${detail ? ': ' + detail : ''}`)
177
- }
178
- const encoded = data && data.choices && data.choices[0]
179
- && data.choices[0].message && data.choices[0].message.audio
180
- && data.choices[0].message.audio.data
181
- if (typeof encoded !== 'string' || !encoded) {
182
- return { ok: false, provider: 'mimo', reason: 'в ответе нет choices[0].message.audio.data' }
183
- }
184
- const audio = Buffer.from(encoded, 'base64')
185
- return {
186
- ok: audio.length > 0,
187
- provider: 'mimo',
188
- audio,
189
- mime: format === 'wav' ? 'audio/wav' : 'audio/mpeg',
190
- reason: audio.length ? '' : 'empty audio',
191
- }
192
- } finally {
193
- cleanup()
194
- }
195
- }
196
-
197
- // MiniMax uses their CLI on purpose; no custom protocol here.
198
- async function minimax() {
199
- try {
200
- const out = await minimaxSpeak(text, {
201
- bin: cfg.minimaxBin,
202
- voice: pick(DEFAULT_VOICES, voices, 'minimax'),
203
- model: pick(DEFAULT_MODELS, models, 'minimax'),
204
- timeoutMs: cfg.timeoutMs,
205
- })
206
- return { ok: true, provider: 'minimax', audio: out.audio, mime: out.mime }
207
- } catch (e) {
208
- return { ok: false, provider: 'minimax', reason: String(e && e.message || e) }
209
- }
210
- }
211
-
212
- async function elevenlabs() {
213
- const key = await resolveKey(cfg.elevenlabsKeyEnv)
214
- if (!key) return { ok: false, provider: 'elevenlabs', reason: `no ${cfg.elevenlabsKeyEnv}` }
215
- const voice = pick(DEFAULT_VOICES, voices, 'elevenlabs')
216
- const { signal, cleanup } = withCloudTimeout()
217
- try {
218
- const res = await fetchImpl(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}`, {
219
- method: 'POST',
220
- headers: { 'xi-api-key': key, 'content-type': 'application/json', accept: 'audio/mpeg' },
221
- body: JSON.stringify({
222
- text,
223
- model_id: pick(DEFAULT_MODELS, models, 'elevenlabs'),
224
- }),
225
- signal,
226
- })
227
- if (!res.ok) throw new Error(`ElevenLabs HTTP ${res.status}`)
228
- const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
229
- return { ok: audio.length > 0, provider: 'elevenlabs', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
230
- } finally {
231
- cleanup()
232
- }
233
- }
234
-
235
- async function google() {
236
- const key = await resolveKey(cfg.googleKeyEnv)
237
- if (!key) return { ok: false, provider: 'google', reason: `no ${cfg.googleKeyEnv}` }
238
- const model = pick(DEFAULT_MODELS, models, 'google')
239
- const voiceName = pick(DEFAULT_VOICES, voices, 'google')
240
- const { signal, cleanup } = withCloudTimeout()
241
- try {
242
- const res = await fetchImpl(
243
- `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(key)}`,
244
- {
245
- method: 'POST',
246
- headers: { 'content-type': 'application/json' },
247
- body: JSON.stringify({
248
- contents: [{ parts: [{ text }] }],
249
- generationConfig: {
250
- responseModalities: ['AUDIO'],
251
- speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName } } },
252
- },
253
- }),
254
- signal,
255
- },
256
- )
257
- if (!res.ok) throw new Error(`Google HTTP ${res.status}`)
258
- const data = await res.json()
259
- const b64 = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData)?.inlineData?.data
260
- if (!b64) return { ok: false, provider: 'google', reason: 'no inline audio' }
261
- const audio = Buffer.from(b64, 'base64')
262
- return { ok: audio.length > 0, provider: 'google', audio, mime: 'audio/wav', reason: audio.length ? '' : 'empty audio' }
263
- } finally {
264
- cleanup()
265
- }
266
- }
267
-
268
- async function azure() {
269
- const key = await resolveKey(cfg.azureKeyEnv)
270
- if (!key) return { ok: false, provider: 'azure', reason: `no ${cfg.azureKeyEnv}` }
271
- const region = (cfg.azureRegion || '').trim()
272
- if (!region) return { ok: false, provider: 'azure', reason: 'azureRegion is empty' }
273
- const voice = pick(DEFAULT_VOICES, voices, 'azure')
274
- const roleCfg = req.role && cfg.roles && cfg.roles[req.role]
275
- const style = roleCfg && roleCfg.ssmlStyle
276
- const safeStyle = style ? String(style).replace(/["<>&]/g, '') : ''
277
- const inner = safeStyle
278
- ? `<mstts:express-as style="${safeStyle}">${escapeXml(text)}</mstts:express-as>`
279
- : escapeXml(text)
280
- const ssml = `<speak version="1.0" xml:lang="${lang || 'en-US'}">`
281
- + `<voice xml:lang="${lang || 'en-US'}" name="${voice}">`
282
- + `${inner}</voice></speak>`
283
- const { signal, cleanup } = withCloudTimeout()
284
- try {
285
- const res = await fetchImpl(`https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`, {
286
- method: 'POST',
287
- headers: {
288
- 'Ocp-Apim-Subscription-Key': key,
289
- 'Content-Type': 'application/ssml+xml',
290
- 'X-Microsoft-OutputFormat': 'audio-16khz-128kbitrate-mono-mp3',
291
- },
292
- body: ssml,
293
- signal,
294
- })
295
- if (!res.ok) throw new Error(`Azure HTTP ${res.status}`)
296
- const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
297
- return { ok: audio.length > 0, provider: 'azure', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
298
- } finally {
299
- cleanup()
300
- }
301
- }
302
-
303
- async function groq() {
304
- const key = await resolveKey(cfg.groqKeyEnv)
305
- if (!key) return { ok: false, provider: 'groq', reason: `no ${cfg.groqKeyEnv}` }
306
- const { signal, cleanup } = withCloudTimeout()
307
- try {
308
- const res = await fetchImpl('https://api.groq.com/openai/v1/audio/speech', {
309
- method: 'POST',
310
- headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
311
- body: JSON.stringify({
312
- model: pick(DEFAULT_MODELS, models, 'groq'),
313
- input: text,
314
- voice: pick(DEFAULT_VOICES, voices, 'groq'),
315
- response_format: 'mp3',
316
- }),
317
- signal,
318
- })
319
- if (!res.ok) throw new Error(`Groq HTTP ${res.status}`)
320
- const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
321
- return { ok: audio.length > 0, provider: 'groq', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
322
- } finally {
323
- cleanup()
324
- }
325
- }
326
-
327
- async function deepgram() {
328
- const key = await resolveKey(cfg.deepgramKeyEnv)
329
- if (!key) return { ok: false, provider: 'deepgram', reason: `no ${cfg.deepgramKeyEnv}` }
330
- const model = pick(DEFAULT_MODELS, models, 'deepgram')
331
- const { signal, cleanup } = withCloudTimeout()
332
- try {
333
- const res = await fetchImpl(`https://api.deepgram.com/v1/speak?model=${encodeURIComponent(model)}`, {
334
- method: 'POST',
335
- headers: { authorization: `Token ${key}`, 'content-type': 'application/json' },
336
- body: JSON.stringify({ text }),
337
- signal,
338
- })
339
- if (!res.ok) throw new Error(`Deepgram HTTP ${res.status}`)
340
- const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
341
- return { ok: audio.length > 0, provider: 'deepgram', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
342
- } finally {
343
- cleanup()
344
- }
345
- }
346
-
347
- async function openrouter() {
348
- const key = await resolveKey(cfg.openrouterKeyEnv)
349
- if (!key) return { ok: false, provider: 'openrouter', reason: `no ${cfg.openrouterKeyEnv}` }
350
- const { signal, cleanup } = withCloudTimeout()
351
- try {
352
- const res = await fetchImpl('https://openrouter.ai/api/v1/audio/speech', {
353
- method: 'POST',
354
- headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
355
- body: JSON.stringify({
356
- model: pick(DEFAULT_MODELS, models, 'openrouter'),
357
- input: text,
358
- voice: pick(DEFAULT_VOICES, voices, 'openrouter'),
359
- response_format: 'mp3',
360
- }),
361
- signal,
362
- })
363
- if (!res.ok) throw new Error(`OpenRouter HTTP ${res.status}`)
364
- const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
365
- return { ok: audio.length > 0, provider: 'openrouter', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
366
- } finally {
367
- cleanup()
368
- }
369
- }
370
-
371
- async function edge() {
372
- try {
373
- const voice = pick(DEFAULT_VOICES, voices, 'edge') || pick(DEFAULT_MODELS, models, 'edge')
374
- const out = await edgeSpeak(text, { bin: cfg.edgeBin, voice, timeoutMs: cfg.timeoutMs })
375
- return { ok: true, provider: 'edge', audio: out.audio, mime: out.mime }
376
- } catch (e) {
377
- return { ok: false, provider: 'edge', reason: String(e && e.message || e) }
378
- }
379
- }
380
-
381
- async function piper() {
382
- try {
383
- const model = pick(DEFAULT_MODELS, models, 'piper') || cfg.piperModel
384
- const out = await piperSpeak(text, { bin: cfg.piperBin, model, timeoutMs: cfg.timeoutMs })
385
- return { ok: true, provider: 'piper', audio: out.audio, mime: out.mime }
386
- } catch (e) {
387
- return { ok: false, provider: 'piper', reason: String(e && e.message || e) }
388
- }
389
- }
390
-
391
- async function espeak() {
392
- try {
393
- const voice = pick(DEFAULT_VOICES, voices, 'espeak') || lang || 'ru'
394
- const out = await espeakSpeak(text, { bin: cfg.espeakBin, voice, timeoutMs: cfg.timeoutMs })
395
- return { ok: true, provider: 'espeak', audio: out.audio, mime: out.mime }
396
- } catch (e) {
397
- return { ok: false, provider: 'espeak', reason: String(e && e.message || e) }
398
- }
399
- }
400
-
401
- async function kokoro() {
402
- try {
403
- const modelPath = deps.modelManager ? deps.modelManager.getModelPath('kokoro') : (cfg.kokoroModelPath || '')
404
- const engine = new KokoroEngine({ modelPath })
405
- if (!engine.isInstalled()) {
406
- return { ok: false, provider: 'kokoro', reason: 'Kokoro ONNX model not installed. Use Edge, Piper, or eSpeak for offline TTS.' }
407
- }
408
- if (!engine.isRuntimeAvailable()) {
409
- return {
410
- ok: false,
411
- provider: 'kokoro',
412
- reason: 'Kokoro ONNX runtime is not bundled. Weights alone do not produce speech. Use Edge, Piper, or eSpeak for offline TTS.',
413
- }
414
- }
415
- const voice = pick(DEFAULT_VOICES, voices, 'kokoro') || 'af_bella'
416
- const audio = await engine.synthesizeWav(text, voice)
417
- return { ok: audio && audio.length > 0, provider: 'kokoro', audio, mime: 'audio/wav' }
418
- } catch (e) {
419
- return { ok: false, provider: 'kokoro', reason: String(e && e.message || e) }
420
- }
421
- }
422
-
423
- async function f5() {
424
- try {
425
- const modelPath = deps.modelManager ? deps.modelManager.getModelPath('f5') : ''
426
- const engine = new F5Engine({ modelPath })
427
- const ok = await engine.ping()
428
- if (!ok) {
429
- return { ok: false, provider: 'f5', reason: 'F5 daemon unavailable. Use Edge, Piper, or eSpeak for offline TTS.' }
430
- }
431
- if (!engine.isRuntimeAvailable()) {
432
- return {
433
- ok: false,
434
- provider: 'f5',
435
- reason: 'F5-TTS synthesis is not bundled. Use Edge, Piper, or eSpeak for offline TTS.',
436
- }
437
- }
438
- const audio = await engine.synthesize(text)
439
- return { ok: !!(audio && audio.length), provider: 'f5', audio, mime: 'audio/wav' }
440
- } catch (e) {
441
- return { ok: false, provider: 'f5', reason: String(e && e.message || e) }
442
- }
443
- }
444
-
445
- const compatible = {}
446
- for (const key of Object.keys(OPENAI_COMPATIBLE)) compatible[key] = openaiCompatible(key)
447
-
448
- return { kokoro, f5, openai, elevenlabs, google, azure, groq, deepgram, openrouter, mimo, ...compatible, edge, piper, espeak, minimax }
449
- }
29
+ const cloud = makeCloudProviders(deps, req, withCloudTimeout)
30
+ const local = makeLocalProviders(deps, req)
450
31
 
451
- function escapeXml(s) {
452
- return String(s)
453
- .replace(/&/g, '&amp;')
454
- .replace(/</g, '&lt;')
455
- .replace(/>/g, '&gt;')
456
- .replace(/"/g, '&quot;')
32
+ return Object.assign({}, cloud, local)
457
33
  }
package/lib/routes.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { writeJson, readBody, isTrustedSettingsRequest } from './http-util.js'
2
- import { addStreamSubscriber, removeStreamSubscriber } from './stream-hub.js'
2
+ import { addStreamSubscriber, removeStreamSubscriber, streamSubscriberCount } from './stream-hub.js'
3
3
  import { pendingKeyWrites, stripSecretsFromConfig, needsApiKey } from './keys.js'
4
4
  import { PROVIDER_KEYS } from './providers.js'
5
5
 
@@ -23,6 +23,7 @@ export function registerHttpRoutes(ctx, api) {
23
23
  pending,
24
24
  getSettingsApi,
25
25
  validateConfig,
26
+ breakerSnapshot,
26
27
  } = api
27
28
  ctx.effect(() => ctx.webServer.register({
28
29
  kind: 'exact',
@@ -44,6 +45,8 @@ export function registerHttpRoutes(ctx, api) {
44
45
  chain: cfg.chain,
45
46
  roles: cfg.roles,
46
47
  providers: PROVIDER_KEYS,
48
+ sseClients: streamSubscriberCount(),
49
+ breaker: typeof breakerSnapshot === 'function' ? breakerSnapshot() : [],
47
50
  })
48
51
  },
49
52
  }), 'dsh-tts: /status route')
@@ -62,7 +65,7 @@ export function registerHttpRoutes(ctx, api) {
62
65
  res.write(': connected\n\n')
63
66
  addStreamSubscriber(res)
64
67
  req.on('close', () => removeStreamSubscriber(res))
65
- } catch {
68
+ } catch { /* SSE connection failed */
66
69
  removeStreamSubscriber(res)
67
70
  }
68
71
  },
@@ -107,9 +110,13 @@ export function registerHttpRoutes(ctx, api) {
107
110
  }
108
111
  try {
109
112
  // Fire installation in background or await initial start
110
- modelManager.installModel(engine).catch(() => {})
113
+ modelManager.installModel(engine).catch(() => { /* background install error tracked in manager state */ })
111
114
  const current = await modelManager.getStatus(engine)
112
- writeJson(res, 200, { ok: true, model: current })
115
+ writeJson(res, 200, {
116
+ ok: true,
117
+ model: current,
118
+ note: 'Weights only. Neural inference runtime is not bundled; speech still requires Edge, Piper, or eSpeak.',
119
+ })
113
120
  } catch (err) {
114
121
  writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
115
122
  }
@@ -132,7 +139,7 @@ export function registerHttpRoutes(ctx, api) {
132
139
  try {
133
140
  const raw = await readBody(req, 16 * 1024)
134
141
  body = JSON.parse(raw.toString('utf8') || '{}')
135
- } catch { body = {} }
142
+ } catch { /* missing body treated as empty */ body = {} }
136
143
  const engine = String(body.engine || '').trim()
137
144
  if (!engine) {
138
145
  writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
@@ -160,7 +167,7 @@ export function registerHttpRoutes(ctx, api) {
160
167
  voiceInstalled = ctx.webServer.hasRoute('/dsh-voice/status')
161
168
  messengerInstalled = ctx.webServer.hasRoute('/dsh-messenger-gateway/status')
162
169
  }
163
- } catch {}
170
+ } catch { /* ignore malformed client frame */ }
164
171
 
165
172
  // Runtime routes are the only reliable signal. Do not probe ../ siblings
166
173
  // or node_modules layouts — they lie outside a normal npm install.
@@ -237,7 +244,7 @@ export function registerHttpRoutes(ctx, api) {
237
244
  path: '/dsh-tts/stats',
238
245
  handler: async (req, res) => {
239
246
  if (req.method === 'GET') {
240
- writeJson(res, 200, { ok: true, total: stats.total, cacheHits: stats.cacheHits, errors: stats.errors, providers: stats.providers })
247
+ writeJson(res, 200, { ok: true, total: stats.total, cacheHits: stats.cacheHits, errors: stats.errors, providers: stats.providers, breaker: typeof breakerSnapshot === 'function' ? breakerSnapshot() : [] })
241
248
  return
242
249
  }
243
250
  if (req.method !== 'DELETE') {
@@ -283,7 +290,7 @@ export function registerHttpRoutes(ctx, api) {
283
290
  return
284
291
  }
285
292
  let payload
286
- try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch {
293
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* invalid JSON treated as empty */
287
294
  writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
288
295
  return
289
296
  }
@@ -328,7 +335,7 @@ export function registerHttpRoutes(ctx, api) {
328
335
  writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
329
336
  return
330
337
  }
331
- try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch {
338
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* invalid JSON treated as empty */
332
339
  writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
333
340
  return
334
341
  }
@@ -378,7 +385,7 @@ export function registerHttpRoutes(ctx, api) {
378
385
  writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
379
386
  }
380
387
  let payload
381
- try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
388
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* invalid JSON treated as empty */ /* invalid JSON treated as empty */ payload = {} }
382
389
  const text = cleanText(payload.text, live())
383
390
  if (!text) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
384
391
  const cfg = live()
package/lib/stream-hub.js CHANGED
@@ -1,31 +1,83 @@
1
1
  // SSE subscriber hub for /dsh-tts/stream.
2
2
 
3
3
  const subscribers = new Set()
4
+ let heartbeatTimer = null
5
+
6
+ function detach(res) {
7
+ try {
8
+ if (res.__dtsOnClose) res.off('close', res.__dtsOnClose)
9
+ if (res.__dtsOnError) res.off('error', res.__dtsOnError)
10
+ } catch { /* listeners may already be gone */ }
11
+ res.__dtsOnClose = null
12
+ res.__dtsOnError = null
13
+ }
14
+
15
+ function safeWrite(res, payload) {
16
+ try {
17
+ res.write(payload)
18
+ return true
19
+ } catch {
20
+ subscribers.delete(res)
21
+ detach(res)
22
+ return false
23
+ }
24
+ }
25
+
26
+ function ensureHeartbeat() {
27
+ if (heartbeatTimer || typeof setInterval !== 'function') return
28
+ heartbeatTimer = setInterval(() => {
29
+ if (!subscribers.size) {
30
+ clearInterval(heartbeatTimer)
31
+ heartbeatTimer = null
32
+ return
33
+ }
34
+ for (const res of [...subscribers]) {
35
+ safeWrite(res, ': ping\n\n')
36
+ }
37
+ }, 15000)
38
+ if (heartbeatTimer.unref) heartbeatTimer.unref()
39
+ }
4
40
 
5
41
  export function addStreamSubscriber(res) {
6
42
  subscribers.add(res)
43
+ const onClose = () => {
44
+ subscribers.delete(res)
45
+ detach(res)
46
+ }
47
+ res.__dtsOnClose = onClose
48
+ res.__dtsOnError = onClose
49
+ try {
50
+ res.on('close', onClose)
51
+ res.on('error', onClose)
52
+ } catch { /* non-node response object in tests */ }
53
+ ensureHeartbeat()
7
54
  }
8
55
 
9
56
  export function removeStreamSubscriber(res) {
10
57
  subscribers.delete(res)
58
+ detach(res)
11
59
  }
12
60
 
13
61
  export function broadcastStream(type, item) {
14
- const payload = `event: ${type}\ndata: ${JSON.stringify(item)}\n\n`
15
- for (const res of subscribers) {
16
- try {
17
- res.write(payload)
18
- } catch {
19
- subscribers.delete(res)
20
- }
62
+ const payload = 'event: ' + type + '\ndata: ' + JSON.stringify(item) + '\n\n'
63
+ for (const res of [...subscribers]) {
64
+ safeWrite(res, payload)
21
65
  }
22
66
  }
23
67
 
68
+ export function streamSubscriberCount() {
69
+ return subscribers.size
70
+ }
71
+
24
72
  export function clearStreamSubscribers() {
25
- for (const res of subscribers) {
73
+ for (const res of [...subscribers]) {
26
74
  try {
27
75
  res.end()
28
76
  } catch { /* already closed */ }
29
77
  }
30
78
  subscribers.clear()
31
- }
79
+ if (heartbeatTimer) {
80
+ clearInterval(heartbeatTimer)
81
+ heartbeatTimer = null
82
+ }
83
+ }