@goodandready/dsh-tts 0.3.23 → 0.4.0

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
@@ -9,14 +9,13 @@ export const PROVIDER_KEYS = [
9
9
  'edge', 'piper', 'espeak', 'minimax',
10
10
  ]
11
11
 
12
- // Провайдеры, говорящие на языке OpenAI: тот же путь /audio/speech, отличаются
13
- // только адресом, моделью и именем ключа. Каждый адрес проверен запросом без
14
- // ключа — все отвечают 401, то есть путь существует и ждёт ключ.
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.
15
14
  export const OPENAI_COMPATIBLE = {
16
15
  siliconflow: { baseUrl: 'https://api.siliconflow.cn/v1', keyEnv: 'SILICONFLOW_API_KEY' },
17
16
  deepinfra: { baseUrl: 'https://api.deepinfra.com/v1/openai', keyEnv: 'DEEPINFRA_API_KEY' },
18
17
  fireworks: { baseUrl: 'https://api.fireworks.ai/inference/v1', keyEnv: 'FIREWORKS_API_KEY' },
19
- // Пользовательский endpoint: адрес и имя ключа приходят из настроек.
18
+ // Custom endpoint: base URL and key name come from settings.
20
19
  custom: { baseUrl: '', keyEnv: 'CUSTOM_TTS_API_KEY' },
21
20
  }
22
21
 
