@goodandready/dsh-tts 0.4.2 → 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.
@@ -41,7 +41,7 @@
41
41
  '.dts-alert{padding:10px 14px;border-radius:8px;font-size:13px}' +
42
42
  '.dts-alert-err{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 20%, transparent)}' +
43
43
  '.dts-alert-warn{background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 10%, transparent);color:var(--dsw-alias-state-warning-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-warning-primary) 20%, transparent)}' +
44
- '.dts-alert-ok{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);border:1px solid rgba(16,185,129,0.2)}' +
44
+ '.dts-alert-ok{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 20%, transparent)}' +
45
45
  '.dts-grow{flex:1;min-width:100px}'
46
46
 
47
47
  const setCssId = 'dsh-tts/settings.module.css'
@@ -18,7 +18,7 @@
18
18
  const AC = window.AudioContext || window.webkitAudioContext
19
19
  if (AC) {
20
20
  const ctx = new AC()
21
- if (ctx.state === 'suspended') ctx.resume().catch(() => {})
21
+ if (ctx.state === 'suspended') ctx.resume().catch(() => { /* audio context unlock failure ignored */ })
22
22
  setTimeout(() => { try { ctx.close() } catch { /* closed */ } }, 300)
23
23
  }
24
24
  } catch { /* no AudioContext */ }
@@ -317,9 +317,9 @@
317
317
  .then((d) => {
318
318
  if (!d || !d.ok) return
319
319
  const audio = new Audio('data:' + (d.mime || 'audio/mpeg') + ';base64,' + d.audioBase64)
320
- audio.play().catch(() => {})
320
+ audio.play().catch(() => { /* chime autoplay blocked or interrupted */ })
321
321
  })
322
- .catch(() => {})
322
+ .catch(() => { /* chime fetch failed or not found */ })
323
323
  }
324
324
 
325
325
  function credOf(map, provider) {
@@ -39,7 +39,7 @@
39
39
  }),
40
40
  React.createElement('button', { type: 'button', className: 'dts-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '\u2191'),
41
41
  React.createElement('button', { type: 'button', className: 'dts-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, '\u2193'),
42
- React.createElement('button', { type: 'button', className: 'dts-mini', title: t('preview'), disabled: !props.writable, onClick: () => props.onPreview(row.provider, row.model, row.voice) }, '\u25b6'),
42
+ React.createElement('button', { type: 'button', className: 'dts-mini', title: t('testProvider') || t('preview'), disabled: !props.writable, onClick: () => props.onPreview(row.provider, row.model, row.voice) }, '\u25b6'),
43
43
  React.createElement('button', { type: 'button', className: 'dts-mini', title: t('remove'), disabled: !props.writable, onClick: () => remove(i) }, '\u00d7'),
44
44
  ),
45
45
  cloud ? React.createElement('div', { className: 'dts-row' },
@@ -60,7 +60,7 @@
60
60
  (() => {
61
61
  const hit = Array.isArray(props.breaker) ? props.breaker.find((b) => b && b.id === row.provider) : null
62
62
  if (!hit || (!hit.open && !hit.lastError)) return null
63
- return React.createElement('div', { className: 'dts-sub' },
63
+ return React.createElement('div', { className: 'dts-sub dts-bad' },
64
64
  (hit.open ? t('breakerOpen') + ' · ' : '') + (hit.lastError || '')
65
65
  )
66
66
  })(),
@@ -9,7 +9,7 @@
9
9
  const [integrations, setIntegrations] = React.useState({ voice: { installed: false }, messenger: { installed: false } })
10
10
  const [telemetry, setTelemetry] = React.useState({ online: true, cacheHits: 0, sseOk: true, total: 0, sseClients: 0, breaker: [], provider: '', queue: 0 })
11
11
  const [advancedOpen, setAdvancedOpen] = React.useState(() => {
12
- try { return localStorage.getItem('dsh-tts/advancedOpen') === '1' } catch { return false }
12
+ try { return localStorage.getItem('dsh-tts/advancedOpen') === '1' } catch { /* localStorage unavailable */ return false }
13
13
  })
14
14
  const autoplay = useAutoplayGate()
15
15
 
@@ -19,7 +19,7 @@
19
19
  if (!s || !s.bind) return null
20
20
  try {
21
21
  return s.bind({ namespace: NS })
22
- } catch (_) {
22
+ } catch (_) { /* settings scope binding failed */
23
23
  return null
24
24
  }
25
25
  }, [ctx])
@@ -29,7 +29,7 @@
29
29
  if (!scope || !scope.subscribe) return () => {}
30
30
  try {
31
31
  return scope.subscribe(cb) || (() => {})
32
- } catch (_) {
32
+ } catch (_) { /* settings scope binding failed */
33
33
  return () => {}
34
34
  }
35
35
  }
@@ -39,7 +39,7 @@
39
39
  if (!scope || !scope.getSnapshot) return SNAPSHOT_LOADING
40
40
  try {
41
41
  return scope.getSnapshot() || SNAPSHOT_LOADING
42
- } catch (_) {
42
+ } catch (_) { /* settings scope binding failed */
43
43
  return SNAPSHOT_LOADING
44
44
  }
45
45
  }, [scope])
