@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/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,
|
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,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, '<')
|
|
17
|
+
.replace(/>/g, '>')
|
|
18
|
+
.replace(/"/g, '"')
|
|
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
|
+
}
|