@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/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
+