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