@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/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 providers = makeProviders(
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
- * Прямого HTTP тут намеренно нет: у MiniMax свой протокол с отдельным
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,278 @@
1
+ import { DEFAULT_MODELS, DEFAULT_VOICES, OPENAI_COMPATIBLE } from "./constants.js"
2
+
3
+ function pick(map, models, key) {
4
+ const chosen = models && typeof models[key] === "string" ? models[key].trim() : ""
5
+ return chosen || map[key]
6
+ }
7
+
8
+ function asBuffer(body) {
9
+ if (Buffer.isBuffer(body)) return body
10
+ return Buffer.from(body)
11
+ }
12
+
13
+ function escapeXml(s) {
14
+ return String(s)
15
+ .replace(/&/g, '&')
16
+ .replace(/</g, '&lt;')
17
+ .replace(/>/g, '&gt;')
18
+ .replace(/"/g, '&quot;')
19
+ }
20
+
21
+ export function makeCloudProviders(deps, req, withCloudTimeout) {
22
+ const { resolveKey, fetchImpl, cfg } = deps
23
+ const { text, lang, models, voices, role } = req
24
+
25
+ async function openai() {
26
+ const key = await resolveKey(cfg.openaiKeyEnv)
27
+ if (!key) return { ok: false, provider: 'openai', reason: `no ${cfg.openaiKeyEnv}` }
28
+ const { signal, cleanup } = withCloudTimeout()
29
+ try {
30
+ const res = await fetchImpl((cfg.openaiBaseUrl || 'https://api.openai.com/v1') + '/audio/speech', {
31
+ method: 'POST',
32
+ headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
33
+ body: JSON.stringify({
34
+ model: pick(DEFAULT_MODELS, models, 'openai'),
35
+ input: text,
36
+ voice: pick(DEFAULT_VOICES, voices, 'openai'),
37
+ response_format: 'mp3',
38
+ }),
39
+ signal,
40
+ })
41
+ if (!res.ok) throw new Error(`OpenAI HTTP ${res.status}`)
42
+ const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
43
+ return { ok: audio.length > 0, provider: 'openai', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
44
+ } finally {
45
+ cleanup()
46
+ }
47
+ }
48
+ function openaiCompatible(key) {
49
+ const spec = OPENAI_COMPATIBLE[key]
50
+ return async function speak() {
51
+ const keyEnv = (cfg[key + 'KeyEnv'] || spec.keyEnv)
52
+ const token = await resolveKey(keyEnv)
53
+ if (!token) return { ok: false, provider: key, reason: `no ${keyEnv}` }
54
+ const base = cfg[key + 'BaseUrl'] || spec.baseUrl
55
+ if (!base) return { ok: false, provider: key, reason: 'no base url configured' }
56
+ const { signal, cleanup } = withCloudTimeout()
57
+ try {
58
+ const res = await fetchImpl(base + '/audio/speech', {
59
+ method: 'POST',
60
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
61
+ body: JSON.stringify({
62
+ model: pick(DEFAULT_MODELS, models, key),
63
+ input: text,
64
+ voice: pick(DEFAULT_VOICES, voices, key),
65
+ response_format: 'mp3',
66
+ }),
67
+ signal,
68
+ })
69
+ if (!res.ok) throw new Error(`${key} HTTP ${res.status}`)
70
+ const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
71
+ return { ok: audio.length > 0, provider: key, audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
72
+ } finally {
73
+ cleanup()
74
+ }
75
+ }
76
+ }
77
+ async function mimo() {
78
+ const key = await resolveKey(cfg.mimoKeyEnv)
79
+ if (!key) return { ok: false, provider: 'mimo', reason: `no ${cfg.mimoKeyEnv}` }
80
+ const base = (cfg.mimoBaseUrl || 'https://api.xiaomimimo.com/v1').replace(/\/+$/, '')
81
+ const format = cfg.mimoFormat === 'wav' ? 'wav' : 'mp3'
82
+ const { signal, cleanup } = withCloudTimeout()
83
+ try {
84
+ const res = await fetchImpl(base + '/chat/completions', {
85
+ method: 'POST',
86
+ headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json', accept: 'application/json' },
87
+ body: JSON.stringify({
88
+ model: pick(DEFAULT_MODELS, models, 'mimo'),
89
+ messages: [{ role: 'assistant', content: text }],
90
+ audio: { format, voice: pick(DEFAULT_VOICES, voices, 'mimo') },
91
+ stream: false,
92
+ }),
93
+ signal,
94
+ })
95
+ const data = await res.json().catch(() => ({}))
96
+ if (!res.ok) {
97
+ const detail = data && data.error && data.error.message
98
+ throw new Error(`MiMo HTTP ${res.status}${detail ? ': ' + detail : ''}`)
99
+ }
100
+ const encoded = data && data.choices && data.choices[0]
101
+ && data.choices[0].message && data.choices[0].message.audio
102
+ && data.choices[0].message.audio.data
103
+ if (typeof encoded !== 'string' || !encoded) {
104
+ return { ok: false, provider: 'mimo', reason: 'response missing choices[0].message.audio.data' }
105
+ }
106
+ const audio = Buffer.from(encoded, 'base64')
107
+ return {
108
+ ok: audio.length > 0,
109
+ provider: 'mimo',
110
+ audio,
111
+ mime: format === 'wav' ? 'audio/wav' : 'audio/mpeg',
112
+ reason: audio.length ? '' : 'empty audio',
113
+ }
114
+ } finally {
115
+ cleanup()
116
+ }
117
+ }
118
+ async function elevenlabs() {
119
+ const key = await resolveKey(cfg.elevenlabsKeyEnv)
120
+ if (!key) return { ok: false, provider: 'elevenlabs', reason: `no ${cfg.elevenlabsKeyEnv}` }
121
+ const voice = pick(DEFAULT_VOICES, voices, 'elevenlabs')
122
+ const { signal, cleanup } = withCloudTimeout()
123
+ try {
124
+ const res = await fetchImpl(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}`, {
125
+ method: 'POST',
126
+ headers: { 'xi-api-key': key, 'content-type': 'application/json', accept: 'audio/mpeg' },
127
+ body: JSON.stringify({
128
+ text,
129
+ model_id: pick(DEFAULT_MODELS, models, 'elevenlabs'),
130
+ }),
131
+ signal,
132
+ })
133
+ if (!res.ok) throw new Error(`ElevenLabs HTTP ${res.status}`)
134
+ const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
135
+ return { ok: audio.length > 0, provider: 'elevenlabs', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
136
+ } finally {
137
+ cleanup()
138
+ }
139
+ }
140
+ async function google() {
141
+ const key = await resolveKey(cfg.googleKeyEnv)
142
+ if (!key) return { ok: false, provider: 'google', reason: `no ${cfg.googleKeyEnv}` }
143
+ const model = pick(DEFAULT_MODELS, models, 'google')
144
+ const voiceName = pick(DEFAULT_VOICES, voices, 'google')
145
+ const { signal, cleanup } = withCloudTimeout()
146
+ try {
147
+ const res = await fetchImpl(
148
+ `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(key)}`,
149
+ {
150
+ method: 'POST',
151
+ headers: { 'content-type': 'application/json' },
152
+ body: JSON.stringify({
153
+ contents: [{ parts: [{ text }] }],
154
+ generationConfig: {
155
+ responseModalities: ['AUDIO'],
156
+ speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName } } },
157
+ },
158
+ }),
159
+ signal,
160
+ },
161
+ )
162
+ if (!res.ok) throw new Error(`Google HTTP ${res.status}`)
163
+ const data = await res.json()
164
+ const b64 = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData)?.inlineData?.data
165
+ if (!b64) return { ok: false, provider: 'google', reason: 'no inline audio' }
166
+ const audio = Buffer.from(b64, 'base64')
167
+ return { ok: audio.length > 0, provider: 'google', audio, mime: 'audio/wav', reason: audio.length ? '' : 'empty audio' }
168
+ } finally {
169
+ cleanup()
170
+ }
171
+ }
172
+ async function azure() {
173
+ const key = await resolveKey(cfg.azureKeyEnv)
174
+ if (!key) return { ok: false, provider: 'azure', reason: `no ${cfg.azureKeyEnv}` }
175
+ const region = (cfg.azureRegion || '').trim()
176
+ if (!region) return { ok: false, provider: 'azure', reason: 'azureRegion is empty' }
177
+ const voice = pick(DEFAULT_VOICES, voices, 'azure')
178
+ const roleCfg = req.role && cfg.roles && cfg.roles[req.role]
179
+ const style = roleCfg && roleCfg.ssmlStyle
180
+ const safeStyle = style ? String(style).replace(/["<>&]/g, '') : ''
181
+ const inner = safeStyle
182
+ ? `<mstts:express-as style="${safeStyle}">${escapeXml(text)}</mstts:express-as>`
183
+ : escapeXml(text)
184
+ const ssml = `<speak version="1.0" xml:lang="${lang || 'en-US'}">`
185
+ + `<voice xml:lang="${lang || 'en-US'}" name="${voice}">`
186
+ + `${inner}</voice></speak>`
187
+ const { signal, cleanup } = withCloudTimeout()
188
+ try {
189
+ const res = await fetchImpl(`https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`, {
190
+ method: 'POST',
191
+ headers: {
192
+ 'Ocp-Apim-Subscription-Key': key,
193
+ 'Content-Type': 'application/ssml+xml',
194
+ 'X-Microsoft-OutputFormat': 'audio-16khz-128kbitrate-mono-mp3',
195
+ },
196
+ body: ssml,
197
+ signal,
198
+ })
199
+ if (!res.ok) throw new Error(`Azure HTTP ${res.status}`)
200
+ const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
201
+ return { ok: audio.length > 0, provider: 'azure', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
202
+ } finally {
203
+ cleanup()
204
+ }
205
+ }
206
+ async function groq() {
207
+ const key = await resolveKey(cfg.groqKeyEnv)
208
+ if (!key) return { ok: false, provider: 'groq', reason: `no ${cfg.groqKeyEnv}` }
209
+ const { signal, cleanup } = withCloudTimeout()
210
+ try {
211
+ const res = await fetchImpl('https://api.groq.com/openai/v1/audio/speech', {
212
+ method: 'POST',
213
+ headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
214
+ body: JSON.stringify({
215
+ model: pick(DEFAULT_MODELS, models, 'groq'),
216
+ input: text,
217
+ voice: pick(DEFAULT_VOICES, voices, 'groq'),
218
+ response_format: 'mp3',
219
+ }),
220
+ signal,
221
+ })
222
+ if (!res.ok) throw new Error(`Groq HTTP ${res.status}`)
223
+ const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
224
+ return { ok: audio.length > 0, provider: 'groq', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
225
+ } finally {
226
+ cleanup()
227
+ }
228
+ }
229
+ async function deepgram() {
230
+ const key = await resolveKey(cfg.deepgramKeyEnv)
231
+ if (!key) return { ok: false, provider: 'deepgram', reason: `no ${cfg.deepgramKeyEnv}` }
232
+ const model = pick(DEFAULT_MODELS, models, 'deepgram')
233
+ const { signal, cleanup } = withCloudTimeout()
234
+ try {
235
+ const res = await fetchImpl(`https://api.deepgram.com/v1/speak?model=${encodeURIComponent(model)}`, {
236
+ method: 'POST',
237
+ headers: { authorization: `Token ${key}`, 'content-type': 'application/json' },
238
+ body: JSON.stringify({ text }),
239
+ signal,
240
+ })
241
+ if (!res.ok) throw new Error(`Deepgram HTTP ${res.status}`)
242
+ const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
243
+ return { ok: audio.length > 0, provider: 'deepgram', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
244
+ } finally {
245
+ cleanup()
246
+ }
247
+ }
248
+ async function openrouter() {
249
+ const key = await resolveKey(cfg.openrouterKeyEnv)
250
+ if (!key) return { ok: false, provider: 'openrouter', reason: `no ${cfg.openrouterKeyEnv}` }
251
+ const { signal, cleanup } = withCloudTimeout()
252
+ try {
253
+ const res = await fetchImpl('https://openrouter.ai/api/v1/audio/speech', {
254
+ method: 'POST',
255
+ headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
256
+ body: JSON.stringify({
257
+ model: pick(DEFAULT_MODELS, models, 'openrouter'),
258
+ input: text,
259
+ voice: pick(DEFAULT_VOICES, voices, 'openrouter'),
260
+ response_format: 'mp3',
261
+ }),
262
+ signal,
263
+ })
264
+ if (!res.ok) throw new Error(`OpenRouter HTTP ${res.status}`)
265
+ const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
266
+ return { ok: audio.length > 0, provider: 'openrouter', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
267
+ } finally {
268
+ cleanup()
269
+ }
270
+ }
271
+
272
+ const compatible = {}
273
+ for (const key of Object.keys(OPENAI_COMPATIBLE)) {
274
+ compatible[key] = openaiCompatible(key)
275
+ }
276
+
277
+ return { openai, elevenlabs, google, azure, groq, deepgram, openrouter, mimo, ...compatible }
278
+ }
@@ -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
+
@@ -0,0 +1,99 @@
1
+ import { edgeSpeak, espeakSpeak, minimaxSpeak, piperSpeak } from "../local.js"
2
+ import { KokoroEngine } from "../engines/kokoro.js"
3
+ import { F5Engine } from "../engines/f5.js"
4
+ import { DEFAULT_MODELS, DEFAULT_VOICES } from "./constants.js"
5
+
6
+ function pick(map, models, key) {
7
+ const chosen = models && typeof models[key] === "string" ? models[key].trim() : ""
8
+ return chosen || map[key]
9
+ }
10
+
11
+ export function makeLocalProviders(deps, req) {
12
+ const { cfg, modelManager } = deps
13
+ const { text, lang, models, voices } = req
14
+
15
+ async function edge() {
16
+ try {
17
+ const voice = pick(DEFAULT_VOICES, voices, 'edge') || pick(DEFAULT_MODELS, models, 'edge')
18
+ const out = await edgeSpeak(text, { bin: cfg.edgeBin, voice, timeoutMs: cfg.timeoutMs })
19
+ return { ok: true, provider: 'edge', audio: out.audio, mime: out.mime }
20
+ } catch (e) {
21
+ return { ok: false, provider: 'edge', reason: String(e && e.message || e) }
22
+ }
23
+ }
24
+ async function piper() {
25
+ try {
26
+ const model = pick(DEFAULT_MODELS, models, 'piper') || cfg.piperModel
27
+ const out = await piperSpeak(text, { bin: cfg.piperBin, model, timeoutMs: cfg.timeoutMs })
28
+ return { ok: true, provider: 'piper', audio: out.audio, mime: out.mime }
29
+ } catch (e) {
30
+ return { ok: false, provider: 'piper', reason: String(e && e.message || e) }
31
+ }
32
+ }
33
+ async function espeak() {
34
+ try {
35
+ const voice = pick(DEFAULT_VOICES, voices, 'espeak') || lang || 'ru'
36
+ const out = await espeakSpeak(text, { bin: cfg.espeakBin, voice, timeoutMs: cfg.timeoutMs })
37
+ return { ok: true, provider: 'espeak', audio: out.audio, mime: out.mime }
38
+ } catch (e) {
39
+ return { ok: false, provider: 'espeak', reason: String(e && e.message || e) }
40
+ }
41
+ }
42
+ async function kokoro() {
43
+ try {
44
+ const modelPath = deps.modelManager ? deps.modelManager.getModelPath('kokoro') : (cfg.kokoroModelPath || '')
45
+ const engine = new KokoroEngine({ modelPath })
46
+ if (!engine.isInstalled()) {
47
+ return { ok: false, provider: 'kokoro', reason: 'Kokoro ONNX model not installed. Use Edge, Piper, or eSpeak for offline TTS.' }
48
+ }
49
+ if (!engine.isRuntimeAvailable()) {
50
+ return {
51
+ ok: false,
52
+ provider: 'kokoro',
53
+ reason: 'Kokoro ONNX runtime is not bundled. Weights alone do not produce speech. Use Edge, Piper, or eSpeak for offline TTS.',
54
+ }
55
+ }
56
+ const voice = pick(DEFAULT_VOICES, voices, 'kokoro') || 'af_bella'
57
+ const audio = await engine.synthesizeWav(text, voice)
58
+ return { ok: audio && audio.length > 0, provider: 'kokoro', audio, mime: 'audio/wav' }
59
+ } catch (e) {
60
+ return { ok: false, provider: 'kokoro', reason: String(e && e.message || e) }
61
+ }
62
+ }
63
+ async function f5() {
64
+ try {
65
+ const modelPath = deps.modelManager ? deps.modelManager.getModelPath('f5') : ''
66
+ const engine = new F5Engine({ modelPath })
67
+ const ok = await engine.ping()
68
+ if (!ok) {
69
+ return { ok: false, provider: 'f5', reason: 'F5 daemon unavailable. Use Edge, Piper, or eSpeak for offline TTS.' }
70
+ }
71
+ if (!engine.isRuntimeAvailable()) {
72
+ return {
73
+ ok: false,
74
+ provider: 'f5',
75
+ reason: 'F5-TTS synthesis is not bundled. Use Edge, Piper, or eSpeak for offline TTS.',
76
+ }
77
+ }
78
+ const audio = await engine.synthesize(text)
79
+ return { ok: !!(audio && audio.length), provider: 'f5', audio, mime: 'audio/wav' }
80
+ } catch (e) {
81
+ return { ok: false, provider: 'f5', reason: String(e && e.message || e) }
82
+ }
83
+ }
84
+ async function minimax() {
85
+ try {
86
+ const out = await minimaxSpeak(text, {
87
+ bin: cfg.minimaxBin,
88
+ voice: pick(DEFAULT_VOICES, voices, 'minimax'),
89
+ model: pick(DEFAULT_MODELS, models, 'minimax'),
90
+ timeoutMs: cfg.timeoutMs,
91
+ })
92
+ return { ok: true, provider: 'minimax', audio: out.audio, mime: out.mime }
93
+ } catch (e) {
94
+ return { ok: false, provider: 'minimax', reason: String(e && e.message || e) }
95
+ }
96
+ }
97
+
98
+ return { kokoro, f5, edge, piper, espeak, minimax }
99
+ }