@goodandready/dsh-tts 0.4.1 → 0.4.2

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
@@ -11,6 +11,7 @@ import { createModelManager } from './engines/manager.js'
11
11
  import { registerHttpRoutes } from './routes.js'
12
12
  import { broadcastStream, clearStreamSubscribers } from './stream-hub.js'
13
13
  import { writeJson } from './http-util.js'
14
+ import { createProviderBreaker } from './breaker.js'
14
15
  import {
15
16
  CLOUD_PROVIDERS,
16
17
  assertCredentialRef,
@@ -126,11 +127,11 @@ export const Config = z.object({
126
127
  approvalText: z
127
128
  .string()
128
129
  .description('What to say when an approval is asked for. The tool name is appended.')
129
- .default('Требуется подтверждение'),
130
+ .default('Approval required'),
130
131
  questionText: z
131
132
  .string()
132
133
  .description('What to say when the agent asks a question. Empty disables it.')
133
- .default('Агент задал вопрос'),
134
+ .default('Agent asked a question'),
134
135
  chime: z
135
136
  .string()
136
137
  .description('Short sound before an announcement: ding, beep or none.')
@@ -177,6 +178,8 @@ export const Config = z.object({
177
178
  let utteranceSeq = 0
178
179
 
179
180
  export function apply(ctx, config) {
181
+ const providerBreaker = createProviderBreaker()
182
+
180
183
  let getConfig = () => config
181
184
  const live = () => Config(structuredClone(getConfig() ?? {})) ?? config
182
185
 
@@ -338,7 +341,7 @@ export function apply(ctx, config) {
338
341
  try {
339
342
  const retold = await summarizeReply(text.slice(0, 8000), cfg, signal)
340
343
  if (retold) cut = speechPhrases(cfg.language).summaryIntro + ' ' + retold
341
- } catch (llmDown) { /* тихий откат к обрезке */ }
344
+ } catch (llmDown) { /* quiet fallback to truncation */ }
342
345
  }
343
346
  text = cut
344
347
  }
@@ -380,10 +383,28 @@ export function apply(ctx, config) {
380
383
 
381
384
  const synthPromise = (async () => {
382
385
  try {
383
- const providers = makeProviders(
386
+ const baseProviders = makeProviders(
384
387
  { resolveKey, fetchImpl: fetch, cfg, modelManager },
385
388
  { text, lang: cfg.autoDetect ? detectLang(text) : cfg.language, signal, models, voices, role },
386
389
  )
390
+ const providers = {}
391
+ for (const key of Object.keys(baseProviders)) {
392
+ const fn = baseProviders[key]
393
+ providers[key] = async () => {
394
+ if (providerBreaker.isOpen(key)) {
395
+ return { ok: false, provider: key, reason: 'circuit open (cooldown)' }
396
+ }
397
+ try {
398
+ const out = await fn()
399
+ if (out && out.ok) providerBreaker.recordSuccess(key)
400
+ else providerBreaker.recordFailure(key, out && out.reason)
401
+ return out
402
+ } catch (err) {
403
+ providerBreaker.recordFailure(key, err)
404
+ throw err
405
+ }
406
+ }
407
+ }
387
408
  const out = await runChain(order, providers)
388
409
  stats.total++
389
410
  const ps = stats.providers[out.provider] || (stats.providers[out.provider] = { n: 0, e: 0, ms: 0 })
@@ -549,6 +570,8 @@ export function apply(ctx, config) {
549
570
 
550
571
 
551
572
  registerHttpRoutes(ctx, {
573
+ breakerSnapshot: () => providerBreaker.snapshot(),
574
+
552
575
  live,
553
576
  modelManager,
554
577
  stats,
package/lib/local.js CHANGED
@@ -83,15 +83,13 @@ export async function edgeSpeak(text, { bin, voice, timeoutMs }) {
83
83
 
84
84
 
85
85
  /**
86
- * MiniMax через их консольную утилиту.
86
+ * MiniMax via their official CLI utility.
87
87
  *
88
- * Прямого HTTP тут намеренно нет: у MiniMax свой протокол с отдельным
89
- * идентификатором группы и своей формой ответа, а официальная утилита уже
90
- * умеет и авторизацию, и выбор голоса. Она же хранит вход — плагину не нужно
91
- * знать ни ключа, ни устройства их API.
88
+ * There is intentionally no direct HTTP here: MiniMax uses its own protocol with a
89
+ * separate group id and response shape, while the official CLI already handles auth
90
+ * and voice selection. It also stores the login — the plugin never sees the key.
92
91
  *
93
- * Утилита ставится отдельно и один раз логинится; без неё провайдер честно
94
- * отказывает, и цепочка идёт дальше.
92
+ * The utility is installed separately and logs in once; without it the provider fails honestly and the chain moves on.
95
93
  */
96
94
  export async function minimaxSpeak(text, { bin, voice, model, timeoutMs }) {
97
95
  const dir = await mkdtemp(path.join(os.tmpdir(), 'dsh-tts-minimax-'))
@@ -0,0 +1,61 @@
1
+ export const PROVIDER_KEYS = [
2
+ 'kokoro', 'f5',
3
+ 'openai', 'elevenlabs', 'google', 'azure', 'groq', 'deepgram', 'openrouter',
4
+ 'siliconflow', 'deepinfra', 'fireworks', 'mimo', 'custom',
5
+ 'edge', 'piper', 'espeak', 'minimax',
6
+ ]
7
+
8
+ // OpenAI-compatible providers share /audio/speech; they differ by base URL,
9
+ // model, and key name. Each URL was probed without a key and returned 401.
10
+ export const OPENAI_COMPATIBLE = {
11
+ siliconflow: { baseUrl: 'https://api.siliconflow.cn/v1', keyEnv: 'SILICONFLOW_API_KEY' },
12
+ deepinfra: { baseUrl: 'https://api.deepinfra.com/v1/openai', keyEnv: 'DEEPINFRA_API_KEY' },
13
+ fireworks: { baseUrl: 'https://api.fireworks.ai/inference/v1', keyEnv: 'FIREWORKS_API_KEY' },
14
+ // Custom endpoint: base URL and key name come from settings.
15
+ custom: { baseUrl: '', keyEnv: 'CUSTOM_TTS_API_KEY' },
16
+ }
17
+
18
+ export const DEFAULT_MODELS = {
19
+ kokoro: 'hexgrad/Kokoro-82M',
20
+ f5: 'F5-TTS',
21
+ mimo: 'mimo-v2.5-tts',
22
+ minimax: '',
23
+ siliconflow: 'FunAudioLLM/CosyVoice2-0.5B',
24
+ deepinfra: 'hexgrad/Kokoro-82M',
25
+ fireworks: 'kokoro',
26
+ openai: 'gpt-4o-mini-tts',
27
+ elevenlabs: 'eleven_multilingual_v2',
28
+ google: 'gemini-2.5-flash-preview-tts',
29
+ azure: 'en-US-JennyNeural',
30
+ groq: 'playai-tts',
31
+ deepgram: 'aura-asteria-en',
32
+ openrouter: 'openai/gpt-4o-mini-tts-2025-12-15',
33
+ custom: '',
34
+ edge: 'ru-RU-SvetlanaNeural',
35
+ piper: '',
36
+ espeak: 'ru',
37
+ }
38
+
39
+ export const DEFAULT_VOICES = {
40
+ kokoro: 'af_bella',
41
+ f5: 'default',
42
+ // MiMo voices use Chinese names; 冰糖 is their default.
43
+ mimo: '冰糖',
44
+ minimax: '',
45
+ siliconflow: 'FunAudioLLM/CosyVoice2-0.5B:alex',
46
+ deepinfra: 'af_bella',
47
+ fireworks: 'af_bella',
48
+ openai: 'alloy',
49
+ elevenlabs: '21m00Tcm4TlvDq8ikWAM',
50
+ google: 'Kore',
51
+ azure: 'en-US-JennyNeural',
52
+ groq: 'Fritz-PlayAI',
53
+ deepgram: '',
54
+ openrouter: 'alloy',
55
+ custom: '',
56
+ edge: 'ru-RU-SvetlanaNeural',
57
+ piper: '',
58
+ espeak: 'ru',
59
+ }
60
+
61
+
package/lib/providers.js CHANGED
@@ -1,66 +1,8 @@
1
1
  import { edgeSpeak, espeakSpeak, minimaxSpeak, piperSpeak } from './local.js'
2
2
  import { KokoroEngine } from './engines/kokoro.js'
3
3
  import { F5Engine } from './engines/f5.js'
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
- }
4
+ import { PROVIDER_KEYS, OPENAI_COMPATIBLE, DEFAULT_MODELS, DEFAULT_VOICES } from './providers/constants.js'
5
+ export { PROVIDER_KEYS, OPENAI_COMPATIBLE, DEFAULT_MODELS, DEFAULT_VOICES }
64
6
 
65
7
  function pick(map, models, key) {
66
8
  const chosen = models && typeof models[key] === 'string' ? models[key].trim() : ''
@@ -179,7 +121,7 @@ export function makeProviders(deps, req) {
179
121
  && data.choices[0].message && data.choices[0].message.audio
180
122
  && data.choices[0].message.audio.data
181
123
  if (typeof encoded !== 'string' || !encoded) {
182
- return { ok: false, provider: 'mimo', reason: 'в ответе нет choices[0].message.audio.data' }
124
+ return { ok: false, provider: 'mimo', reason: 'response missing choices[0].message.audio.data' }
183
125
  }
184
126
  const audio = Buffer.from(encoded, 'base64')
185
127
  return {
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')
@@ -109,7 +112,11 @@ export function registerHttpRoutes(ctx, api) {
109
112
  // Fire installation in background or await initial start
110
113
  modelManager.installModel(engine).catch(() => {})
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
+ }
package/lib/text.js CHANGED
@@ -30,7 +30,7 @@ export function applyNarrationFilters(text, cfg) {
30
30
  if (cfg && cfg.removeRegex) {
31
31
  try {
32
32
  out = out.replace(new RegExp(cfg.removeRegex, 'g'), ' ')
33
- } catch (badUserRegex) { /* кривая регулярка не роняет озвучку */ }
33
+ } catch (badUserRegex) { /* bad user regex must not break speech */ }
34
34
  }
35
35
  if (cfg && cfg.skipActions) out = out.replace(/(^|\s)\*[^*\n]+\*/g, '$1')
36
36
  if (cfg && cfg.narrateQuotesOnly) {
@@ -100,7 +100,7 @@ export function applyPronunciation(text, rules, language, enableBuiltinIt = fals
100
100
  else if (rule.whole) re = new RegExp('(?<![\\p{L}\\p{N}])' + escapeRe(rule.from) + '(?![\\p{L}\\p{N}])', 'gu')
101
101
  else re = new RegExp(escapeRe(rule.from), 'g')
102
102
  out = out.replace(re, to)
103
- } catch (badRule) { /* кривое правило не роняет озвучку */ }
103
+ } catch (badRule) { /* bad rule must not break speech */ }
104
104
  }
105
105
  return out
106
106
  }
@@ -174,7 +174,7 @@ export function restoreAbbreviations(text) {
174
174
  }
175
175
 
176
176
  /**
177
- * Режет текст на куски по границам предложений.
177
+ * Split text into chunks on sentence boundaries.
178
178
  *
179
179
  * Нужно, чтобы чтение начиналось почти сразу: длинный ответ синтезируется
180
180
  * целиком долго, а первое предложение — быстро. Куски короче нижнего порога
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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",