@goodandready/dsh-tts 0.3.22 → 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/README.md +12 -12
- package/README.ru.md +13 -10
- package/README.zh.md +10 -10
- package/lib/cache.js +4 -5
- package/lib/client.js +105 -88
- package/lib/engines/f5.js +45 -33
- package/lib/engines/kokoro.js +21 -29
- package/lib/engines/types.js +26 -13
- package/lib/http-util.js +27 -0
- package/lib/index.js +70 -467
- package/lib/providers.js +105 -25
- package/lib/routes.js +400 -0
- package/lib/stream-hub.js +31 -0
- package/lib/text.js +46 -19
- package/package.json +6 -3
- package/scripts/f5_daemon.py +40 -0
- package/lib/client/worklet.js +0 -71
- package/lib/router.js +0 -4
package/lib/providers.js
CHANGED
|
@@ -9,14 +9,13 @@ export const PROVIDER_KEYS = [
|
|
|
9
9
|
'edge', 'piper', 'espeak', 'minimax',
|
|
10
10
|
]
|
|
11
11
|
|
|
12
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
46
|
+
// MiMo voices use Chinese names; 冰糖 is their default.
|
|
48
47
|
mimo: '冰糖',
|
|
49
48
|
minimax: '',
|
|
50
49
|
siliconflow: 'FunAudioLLM/CosyVoice2-0.5B:alex',
|
|
@@ -75,12 +74,32 @@ function asBuffer(body) {
|
|
|
75
74
|
|
|
76
75
|
export function makeProviders(deps, req) {
|
|
77
76
|
const { resolveKey, fetchImpl, cfg } = deps
|
|
78
|
-
const { text, lang, signal, models, voices } = req
|
|
77
|
+
const { text, lang, signal: parentSignal, models, voices } = req
|
|
78
|
+
const cloudTimeoutMs = Number(cfg.cloudTimeoutMs) > 0 ? Number(cfg.cloudTimeoutMs) : 10000
|
|
79
|
+
|
|
80
|
+
function withCloudTimeout() {
|
|
81
|
+
const controller = new AbortController()
|
|
82
|
+
let timedOut = false
|
|
83
|
+
const timer = setTimeout(() => {
|
|
84
|
+
timedOut = true
|
|
85
|
+
controller.abort(new Error(`Cloud TTS timeout after ${cloudTimeoutMs}ms`))
|
|
86
|
+
}, cloudTimeoutMs)
|
|
87
|
+
if (parentSignal) {
|
|
88
|
+
if (parentSignal.aborted) controller.abort(parentSignal.reason)
|
|
89
|
+
else parentSignal.addEventListener('abort', () => controller.abort(parentSignal.reason), { once: true })
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
signal: controller.signal,
|
|
93
|
+
cleanup: () => clearTimeout(timer),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
79
96
|
|
|
80
97
|
async function openai() {
|
|
81
98
|
const key = await resolveKey(cfg.openaiKeyEnv)
|
|
82
99
|
if (!key) return { ok: false, provider: 'openai', reason: `no ${cfg.openaiKeyEnv}` }
|
|
83
|
-
const
|
|
100
|
+
const { signal, cleanup } = withCloudTimeout()
|
|
101
|
+
try {
|
|
102
|
+
const res = await fetchImpl((cfg.openaiBaseUrl || 'https://api.openai.com/v1') + '/audio/speech', {
|
|
84
103
|
method: 'POST',
|
|
85
104
|
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
86
105
|
body: JSON.stringify({
|
|
@@ -94,10 +113,13 @@ export function makeProviders(deps, req) {
|
|
|
94
113
|
if (!res.ok) throw new Error(`OpenAI HTTP ${res.status}`)
|
|
95
114
|
const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
|
|
96
115
|
return { ok: audio.length > 0, provider: 'openai', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
|
|
116
|
+
} finally {
|
|
117
|
+
cleanup()
|
|
118
|
+
}
|
|
97
119
|
}
|
|
98
120
|
|
|
99
|
-
|
|
100
|
-
|
|
121
|
+
// One implementation for all OpenAI-compatible providers: base URL and key
|
|
122
|
+
// name come from the table; everything else is identical.
|
|
101
123
|
function openaiCompatible(key) {
|
|
102
124
|
const spec = OPENAI_COMPATIBLE[key]
|
|
103
125
|
return async function speak() {
|
|
@@ -106,7 +128,9 @@ export function makeProviders(deps, req) {
|
|
|
106
128
|
if (!token) return { ok: false, provider: key, reason: `no ${keyEnv}` }
|
|
107
129
|
const base = cfg[key + 'BaseUrl'] || spec.baseUrl
|
|
108
130
|
if (!base) return { ok: false, provider: key, reason: 'no base url configured' }
|
|
109
|
-
const
|
|
131
|
+
const { signal, cleanup } = withCloudTimeout()
|
|
132
|
+
try {
|
|
133
|
+
const res = await fetchImpl(base + '/audio/speech', {
|
|
110
134
|
method: 'POST',
|
|
111
135
|
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
112
136
|
body: JSON.stringify({
|
|
@@ -120,17 +144,22 @@ export function makeProviders(deps, req) {
|
|
|
120
144
|
if (!res.ok) throw new Error(`${key} HTTP ${res.status}`)
|
|
121
145
|
const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
|
|
122
146
|
return { ok: audio.length > 0, provider: key, audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
|
|
147
|
+
} finally {
|
|
148
|
+
cleanup()
|
|
149
|
+
}
|
|
123
150
|
}
|
|
124
151
|
}
|
|
125
152
|
|
|
126
|
-
|
|
127
|
-
|
|
153
|
+
// Xiaomi MiMo. Synthesis goes through chat: text as a message, voice/format
|
|
154
|
+
// as a separate field; audio returns base64 in the response.
|
|
128
155
|
async function mimo() {
|
|
129
156
|
const key = await resolveKey(cfg.mimoKeyEnv)
|
|
130
157
|
if (!key) return { ok: false, provider: 'mimo', reason: `no ${cfg.mimoKeyEnv}` }
|
|
131
158
|
const base = (cfg.mimoBaseUrl || 'https://api.xiaomimimo.com/v1').replace(/\/+$/, '')
|
|
132
159
|
const format = cfg.mimoFormat === 'wav' ? 'wav' : 'mp3'
|
|
133
|
-
const
|
|
160
|
+
const { signal, cleanup } = withCloudTimeout()
|
|
161
|
+
try {
|
|
162
|
+
const res = await fetchImpl(base + '/chat/completions', {
|
|
134
163
|
method: 'POST',
|
|
135
164
|
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json', accept: 'application/json' },
|
|
136
165
|
body: JSON.stringify({
|
|
@@ -160,9 +189,12 @@ export function makeProviders(deps, req) {
|
|
|
160
189
|
mime: format === 'wav' ? 'audio/wav' : 'audio/mpeg',
|
|
161
190
|
reason: audio.length ? '' : 'empty audio',
|
|
162
191
|
}
|
|
192
|
+
} finally {
|
|
193
|
+
cleanup()
|
|
194
|
+
}
|
|
163
195
|
}
|
|
164
196
|
|
|
165
|
-
// MiniMax
|
|
197
|
+
// MiniMax uses their CLI on purpose; no custom protocol here.
|
|
166
198
|
async function minimax() {
|
|
167
199
|
try {
|
|
168
200
|
const out = await minimaxSpeak(text, {
|
|
@@ -181,7 +213,9 @@ export function makeProviders(deps, req) {
|
|
|
181
213
|
const key = await resolveKey(cfg.elevenlabsKeyEnv)
|
|
182
214
|
if (!key) return { ok: false, provider: 'elevenlabs', reason: `no ${cfg.elevenlabsKeyEnv}` }
|
|
183
215
|
const voice = pick(DEFAULT_VOICES, voices, 'elevenlabs')
|
|
184
|
-
const
|
|
216
|
+
const { signal, cleanup } = withCloudTimeout()
|
|
217
|
+
try {
|
|
218
|
+
const res = await fetchImpl(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}`, {
|
|
185
219
|
method: 'POST',
|
|
186
220
|
headers: { 'xi-api-key': key, 'content-type': 'application/json', accept: 'audio/mpeg' },
|
|
187
221
|
body: JSON.stringify({
|
|
@@ -193,6 +227,9 @@ export function makeProviders(deps, req) {
|
|
|
193
227
|
if (!res.ok) throw new Error(`ElevenLabs HTTP ${res.status}`)
|
|
194
228
|
const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
|
|
195
229
|
return { ok: audio.length > 0, provider: 'elevenlabs', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
|
|
230
|
+
} finally {
|
|
231
|
+
cleanup()
|
|
232
|
+
}
|
|
196
233
|
}
|
|
197
234
|
|
|
198
235
|
async function google() {
|
|
@@ -200,7 +237,9 @@ export function makeProviders(deps, req) {
|
|
|
200
237
|
if (!key) return { ok: false, provider: 'google', reason: `no ${cfg.googleKeyEnv}` }
|
|
201
238
|
const model = pick(DEFAULT_MODELS, models, 'google')
|
|
202
239
|
const voiceName = pick(DEFAULT_VOICES, voices, 'google')
|
|
203
|
-
const
|
|
240
|
+
const { signal, cleanup } = withCloudTimeout()
|
|
241
|
+
try {
|
|
242
|
+
const res = await fetchImpl(
|
|
204
243
|
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(key)}`,
|
|
205
244
|
{
|
|
206
245
|
method: 'POST',
|
|
@@ -221,6 +260,9 @@ export function makeProviders(deps, req) {
|
|
|
221
260
|
if (!b64) return { ok: false, provider: 'google', reason: 'no inline audio' }
|
|
222
261
|
const audio = Buffer.from(b64, 'base64')
|
|
223
262
|
return { ok: audio.length > 0, provider: 'google', audio, mime: 'audio/wav', reason: audio.length ? '' : 'empty audio' }
|
|
263
|
+
} finally {
|
|
264
|
+
cleanup()
|
|
265
|
+
}
|
|
224
266
|
}
|
|
225
267
|
|
|
226
268
|
async function azure() {
|
|
@@ -238,7 +280,9 @@ export function makeProviders(deps, req) {
|
|
|
238
280
|
const ssml = `<speak version="1.0" xml:lang="${lang || 'en-US'}">`
|
|
239
281
|
+ `<voice xml:lang="${lang || 'en-US'}" name="${voice}">`
|
|
240
282
|
+ `${inner}</voice></speak>`
|
|
241
|
-
const
|
|
283
|
+
const { signal, cleanup } = withCloudTimeout()
|
|
284
|
+
try {
|
|
285
|
+
const res = await fetchImpl(`https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`, {
|
|
242
286
|
method: 'POST',
|
|
243
287
|
headers: {
|
|
244
288
|
'Ocp-Apim-Subscription-Key': key,
|
|
@@ -251,12 +295,17 @@ export function makeProviders(deps, req) {
|
|
|
251
295
|
if (!res.ok) throw new Error(`Azure HTTP ${res.status}`)
|
|
252
296
|
const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
|
|
253
297
|
return { ok: audio.length > 0, provider: 'azure', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
|
|
298
|
+
} finally {
|
|
299
|
+
cleanup()
|
|
300
|
+
}
|
|
254
301
|
}
|
|
255
302
|
|
|
256
303
|
async function groq() {
|
|
257
304
|
const key = await resolveKey(cfg.groqKeyEnv)
|
|
258
305
|
if (!key) return { ok: false, provider: 'groq', reason: `no ${cfg.groqKeyEnv}` }
|
|
259
|
-
const
|
|
306
|
+
const { signal, cleanup } = withCloudTimeout()
|
|
307
|
+
try {
|
|
308
|
+
const res = await fetchImpl('https://api.groq.com/openai/v1/audio/speech', {
|
|
260
309
|
method: 'POST',
|
|
261
310
|
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
262
311
|
body: JSON.stringify({
|
|
@@ -270,13 +319,18 @@ export function makeProviders(deps, req) {
|
|
|
270
319
|
if (!res.ok) throw new Error(`Groq HTTP ${res.status}`)
|
|
271
320
|
const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
|
|
272
321
|
return { ok: audio.length > 0, provider: 'groq', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
|
|
322
|
+
} finally {
|
|
323
|
+
cleanup()
|
|
324
|
+
}
|
|
273
325
|
}
|
|
274
326
|
|
|
275
327
|
async function deepgram() {
|
|
276
328
|
const key = await resolveKey(cfg.deepgramKeyEnv)
|
|
277
329
|
if (!key) return { ok: false, provider: 'deepgram', reason: `no ${cfg.deepgramKeyEnv}` }
|
|
278
330
|
const model = pick(DEFAULT_MODELS, models, 'deepgram')
|
|
279
|
-
const
|
|
331
|
+
const { signal, cleanup } = withCloudTimeout()
|
|
332
|
+
try {
|
|
333
|
+
const res = await fetchImpl(`https://api.deepgram.com/v1/speak?model=${encodeURIComponent(model)}`, {
|
|
280
334
|
method: 'POST',
|
|
281
335
|
headers: { authorization: `Token ${key}`, 'content-type': 'application/json' },
|
|
282
336
|
body: JSON.stringify({ text }),
|
|
@@ -285,12 +339,17 @@ export function makeProviders(deps, req) {
|
|
|
285
339
|
if (!res.ok) throw new Error(`Deepgram HTTP ${res.status}`)
|
|
286
340
|
const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
|
|
287
341
|
return { ok: audio.length > 0, provider: 'deepgram', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
|
|
342
|
+
} finally {
|
|
343
|
+
cleanup()
|
|
344
|
+
}
|
|
288
345
|
}
|
|
289
346
|
|
|
290
347
|
async function openrouter() {
|
|
291
348
|
const key = await resolveKey(cfg.openrouterKeyEnv)
|
|
292
349
|
if (!key) return { ok: false, provider: 'openrouter', reason: `no ${cfg.openrouterKeyEnv}` }
|
|
293
|
-
const
|
|
350
|
+
const { signal, cleanup } = withCloudTimeout()
|
|
351
|
+
try {
|
|
352
|
+
const res = await fetchImpl('https://openrouter.ai/api/v1/audio/speech', {
|
|
294
353
|
method: 'POST',
|
|
295
354
|
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
296
355
|
body: JSON.stringify({
|
|
@@ -304,6 +363,9 @@ export function makeProviders(deps, req) {
|
|
|
304
363
|
if (!res.ok) throw new Error(`OpenRouter HTTP ${res.status}`)
|
|
305
364
|
const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
|
|
306
365
|
return { ok: audio.length > 0, provider: 'openrouter', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
|
|
366
|
+
} finally {
|
|
367
|
+
cleanup()
|
|
368
|
+
}
|
|
307
369
|
}
|
|
308
370
|
|
|
309
371
|
async function edge() {
|
|
@@ -340,6 +402,16 @@ export function makeProviders(deps, req) {
|
|
|
340
402
|
try {
|
|
341
403
|
const modelPath = deps.modelManager ? deps.modelManager.getModelPath('kokoro') : (cfg.kokoroModelPath || '')
|
|
342
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
|
+
}
|
|
343
415
|
const voice = pick(DEFAULT_VOICES, voices, 'kokoro') || 'af_bella'
|
|
344
416
|
const audio = await engine.synthesizeWav(text, voice)
|
|
345
417
|
return { ok: audio && audio.length > 0, provider: 'kokoro', audio, mime: 'audio/wav' }
|
|
@@ -350,13 +422,21 @@ export function makeProviders(deps, req) {
|
|
|
350
422
|
|
|
351
423
|
async function f5() {
|
|
352
424
|
try {
|
|
353
|
-
const modelPath = deps.modelManager ? deps.modelManager.getModelPath('f5') :
|
|
425
|
+
const modelPath = deps.modelManager ? deps.modelManager.getModelPath('f5') : ''
|
|
354
426
|
const engine = new F5Engine({ modelPath })
|
|
355
427
|
const ok = await engine.ping()
|
|
356
|
-
if (!ok)
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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' }
|
|
360
440
|
} catch (e) {
|
|
361
441
|
return { ok: false, provider: 'f5', reason: String(e && e.message || e) }
|
|
362
442
|
}
|