@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/breaker.js +64 -0
- package/lib/cache.js +1 -1
- package/lib/client-src/10-styles.js +8 -8
- package/lib/client-src/20-player.js +57 -19
- package/lib/client-src/30-locales.js +36 -0
- package/lib/client-src/40-editors.js +30 -50
- package/lib/client-src/50-settings.js +86 -29
- package/lib/client-src/60-card-dock.js +50 -44
- package/lib/client.js +267 -150
- package/lib/index.js +27 -4
- package/lib/local.js +5 -7
- package/lib/providers/constants.js +61 -0
- package/lib/providers.js +3 -61
- package/lib/routes.js +15 -8
- package/lib/stream-hub.js +61 -9
- package/lib/text.js +3 -3
- package/package.json +1 -1
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
|
|
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
|
-
*
|
|
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
|
|
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: '
|
|
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, {
|
|
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 =
|
|
15
|
-
for (const res of subscribers) {
|
|
16
|
-
|
|
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.
|
|
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",
|