@@ -44,7 +43,7 @@ export const DEFAULT_MODELS = {
44
43
  export const DEFAULT_VOICES = {
45
44
  kokoro: 'af_bella',
46
45
  f5: 'default',
47
- // Голоса у MiMo названы по-китайски; «冰糖» — их же значение по умолчанию.
46
+ // MiMo voices use Chinese names; 冰糖 is their default.
48
47
  mimo: '冰糖',
49
48
  minimax: '',
50
49
  siliconflow: 'FunAudioLLM/CosyVoice2-0.5B:alex',
@@ -119,8 +118,8 @@ export function makeProviders(deps, req) {
119
118
  }
120
119
  }
121
120
 
122
- // Одна реализация на всех, кто говорит на языке OpenAI: адрес и имя ключа
123
- // берутся из таблицы, остальное совпадает буква в букву.
121
+ // One implementation for all OpenAI-compatible providers: base URL and key
122
+ // name come from the table; everything else is identical.
124
123
  function openaiCompatible(key) {
125
124
  const spec = OPENAI_COMPATIBLE[key]
126
125
  return async function speak() {
@@ -151,8 +150,8 @@ export function makeProviders(deps, req) {
151
150
  }
152
151
  }
153
152
 
154
- // Xiaomi MiMo. Синтез идёт через чат: текст кладётся сообщением, а голос и
155
- // формат — отдельным полем; готовый звук приезжает в ответе base64.
153
+ // Xiaomi MiMo. Synthesis goes through chat: text as a message, voice/format
154
+ // as a separate field; audio returns base64 in the response.
156
155
  async function mimo() {
157
156
  const key = await resolveKey(cfg.mimoKeyEnv)
158
157
  if (!key) return { ok: false, provider: 'mimo', reason: `no ${cfg.mimoKeyEnv}` }
@@ -195,7 +194,7 @@ export function makeProviders(deps, req) {
195
194
  }
196
195
  }
197
196
 
198
- // MiniMax — через их утилиту: своего протокола здесь нет намеренно.
197
+ // MiniMax uses their CLI on purpose; no custom protocol here.
199
198
  async function minimax() {
200
199
  try {
201
200
  const out = await minimaxSpeak(text, {
@@ -404,7 +403,14 @@ export function makeProviders(deps, req) {
404
403
  const modelPath = deps.modelManager ? deps.modelManager.getModelPath('kokoro') : (cfg.kokoroModelPath || '')
405
404
  const engine = new KokoroEngine({ modelPath })
406
405
  if (!engine.isInstalled()) {
407
- return { ok: false, provider: 'kokoro', reason: 'Kokoro ONNX model not installed. Use Piper or eSpeak for offline TTS.' }
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
+ }
408
414
  }
409
415
  const voice = pick(DEFAULT_VOICES, voices, 'kokoro') || 'af_bella'
410
416
  const audio = await engine.synthesizeWav(text, voice)
@@ -416,16 +422,21 @@ export function makeProviders(deps, req) {
416
422
 
417
423
  async function f5() {
418
424
  try {
419
- const modelPath = deps.modelManager ? deps.modelManager.getModelPath('f5') : (cfg.f5ModelPath || '')
425
+ const modelPath = deps.modelManager ? deps.modelManager.getModelPath('f5') : ''
420
426
  const engine = new F5Engine({ modelPath })
421
427
  const ok = await engine.ping()
422
- if (!ok) return { ok: false, provider: 'f5', reason: 'F5 daemon unavailable. Use Piper or eSpeak for offline TTS.' }
423
- const kokoroFallback = new KokoroEngine({ modelPath })
424
- if (!kokoroFallback.isInstalled()) {
425
- return { ok: false, provider: 'f5', reason: 'F5 model weights not installed. Use Piper or eSpeak for offline TTS.' }
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
+ }
426
437
  }
427
- const audio = await kokoroFallback.synthesizeWav(text, 'af_bella')
428
- return { ok: true, provider: 'f5', audio, mime: 'audio/wav' }
438
+ const audio = await engine.synthesize(text)
439
+ return { ok: !!(audio && audio.length), provider: 'f5', audio, mime: 'audio/wav' }
429
440
  } catch (e) {
430
441
  return { ok: false, provider: 'f5', reason: String(e && e.message || e) }
431
442
  }
package/lib/routes.js ADDED
@@ -0,0 +1,400 @@
1
+ import { writeJson, readBody, isTrustedSettingsRequest } from './http-util.js'
2
+ import { addStreamSubscriber, removeStreamSubscriber } from './stream-hub.js'
3
+ import { pendingKeyWrites, stripSecretsFromConfig, needsApiKey } from './keys.js'
4
+ import { PROVIDER_KEYS } from './providers.js'
5
+
6
+ /**
7
+ * Register all /dsh-tts HTTP routes.
8
+ * @param {object} ctx cordis context
9
+ * @param {object} api live bindings from apply()
10
+ */
11
+ export function registerHttpRoutes(ctx, api) {
12
+ const {
13
+ live,
14
+ modelManager,
15
+ stats,
16
+ speechCache,
17
+ cleanText,
18
+ synthesize,
19
+ credentialsView,
20
+ configResponse,
21
+ storeProviderKey,
22
+ clearProviderKey,
23
+ pending,
24
+ } = api
25
+ ctx.effect(() => ctx.webServer.register({
26
+ kind: 'exact',
27
+ path: '/dsh-tts/status',
28
+ handler: async (req, res) => {
29
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
30
+ const cfg = live()
31
+ writeJson(res, 200, {
32
+ ok: true,
33
+ speakReplies: cfg.speakReplies,
34
+ enableLocalEngines: cfg.enableLocalEngines,
35
+ kokoroEnabled: cfg.kokoroEnabled,
36
+ f5Enabled: cfg.f5Enabled,
37
+ streamingEnabled: cfg.streamingEnabled,
38
+ rate: cfg.rate,
39
+ chime: cfg.chime,
40
+ bargeIn: cfg.bargeIn,
41
+ language: cfg.language,
42
+ chain: cfg.chain,
43
+ roles: cfg.roles,
44
+ providers: PROVIDER_KEYS,
45
+ })
46
+ },
47
+ }), 'dsh-tts: /status route')
48
+
49
+ ctx.effect(() => ctx.webServer.register({
50
+ kind: 'exact',
51
+ path: '/dsh-tts/stream',
52
+ handler: async (req, res) => {
53
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
54
+ try {
55
+ res.writeHead(200, {
56
+ 'Content-Type': 'text/event-stream',
57
+ 'Cache-Control': 'no-cache, no-transform',
58
+ 'Connection': 'keep-alive',
59
+ })
60
+ res.write(': connected\n\n')
61
+ addStreamSubscriber(res)
62
+ req.on('close', () => removeStreamSubscriber(res))
63
+ } catch {
64
+ removeStreamSubscriber(res)
65
+ }
66
+ },
67
+ }), 'dsh-tts: /stream route')
68
+
69
+
70
+ ctx.effect(() => ctx.webServer.register({
71
+ kind: 'exact',
72
+ path: '/dsh-tts/models/status',
73
+ handler: async (req, res) => {
74
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
75
+ try {
76
+ const list = await modelManager.listStatus()
77
+ writeJson(res, 200, { ok: true, models: list })
78
+ } catch (err) {
79
+ writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
80
+ }
81
+ },
82
+ }), 'dsh-tts: /models/status route')
83
+
84
+ ctx.effect(() => ctx.webServer.register({
85
+ kind: 'exact',
86
+ path: '/dsh-tts/models/install',
87
+ handler: async (req, res) => {
88
+ if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
89
+ if (!isTrustedSettingsRequest(req)) {
90
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
91
+ return
92
+ }
93
+ let body = {}
94
+ try {
95
+ const raw = await readBody(req, 16 * 1024)
96
+ body = JSON.parse(raw.toString('utf8') || '{}')
97
+ } catch (e) {
98
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
99
+ return
100
+ }
101
+ const engine = String(body.engine || '').trim()
102
+ if (!engine) {
103
+ writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
104
+ return
105
+ }
106
+ try {
107
+ // Fire installation in background or await initial start
108
+ modelManager.installModel(engine).catch(() => {})
109
+ const current = await modelManager.getStatus(engine)
110
+ writeJson(res, 200, { ok: true, model: current })
111
+ } catch (err) {
112
+ writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
113
+ }
114
+ },
115
+ }), 'dsh-tts: /models/install route')
116
+
117
+ ctx.effect(() => ctx.webServer.register({
118
+ kind: 'exact',
119
+ path: '/dsh-tts/models/delete',
120
+ handler: async (req, res) => {
121
+ if (req.method !== 'DELETE' && req.method !== 'POST') {
122
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'DELETE or POST only' } })
123
+ return
124
+ }
125
+ if (!isTrustedSettingsRequest(req)) {
126
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
127
+ return
128
+ }
129
+ let body = {}
130
+ try {
131
+ const raw = await readBody(req, 16 * 1024)
132
+ body = JSON.parse(raw.toString('utf8') || '{}')
133
+ } catch { body = {} }
134
+ const engine = String(body.engine || '').trim()
135
+ if (!engine) {
136
+ writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
137
+ return
138
+ }
139
+ try {
140
+ const result = await modelManager.deleteModel(engine)
141
+ writeJson(res, 200, { ok: true, model: result })
142
+ } catch (err) {
143
+ writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
144
+ }
145
+ },
146
+ }), 'dsh-tts: /models/delete route')
147
+
148
+ ctx.effect(() => ctx.webServer.register({
149
+ kind: 'exact',
150
+ path: '/dsh-tts/integrations',
151
+ handler: async (req, res) => {
152
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
153
+ let voiceInstalled = false
154
+ let messengerInstalled = false
155
+
156
+ try {
157
+ if (typeof ctx.webServer?.hasRoute === 'function') {
158
+ voiceInstalled = ctx.webServer.hasRoute('/dsh-voice/status')
159
+ messengerInstalled = ctx.webServer.hasRoute('/dsh-messenger-gateway/status')
160
+ }
161
+ } catch {}
162
+
163
+ // Runtime routes are the only reliable signal. Do not probe ../ siblings
164
+ // or node_modules layouts — they lie outside a normal npm install.
165
+
166
+ writeJson(res, 200, {
167
+ ok: true,
168
+ voice: {
169
+ installed: voiceInstalled,
170
+ package: '@goodandready/dsh-voice',
171
+ hint: 'dsh plugin --profile web add @goodandready/dsh-voice',
172
+ },
173
+ messenger: {
174
+ installed: messengerInstalled,
175
+ package: '@goodandready/dsh-messenger-gateway',
176
+ hint: 'dsh plugin --profile web add @goodandready/dsh-messenger-gateway',
177
+ },
178
+ })
179
+ },
180
+ }), 'dsh-tts: /integrations route')
181
+
182
+ ctx.effect(() => ctx.webServer.register({
183
+ kind: 'exact',
184
+ path: '/dsh-tts/cache',
185
+ handler: async (req, res) => {
186
+ if (req.method !== 'DELETE') {
187
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'DELETE only' } })
188
+ return
189
+ }
190
+ if (!isTrustedSettingsRequest(req)) {
191
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
192
+ return
193
+ }
194
+ const removed = await speechCache.clear()
195
+ writeJson(res, 200, { ok: true, removed })
196
+ },
197
+ }), 'dsh-tts: /cache route')
198
+
199
+ ctx.effect(() => ctx.webServer.register({
200
+ kind: 'exact',
201
+ path: '/dsh-tts/preview',
202
+ handler: async (req, res) => {
203
+ if (req.method !== 'POST') {
204
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
205
+ return
206
+ }
207
+ if (!isTrustedSettingsRequest(req)) {
208
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
209
+ return
210
+ }
211
+ let body = {}
212
+ try {
213
+ const raw = await readBody(req, 64 * 1024)
214
+ body = JSON.parse(raw.toString('utf8') || '{}')
215
+ } catch (e) {
216
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
217
+ return
218
+ }
219
+ const cfg = live()
220
+ const phrase = String(cfg.language || '').toLowerCase().startsWith('ru') ? 'Проверка голоса.' : 'Voice check.'
221
+ try {
222
+ const probe = Object.assign({}, cfg, {
223
+ chain: [{ provider: body.provider || '', model: body.model || '', voice: body.voice || '' }],
224
+ })
225
+ const out = await synthesize(String(body.text || phrase), probe, null, 'reply')
226
+ writeJson(res, 200, { ok: true, mime: out.mime, audioBase64: Buffer.from(out.audio).toString('base64') })
227
+ } catch (e) {
228
+ writeJson(res, 502, { ok: false, error: { code: 'tts', message: String(e && e.message || e) } })
229
+ }
230
+ },
231
+ }), 'dsh-tts: /preview route')
232
+
233
+ ctx.effect(() => ctx.webServer.register({
234
+ kind: 'exact',
235
+ path: '/dsh-tts/stats',
236
+ handler: async (req, res) => {
237
+ if (req.method === 'GET') {
238
+ writeJson(res, 200, { ok: true, total: stats.total, cacheHits: stats.cacheHits, errors: stats.errors, providers: stats.providers })
239
+ return
240
+ }
241
+ if (req.method !== 'DELETE') {
242
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or DELETE' } })
243
+ return
244
+ }
245
+ if (!isTrustedSettingsRequest(req)) {
246
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
247
+ return
248
+ }
249
+ stats.total = 0
250
+ stats.cacheHits = 0
251
+ stats.errors = 0
252
+ stats.providers = {}
253
+ writeJson(res, 200, { ok: true })
254
+ },
255
+ }), 'dsh-tts: /stats route')
256
+
257
+ ctx.effect(() => ctx.webServer.register({
258
+ kind: 'exact',
259
+ path: '/dsh-tts/config',
260
+ handler: async (req, res) => {
261
+ if (req.method === 'GET') {
262
+ writeJson(res, 200, await configResponse())
263
+ return
264
+ }
265
+ if (req.method !== 'PUT' && req.method !== 'POST') {
266
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or PUT' } })
267
+ return
268
+ }
269
+ if (!isTrustedSettingsRequest(req)) {
270
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-tts: settings writes are same-origin only' } })
271
+ return
272
+ }
273
+ if (!settingsApi) {
274
+ writeJson(res, 503, { ok: false, error: { code: 'settings', message: 'settings not ready' } })
275
+ return
276
+ }
277
+ let raw
278
+ try { raw = await readBody(req, 256 * 1024) } catch (e) {
279
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
280
+ return
281
+ }
282
+ let payload
283
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch {
284
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
285
+ return
286
+ }
287
+ if (payload && typeof payload.config === 'object') {
288
+ payload = { ...payload.config, keys: payload.keys }
289
+ }
290
+ const keys = pendingKeyWrites(payload && payload.keys)
291
+ const stripped = stripSecretsFromConfig(payload)
292
+ let parsed
293
+ try { parsed = Config(stripped) } catch (e) {
294
+ writeJson(res, 400, { ok: false, error: { code: 'schema', message: String(e && e.message || e) } })
295
+ return
296
+ }
297
+ try {
298
+ for (const item of keys) {
299
+ await storeProviderKey(item.provider, item.value)
300
+ }
301
+ await settingsApi.replace(parsed)
302
+ writeJson(res, 200, await configResponse())
303
+ } catch (e) {
304
+ writeJson(res, 500, { ok: false, error: { code: 'save', message: String(e && e.message || e) } })
305
+ }
306
+ },
307
+ }), 'dsh-tts: /config route')
308
+
309
+ ctx.effect(() => ctx.webServer.register({
310
+ kind: 'exact',
311
+ path: '/dsh-tts/credential',
312
+ handler: async (req, res) => {
313
+ if (req.method !== 'PUT' && req.method !== 'DELETE') {
314
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'PUT or DELETE only' } })
315
+ return
316
+ }
317
+ if (!isTrustedSettingsRequest(req)) {
318
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-tts: keys are same-origin only' } })
319
+ return
320
+ }
321
+ let payload = {}
322
+ if (req.method === 'PUT' || (req.headers['content-length'] && Number(req.headers['content-length']) > 0)) {
323
+ let raw
324
+ try { raw = await readBody(req, 16 * 1024) } catch (e) {
325
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
326
+ return
327
+ }
328
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch {
329
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
330
+ return
331
+ }
332
+ }
333
+ const provider = typeof payload.provider === 'string' ? payload.provider.trim() : ''
334
+ if (!needsApiKey(provider)) {
335
+ writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown cloud provider' } })
336
+ return
337
+ }
338
+ try {
339
+ if (req.method === 'DELETE') {
340
+ const ref = await clearProviderKey(provider)
341
+ writeJson(res, 200, { ok: true, ref, credentials: await credentialsView() })
342
+ return
343
+ }
344
+ const ref = await storeProviderKey(provider, payload.value)
345
+ writeJson(res, 200, { ok: true, ref, credentials: await credentialsView() })
346
+ } catch (e) {
347
+ writeJson(res, 400, { ok: false, error: { code: 'credential', message: String(e && e.message || e) } })
348
+ }
349
+ },
350
+ }), 'dsh-tts: /credential route')
351
+
352
+ ctx.effect(() => ctx.webServer.register({
353
+ kind: 'exact',
354
+ path: '/dsh-tts/pending',
355
+ handler: async (req, res) => {
356
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
357
+ const url = new URL(req.url || '/', 'http://127.0.0.1')
358
+ const after = url.searchParams.get('after') || ''
359
+ let items = pending.slice()
360
+ if (after) {
361
+ const idx = items.findIndex((x) => x.id === after)
362
+ items = idx >= 0 ? items.slice(idx + 1) : items
363
+ }
364
+ writeJson(res, 200, { ok: true, items })
365
+ },
366
+ }), 'dsh-tts: /pending route')
367
+
368
+ ctx.effect(() => ctx.webServer.register({
369
+ kind: 'exact',
370
+ path: '/dsh-tts/speak',
371
+ handler: async (req, res) => {
372
+ if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
373
+ let raw
374
+ try { raw = await readBody(req, 256 * 1024) } catch (e) {
375
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
376
+ }
377
+ let payload
378
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
379
+ const text = cleanText(payload.text, live())
380
+ if (!text) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
381
+ const cfg = live()
382
+ const controller = new AbortController()
383
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
384
+ try {
385
+ const out = await synthesize(text, cfg, controller.signal)
386
+ writeJson(res, 200, {
387
+ ok: true,
388
+ provider: out.provider,
389
+ mime: out.mime,
390
+ audioBase64: Buffer.from(out.audio).toString('base64'),
391
+ tookMs: out.tookMs,
392
+ })
393
+ } catch (e) {
394
+ writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
395
+ } finally {
396
+ clearTimeout(timer)
397
+ }
398
+ },
399
+ }), 'dsh-tts: /speak route')
400
+ }
@@ -0,0 +1,31 @@
1
+ // SSE subscriber hub for /dsh-tts/stream.
2
+
3
+ const subscribers = new Set()
4
+
5
+ export function addStreamSubscriber(res) {
6
+ subscribers.add(res)
7
+ }
8
+
9
+ export function removeStreamSubscriber(res) {
10
+ subscribers.delete(res)
11
+ }
12
+
13
+ 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
+ }
21
+ }
22
+ }
23
+
24
+ export function clearStreamSubscribers() {
25
+ for (const res of subscribers) {
26
+ try {
27
+ res.end()
28
+ } catch { /* already closed */ }
29
+ }
30
+ subscribers.clear()
31
+ }
package/lib/text.js CHANGED
@@ -11,8 +11,8 @@ export function blocksToText(content) {
11
11
  return parts.join('\n')
12
12
  }
