@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 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
- const st = await fs.promises.stat(full)
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
- const done = () => { URL.revokeObjectURL(url); player.audio = null; playerChanged(); resolve() }
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) { resolve() }
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 (player.audio) { try { player.audio.pause() } catch (already) { /* стоит */ } }
202
- player.audio = null
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 (player.audio) { try { player.audio.pause() } catch (already) { /* стоит */ } }
218
- player.audio = null
219
- playerChanged()
220
- if (player.queue.length) drainQueue()
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 writable = true
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
- let moved = false
1413
- try {
1414
- moved = !!ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
1415
- {
1416
- name: 'settings.plugin.item',
1417
- key: NS,
1418
- locale: NS,
1419
- inject: () => ({ ctx: ctx }),
1420
- },
1421
- TtsCard,
1422
- ))
1423
- } catch (noPluginItemSlot) { moved = false }
1424
- if (moved) return
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 (recent.seen.has(item.id)) return
1465
- recent.seen.add(item.id)
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 (recent.seen.has(item.id)) return
1475
- recent.seen.add(item.id)
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 {}
@@ -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
  }
@@ -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 chunks = []
169
- let loaded = 0
170
-
171
- while (true) {
172
- const { done, value } = await reader.read()
173
- if (done) break
174
- chunks.push(value)
175
- loaded += value.length
176
- downloadState.downloadedBytes = loaded
177
- downloadState.progress = Math.min(100, Math.round((loaded / (downloadState.totalBytes || 1)) * 100))
178
- if (typeof options.onProgress === 'function') {
179
- options.onProgress(downloadState)
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
- downloadState.downloadedBytes = buf.length
186
- downloadState.progress = 100
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
- await fs.promises.writeFile(tmpFile, Buffer.from(txt, 'utf8'))
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
- export const name = 'dsh-tts'
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 || 8
468
- while (pending.length > max) pending.shift()
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 rootExt = path.resolve('B:/Project/DEV/dhsplugins/dsh-voice')
700
- if (fs.existsSync(rootDev) || fs.existsSync(rootExt)) {
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 rootExt = path.resolve('B:/Project/DEV/dhsplugins/dsh-messenger-gateway')
710
- if (fs.existsSync(rootDev) || fs.existsSync(rootExt)) {
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 chunks = []
764
- for await (const c of req) chunks.push(c)
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
- if (quotes) out = quotes
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.20",
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-runtime",
51
- "@deepseek-ai/dsh-client-ui-slots"
50
+ "@deepseek-ai/dsh-client-locale",
51
+ "@deepseek-ai/dsh-client-connection"
52
52
  ]
53
53
  }
54
54
  },