@@ -119,7 +119,7 @@
119
119
  headers: { 'Content-Type': 'application/json' },
120
120
  body: JSON.stringify({ provider: provider, value: value }),
121
121
  })
122
- const data = await res.json().catch(() => ({}))
122
+ const data = await res.json().catch(() => ({ /* parse error */ }))
123
123
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
124
124
  if (data && data.credentials) setCredentials(data.credentials)
125
125
  setKeyDrafts((d) => Object.assign({}, d, { [provider]: '' }))
@@ -133,7 +133,7 @@
133
133
  headers: { 'Content-Type': 'application/json' },
134
134
  body: JSON.stringify({ provider: provider }),
135
135
  })
136
- const data = await res.json().catch(() => ({}))
136
+ const data = await res.json().catch(() => ({ /* parse error */ }))
137
137
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
138
138
  if (data && data.credentials) setCredentials(data.credentials)
139
139
  setKeyDrafts((d) => Object.assign({}, d, { [provider]: '' }))
@@ -154,7 +154,7 @@
154
154
  headers: { 'Content-Type': 'application/json' },
155
155
  body: JSON.stringify(Object.assign({}, draft, { keys: keys })),
156
156
  })
157
- const data = await res.json().catch(() => ({}))
157
+ const data = await res.json().catch(() => ({ /* parse error */ }))
158
158
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
159
159
  applyPayload(data)
160
160
  setKeyDrafts({})
@@ -187,7 +187,7 @@
187
187
  .then((r) => r.json())
188
188
  .then((d) => {
189
189
  if (!d.ok) throw new Error((d.error && d.error.message) || 'HTTP error')
190
- new Audio('data:' + d.mime + ';base64,' + d.audioBase64).play().catch(() => {})
190
+ new Audio('data:' + d.mime + ';base64,' + d.audioBase64).play().catch(() => { /* autoplay blocked */ })
191
191
  })
192
192
  .catch((e) => setErr(String(e && e.message ? e.message : e)))
193
193
  }
