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