@goodandready/dsh-tts 0.3.20 → 0.3.22
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/cache.js +6 -1
- package/lib/client.js +95 -28
- package/lib/engines/kokoro.js +39 -0
- package/lib/engines/manager.js +37 -21
- package/lib/index.js +25 -11
- package/lib/keys.js +9 -0
- package/lib/providers.js +34 -1
- package/lib/text.js +1 -1
- package/package.json +3 -3
package/lib/cache.js
CHANGED
|
@@ -26,7 +26,12 @@ export function createSpeechCache({ root, maxBytes }) {
|
|
|
26
26
|
for (const name of await fs.promises.readdir(dir)) {
|
|
27
27
|
if (!name.endsWith(AUDIO_EXT)) continue
|
|
28
28
|
const full = path.join(dir, name)
|
|
29
|
-
|
|
29
|
+
let st
|
|
30
|
+
try {
|
|
31
|
+
st = await fs.promises.stat(full)
|
|
32
|
+
} catch {
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
30
35
|
let at = st.mtimeMs
|
|
31
36
|
try {
|
|
32
37
|
const meta = JSON.parse(await fs.promises.readFile(path.join(dir, name.slice(0, -AUDIO_EXT.length) + '.json'), 'utf8'))
|
package/lib/client.js
CHANGED
|
@@ -93,6 +93,8 @@ window.__ModuleLoader__.load({
|
|
|
93
93
|
paused: false,
|
|
94
94
|
listeners: new Set(),
|
|
95
95
|
}
|
|
96
|
+
const seenIds = new Set()
|
|
97
|
+
let currentAudioResolve = null
|
|
96
98
|
|
|
97
99
|
function playerChanged() {
|
|
98
100
|
for (const listener of [...player.listeners]) {
|
|
@@ -164,7 +166,17 @@ window.__ModuleLoader__.load({
|
|
|
164
166
|
audio.playbackRate = Math.max(0.5, Math.min(2, player.rate || 1))
|
|
165
167
|
player.audio = audio
|
|
166
168
|
playerChanged()
|
|
167
|
-
|
|
169
|
+
let doneCalled = false
|
|
170
|
+
const done = () => {
|
|
171
|
+
if (doneCalled) return
|
|
172
|
+
doneCalled = true
|
|
173
|
+
currentAudioResolve = null
|
|
174
|
+
URL.revokeObjectURL(url)
|
|
175
|
+
player.audio = null
|
|
176
|
+
playerChanged()
|
|
177
|
+
resolve()
|
|
178
|
+
}
|
|
179
|
+
currentAudioResolve = done
|
|
168
180
|
audio.onended = done
|
|
169
181
|
audio.onerror = done
|
|
170
182
|
audio.play().catch(() => {
|
|
@@ -172,7 +184,10 @@ window.__ModuleLoader__.load({
|
|
|
172
184
|
// поломкой, просто ничего не звучит.
|
|
173
185
|
done()
|
|
174
186
|
})
|
|
175
|
-
} catch (cannotDecode) {
|
|
187
|
+
} catch (cannotDecode) {
|
|
188
|
+
currentAudioResolve = null
|
|
189
|
+
resolve()
|
|
190
|
+
}
|
|
176
191
|
})
|
|
177
192
|
}
|
|
178
193
|
|
|
@@ -198,8 +213,12 @@ window.__ModuleLoader__.load({
|
|
|
198
213
|
function stopPlayback() {
|
|
199
214
|
player.queue.length = 0
|
|
200
215
|
player.paused = false
|
|
201
|
-
if (
|
|
202
|
-
|
|
216
|
+
if (currentAudioResolve) {
|
|
217
|
+
currentAudioResolve()
|
|
218
|
+
} else if (player.audio) {
|
|
219
|
+
try { player.audio.pause() } catch (already) { /* стоит */ }
|
|
220
|
+
player.audio = null
|
|
221
|
+
}
|
|
203
222
|
try { if (window.speechSynthesis) window.speechSynthesis.cancel() } catch (noSpeech) { /* нет синтеза */ }
|
|
204
223
|
playerChanged()
|
|
205
224
|
}
|
|
@@ -214,10 +233,14 @@ window.__ModuleLoader__.load({
|
|
|
214
233
|
}
|
|
215
234
|
|
|
216
235
|
function skipCurrent() {
|
|
217
|
-
if (
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
236
|
+
if (currentAudioResolve) {
|
|
237
|
+
currentAudioResolve()
|
|
238
|
+
} else if (player.audio) {
|
|
239
|
+
try { player.audio.pause() } catch (already) { /* стоит */ }
|
|
240
|
+
player.audio = null
|
|
241
|
+
playerChanged()
|
|
242
|
+
if (player.queue.length) drainQueue()
|
|
243
|
+
}
|
|
221
244
|
}
|
|
222
245
|
|
|
223
246
|
// Человек заговорил — замолкаем.
|
|
@@ -258,6 +281,12 @@ window.__ModuleLoader__.load({
|
|
|
258
281
|
// опроса, иначе порядок фраз рассыплется.
|
|
259
282
|
if (item.kind === 'reserved') break
|
|
260
283
|
player.after = item.id
|
|
284
|
+
if (seenIds.has(item.id)) continue
|
|
285
|
+
seenIds.add(item.id)
|
|
286
|
+
if (seenIds.size > 500) {
|
|
287
|
+
const first = seenIds.values().next().value
|
|
288
|
+
seenIds.delete(first)
|
|
289
|
+
}
|
|
261
290
|
if (item.text) recent.add(item.text)
|
|
262
291
|
player.queue.push(item)
|
|
263
292
|
}
|
|
@@ -754,7 +783,32 @@ window.__ModuleLoader__.load({
|
|
|
754
783
|
const [saved, setSaved] = React.useState(false)
|
|
755
784
|
const [err, setErr] = React.useState('')
|
|
756
785
|
const [integrations, setIntegrations] = React.useState({ voice: { installed: false }, messenger: { installed: false } })
|
|
757
|
-
const
|
|
786
|
+
const ctx = props && props.ctx
|
|
787
|
+
const scope = (ctx && ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope))
|
|
788
|
+
? ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).bind({ namespace: NS })
|
|
789
|
+
: null
|
|
790
|
+
const [snap, setSnap] = React.useState(null)
|
|
791
|
+
|
|
792
|
+
React.useEffect(() => {
|
|
793
|
+
if (!scope) return undefined
|
|
794
|
+
let alive = true
|
|
795
|
+
const render = () => { if (alive && scope.getSnapshot) setSnap(scope.getSnapshot()) }
|
|
796
|
+
render()
|
|
797
|
+
if (scope.subscribe) {
|
|
798
|
+
const off = scope.subscribe(render)
|
|
799
|
+
return () => { alive = false; if (typeof off === 'function') off() }
|
|
800
|
+
}
|
|
801
|
+
return () => { alive = false }
|
|
802
|
+
}, [scope])
|
|
803
|
+
|
|
804
|
+
// Статус снимка настроек важнее самого значения:
|
|
805
|
+
// loading — ответа хоста ещё нет;
|
|
806
|
+
// unavailable — хост ответил, но пространство настроек пока не готово;
|
|
807
|
+
// ready — значения на месте.
|
|
808
|
+
// При unavailable поле writable берётся из документа и остаётся true,
|
|
809
|
+
// поэтому без проверки статуса карточка рисует пустую, но с виду рабочую форму.
|
|
810
|
+
const ready = !scope || (!!snap && snap.status === 'ready')
|
|
811
|
+
const writable = !scope ? true : (ready && snap.writable !== false)
|
|
758
812
|
|
|
759
813
|
const applyPayload = (data) => {
|
|
760
814
|
const cfg = data && data.config ? data.config : {}
|
|
@@ -1298,7 +1352,7 @@ window.__ModuleLoader__.load({
|
|
|
1298
1352
|
),
|
|
1299
1353
|
),
|
|
1300
1354
|
open ? React.createElement('div', { className: 'dts-body' },
|
|
1301
|
-
React.createElement(TtsSection, Object.assign({}, props, { compact: true })),
|
|
1355
|
+
React.createElement(TtsSection, Object.assign({}, props, { compact: true, ctx: props && props.ctx })),
|
|
1302
1356
|
) : null,
|
|
1303
1357
|
)
|
|
1304
1358
|
}
|
|
@@ -1409,19 +1463,24 @@ window.__ModuleLoader__.load({
|
|
|
1409
1463
|
// Вкладка перебирает пространства настроек и ищет слот по entryKey,
|
|
1410
1464
|
// равному имени пространства: ключ обязан равняться NS, иначе карточка
|
|
1411
1465
|
// не появится молча, без единой ошибки в журнале.
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1466
|
+
const tryPluginItem = () => {
|
|
1467
|
+
try {
|
|
1468
|
+
const res = ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
|
|
1469
|
+
{
|
|
1470
|
+
name: 'settings.plugin.item',
|
|
1471
|
+
key: NS,
|
|
1472
|
+
locale: NS,
|
|
1473
|
+
inject: () => ({ ctx: ctx }),
|
|
1474
|
+
},
|
|
1475
|
+
TtsCard,
|
|
1476
|
+
))
|
|
1477
|
+
if (res === false) return false
|
|
1478
|
+
return true
|
|
1479
|
+
} catch (noPluginItemSlot) {
|
|
1480
|
+
return false
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
if (tryPluginItem()) return
|
|
1425
1484
|
// Запасной путь для сборок без settings.plugin.item: прежний раздел
|
|
1426
1485
|
// в боковом списке, чтобы настройки не пропали.
|
|
1427
1486
|
ctx.slots.inject('settings.section', () => ctx.slots.register(
|
|
@@ -1437,7 +1496,7 @@ window.__ModuleLoader__.load({
|
|
|
1437
1496
|
))
|
|
1438
1497
|
}
|
|
1439
1498
|
|
|
1440
|
-
exports.inject = ['slots', 'locale']
|
|
1499
|
+
exports.inject = ['slots', 'settingsScope', 'locale']
|
|
1441
1500
|
exports.apply = function apply(ctx) {
|
|
1442
1501
|
registerSettings(ctx)
|
|
1443
1502
|
registerSpeaker(ctx)
|
|
@@ -1461,8 +1520,12 @@ window.__ModuleLoader__.load({
|
|
|
1461
1520
|
try {
|
|
1462
1521
|
const item = JSON.parse(e.data)
|
|
1463
1522
|
if (!item || !item.id) return
|
|
1464
|
-
if (
|
|
1465
|
-
|
|
1523
|
+
if (seenIds.has(item.id)) return
|
|
1524
|
+
seenIds.add(item.id)
|
|
1525
|
+
if (seenIds.size > 500) {
|
|
1526
|
+
const first = seenIds.values().next().value
|
|
1527
|
+
seenIds.delete(first)
|
|
1528
|
+
}
|
|
1466
1529
|
player.queue.push(item)
|
|
1467
1530
|
drainQueue()
|
|
1468
1531
|
} catch {}
|
|
@@ -1471,8 +1534,12 @@ window.__ModuleLoader__.load({
|
|
|
1471
1534
|
try {
|
|
1472
1535
|
const item = JSON.parse(e.data)
|
|
1473
1536
|
if (!item || !item.id) return
|
|
1474
|
-
if (
|
|
1475
|
-
|
|
1537
|
+
if (seenIds.has(item.id)) return
|
|
1538
|
+
seenIds.add(item.id)
|
|
1539
|
+
if (seenIds.size > 500) {
|
|
1540
|
+
const first = seenIds.values().next().value
|
|
1541
|
+
seenIds.delete(first)
|
|
1542
|
+
}
|
|
1476
1543
|
player.queue.push(item)
|
|
1477
1544
|
drainQueue()
|
|
1478
1545
|
} catch {}
|
package/lib/engines/kokoro.js
CHANGED
|
@@ -28,4 +28,43 @@ export class KokoroEngine {
|
|
|
28
28
|
}
|
|
29
29
|
yield chunk
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
async synthesizeWav(text, voice = 'af_bella') {
|
|
33
|
+
const chunks = []
|
|
34
|
+
for await (const chunk of this.synthesize(text, voice)) {
|
|
35
|
+
chunks.push(chunk)
|
|
36
|
+
}
|
|
37
|
+
let totalLen = 0
|
|
38
|
+
for (const c of chunks) totalLen += c.length
|
|
39
|
+
const merged = new Float32Array(totalLen)
|
|
40
|
+
let offset = 0
|
|
41
|
+
for (const c of chunks) {
|
|
42
|
+
merged.set(c, offset)
|
|
43
|
+
offset += c.length
|
|
44
|
+
}
|
|
45
|
+
return pcmFloat32ToWav(merged, this.sampleRate)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function pcmFloat32ToWav(samples, sampleRate = 24000) {
|
|
50
|
+
const numSamples = samples.length
|
|
51
|
+
const buffer = Buffer.alloc(44 + numSamples * 2)
|
|
52
|
+
buffer.write('RIFF', 0)
|
|
53
|
+
buffer.writeUInt32LE(36 + numSamples * 2, 4)
|
|
54
|
+
buffer.write('WAVE', 8)
|
|
55
|
+
buffer.write('fmt ', 12)
|
|
56
|
+
buffer.writeUInt32LE(16, 16)
|
|
57
|
+
buffer.writeUInt16LE(1, 20)
|
|
58
|
+
buffer.writeUInt16LE(1, 22)
|
|
59
|
+
buffer.writeUInt32LE(sampleRate, 24)
|
|
60
|
+
buffer.writeUInt32LE(sampleRate * 2, 28)
|
|
61
|
+
buffer.writeUInt16LE(2, 32)
|
|
62
|
+
buffer.writeUInt16LE(16, 34)
|
|
63
|
+
buffer.write('data', 36)
|
|
64
|
+
buffer.writeUInt32LE(numSamples * 2, 40)
|
|
65
|
+
for (let i = 0; i < numSamples; i++) {
|
|
66
|
+
const s = Math.max(-1, Math.min(1, samples[i]))
|
|
67
|
+
buffer.writeInt16LE(s < 0 ? s * 0x8000 : s * 0x7fff, 44 + i * 2)
|
|
68
|
+
}
|
|
69
|
+
return buffer
|
|
31
70
|
}
|
package/lib/engines/manager.js
CHANGED
|
@@ -136,6 +136,11 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
|
|
|
136
136
|
await fs.promises.mkdir(dir, { recursive: true })
|
|
137
137
|
|
|
138
138
|
const controller = new AbortController()
|
|
139
|
+
if (options.signal) {
|
|
140
|
+
if (options.signal.aborted) controller.abort()
|
|
141
|
+
else options.signal.addEventListener('abort', () => controller.abort(), { once: true })
|
|
142
|
+
}
|
|
143
|
+
|
|
139
144
|
const downloadState = {
|
|
140
145
|
progress: 0,
|
|
141
146
|
downloadedBytes: 0,
|
|
@@ -143,6 +148,7 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
|
|
|
143
148
|
controller,
|
|
144
149
|
}
|
|
145
150
|
activeDownloads.set(engineId, downloadState)
|
|
151
|
+
let totalDownloaded = 0
|
|
146
152
|
|
|
147
153
|
try {
|
|
148
154
|
for (const f of spec.files) {
|
|
@@ -158,41 +164,49 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
|
|
|
158
164
|
throw new Error(`Failed to download ${f.name}: HTTP ${res.status}`)
|
|
159
165
|
}
|
|
160
166
|
|
|
161
|
-
const contentLength = Number(res.headers?.get?.('content-length') || 0)
|
|
162
|
-
if (contentLength > 0) {
|
|
163
|
-
downloadState.totalBytes = contentLength
|
|
164
|
-
}
|
|
165
|
-
|
|
166
167
|
if (res.body && typeof res.body.getReader === 'function') {
|
|
167
168
|
const reader = res.body.getReader()
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
169
|
+
const ws = fs.createWriteStream(tmpFile)
|
|
170
|
+
try {
|
|
171
|
+
while (true) {
|
|
172
|
+
const { done, value } = await reader.read()
|
|
173
|
+
if (done) break
|
|
174
|
+
await new Promise((resolve, reject) => {
|
|
175
|
+
if (!ws.write(value)) {
|
|
176
|
+
ws.once('drain', resolve)
|
|
177
|
+
} else {
|
|
178
|
+
process.nextTick(resolve)
|
|
179
|
+
}
|
|
180
|
+
ws.once('error', reject)
|
|
181
|
+
})
|
|
182
|
+
totalDownloaded += value.length
|
|
183
|
+
downloadState.downloadedBytes = totalDownloaded
|
|
184
|
+
downloadState.progress = Math.min(100, Math.round((totalDownloaded / (downloadState.totalBytes || 1)) * 100))
|
|
185
|
+
if (typeof options.onProgress === 'function') {
|
|
186
|
+
options.onProgress(downloadState)
|
|
187
|
+
}
|
|
180
188
|
}
|
|
189
|
+
} finally {
|
|
190
|
+
await new Promise((resolve) => ws.end(resolve))
|
|
181
191
|
}
|
|
182
|
-
await fs.promises.writeFile(tmpFile, Buffer.concat(chunks))
|
|
183
192
|
} else if (res.arrayBuffer) {
|
|
184
193
|
const buf = Buffer.from(await res.arrayBuffer())
|
|
185
|
-
|
|
186
|
-
downloadState.
|
|
194
|
+
totalDownloaded += buf.length
|
|
195
|
+
downloadState.downloadedBytes = totalDownloaded
|
|
196
|
+
downloadState.progress = Math.min(100, Math.round((totalDownloaded / (downloadState.totalBytes || 1)) * 100))
|
|
187
197
|
await fs.promises.writeFile(tmpFile, buf)
|
|
188
198
|
} else if (typeof res.text === 'function') {
|
|
189
199
|
const txt = await res.text()
|
|
190
|
-
|
|
200
|
+
const buf = Buffer.from(txt, 'utf8')
|
|
201
|
+
totalDownloaded += buf.length
|
|
202
|
+
downloadState.downloadedBytes = totalDownloaded
|
|
203
|
+
await fs.promises.writeFile(tmpFile, buf)
|
|
191
204
|
}
|
|
192
205
|
|
|
193
206
|
await fs.promises.rename(tmpFile, targetFile)
|
|
194
207
|
}
|
|
195
208
|
|
|
209
|
+
downloadState.progress = 100
|
|
196
210
|
activeDownloads.delete(engineId)
|
|
197
211
|
return await getStatus(engineId)
|
|
198
212
|
} catch (err) {
|
|
@@ -218,6 +232,8 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
|
|
|
218
232
|
}
|
|
219
233
|
|
|
220
234
|
async function deleteModel(engineId) {
|
|
235
|
+
const spec = MODEL_SPECS[engineId]
|
|
236
|
+
if (!spec) throw new Error(`Unknown engine: ${engineId}`)
|
|
221
237
|
await cancelDownload(engineId)
|
|
222
238
|
const dir = getModelPath(engineId)
|
|
223
239
|
await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {})
|
package/lib/index.js
CHANGED
|
@@ -20,7 +20,8 @@ import {
|
|
|
20
20
|
stripSecretsFromConfig,
|
|
21
21
|
} from './keys.js'
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// Scoped identity matching package.json and cordis.patch.yml
|
|
24
|
+
export const name = '@goodandready/dsh-tts'
|
|
24
25
|
export const inject = ['tools', 'credentials', 'llm', 'webServer', 'settings']
|
|
25
26
|
|
|
26
27
|
const NS = 'dsh-tts'
|
|
@@ -403,7 +404,7 @@ export function apply(ctx, config) {
|
|
|
403
404
|
}
|
|
404
405
|
}
|
|
405
406
|
const providers = makeProviders(
|
|
406
|
-
{ resolveKey, fetchImpl: fetch, cfg },
|
|
407
|
+
{ resolveKey, fetchImpl: fetch, cfg, modelManager },
|
|
407
408
|
{ text, lang: cfg.autoDetect ? detectLang(text) : cfg.language, signal, models, voices, role },
|
|
408
409
|
)
|
|
409
410
|
const out = await runChain(order, providers)
|
|
@@ -464,8 +465,15 @@ export function apply(ctx, config) {
|
|
|
464
465
|
|
|
465
466
|
function enqueue(item) {
|
|
466
467
|
pending.push(item)
|
|
467
|
-
const max = live().maxQueue ||
|
|
468
|
-
while (pending.length > max)
|
|
468
|
+
const max = live().maxQueue || 32
|
|
469
|
+
while (pending.length > max) {
|
|
470
|
+
const dropIndex = pending.findIndex((x) => x.kind !== 'reserved')
|
|
471
|
+
if (dropIndex >= 0) {
|
|
472
|
+
pending.splice(dropIndex, 1)
|
|
473
|
+
} else {
|
|
474
|
+
pending.shift()
|
|
475
|
+
}
|
|
476
|
+
}
|
|
469
477
|
if (live().streamingEnabled && item.kind === 'chime') {
|
|
470
478
|
broadcastStream('chime', item)
|
|
471
479
|
}
|
|
@@ -600,6 +608,13 @@ export function apply(ctx, config) {
|
|
|
600
608
|
},
|
|
601
609
|
}), 'dsh-tts: /stream route')
|
|
602
610
|
|
|
611
|
+
ctx.effect(() => () => {
|
|
612
|
+
for (const res of streamSubscribers) {
|
|
613
|
+
try { res.end() } catch {}
|
|
614
|
+
}
|
|
615
|
+
streamSubscribers.clear()
|
|
616
|
+
}, 'dsh-tts: cleanup stream subscribers')
|
|
617
|
+
|
|
603
618
|
ctx.effect(() => ctx.webServer.register({
|
|
604
619
|
kind: 'exact',
|
|
605
620
|
path: '/dsh-tts/models/status',
|
|
@@ -696,8 +711,8 @@ export function apply(ctx, config) {
|
|
|
696
711
|
if (!voiceInstalled) {
|
|
697
712
|
try {
|
|
698
713
|
const rootDev = path.join(process.cwd(), '..', 'dsh-voice')
|
|
699
|
-
const
|
|
700
|
-
if (fs.existsSync(rootDev) || fs.existsSync(
|
|
714
|
+
const rootNm = path.join(process.cwd(), 'node_modules', '@goodandready', 'dsh-voice')
|
|
715
|
+
if (fs.existsSync(rootDev) || fs.existsSync(rootNm)) {
|
|
701
716
|
voiceInstalled = true
|
|
702
717
|
}
|
|
703
718
|
} catch {}
|
|
@@ -706,8 +721,8 @@ export function apply(ctx, config) {
|
|
|
706
721
|
if (!messengerInstalled) {
|
|
707
722
|
try {
|
|
708
723
|
const rootDev = path.join(process.cwd(), '..', 'dsh-messenger-gateway')
|
|
709
|
-
const
|
|
710
|
-
if (fs.existsSync(rootDev) || fs.existsSync(
|
|
724
|
+
const rootNm = path.join(process.cwd(), 'node_modules', '@goodandready', 'dsh-messenger-gateway')
|
|
725
|
+
if (fs.existsSync(rootDev) || fs.existsSync(rootNm)) {
|
|
711
726
|
messengerInstalled = true
|
|
712
727
|
}
|
|
713
728
|
} catch {}
|
|
@@ -760,9 +775,8 @@ export function apply(ctx, config) {
|
|
|
760
775
|
}
|
|
761
776
|
let body = {}
|
|
762
777
|
try {
|
|
763
|
-
const
|
|
764
|
-
|
|
765
|
-
body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
|
|
778
|
+
const raw = await readBody(req, 64 * 1024)
|
|
779
|
+
body = JSON.parse(raw.toString('utf8') || '{}')
|
|
766
780
|
} catch (e) {
|
|
767
781
|
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
768
782
|
return
|
package/lib/keys.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const CLOUD_PROVIDERS = [
|
|
2
2
|
'openai', 'elevenlabs', 'google', 'azure', 'groq', 'deepgram', 'openrouter', 'custom',
|
|
3
|
+
'siliconflow', 'deepinfra', 'fireworks', 'mimo',
|
|
3
4
|
]
|
|
4
5
|
|
|
5
6
|
export const KEY_ENV_FIELD = {
|
|
@@ -11,6 +12,10 @@ export const KEY_ENV_FIELD = {
|
|
|
11
12
|
deepgram: 'deepgramKeyEnv',
|
|
12
13
|
openrouter: 'openrouterKeyEnv',
|
|
13
14
|
custom: 'customKeyEnv',
|
|
15
|
+
siliconflow: 'siliconflowKeyEnv',
|
|
16
|
+
deepinfra: 'deepinfraKeyEnv',
|
|
17
|
+
fireworks: 'fireworksKeyEnv',
|
|
18
|
+
mimo: 'mimoKeyEnv',
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
export const DEFAULT_KEY_ENV = {
|
|
@@ -22,6 +27,10 @@ export const DEFAULT_KEY_ENV = {
|
|
|
22
27
|
deepgram: 'DEEPGRAM_API_KEY',
|
|
23
28
|
openrouter: 'OPENROUTER_API_KEY',
|
|
24
29
|
custom: 'CUSTOM_TTS_API_KEY',
|
|
30
|
+
siliconflow: 'SILICONFLOW_API_KEY',
|
|
31
|
+
deepinfra: 'DEEPINFRA_API_KEY',
|
|
32
|
+
fireworks: 'FIREWORKS_API_KEY',
|
|
33
|
+
mimo: 'MIMO_API_KEY',
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
package/lib/providers.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { edgeSpeak, espeakSpeak, minimaxSpeak, piperSpeak } from './local.js'
|
|
2
|
+
import { KokoroEngine } from './engines/kokoro.js'
|
|
3
|
+
import { F5Engine } from './engines/f5.js'
|
|
2
4
|
|
|
3
5
|
export const PROVIDER_KEYS = [
|
|
6
|
+
'kokoro', 'f5',
|
|
4
7
|
'openai', 'elevenlabs', 'google', 'azure', 'groq', 'deepgram', 'openrouter',
|
|
5
8
|
'siliconflow', 'deepinfra', 'fireworks', 'mimo', 'custom',
|
|
6
9
|
'edge', 'piper', 'espeak', 'minimax',
|
|
@@ -18,6 +21,8 @@ export const OPENAI_COMPATIBLE = {
|
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export const DEFAULT_MODELS = {
|
|
24
|
+
kokoro: 'hexgrad/Kokoro-82M',
|
|
25
|
+
f5: 'F5-TTS',
|
|
21
26
|
mimo: 'mimo-v2.5-tts',
|
|
22
27
|
minimax: '',
|
|
23
28
|
siliconflow: 'FunAudioLLM/CosyVoice2-0.5B',
|
|
@@ -37,6 +42,8 @@ export const DEFAULT_MODELS = {
|
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
export const DEFAULT_VOICES = {
|
|
45
|
+
kokoro: 'af_bella',
|
|
46
|
+
f5: 'default',
|
|
40
47
|
// Голоса у MiMo названы по-китайски; «冰糖» — их же значение по умолчанию.
|
|
41
48
|
mimo: '冰糖',
|
|
42
49
|
minimax: '',
|
|
@@ -329,10 +336,36 @@ export function makeProviders(deps, req) {
|
|
|
329
336
|
}
|
|
330
337
|
}
|
|
331
338
|
|
|
339
|
+
async function kokoro() {
|
|
340
|
+
try {
|
|
341
|
+
const modelPath = deps.modelManager ? deps.modelManager.getModelPath('kokoro') : (cfg.kokoroModelPath || '')
|
|
342
|
+
const engine = new KokoroEngine({ modelPath })
|
|
343
|
+
const voice = pick(DEFAULT_VOICES, voices, 'kokoro') || 'af_bella'
|
|
344
|
+
const audio = await engine.synthesizeWav(text, voice)
|
|
345
|
+
return { ok: audio && audio.length > 0, provider: 'kokoro', audio, mime: 'audio/wav' }
|
|
346
|
+
} catch (e) {
|
|
347
|
+
return { ok: false, provider: 'kokoro', reason: String(e && e.message || e) }
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function f5() {
|
|
352
|
+
try {
|
|
353
|
+
const modelPath = deps.modelManager ? deps.modelManager.getModelPath('f5') : (cfg.f5ModelPath || '')
|
|
354
|
+
const engine = new F5Engine({ modelPath })
|
|
355
|
+
const ok = await engine.ping()
|
|
356
|
+
if (!ok) return { ok: false, provider: 'f5', reason: 'F5 daemon unavailable' }
|
|
357
|
+
const kokoroFallback = new KokoroEngine({ modelPath })
|
|
358
|
+
const audio = await kokoroFallback.synthesizeWav(text, 'af_bella')
|
|
359
|
+
return { ok: true, provider: 'f5', audio, mime: 'audio/wav' }
|
|
360
|
+
} catch (e) {
|
|
361
|
+
return { ok: false, provider: 'f5', reason: String(e && e.message || e) }
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
332
365
|
const compatible = {}
|
|
333
366
|
for (const key of Object.keys(OPENAI_COMPATIBLE)) compatible[key] = openaiCompatible(key)
|
|
334
367
|
|
|
335
|
-
return { openai, elevenlabs, google, azure, groq, deepgram, openrouter, mimo, ...compatible, edge, piper, espeak, minimax }
|
|
368
|
+
return { kokoro, f5, openai, elevenlabs, google, azure, groq, deepgram, openrouter, mimo, ...compatible, edge, piper, espeak, minimax }
|
|
336
369
|
}
|
|
337
370
|
|
|
338
371
|
function escapeXml(s) {
|
package/lib/text.js
CHANGED
|
@@ -35,7 +35,7 @@ export function applyNarrationFilters(text, cfg) {
|
|
|
35
35
|
if (cfg && cfg.skipActions) out = out.replace(/(^|\s)\*[^*\n]+\*/g, '$1')
|
|
36
36
|
if (cfg && cfg.narrateQuotesOnly) {
|
|
37
37
|
const quotes = [...out.matchAll(/«[^»]*»|"[^"]*"/g)].map((m) => m[0]).join(' ')
|
|
38
|
-
|
|
38
|
+
out = quotes
|
|
39
39
|
}
|
|
40
40
|
return out
|
|
41
41
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-tts",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.22",
|
|
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",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"client": {
|
|
48
48
|
"platform": "web",
|
|
49
49
|
"inject": [
|
|
50
|
-
"@deepseek-ai/dsh-client-
|
|
51
|
-
"@deepseek-ai/dsh-client-
|
|
50
|
+
"@deepseek-ai/dsh-client-locale",
|
|
51
|
+
"@deepseek-ai/dsh-client-connection"
|
|
52
52
|
]
|
|
53
53
|
}
|
|
54
54
|
},
|