package/lib/client.js CHANGED
@@ -126,7 +126,7 @@ window.__ModuleLoader__.load({
126
126
  '.dts-alert{padding:10px 14px;border-radius:8px;font-size:13px}' +
127
127
  '.dts-alert-err{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 20%, transparent)}' +
128
128
  '.dts-alert-warn{background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 10%, transparent);color:var(--dsw-alias-state-warning-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-warning-primary) 20%, transparent)}' +
129
- '.dts-alert-ok{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);border:1px solid rgba(16,185,129,0.2)}' +
129
+ '.dts-alert-ok{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 20%, transparent)}' +
130
130
  '.dts-grow{flex:1;min-width:100px}'
131
131
 
132
132
  const setCssId = 'dsh-tts/settings.module.css'
@@ -157,7 +157,7 @@ window.__ModuleLoader__.load({
157
157
  const AC = window.AudioContext || window.webkitAudioContext
158
158
  if (AC) {
159
159
  const ctx = new AC()
160
- if (ctx.state === 'suspended') ctx.resume().catch(() => {})
160
+ if (ctx.state === 'suspended') ctx.resume().catch(() => { /* audio context unlock failure ignored */ })
161
161
  setTimeout(() => { try { ctx.close() } catch { /* closed */ } }, 300)
162
162
  }
163
163
  } catch { /* no AudioContext */ }
@@ -456,9 +456,9 @@ window.__ModuleLoader__.load({
456
456
  .then((d) => {
457
457
  if (!d || !d.ok) return
458
458
  const audio = new Audio('data:' + (d.mime || 'audio/mpeg') + ';base64,' + d.audioBase64)
459
- audio.play().catch(() => {})
459
+ audio.play().catch(() => { /* chime autoplay blocked or interrupted */ })
460
460
  })
461
- .catch(() => {})
461
+ .catch(() => { /* chime fetch failed or not found */ })
462
462
  }
463
463
 
464
464
  function credOf(map, provider) {
@@ -867,7 +867,7 @@ window.__ModuleLoader__.load({
867
867
  }),
868
868
  React.createElement('button', { type: 'button', className: 'dts-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '\u2191'),
869
869
  React.createElement('button', { type: 'button', className: 'dts-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, '\u2193'),
870
- React.createElement('button', { type: 'button', className: 'dts-mini', title: t('preview'), disabled: !props.writable, onClick: () => props.onPreview(row.provider, row.model, row.voice) }, '\u25b6'),
870
+ React.createElement('button', { type: 'button', className: 'dts-mini', title: t('testProvider') || t('preview'), disabled: !props.writable, onClick: () => props.onPreview(row.provider, row.model, row.voice) }, '\u25b6'),
871
871
  React.createElement('button', { type: 'button', className: 'dts-mini', title: t('remove'), disabled: !props.writable, onClick: () => remove(i) }, '\u00d7'),
872
872
  ),
873
873
  cloud ? React.createElement('div', { className: 'dts-row' },
@@ -888,7 +888,7 @@ window.__ModuleLoader__.load({
888
888
  (() => {
889
889
  const hit = Array.isArray(props.breaker) ? props.breaker.find((b) => b && b.id === row.provider) : null
890
890
  if (!hit || (!hit.open && !hit.lastError)) return null
891
- return React.createElement('div', { className: 'dts-sub' },
891
+ return React.createElement('div', { className: 'dts-sub dts-bad' },
892
892
  (hit.open ? t('breakerOpen') + ' · ' : '') + (hit.lastError || '')
893
893
  )
894
894
  })(),
@@ -1227,7 +1227,7 @@ window.__ModuleLoader__.load({
1227
1227
  const [integrations, setIntegrations] = React.useState({ voice: { installed: false }, messenger: { installed: false } })
1228
1228
  const [telemetry, setTelemetry] = React.useState({ online: true, cacheHits: 0, sseOk: true, total: 0, sseClients: 0, breaker: [], provider: '', queue: 0 })
1229
1229
  const [advancedOpen, setAdvancedOpen] = React.useState(() => {
1230
- try { return localStorage.getItem('dsh-tts/advancedOpen') === '1' } catch { return false }
1230
+ try { return localStorage.getItem('dsh-tts/advancedOpen') === '1' } catch { /* localStorage unavailable */ return false }
1231
1231
  })
1232
1232
  const autoplay = useAutoplayGate()
1233
1233
 
@@ -1237,7 +1237,7 @@ window.__ModuleLoader__.load({
1237
1237
  if (!s || !s.bind) return null
1238
1238
  try {
1239
1239
  return s.bind({ namespace: NS })
1240
- } catch (_) {
1240
+ } catch (_) { /* settings scope binding failed */
1241
1241
  return null
1242
1242
  }
1243
1243
  }, [ctx])
@@ -1247,7 +1247,7 @@ window.__ModuleLoader__.load({
1247
1247
  if (!scope || !scope.subscribe) return () => {}
1248
1248
  try {
1249
1249
  return scope.subscribe(cb) || (() => {})
1250
- } catch (_) {
1250
+ } catch (_) { /* settings scope binding failed */
1251
1251
  return () => {}
1252
1252
  }
1253
1253
  }
@@ -1257,7 +1257,7 @@ window.__ModuleLoader__.load({
1257
1257
  if (!scope || !scope.getSnapshot) return SNAPSHOT_LOADING
1258
1258
  try {
1259
1259
  return scope.getSnapshot() || SNAPSHOT_LOADING
1260
- } catch (_) {
1260
+ } catch (_) { /* settings scope binding failed */
1261
1261
  return SNAPSHOT_LOADING
1262
1262
  }
1263
1263
  }, [scope])
@@ -1337,7 +1337,7 @@ window.__ModuleLoader__.load({
1337
1337
  headers: { 'Content-Type': 'application/json' },
1338
1338
  body: JSON.stringify({ provider: provider, value: value }),
1339
1339
  })
1340
- const data = await res.json().catch(() => ({}))
1340
+ const data = await res.json().catch(() => ({ /* parse error */ }))
1341
1341
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
1342
1342
  if (data && data.credentials) setCredentials(data.credentials)
1343
1343
  setKeyDrafts((d) => Object.assign({}, d, { [provider]: '' }))
@@ -1351,7 +1351,7 @@ window.__ModuleLoader__.load({
1351
1351
  headers: { 'Content-Type': 'application/json' },
1352
1352
  body: JSON.stringify({ provider: provider }),
1353
1353
  })
1354
- const data = await res.json().catch(() => ({}))
1354
+ const data = await res.json().catch(() => ({ /* parse error */ }))
1355
1355
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
1356
1356
  if (data && data.credentials) setCredentials(data.credentials)
1357
1357
  setKeyDrafts((d) => Object.assign({}, d, { [provider]: '' }))
@@ -1372,7 +1372,7 @@ window.__ModuleLoader__.load({
1372
1372
  headers: { 'Content-Type': 'application/json' },
1373
1373
  body: JSON.stringify(Object.assign({}, draft, { keys: keys })),
1374
1374
  })
1375
- const data = await res.json().catch(() => ({}))
1375
+ const data = await res.json().catch(() => ({ /* parse error */ }))
1376
1376
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
1377
1377
  applyPayload(data)
1378
1378
  setKeyDrafts({})
@@ -1405,7 +1405,7 @@ window.__ModuleLoader__.load({
1405
1405
  .then((r) => r.json())
1406
1406
  .then((d) => {
1407
1407
  if (!d.ok) throw new Error((d.error && d.error.message) || 'HTTP error')
1408
- new Audio('data:' + d.mime + ';base64,' + d.audioBase64).play().catch(() => {})
1408
+ new Audio('data:' + d.mime + ';base64,' + d.audioBase64).play().catch(() => { /* autoplay blocked */ })
1409
1409
  })
1410
1410
  .catch((e) => setErr(String(e && e.message ? e.message : e)))
1411
1411
  }
@@ -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,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
+ }
package/lib/providers.js CHANGED
@@ -1,22 +1,12 @@
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 { PROVIDER_KEYS, OPENAI_COMPATIBLE, DEFAULT_MODELS, DEFAULT_VOICES } from './providers/constants.js'
5
- export { PROVIDER_KEYS, OPENAI_COMPATIBLE, DEFAULT_MODELS, DEFAULT_VOICES }
6
-
7
- function pick(map, models, key) {
8
- const chosen = models && typeof models[key] === 'string' ? models[key].trim() : ''
9
- return chosen || map[key]
10
- }
1
+ import { PROVIDER_KEYS, OPENAI_COMPATIBLE, DEFAULT_MODELS, DEFAULT_VOICES } from "./providers/constants.js"
2
+ import { makeCloudProviders } from "./providers/cloud.js"
3
+ import { makeLocalProviders } from "./providers/local.js"
11
4
 
12
- function asBuffer(body) {
13
- if (Buffer.isBuffer(body)) return body
14
- return Buffer.from(body)
15
- }
5
+ export { PROVIDER_KEYS, OPENAI_COMPATIBLE, DEFAULT_MODELS, DEFAULT_VOICES }
16
6
 
17
7
  export function makeProviders(deps, req) {
18
- const { resolveKey, fetchImpl, cfg } = deps
19
- const { text, lang, signal: parentSignal, models, voices } = req
8
+ const { cfg } = deps
9
+ const { signal: parentSignal } = req
20
10
  const cloudTimeoutMs = Number(cfg.cloudTimeoutMs) > 0 ? Number(cfg.cloudTimeoutMs) : 10000
21
11
 
22
12
  function withCloudTimeout() {
@@ -24,11 +14,11 @@ export function makeProviders(deps, req) {
24
14
  let timedOut = false
25
15
  const timer = setTimeout(() => {
26
16
  timedOut = true
27
- controller.abort(new Error(`Cloud TTS timeout after ${cloudTimeoutMs}ms`))
17
+ controller.abort(new Error())
28
18
  }, cloudTimeoutMs)
29
19
  if (parentSignal) {
30
20
  if (parentSignal.aborted) controller.abort(parentSignal.reason)
31
- else parentSignal.addEventListener('abort', () => controller.abort(parentSignal.reason), { once: true })
21
+ else parentSignal.addEventListener(abort, () => controller.abort(parentSignal.reason), { once: true })
32
22
  }
33
23
  return {
34
24
  signal: controller.signal,
@@ -36,364 +26,8 @@ export function makeProviders(deps, req) {
36
26
  }
37
27
  }
38
28
 
39
- async function openai() {
40
- const key = await resolveKey(cfg.openaiKeyEnv)
41
- if (!key) return { ok: false, provider: 'openai', reason: `no ${cfg.openaiKeyEnv}` }
42
- const { signal, cleanup } = withCloudTimeout()
43
- try {
44
- const res = await fetchImpl((cfg.openaiBaseUrl || 'https://api.openai.com/v1') + '/audio/speech', {
45
- method: 'POST',
46
- headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
47
- body: JSON.stringify({
48
- model: pick(DEFAULT_MODELS, models, 'openai'),
49
- input: text,
50
- voice: pick(DEFAULT_VOICES, voices, 'openai'),
51
- response_format: 'mp3',
52
- }),
53
- signal,
54
- })
55
- if (!res.ok) throw new Error(`OpenAI HTTP ${res.status}`)
56
- const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
57
- return { ok: audio.length > 0, provider: 'openai', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
58
- } finally {
59
- cleanup()
60
- }
61
- }
62
-
63
- // One implementation for all OpenAI-compatible providers: base URL and key
64
- // name come from the table; everything else is identical.
65
- function openaiCompatible(key) {
66
- const spec = OPENAI_COMPATIBLE[key]
67
- return async function speak() {
68
- const keyEnv = (cfg[key + 'KeyEnv'] || spec.keyEnv)
69
- const token = await resolveKey(keyEnv)
70
- if (!token) return { ok: false, provider: key, reason: `no ${keyEnv}` }
71
- const base = cfg[key + 'BaseUrl'] || spec.baseUrl
72
- if (!base) return { ok: false, provider: key, reason: 'no base url configured' }
73
- const { signal, cleanup } = withCloudTimeout()
74
- try {
75
- const res = await fetchImpl(base + '/audio/speech', {
76
- method: 'POST',
77
- headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
78
- body: JSON.stringify({
79
- model: pick(DEFAULT_MODELS, models, key),
80
- input: text,
81
- voice: pick(DEFAULT_VOICES, voices, key),
82
- response_format: 'mp3',
83
- }),
84
- signal,
85
- })
86
- if (!res.ok) throw new Error(`${key} HTTP ${res.status}`)
87
- const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
88
- return { ok: audio.length > 0, provider: key, audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
89
- } finally {
90
- cleanup()
91
- }
92
- }
93
- }
94
-
95
- // Xiaomi MiMo. Synthesis goes through chat: text as a message, voice/format
96
- // as a separate field; audio returns base64 in the response.
97
- async function mimo() {
98
- const key = await resolveKey(cfg.mimoKeyEnv)
99
- if (!key) return { ok: false, provider: 'mimo', reason: `no ${cfg.mimoKeyEnv}` }
100
- const base = (cfg.mimoBaseUrl || 'https://api.xiaomimimo.com/v1').replace(/\/+$/, '')
101
- const format = cfg.mimoFormat === 'wav' ? 'wav' : 'mp3'
102
- const { signal, cleanup } = withCloudTimeout()
103
- try {
104
- const res = await fetchImpl(base + '/chat/completions', {
105
- method: 'POST',
106
- headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json', accept: 'application/json' },
107
- body: JSON.stringify({
108
- model: pick(DEFAULT_MODELS, models, 'mimo'),
109
- messages: [{ role: 'assistant', content: text }],
110
- audio: { format, voice: pick(DEFAULT_VOICES, voices, 'mimo') },
111
- stream: false,
112
- }),
113
- signal,
114
- })
115
- const data = await res.json().catch(() => ({}))
116
- if (!res.ok) {
117
- const detail = data && data.error && data.error.message
118
- throw new Error(`MiMo HTTP ${res.status}${detail ? ': ' + detail : ''}`)
119
- }
120
- const encoded = data && data.choices && data.choices[0]
121
- && data.choices[0].message && data.choices[0].message.audio
122
- && data.choices[0].message.audio.data
123
- if (typeof encoded !== 'string' || !encoded) {
124
- return { ok: false, provider: 'mimo', reason: 'response missing choices[0].message.audio.data' }
125
- }
126
- const audio = Buffer.from(encoded, 'base64')
127
- return {
128
- ok: audio.length > 0,
129
- provider: 'mimo',
130
- audio,
131
- mime: format === 'wav' ? 'audio/wav' : 'audio/mpeg',
132
- reason: audio.length ? '' : 'empty audio',
133
- }
134
- } finally {
135
- cleanup()
136
- }
137
- }
138
-
139
- // MiniMax uses their CLI on purpose; no custom protocol here.
140
- async function minimax() {
141
- try {
142
- const out = await minimaxSpeak(text, {
143
- bin: cfg.minimaxBin,
144
- voice: pick(DEFAULT_VOICES, voices, 'minimax'),
145
- model: pick(DEFAULT_MODELS, models, 'minimax'),
146
- timeoutMs: cfg.timeoutMs,
147
- })
148
- return { ok: true, provider: 'minimax', audio: out.audio, mime: out.mime }
149
- } catch (e) {
150
- return { ok: false, provider: 'minimax', reason: String(e && e.message || e) }
151
- }
152
- }
153
-
154
- async function elevenlabs() {
155
- const key = await resolveKey(cfg.elevenlabsKeyEnv)
156
- if (!key) return { ok: false, provider: 'elevenlabs', reason: `no ${cfg.elevenlabsKeyEnv}` }
157
- const voice = pick(DEFAULT_VOICES, voices, 'elevenlabs')
158
- const { signal, cleanup } = withCloudTimeout()
159
- try {
160
- const res = await fetchImpl(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}`, {
161
- method: 'POST',
162
- headers: { 'xi-api-key': key, 'content-type': 'application/json', accept: 'audio/mpeg' },
163
- body: JSON.stringify({
164
- text,
165
- model_id: pick(DEFAULT_MODELS, models, 'elevenlabs'),
166
- }),
167
- signal,
168
- })
169
- if (!res.ok) throw new Error(`ElevenLabs HTTP ${res.status}`)
170
- const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
171
- return { ok: audio.length > 0, provider: 'elevenlabs', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
172
- } finally {
173
- cleanup()
174
- }
175
- }
176
-
177
- async function google() {
178
- const key = await resolveKey(cfg.googleKeyEnv)
179
- if (!key) return { ok: false, provider: 'google', reason: `no ${cfg.googleKeyEnv}` }
180
- const model = pick(DEFAULT_MODELS, models, 'google')
181
- const voiceName = pick(DEFAULT_VOICES, voices, 'google')
182
- const { signal, cleanup } = withCloudTimeout()
183
- try {
184
- const res = await fetchImpl(
185
- `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(key)}`,
186
- {
187
- method: 'POST',
188
- headers: { 'content-type': 'application/json' },
189
- body: JSON.stringify({
190
- contents: [{ parts: [{ text }] }],
191
- generationConfig: {
192
- responseModalities: ['AUDIO'],
193
- speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName } } },
194
- },
195
- }),
196
- signal,
197
- },
198
- )
199
- if (!res.ok) throw new Error(`Google HTTP ${res.status}`)
200
- const data = await res.json()
201
- const b64 = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData)?.inlineData?.data
202
- if (!b64) return { ok: false, provider: 'google', reason: 'no inline audio' }
203
- const audio = Buffer.from(b64, 'base64')
204
- return { ok: audio.length > 0, provider: 'google', audio, mime: 'audio/wav', reason: audio.length ? '' : 'empty audio' }
205
- } finally {
206
- cleanup()
207
- }
208
- }
209
-
210
- async function azure() {
211
- const key = await resolveKey(cfg.azureKeyEnv)
212
- if (!key) return { ok: false, provider: 'azure', reason: `no ${cfg.azureKeyEnv}` }
213
- const region = (cfg.azureRegion || '').trim()
214
- if (!region) return { ok: false, provider: 'azure', reason: 'azureRegion is empty' }
215
- const voice = pick(DEFAULT_VOICES, voices, 'azure')
216
- const roleCfg = req.role && cfg.roles && cfg.roles[req.role]
217
- const style = roleCfg && roleCfg.ssmlStyle
218
- const safeStyle = style ? String(style).replace(/["<>&]/g, '') : ''
219
- const inner = safeStyle
220
- ? `<mstts:express-as style="${safeStyle}">${escapeXml(text)}</mstts:express-as>`
221
- : escapeXml(text)
222
- const ssml = `<speak version="1.0" xml:lang="${lang || 'en-US'}">`
223
- + `<voice xml:lang="${lang || 'en-US'}" name="${voice}">`
224
- + `${inner}</voice></speak>`
225
- const { signal, cleanup } = withCloudTimeout()
226
- try {
227
- const res = await fetchImpl(`https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`, {
228
- method: 'POST',
229
- headers: {
230
- 'Ocp-Apim-Subscription-Key': key,
231
- 'Content-Type': 'application/ssml+xml',
232
- 'X-Microsoft-OutputFormat': 'audio-16khz-128kbitrate-mono-mp3',
233
- },
234
- body: ssml,
235
- signal,
236
- })
237
- if (!res.ok) throw new Error(`Azure HTTP ${res.status}`)
238
- const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
239
- return { ok: audio.length > 0, provider: 'azure', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
240
- } finally {
241
- cleanup()
242
- }
243
- }
244
-
245
- async function groq() {
246
- const key = await resolveKey(cfg.groqKeyEnv)
247
- if (!key) return { ok: false, provider: 'groq', reason: `no ${cfg.groqKeyEnv}` }
248
- const { signal, cleanup } = withCloudTimeout()
249
- try {
250
- const res = await fetchImpl('https://api.groq.com/openai/v1/audio/speech', {
251
- method: 'POST',
252
- headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
253
- body: JSON.stringify({
254
- model: pick(DEFAULT_MODELS, models, 'groq'),
255
- input: text,
256
- voice: pick(DEFAULT_VOICES, voices, 'groq'),
257
- response_format: 'mp3',
258
- }),
259
- signal,
260
- })
261
- if (!res.ok) throw new Error(`Groq HTTP ${res.status}`)
262
- const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
263
- return { ok: audio.length > 0, provider: 'groq', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
264
- } finally {
265
- cleanup()
266
- }
267
- }
268
-
269
- async function deepgram() {
270
- const key = await resolveKey(cfg.deepgramKeyEnv)
271
- if (!key) return { ok: false, provider: 'deepgram', reason: `no ${cfg.deepgramKeyEnv}` }
272
- const model = pick(DEFAULT_MODELS, models, 'deepgram')
273
- const { signal, cleanup } = withCloudTimeout()
274
- try {
275
- const res = await fetchImpl(`https://api.deepgram.com/v1/speak?model=${encodeURIComponent(model)}`, {
276
- method: 'POST',
277
- headers: { authorization: `Token ${key}`, 'content-type': 'application/json' },
278
- body: JSON.stringify({ text }),
279
- signal,
280
- })
281
- if (!res.ok) throw new Error(`Deepgram HTTP ${res.status}`)
282
- const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
283
- return { ok: audio.length > 0, provider: 'deepgram', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
284
- } finally {
285
- cleanup()
286
- }
287
- }
288
-
289
- async function openrouter() {
290
- const key = await resolveKey(cfg.openrouterKeyEnv)
291
- if (!key) return { ok: false, provider: 'openrouter', reason: `no ${cfg.openrouterKeyEnv}` }
292
- const { signal, cleanup } = withCloudTimeout()
293
- try {
294
- const res = await fetchImpl('https://openrouter.ai/api/v1/audio/speech', {
295
- method: 'POST',
296
- headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
297
- body: JSON.stringify({
298
- model: pick(DEFAULT_MODELS, models, 'openrouter'),
299
- input: text,
300
- voice: pick(DEFAULT_VOICES, voices, 'openrouter'),
301
- response_format: 'mp3',
302
- }),
303
- signal,
304
- })
305
- if (!res.ok) throw new Error(`OpenRouter HTTP ${res.status}`)
306
- const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
307
- return { ok: audio.length > 0, provider: 'openrouter', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
308
- } finally {
309
- cleanup()
310
- }
311
- }
312
-
313
- async function edge() {
314
- try {
315
- const voice = pick(DEFAULT_VOICES, voices, 'edge') || pick(DEFAULT_MODELS, models, 'edge')
316
- const out = await edgeSpeak(text, { bin: cfg.edgeBin, voice, timeoutMs: cfg.timeoutMs })
317
- return { ok: true, provider: 'edge', audio: out.audio, mime: out.mime }
318
- } catch (e) {
319
- return { ok: false, provider: 'edge', reason: String(e && e.message || e) }
320
- }
321
- }
322
-
323
- async function piper() {
324
- try {
325
- const model = pick(DEFAULT_MODELS, models, 'piper') || cfg.piperModel
326
- const out = await piperSpeak(text, { bin: cfg.piperBin, model, timeoutMs: cfg.timeoutMs })
327
- return { ok: true, provider: 'piper', audio: out.audio, mime: out.mime }
328
- } catch (e) {
329
- return { ok: false, provider: 'piper', reason: String(e && e.message || e) }
330
- }
331
- }
332
-
333
- async function espeak() {
334
- try {
335
- const voice = pick(DEFAULT_VOICES, voices, 'espeak') || lang || 'ru'
336
- const out = await espeakSpeak(text, { bin: cfg.espeakBin, voice, timeoutMs: cfg.timeoutMs })
337
- return { ok: true, provider: 'espeak', audio: out.audio, mime: out.mime }
338
- } catch (e) {
339
- return { ok: false, provider: 'espeak', reason: String(e && e.message || e) }
340
- }
341
- }
342
-
343
- async function kokoro() {
344
- try {
345
- const modelPath = deps.modelManager ? deps.modelManager.getModelPath('kokoro') : (cfg.kokoroModelPath || '')
346
- const engine = new KokoroEngine({ modelPath })
347
- if (!engine.isInstalled()) {
348
- return { ok: false, provider: 'kokoro', reason: 'Kokoro ONNX model not installed. Use Edge, Piper, or eSpeak for offline TTS.' }
349
- }
350
- if (!engine.isRuntimeAvailable()) {
351
- return {
352
- ok: false,
353
- provider: 'kokoro',
354
- reason: 'Kokoro ONNX runtime is not bundled. Weights alone do not produce speech. Use Edge, Piper, or eSpeak for offline TTS.',
355
- }
356
- }
357
- const voice = pick(DEFAULT_VOICES, voices, 'kokoro') || 'af_bella'
358
- const audio = await engine.synthesizeWav(text, voice)
359
- return { ok: audio && audio.length > 0, provider: 'kokoro', audio, mime: 'audio/wav' }
360
- } catch (e) {
361
- return { ok: false, provider: 'kokoro', reason: String(e && e.message || e) }
362
- }
363
- }
364
-
365
- async function f5() {
366
- try {
367
- const modelPath = deps.modelManager ? deps.modelManager.getModelPath('f5') : ''
368
- const engine = new F5Engine({ modelPath })
369
- const ok = await engine.ping()
370
- if (!ok) {
371
- return { ok: false, provider: 'f5', reason: 'F5 daemon unavailable. Use Edge, Piper, or eSpeak for offline TTS.' }
372
- }
373
- if (!engine.isRuntimeAvailable()) {
374
- return {
375
- ok: false,
376
- provider: 'f5',
377
- reason: 'F5-TTS synthesis is not bundled. Use Edge, Piper, or eSpeak for offline TTS.',
378
- }
379
- }
380
- const audio = await engine.synthesize(text)
381
- return { ok: !!(audio && audio.length), provider: 'f5', audio, mime: 'audio/wav' }
382
- } catch (e) {
383
- return { ok: false, provider: 'f5', reason: String(e && e.message || e) }
384
- }
385
- }
386
-
387
- const compatible = {}
388
- for (const key of Object.keys(OPENAI_COMPATIBLE)) compatible[key] = openaiCompatible(key)
389
-
390
- return { kokoro, f5, openai, elevenlabs, google, azure, groq, deepgram, openrouter, mimo, ...compatible, edge, piper, espeak, minimax }
391
- }
29
+ const cloud = makeCloudProviders(deps, req, withCloudTimeout)
30
+ const local = makeLocalProviders(deps, req)
392
31
 
393
- function escapeXml(s) {
394
- return String(s)
395
- .replace(/&/g, '&amp;')
396
- .replace(/</g, '&lt;')
397
- .replace(/>/g, '&gt;')
398
- .replace(/"/g, '&quot;')
32
+ return Object.assign({}, cloud, local)
399
33
  }
package/lib/routes.js CHANGED
@@ -65,7 +65,7 @@ export function registerHttpRoutes(ctx, api) {
65
65
  res.write(': connected\n\n')
66
66
  addStreamSubscriber(res)
67
67
  req.on('close', () => removeStreamSubscriber(res))
68
- } catch {
68
+ } catch { /* SSE connection failed */
69
69
  removeStreamSubscriber(res)
70
70
  }
71
71
  },
@@ -110,7 +110,7 @@ export function registerHttpRoutes(ctx, api) {
110
110
  }
111
111
  try {
112
112
  // Fire installation in background or await initial start
113
- modelManager.installModel(engine).catch(() => {})
113
+ modelManager.installModel(engine).catch(() => { /* background install error tracked in manager state */ })
114
114
  const current = await modelManager.getStatus(engine)
115
115
  writeJson(res, 200, {
116
116
  ok: true,
package/lib/text.js CHANGED
@@ -176,12 +176,12 @@ export function restoreAbbreviations(text) {
176
176
  /**
177
177
  * Split text into chunks on sentence boundaries.
178
178
  *
179
- * Нужно, чтобы чтение начиналось почти сразу: длинный ответ синтезируется
180
- * целиком долго, а первое предложение — быстро. Куски короче нижнего порога
181
- * прилипают к следующему: отдельная фраза «Да.» звучит рвано.
179
+ * Enables low latency playback: synthesizing a whole long reply takes seconds,
180
+ * whereas the first sentence is ready almost immediately. Chunks shorter than
181
+ * the min threshold attach to the next one so speech sounds fluid.
182
182
  *
183
- * @param text {string} уже очищенный текст
184
- * @param maxChars {number} верхняя граница куска
183
+ * @param text {string} pre-cleaned text
184
+ * @param maxChars {number} upper bound for one chunk
185
185
  * @returns {string[]}
186
186
  */
187
187
  export function splitSentences(text, maxChars) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.4.2",
3
+ "version": "0.4.5",
4
4
  "description": "Text-to-speech for DeepSeek Harness: speak agent replies in the Web UI with a provider fallback chain (OpenAI, ElevenLabs, Google, Azure, Groq, Deepgram, OpenRouter, Edge, Piper, eSpeak).",
5
5
  "license": "MIT",
6
6
  "type": "module",