@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/lib/providers.js CHANGED
@@ -9,14 +9,13 @@ export const PROVIDER_KEYS = [
9
9
  'edge', 'piper', 'espeak', 'minimax',
10
10
  ]
11
11
 
12
- // Провайдеры, говорящие на языке OpenAI: тот же путь /audio/speech, отличаются
13
- // только адресом, моделью и именем ключа. Каждый адрес проверен запросом без
14
- // ключа — все отвечают 401, то есть путь существует и ждёт ключ.
12
+ // OpenAI-compatible providers share /audio/speech; they differ by base URL,
13
+ // model, and key name. Each URL was probed without a key and returned 401.
15
14
  export const OPENAI_COMPATIBLE = {
16
15
  siliconflow: { baseUrl: 'https://api.siliconflow.cn/v1', keyEnv: 'SILICONFLOW_API_KEY' },
17
16
  deepinfra: { baseUrl: 'https://api.deepinfra.com/v1/openai', keyEnv: 'DEEPINFRA_API_KEY' },
18
17
  fireworks: { baseUrl: 'https://api.fireworks.ai/inference/v1', keyEnv: 'FIREWORKS_API_KEY' },
19
- // Пользовательский endpoint: адрес и имя ключа приходят из настроек.
18
+ // Custom endpoint: base URL and key name come from settings.
20
19
  custom: { baseUrl: '', keyEnv: 'CUSTOM_TTS_API_KEY' },
21
20
  }
22
21
 
@@ -44,7 +43,7 @@ export const DEFAULT_MODELS = {
44
43
  export const DEFAULT_VOICES = {
45
44
  kokoro: 'af_bella',
46
45
  f5: 'default',
47
- // Голоса у MiMo названы по-китайски; «冰糖» — их же значение по умолчанию.
46
+ // MiMo voices use Chinese names; 冰糖 is their default.
48
47
  mimo: '冰糖',
49
48
  minimax: '',
50
49
  siliconflow: 'FunAudioLLM/CosyVoice2-0.5B:alex',
@@ -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 res = await fetchImpl((cfg.openaiBaseUrl || 'https://api.openai.com/v1') + '/audio/speech', {
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
- // Одна реализация на всех, кто говорит на языке OpenAI: адрес и имя ключа
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 res = await fetchImpl(base + '/audio/speech', {
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
- // Xiaomi MiMo. Синтез идёт через чат: текст кладётся сообщением, а голос и
127
- // формат — отдельным полем; готовый звук приезжает в ответе base64.
153
+ // Xiaomi MiMo. Synthesis goes through chat: text as a message, voice/format
154
+ // as a separate field; audio returns base64 in the response.
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 res = await fetchImpl(base + '/chat/completions', {
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 res = await fetchImpl(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}`, {
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 res = await fetchImpl(
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 res = await fetchImpl(`https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`, {
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 res = await fetchImpl('https://api.groq.com/openai/v1/audio/speech', {
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 res = await fetchImpl(`https://api.deepgram.com/v1/speak?model=${encodeURIComponent(model)}`, {
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 res = await fetchImpl('https://openrouter.ai/api/v1/audio/speech', {
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') : (cfg.f5ModelPath || '')
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) return { ok: false, provider: 'f5', reason: 'F5 daemon unavailable' }
357
- const kokoroFallback = new KokoroEngine({ modelPath })
358
- const audio = await kokoroFallback.synthesizeWav(text, 'af_bella')
359
- return { ok: true, provider: 'f5', audio, mime: 'audio/wav' }
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
  }