@goodandready/dsh-tts 0.4.0 → 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/README.md +10 -0
- package/README.ru.md +10 -0
- package/README.zh.md +8 -0
- package/lib/breaker.js +64 -0
- package/lib/cache.js +1 -1
- package/lib/client-src/00-open.js +85 -0
- package/lib/client-src/10-styles.js +54 -0
- package/lib/client-src/20-player.js +328 -0
- package/lib/client-src/30-locales.js +361 -0
- package/lib/client-src/40-editors.js +390 -0
- package/lib/client-src/50-settings.js +398 -0
- package/lib/client-src/60-card-dock.js +235 -0
- package/lib/client.js +896 -622
- package/lib/index.js +29 -4
- package/lib/local.js +5 -7
- package/lib/providers/constants.js +61 -0
- package/lib/providers.js +3 -61
- package/lib/routes.js +19 -9
- package/lib/stream-hub.js +61 -9
- package/lib/text.js +3 -3
- package/package.json +4 -2
- package/scripts/build-client.mjs +19 -0
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,
|
|
@@ -560,6 +583,8 @@ export function apply(ctx, config) {
|
|
|
560
583
|
storeProviderKey,
|
|
561
584
|
clearProviderKey,
|
|
562
585
|
pending,
|
|
586
|
+
getSettingsApi: () => settingsApi,
|
|
587
|
+
validateConfig: (obj) => Config(obj),
|
|
563
588
|
})
|
|
564
589
|
|
|
565
590
|
ctx.effect(() => () => {
|
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
|
|
|
@@ -21,6 +21,9 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
21
21
|
storeProviderKey,
|
|
22
22
|
clearProviderKey,
|
|
23
23
|
pending,
|
|
24
|
+
getSettingsApi,
|
|
25
|
+
validateConfig,
|
|
26
|
+
breakerSnapshot,
|
|
24
27
|
} = api
|
|
25
28
|
ctx.effect(() => ctx.webServer.register({
|
|
26
29
|
kind: 'exact',
|
|
@@ -42,6 +45,8 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
42
45
|
chain: cfg.chain,
|
|
43
46
|
roles: cfg.roles,
|
|
44
47
|
providers: PROVIDER_KEYS,
|
|
48
|
+
sseClients: streamSubscriberCount(),
|
|
49
|
+
breaker: typeof breakerSnapshot === 'function' ? breakerSnapshot() : [],
|
|
45
50
|
})
|
|
46
51
|
},
|
|
47
52
|
}), 'dsh-tts: /status route')
|
|
@@ -107,7 +112,11 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
107
112
|
// Fire installation in background or await initial start
|
|
108
113
|
modelManager.installModel(engine).catch(() => {})
|
|
109
114
|
const current = await modelManager.getStatus(engine)
|
|
110
|
-
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
|
+
})
|
|
111
120
|
} catch (err) {
|
|
112
121
|
writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
|
|
113
122
|
}
|
|
@@ -130,7 +139,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
130
139
|
try {
|
|
131
140
|
const raw = await readBody(req, 16 * 1024)
|
|
132
141
|
body = JSON.parse(raw.toString('utf8') || '{}')
|
|
133
|
-
} catch { body = {} }
|
|
142
|
+
} catch { /* missing body treated as empty */ body = {} }
|
|
134
143
|
const engine = String(body.engine || '').trim()
|
|
135
144
|
if (!engine) {
|
|
136
145
|
writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
|
|
@@ -158,7 +167,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
158
167
|
voiceInstalled = ctx.webServer.hasRoute('/dsh-voice/status')
|
|
159
168
|
messengerInstalled = ctx.webServer.hasRoute('/dsh-messenger-gateway/status')
|
|
160
169
|
}
|
|
161
|
-
} catch {}
|
|
170
|
+
} catch { /* ignore malformed client frame */ }
|
|
162
171
|
|
|
163
172
|
// Runtime routes are the only reliable signal. Do not probe ../ siblings
|
|
164
173
|
// or node_modules layouts — they lie outside a normal npm install.
|
|
@@ -235,7 +244,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
235
244
|
path: '/dsh-tts/stats',
|
|
236
245
|
handler: async (req, res) => {
|
|
237
246
|
if (req.method === 'GET') {
|
|
238
|
-
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() : [] })
|
|
239
248
|
return
|
|
240
249
|
}
|
|
241
250
|
if (req.method !== 'DELETE') {
|
|
@@ -270,6 +279,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
270
279
|
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-tts: settings writes are same-origin only' } })
|
|
271
280
|
return
|
|
272
281
|
}
|
|
282
|
+
const settingsApi = typeof getSettingsApi === 'function' ? getSettingsApi() : null
|
|
273
283
|
if (!settingsApi) {
|
|
274
284
|
writeJson(res, 503, { ok: false, error: { code: 'settings', message: 'settings not ready' } })
|
|
275
285
|
return
|
|
@@ -280,7 +290,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
280
290
|
return
|
|
281
291
|
}
|
|
282
292
|
let payload
|
|
283
|
-
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch {
|
|
293
|
+
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* invalid JSON treated as empty */
|
|
284
294
|
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
285
295
|
return
|
|
286
296
|
}
|
|
@@ -290,7 +300,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
290
300
|
const keys = pendingKeyWrites(payload && payload.keys)
|
|
291
301
|
const stripped = stripSecretsFromConfig(payload)
|
|
292
302
|
let parsed
|
|
293
|
-
try { parsed =
|
|
303
|
+
try { parsed = validateConfig(stripped) } catch (e) {
|
|
294
304
|
writeJson(res, 400, { ok: false, error: { code: 'schema', message: String(e && e.message || e) } })
|
|
295
305
|
return
|
|
296
306
|
}
|
|
@@ -325,7 +335,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
325
335
|
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
326
336
|
return
|
|
327
337
|
}
|
|
328
|
-
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch {
|
|
338
|
+
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* invalid JSON treated as empty */
|
|
329
339
|
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
330
340
|
return
|
|
331
341
|
}
|
|
@@ -375,7 +385,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
375
385
|
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
|
|
376
386
|
}
|
|
377
387
|
let payload
|
|
378
|
-
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 = {} }
|
|
379
389
|
const text = cleanText(payload.text, live())
|
|
380
390
|
if (!text) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
|
|
381
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",
|
|
@@ -41,7 +41,9 @@
|
|
|
41
41
|
"url": "https://github.com/GooDAnDReaDY/dsh-tts/issues"
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
|
-
"test": "node --test test/*.test.mjs"
|
|
44
|
+
"test": "node --test test/*.test.mjs",
|
|
45
|
+
"build:client": "node scripts/build-client.mjs",
|
|
46
|
+
"pretest": "node scripts/build-client.mjs"
|
|
45
47
|
},
|
|
46
48
|
"dsh": {
|
|
47
49
|
"bundle": {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Concatenate lib/client-src fragments into lib/client.js (single ModuleLoader entry).
|
|
3
|
+
import { readdir, readFile, writeFile } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
|
|
8
|
+
const srcDir = path.join(root, 'lib', 'client-src')
|
|
9
|
+
const outFile = path.join(root, 'lib', 'client.js')
|
|
10
|
+
|
|
11
|
+
const files = (await readdir(srcDir)).filter((f) => f.endsWith('.js')).sort()
|
|
12
|
+
if (files.length === 0) throw new Error('no client-src fragments')
|
|
13
|
+
let out = ''
|
|
14
|
+
for (const f of files) {
|
|
15
|
+
out += await readFile(path.join(srcDir, f), 'utf8')
|
|
16
|
+
if (!out.endsWith('\n')) out += '\n'
|
|
17
|
+
}
|
|
18
|
+
await writeFile(outFile, out, 'utf8')
|
|
19
|
+
console.log(`built lib/client.js from ${files.length} fragments (${out.length} bytes)`)
|