13
13
 
14
- // Короткие пометки вместо выброшенной разметки. Формулировки выбираются по
15
- // языку озвучки из настроек; подстановки {n} заполняются числом строк.
14
+ // Short spoken notices replace dropped markup. Wording follows
15
+ // the configured speech language; {n} is filled with a line/row count.
16
16
  export const SPEECH_PHRASES = {
17
17
  en: { codeBlock: 'code block, {n} lines', table: 'table, {n} rows', summaryIntro: 'Summary of the reply.' },
18
18
  ru: { codeBlock: 'блок кода, {n} строк', table: 'таблица, {n} строк', summaryIntro: 'Пересказ ответа.' },
@@ -23,8 +23,8 @@ export function speechPhrases(language) {
23
23
  return lang.startsWith('ru') ? SPEECH_PHRASES.ru : SPEECH_PHRASES.en
24
24
  }
25
25
 
26
- // Фильтры наррации: вырезания поверх общей чистки, до словаря произношения.
27
- // Порядок: своя регулярка -> блоки *действий* -> режим «только цитаты».
26
+ // Narration filters run on top of general scrubbing, before pronunciation.
27
+ // Order: custom regex -> *action* blocks -> quotes-only mode.
28
28
  export function applyNarrationFilters(text, cfg) {
29
29
  let out = String(text || '')
30
30
  if (cfg && cfg.removeRegex) {
@@ -40,7 +40,7 @@ export function applyNarrationFilters(text, cfg) {
40
40
  return out
41
41
  }
42
42
 
43
- // Грубая эвристика языка куска: где букв больше, тот язык и подсказываем.
43
+ // Rough language heuristic: whichever script has more letters wins.
44
44
  export function detectLang(text) {
45
45
  const s = String(text || '')
46
46
  const cyr = (s.match(/[\u0400-\u04FF]/g) || []).length
@@ -48,10 +48,10 @@ export function detectLang(text) {
48
48
  return cyr > lat ? 'ru' : 'en'
49
49
  }
50
50
 
51
- // Словарь произношения: правила применяются сверху вниз, каждое по одному
52
- // разу ко всему тексту. Левая часть — обычный текст; запись вида /…/флаги
53
- // трактуется как регулярное выражение для тех, кто умеет. whole=true требует
54
- // совпадения целого слова (границы по буквам/цифрам Unicode).
51
+ // Pronunciation rules apply top-down, once each over the whole text.
52
+ // Left side is plain text; /…/flags form is treated as a regular expression.
53
+ // whole=true requires a full word match (Unicode letter/digit boundaries).
54
+
55
55
  function escapeRe(s) {
56
56
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
57
57
  }
@@ -114,8 +114,8 @@ export function stripForSpeech(raw, maxChars, opts) {
114
114
  const skipCode = options.skipCode !== false
115
115
  const phrases = options.phrases || SPEECH_PHRASES.en
116
116
  let text = String(raw || '')
117
- // Чистка обязана идти до нарезки на предложения, иначе половина блока
118
- // уедет в первый кусок и будет озвучена.
117
+ // Scrubbing must run before sentence splitting or half a fenced block ends
118
+ // up in the first chunk and is spoken.
119
119
  if (skipCode) {
120
120
  text = text.replace(/```[\s\S]*?```/g, (block) => {
121
121
  const inner = block.replace(/^```[^\n]*\n?/, '').replace(/\n?```\s*$/, '')
@@ -124,8 +124,8 @@ export function stripForSpeech(raw, maxChars, opts) {
124
124
  })
125
125
  text = text.replace(/`[^`\n]+`/g, ' ')
126
126
  }
127
- // Таблицы Markdown: подряд идущие строки, начинающиеся с |; разделительная
128
- // шапка |---| в счёт строк не идёт.
127
+ // Markdown tables: consecutive lines starting with |; the |---| separator
128
+ // row is not counted as a data row.
129
129
  text = text.replace(/(?:^[ \t]*\|.*\|[ \t]*$\n?)+/gm, (run) => {
130
130
  const rows = run.trim().split('\n')
131
131
  .filter((line) => !/^\s*\|[\s:|-]+\|\s*$/.test(line)).length
@@ -136,7 +136,7 @@ export function stripForSpeech(raw, maxChars, opts) {
136
136
  text = text.replace(/[#*_>]+/g, ' ')
137
137
  text = text.replace(/MEDIA:[^\s]+/g, ' ')
138
138
  text = text.replace(/\s+/g, ' ').trim()
139
- // maxChars === 0 отключает обрезку; не задан — дефолтные 4000.
139
+ // maxChars === 0 disables truncation; unset means the 4000 default.
140
140
  const requested = Number(maxChars)
141
141
  if (requested > 0 && text.length > requested) text = text.slice(0, requested)
142
142
  return text
@@ -147,13 +147,13 @@ export function assistantText(message) {
147
147
  return blocksToText(message.content)
148
148
  }
149
149
 
150
- // Защита распространенных сокращений от ложного разбиения на предложения
150
+ // Protect common abbreviations from false sentence splits
151
151
  const PROTECTED_ABBREVIATIONS = [
152
- // Русские сокращения
152
+ // Russian abbreviations
153
153
  'т.е.', 'т.д.', 'т.п.', 'т.к.', 'руб.', 'коп.', 'ул.', 'пер.', 'пр.', 'просп.',
154
154
  'д.', 'корп.', 'стр.', 'кв.', 'г.', 'гг.', 'в.', 'вв.', 'см.', 'ср.', 'напр.',
155
155
  'рис.', 'табл.', 'им.', 'акад.', 'проф.', 'доц.', 'млн.', 'млрд.', 'тыс.',
156
- // Английские сокращения
156
+ // English abbreviations
157
157
  'e.g.', 'i.e.', 'vs.', 'etc.', 'dr.', 'mr.', 'mrs.', 'ms.', 'prof.',
158
158
  'approx.', 'dept.', 'est.', 'min.', 'max.', 'no.', 'vol.', 'p.m.', 'a.m.'
159
159
  ]
@@ -193,13 +193,13 @@ export function splitSentences(text, maxChars) {
193
193
  const source = protectAbbreviations(raw)
194
194
  const pieces = []
195
195
  let current = ''
196
- // Границей считаем точку, вопрос, восклицание, многоточие и перевод строки.
196
+ // Boundaries are period, question, exclamation, ellipsis, and newline.
197
197
  for (const part of source.split(/(?<=[.!?…])\s+|\n+/)) {
198
198
  const chunk = String(part || '').trim()
199
199
  if (!chunk) continue
200
200
  if (!current) { current = chunk } else if (current.length < min) { current += ' ' + chunk }
201
201
  else { pieces.push(current); current = chunk }
202
- // Кусок длиннее предела режем по словам: провайдеры не любят простыни.
202
+ // Oversized pieces are split on words: providers choke on walls of text.
203
203
  while (current.length > limit) {
204
204
  let cut = current.lastIndexOf(' ', limit)
205
205
  if (cut < min) cut = limit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.3.23",
3
+ "version": "0.4.0",
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",
@@ -13,9 +13,12 @@
13
13
  },
14
14
  "files": [
15
15
  "lib/",
16
+ "scripts/",
16
17
  "cordis.patch.yml",
17
18
  "README.md",
18
- "LICENSE"
19
+ "LICENSE",
20
+ "README.ru.md",
21
+ "README.zh.md"
19
22
  ],
20
23
  "keywords": [
21
24
  "dsh",