@goodandready/dsh-tts 0.1.1
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/LICENSE +21 -0
- package/README.md +100 -0
- package/cordis.patch.yml +8 -0
- package/lib/chain.js +33 -0
- package/lib/client.js +310 -0
- package/lib/index.js +458 -0
- package/lib/keys.js +74 -0
- package/lib/local.js +83 -0
- package/lib/providers.js +229 -0
- package/lib/text.js +31 -0
- package/package.json +63 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import z from '@deepseek-ai/schemastery'
|
|
2
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
3
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
4
|
+
import { runChain } from './chain.js'
|
|
5
|
+
import { makeProviders, PROVIDER_KEYS, DEFAULT_MODELS, DEFAULT_VOICES } from './providers.js'
|
|
6
|
+
import { assistantText, stripForSpeech } from './text.js'
|
|
7
|
+
import {
|
|
8
|
+
CLOUD_PROVIDERS,
|
|
9
|
+
assertCredentialRef,
|
|
10
|
+
keyEnvName,
|
|
11
|
+
needsApiKey,
|
|
12
|
+
pendingKeyWrites,
|
|
13
|
+
publicConfig,
|
|
14
|
+
stripSecretsFromConfig,
|
|
15
|
+
} from './keys.js'
|
|
16
|
+
|
|
17
|
+
export const name = 'dsh-tts'
|
|
18
|
+
export const inject = ['tools', 'credentials', 'webServer', 'settings']
|
|
19
|
+
|
|
20
|
+
const NS = 'dsh-tts'
|
|
21
|
+
|
|
22
|
+
const ChainEntry = z.object({
|
|
23
|
+
provider: z.string().default('espeak')
|
|
24
|
+
.description(`Provider key. One of: ${PROVIDER_KEYS.join(', ')}.`),
|
|
25
|
+
model: z.string().default('')
|
|
26
|
+
.description('Model override. Empty means the provider default.'),
|
|
27
|
+
voice: z.string().default('')
|
|
28
|
+
.description('Voice override. Empty means the provider default.'),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export const Config = z.object({
|
|
32
|
+
speakReplies: z.boolean().default(false)
|
|
33
|
+
.description('When on, speak each finished agent reply in the Web UI.'),
|
|
34
|
+
language: z.string().default('ru'),
|
|
35
|
+
maxChars: z.number().default(4000)
|
|
36
|
+
.description('Longer replies are truncated before synthesis.'),
|
|
37
|
+
chain: z.array(ChainEntry)
|
|
38
|
+
.default([
|
|
39
|
+
{ provider: 'edge', model: '', voice: 'ru-RU-SvetlanaNeural' },
|
|
40
|
+
{ provider: 'piper', model: '', voice: '' },
|
|
41
|
+
{ provider: 'espeak', model: '', voice: 'ru' },
|
|
42
|
+
])
|
|
43
|
+
.description('Fallback chain. Order is the order of attempts. A provider without a key is skipped.'),
|
|
44
|
+
openaiKeyEnv: z.string().default('OPENAI_API_KEY'),
|
|
45
|
+
openaiBaseUrl: z.string().default('https://api.openai.com/v1'),
|
|
46
|
+
elevenlabsKeyEnv: z.string().default('ELEVENLABS_API_KEY'),
|
|
47
|
+
googleKeyEnv: z.string().default('GEMINI_API_KEY'),
|
|
48
|
+
azureKeyEnv: z.string().default('AZURE_SPEECH_KEY'),
|
|
49
|
+
azureRegion: z.string().default('')
|
|
50
|
+
.description('Azure Speech region, e.g. eastus. Required for the azure provider.'),
|
|
51
|
+
groqKeyEnv: z.string().default('GROQ_API_KEY'),
|
|
52
|
+
deepgramKeyEnv: z.string().default('DEEPGRAM_API_KEY'),
|
|
53
|
+
openrouterKeyEnv: z.string().default('OPENROUTER_API_KEY'),
|
|
54
|
+
edgeBin: z.string().default('edge-tts')
|
|
55
|
+
.description('edge-tts CLI. Looked up in PATH unless an absolute path is given.'),
|
|
56
|
+
piperBin: z.string().default('piper'),
|
|
57
|
+
piperModel: z.string().default('')
|
|
58
|
+
.description('Path to a Piper ONNX model. The piper provider is skipped while this is empty.'),
|
|
59
|
+
espeakBin: z.string().default('espeak-ng'),
|
|
60
|
+
timeoutMs: z.number().default(60000),
|
|
61
|
+
maxQueue: z.number().default(8),
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
function writeJson(res, code, body) {
|
|
65
|
+
try {
|
|
66
|
+
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
|
|
67
|
+
res.end(JSON.stringify(body))
|
|
68
|
+
} catch { /* socket closed */ }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readBody(req, maxBytes) {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const chunks = []
|
|
74
|
+
let size = 0
|
|
75
|
+
req.on('data', (c) => {
|
|
76
|
+
size += c.length
|
|
77
|
+
if (size > maxBytes) { reject(new Error('body too large')); req.destroy(); return }
|
|
78
|
+
chunks.push(c)
|
|
79
|
+
})
|
|
80
|
+
req.on('end', () => resolve(Buffer.concat(chunks)))
|
|
81
|
+
req.on('error', reject)
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Reject cross-site POSTs. Do not require loopback or Origin==Host: the Web UI is used over LAN and reverse proxies. */
|
|
86
|
+
function isTrustedSettingsRequest(request) {
|
|
87
|
+
return request.headers['sec-fetch-site'] !== 'cross-site'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let utteranceSeq = 0
|
|
91
|
+
|
|
92
|
+
export function apply(ctx, config) {
|
|
93
|
+
let getConfig = () => config
|
|
94
|
+
const live = () => Config(structuredClone(getConfig() ?? {})) ?? config
|
|
95
|
+
|
|
96
|
+
let settingsApi
|
|
97
|
+
ctx.inject(['settings'], (sctx) => {
|
|
98
|
+
const scope = sctx.settings.register(NS, Config, { base: config })
|
|
99
|
+
settingsApi = scope
|
|
100
|
+
getConfig = () => scope.get() ?? config
|
|
101
|
+
sctx.effect(() => () => {
|
|
102
|
+
settingsApi = undefined
|
|
103
|
+
getConfig = () => config
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const pending = []
|
|
108
|
+
const collectors = new Map()
|
|
109
|
+
|
|
110
|
+
async function resolveKey(ref) {
|
|
111
|
+
try {
|
|
112
|
+
const resolved = await ctx.credentials.resolve(credentialRef(ref))
|
|
113
|
+
if (resolved && resolved.value) return resolved.value
|
|
114
|
+
} catch { /* fall through to env */ }
|
|
115
|
+
return process.env[ref] || ''
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function describeProvider(provider) {
|
|
119
|
+
const ref = keyEnvName(live(), provider)
|
|
120
|
+
const base = { provider, ref, configured: false, writable: true }
|
|
121
|
+
if (!ref) return base
|
|
122
|
+
try {
|
|
123
|
+
if (typeof ctx.credentials.describe === 'function') {
|
|
124
|
+
const d = await ctx.credentials.describe(credentialRef(ref))
|
|
125
|
+
return {
|
|
126
|
+
provider,
|
|
127
|
+
ref,
|
|
128
|
+
configured: !!(d && d.configured),
|
|
129
|
+
writable: d && d.writable === false ? false : true,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const resolved = await ctx.credentials.resolve(credentialRef(ref))
|
|
133
|
+
return { provider, ref, configured: !!(resolved && resolved.value), writable: true }
|
|
134
|
+
} catch {
|
|
135
|
+
return base
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function credentialsView() {
|
|
140
|
+
const out = {}
|
|
141
|
+
for (const provider of CLOUD_PROVIDERS) {
|
|
142
|
+
out[provider] = await describeProvider(provider)
|
|
143
|
+
}
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function storeProviderKey(provider, value) {
|
|
148
|
+
if (!needsApiKey(provider)) {
|
|
149
|
+
throw new Error(`${provider} does not take an API key`)
|
|
150
|
+
}
|
|
151
|
+
const trimmed = String(value || '').trim()
|
|
152
|
+
if (!trimmed) {
|
|
153
|
+
throw new Error('an empty key cannot be stored')
|
|
154
|
+
}
|
|
155
|
+
if (typeof ctx.credentials.set !== 'function') {
|
|
156
|
+
throw new Error('no credentials service is mounted')
|
|
157
|
+
}
|
|
158
|
+
const ref = assertCredentialRef(keyEnvName(live(), provider))
|
|
159
|
+
await ctx.credentials.set(credentialRef(ref), trimmed)
|
|
160
|
+
return ref
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function clearProviderKey(provider) {
|
|
164
|
+
if (!needsApiKey(provider)) {
|
|
165
|
+
throw new Error(`${provider} does not take an API key`)
|
|
166
|
+
}
|
|
167
|
+
if (typeof ctx.credentials.unset !== 'function') {
|
|
168
|
+
throw new Error('no credentials service is mounted')
|
|
169
|
+
}
|
|
170
|
+
const ref = assertCredentialRef(keyEnvName(live(), provider))
|
|
171
|
+
await ctx.credentials.unset(credentialRef(ref))
|
|
172
|
+
return ref
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function configResponse() {
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
config: publicConfig(live()),
|
|
179
|
+
credentials: await credentialsView(),
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function synthesize(rawText, cfg, signal) {
|
|
184
|
+
const text = stripForSpeech(rawText, cfg.maxChars)
|
|
185
|
+
if (!text) throw new Error('nothing to speak')
|
|
186
|
+
const models = {}
|
|
187
|
+
const voices = {}
|
|
188
|
+
const order = []
|
|
189
|
+
for (const entry of Array.isArray(cfg.chain) ? cfg.chain : []) {
|
|
190
|
+
if (!PROVIDER_KEYS.includes(entry.provider)) continue
|
|
191
|
+
order.push(entry.provider)
|
|
192
|
+
models[entry.provider] = entry.model || DEFAULT_MODELS[entry.provider]
|
|
193
|
+
voices[entry.provider] = entry.voice || DEFAULT_VOICES[entry.provider]
|
|
194
|
+
}
|
|
195
|
+
const providers = makeProviders(
|
|
196
|
+
{ resolveKey, fetchImpl: fetch, cfg },
|
|
197
|
+
{ text, lang: cfg.language, signal, models, voices },
|
|
198
|
+
)
|
|
199
|
+
return runChain(order, providers)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function enqueue(item) {
|
|
203
|
+
pending.push(item)
|
|
204
|
+
const max = live().maxQueue || 8
|
|
205
|
+
while (pending.length > max) pending.shift()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
ctx.effect(() => ctx.on('session/event', (session, event) => {
|
|
209
|
+
const cfg = live()
|
|
210
|
+
if (!cfg.speakReplies) return
|
|
211
|
+
const sid = session && session.id
|
|
212
|
+
if (!sid) return
|
|
213
|
+
if (event.type === 'assistant/message') {
|
|
214
|
+
const text = assistantText(event.data && event.data.message)
|
|
215
|
+
if (!text) return
|
|
216
|
+
const cur = collectors.get(sid) || { parts: [] }
|
|
217
|
+
cur.parts.push(text)
|
|
218
|
+
collectors.set(sid, cur)
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
if (event.type !== 'turn/end') return
|
|
222
|
+
const cur = collectors.get(sid)
|
|
223
|
+
collectors.delete(sid)
|
|
224
|
+
const raw = cur && Array.isArray(cur.parts) ? cur.parts.join('\n') : ''
|
|
225
|
+
const text = stripForSpeech(raw, cfg.maxChars)
|
|
226
|
+
if (!text) return
|
|
227
|
+
const id = `u${++utteranceSeq}`
|
|
228
|
+
const controller = new AbortController()
|
|
229
|
+
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
230
|
+
synthesize(text, cfg, controller.signal)
|
|
231
|
+
.then((out) => {
|
|
232
|
+
enqueue({
|
|
233
|
+
id,
|
|
234
|
+
sessionId: sid,
|
|
235
|
+
provider: out.provider,
|
|
236
|
+
mime: out.mime,
|
|
237
|
+
audioBase64: Buffer.from(out.audio).toString('base64'),
|
|
238
|
+
tookMs: out.tookMs,
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
.catch((err) => {
|
|
242
|
+
enqueue({
|
|
243
|
+
id,
|
|
244
|
+
sessionId: sid,
|
|
245
|
+
error: String(err && err.message || err),
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
.finally(() => clearTimeout(timer))
|
|
249
|
+
}), 'dsh-tts: speak finished replies')
|
|
250
|
+
|
|
251
|
+
ctx.effect(() => ctx.webServer.register({
|
|
252
|
+
kind: 'exact',
|
|
253
|
+
path: '/dsh-tts/status',
|
|
254
|
+
handler: async (req, res) => {
|
|
255
|
+
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
256
|
+
const cfg = live()
|
|
257
|
+
writeJson(res, 200, {
|
|
258
|
+
ok: true,
|
|
259
|
+
speakReplies: cfg.speakReplies,
|
|
260
|
+
language: cfg.language,
|
|
261
|
+
chain: cfg.chain,
|
|
262
|
+
providers: PROVIDER_KEYS,
|
|
263
|
+
})
|
|
264
|
+
},
|
|
265
|
+
}), 'dsh-tts: /status route')
|
|
266
|
+
|
|
267
|
+
ctx.effect(() => ctx.webServer.register({
|
|
268
|
+
kind: 'exact',
|
|
269
|
+
path: '/dsh-tts/config',
|
|
270
|
+
handler: async (req, res) => {
|
|
271
|
+
if (req.method === 'GET') {
|
|
272
|
+
writeJson(res, 200, await configResponse())
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
if (req.method !== 'PUT' && req.method !== 'POST') {
|
|
276
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or PUT' } })
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
280
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-tts: settings writes are same-origin only' } })
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
if (!settingsApi) {
|
|
284
|
+
writeJson(res, 503, { ok: false, error: { code: 'settings', message: 'settings not ready' } })
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
let raw
|
|
288
|
+
try { raw = await readBody(req, 256 * 1024) } catch (e) {
|
|
289
|
+
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
let payload
|
|
293
|
+
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch {
|
|
294
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
if (payload && typeof payload.config === 'object') {
|
|
298
|
+
payload = { ...payload.config, keys: payload.keys }
|
|
299
|
+
}
|
|
300
|
+
const keys = pendingKeyWrites(payload && payload.keys)
|
|
301
|
+
const stripped = stripSecretsFromConfig(payload)
|
|
302
|
+
let parsed
|
|
303
|
+
try { parsed = Config(stripped) } catch (e) {
|
|
304
|
+
writeJson(res, 400, { ok: false, error: { code: 'schema', message: String(e && e.message || e) } })
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
for (const item of keys) {
|
|
309
|
+
await storeProviderKey(item.provider, item.value)
|
|
310
|
+
}
|
|
311
|
+
await settingsApi.replace(parsed)
|
|
312
|
+
writeJson(res, 200, await configResponse())
|
|
313
|
+
} catch (e) {
|
|
314
|
+
writeJson(res, 500, { ok: false, error: { code: 'save', message: String(e && e.message || e) } })
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
}), 'dsh-tts: /config route')
|
|
318
|
+
|
|
319
|
+
ctx.effect(() => ctx.webServer.register({
|
|
320
|
+
kind: 'exact',
|
|
321
|
+
path: '/dsh-tts/credential',
|
|
322
|
+
handler: async (req, res) => {
|
|
323
|
+
if (req.method !== 'PUT' && req.method !== 'DELETE') {
|
|
324
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'PUT or DELETE only' } })
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
328
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-tts: keys are same-origin only' } })
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
let payload = {}
|
|
332
|
+
if (req.method === 'PUT' || (req.headers['content-length'] && Number(req.headers['content-length']) > 0)) {
|
|
333
|
+
let raw
|
|
334
|
+
try { raw = await readBody(req, 16 * 1024) } catch (e) {
|
|
335
|
+
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch {
|
|
339
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const provider = typeof payload.provider === 'string' ? payload.provider.trim() : ''
|
|
344
|
+
if (!needsApiKey(provider)) {
|
|
345
|
+
writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown cloud provider' } })
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
if (req.method === 'DELETE') {
|
|
350
|
+
const ref = await clearProviderKey(provider)
|
|
351
|
+
writeJson(res, 200, { ok: true, ref, credentials: await credentialsView() })
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
const ref = await storeProviderKey(provider, payload.value)
|
|
355
|
+
writeJson(res, 200, { ok: true, ref, credentials: await credentialsView() })
|
|
356
|
+
} catch (e) {
|
|
357
|
+
writeJson(res, 400, { ok: false, error: { code: 'credential', message: String(e && e.message || e) } })
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
}), 'dsh-tts: /credential route')
|
|
361
|
+
|
|
362
|
+
ctx.effect(() => ctx.webServer.register({
|
|
363
|
+
kind: 'exact',
|
|
364
|
+
path: '/dsh-tts/pending',
|
|
365
|
+
handler: async (req, res) => {
|
|
366
|
+
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
367
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1')
|
|
368
|
+
const after = url.searchParams.get('after') || ''
|
|
369
|
+
let items = pending.slice()
|
|
370
|
+
if (after) {
|
|
371
|
+
const idx = items.findIndex((x) => x.id === after)
|
|
372
|
+
items = idx >= 0 ? items.slice(idx + 1) : items
|
|
373
|
+
}
|
|
374
|
+
writeJson(res, 200, { ok: true, items })
|
|
375
|
+
},
|
|
376
|
+
}), 'dsh-tts: /pending route')
|
|
377
|
+
|
|
378
|
+
ctx.effect(() => ctx.webServer.register({
|
|
379
|
+
kind: 'exact',
|
|
380
|
+
path: '/dsh-tts/speak',
|
|
381
|
+
handler: async (req, res) => {
|
|
382
|
+
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
383
|
+
let raw
|
|
384
|
+
try { raw = await readBody(req, 256 * 1024) } catch (e) {
|
|
385
|
+
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
|
|
386
|
+
}
|
|
387
|
+
let payload
|
|
388
|
+
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
|
|
389
|
+
const text = stripForSpeech(payload.text, live().maxChars)
|
|
390
|
+
if (!text) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
|
|
391
|
+
const cfg = live()
|
|
392
|
+
const controller = new AbortController()
|
|
393
|
+
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
394
|
+
try {
|
|
395
|
+
const out = await synthesize(text, cfg, controller.signal)
|
|
396
|
+
writeJson(res, 200, {
|
|
397
|
+
ok: true,
|
|
398
|
+
provider: out.provider,
|
|
399
|
+
mime: out.mime,
|
|
400
|
+
audioBase64: Buffer.from(out.audio).toString('base64'),
|
|
401
|
+
tookMs: out.tookMs,
|
|
402
|
+
})
|
|
403
|
+
} catch (e) {
|
|
404
|
+
writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
|
|
405
|
+
} finally {
|
|
406
|
+
clearTimeout(timer)
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
}), 'dsh-tts: /speak route')
|
|
410
|
+
|
|
411
|
+
ctx.tools.register(
|
|
412
|
+
defineTool({
|
|
413
|
+
name: 'speak_text',
|
|
414
|
+
description:
|
|
415
|
+
'Synthesize speech from text using the dsh-tts provider fallback chain. '
|
|
416
|
+
+ 'Returns audio as base64 plus the provider that succeeded. '
|
|
417
|
+
+ 'Use when the user asks to hear text; do not dump the audio into the model context.',
|
|
418
|
+
parameters: {
|
|
419
|
+
text: { type: 'string', required: true, description: 'Text to speak.' },
|
|
420
|
+
},
|
|
421
|
+
output: {
|
|
422
|
+
schema: {
|
|
423
|
+
type: 'object',
|
|
424
|
+
additionalProperties: false,
|
|
425
|
+
properties: {
|
|
426
|
+
ok: { type: 'boolean' },
|
|
427
|
+
provider: { type: 'string' },
|
|
428
|
+
mime: { type: 'string' },
|
|
429
|
+
bytes: { type: 'number' },
|
|
430
|
+
tookMs: { type: 'number' },
|
|
431
|
+
error: { type: 'string' },
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
render: (_args, value) => [{
|
|
435
|
+
type: 'text',
|
|
436
|
+
text: value && value.ok
|
|
437
|
+
? `Spoken with ${value.provider} (${value.mime}, ${value.bytes || 0} bytes).`
|
|
438
|
+
: `TTS failed: ${value && value.error ? value.error : 'unknown'}`,
|
|
439
|
+
}],
|
|
440
|
+
},
|
|
441
|
+
execute: async (args, exec) => {
|
|
442
|
+
const cfg = live()
|
|
443
|
+
try {
|
|
444
|
+
const out = await synthesize(String(args.text || ''), cfg, exec.signal)
|
|
445
|
+
return {
|
|
446
|
+
ok: true,
|
|
447
|
+
provider: out.provider,
|
|
448
|
+
mime: out.mime,
|
|
449
|
+
bytes: out.audio.length,
|
|
450
|
+
tookMs: out.tookMs,
|
|
451
|
+
}
|
|
452
|
+
} catch (e) {
|
|
453
|
+
return { ok: false, error: String(e && e.message || e) }
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
}),
|
|
457
|
+
)
|
|
458
|
+
}
|
package/lib/keys.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export const CLOUD_PROVIDERS = [
|
|
2
|
+
'openai', 'elevenlabs', 'google', 'azure', 'groq', 'deepgram', 'openrouter',
|
|
3
|
+
]
|
|
4
|
+
|
|
5
|
+
export const KEY_ENV_FIELD = {
|
|
6
|
+
openai: 'openaiKeyEnv',
|
|
7
|
+
elevenlabs: 'elevenlabsKeyEnv',
|
|
8
|
+
google: 'googleKeyEnv',
|
|
9
|
+
azure: 'azureKeyEnv',
|
|
10
|
+
groq: 'groqKeyEnv',
|
|
11
|
+
deepgram: 'deepgramKeyEnv',
|
|
12
|
+
openrouter: 'openrouterKeyEnv',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_KEY_ENV = {
|
|
16
|
+
openai: 'OPENAI_API_KEY',
|
|
17
|
+
elevenlabs: 'ELEVENLABS_API_KEY',
|
|
18
|
+
google: 'GEMINI_API_KEY',
|
|
19
|
+
azure: 'AZURE_SPEECH_KEY',
|
|
20
|
+
groq: 'GROQ_API_KEY',
|
|
21
|
+
deepgram: 'DEEPGRAM_API_KEY',
|
|
22
|
+
openrouter: 'OPENROUTER_API_KEY',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
26
|
+
|
|
27
|
+
export function needsApiKey(provider) {
|
|
28
|
+
return Object.prototype.hasOwnProperty.call(KEY_ENV_FIELD, provider)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function keyEnvName(cfg, provider) {
|
|
32
|
+
const field = KEY_ENV_FIELD[provider]
|
|
33
|
+
if (!field) return ''
|
|
34
|
+
const name = String((cfg && cfg[field]) || '').trim()
|
|
35
|
+
return name || DEFAULT_KEY_ENV[provider] || ''
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function assertCredentialRef(ref) {
|
|
39
|
+
if (!REF_PATTERN.test(ref)) {
|
|
40
|
+
throw new Error('credential ref must be an environment variable name')
|
|
41
|
+
}
|
|
42
|
+
return ref
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Drop write-only key material so it never lands in plugin settings. */
|
|
46
|
+
export function stripSecretsFromConfig(payload) {
|
|
47
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return {}
|
|
48
|
+
const config = { ...payload }
|
|
49
|
+
delete config.keys
|
|
50
|
+
delete config.credentials
|
|
51
|
+
for (const key of Object.keys(config)) {
|
|
52
|
+
if (/KeyEnv$/.test(key)) continue
|
|
53
|
+
if (/(^|.*)(apiKey|api_key|secret|token|password)$/i.test(key) || /Key$/.test(key)) {
|
|
54
|
+
delete config[key]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return config
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function pendingKeyWrites(keys) {
|
|
61
|
+
const out = []
|
|
62
|
+
if (!keys || typeof keys !== 'object') return out
|
|
63
|
+
for (const provider of Object.keys(keys)) {
|
|
64
|
+
if (!needsApiKey(provider)) continue
|
|
65
|
+
const value = typeof keys[provider] === 'string' ? keys[provider].trim() : ''
|
|
66
|
+
if (!value) continue
|
|
67
|
+
out.push({ provider, value })
|
|
68
|
+
}
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function publicConfig(cfg) {
|
|
73
|
+
return stripSecretsFromConfig(cfg)
|
|
74
|
+
}
|
package/lib/local.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
function spawnCollect(bin, args, { stdinText, timeoutMs } = {}) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const proc = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
9
|
+
const out = []
|
|
10
|
+
const err = []
|
|
11
|
+
const timer = setTimeout(() => {
|
|
12
|
+
try { proc.kill('SIGKILL') } catch { /* gone */ }
|
|
13
|
+
reject(new Error(`${bin} timed out`))
|
|
14
|
+
}, timeoutMs || 60000)
|
|
15
|
+
proc.stdout.on('data', (c) => out.push(c))
|
|
16
|
+
proc.stderr.on('data', (c) => err.push(c))
|
|
17
|
+
proc.on('error', (e) => {
|
|
18
|
+
clearTimeout(timer)
|
|
19
|
+
reject(new Error(`${bin} unavailable: ${e.message}`))
|
|
20
|
+
})
|
|
21
|
+
proc.on('close', (code) => {
|
|
22
|
+
clearTimeout(timer)
|
|
23
|
+
const stdout = Buffer.concat(out)
|
|
24
|
+
const stderr = Buffer.concat(err).toString('utf8').slice(0, 240)
|
|
25
|
+
if (code !== 0) {
|
|
26
|
+
reject(new Error(`${bin} exit ${code}: ${stderr}`))
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
resolve(stdout)
|
|
30
|
+
})
|
|
31
|
+
if (stdinText != null) {
|
|
32
|
+
proc.stdin.on('error', () => {})
|
|
33
|
+
proc.stdin.end(Buffer.from(String(stdinText), 'utf8'))
|
|
34
|
+
} else {
|
|
35
|
+
proc.stdin.end()
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function espeakSpeak(text, { bin, voice, timeoutMs }) {
|
|
41
|
+
const bytes = await spawnCollect(bin || 'espeak-ng', [
|
|
42
|
+
'-v', voice || 'ru',
|
|
43
|
+
'--stdout',
|
|
44
|
+
'-s', '165',
|
|
45
|
+
], { stdinText: text, timeoutMs })
|
|
46
|
+
if (!bytes || bytes.length < 64) throw new Error('espeak produced empty audio')
|
|
47
|
+
return { audio: bytes, mime: 'audio/wav' }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function piperSpeak(text, { bin, model, timeoutMs }) {
|
|
51
|
+
if (!model) throw new Error('piper model path is empty')
|
|
52
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), 'dsh-tts-piper-'))
|
|
53
|
+
const outFile = path.join(dir, 'out.wav')
|
|
54
|
+
try {
|
|
55
|
+
await spawnCollect(bin || 'piper', [
|
|
56
|
+
'--model', model,
|
|
57
|
+
'--output_file', outFile,
|
|
58
|
+
], { stdinText: text, timeoutMs })
|
|
59
|
+
const bytes = await readFile(outFile)
|
|
60
|
+
if (!bytes || bytes.length < 64) throw new Error('piper produced empty audio')
|
|
61
|
+
return { audio: bytes, mime: 'audio/wav' }
|
|
62
|
+
} finally {
|
|
63
|
+
await rm(dir, { recursive: true, force: true })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function edgeSpeak(text, { bin, voice, timeoutMs }) {
|
|
68
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), 'dsh-tts-edge-'))
|
|
69
|
+
const outFile = path.join(dir, 'out.mp3')
|
|
70
|
+
try {
|
|
71
|
+
await spawnCollect(bin || 'edge-tts', [
|
|
72
|
+
'--voice', voice || 'ru-RU-SvetlanaNeural',
|
|
73
|
+
'--text', text,
|
|
74
|
+
'--write-media', outFile,
|
|
75
|
+
], { timeoutMs })
|
|
76
|
+
const bytes = await readFile(outFile)
|
|
77
|
+
if (!bytes || bytes.length < 32) throw new Error('edge-tts produced empty audio')
|
|
78
|
+
return { audio: bytes, mime: 'audio/mpeg' }
|
|
79
|
+
} finally {
|
|
80
|
+
await rm(dir, { recursive: true, force: true })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|