@goodandready/dsh-tts 0.3.20 → 0.3.21

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
  }
@@ -1461,8 +1490,12 @@ window.__ModuleLoader__.load({
1461
1490
  try {
1462
1491
  const item = JSON.parse(e.data)
1463
1492
  if (!item || !item.id) return
1464
- if (recent.seen.has(item.id)) return
1465
- recent.seen.add(item.id)
1493
+ if (seenIds.has(item.id)) return
1494
+ seenIds.add(item.id)
1495
+ if (seenIds.size > 500) {
1496
+ const first = seenIds.values().next().value
1497
+ seenIds.delete(first)
1498
+ }
1466
1499
  player.queue.push(item)
1467
1500
  drainQueue()
1468
1501
  } catch {}
@@ -1471,8 +1504,12 @@ window.__ModuleLoader__.load({
1471
1504
  try {
1472
1505
  const item = JSON.parse(e.data)
1473
1506
  if (!item || !item.id) return
1474
- if (recent.seen.has(item.id)) return
1475
- recent.seen.add(item.id)
1507
+ if (seenIds.has(item.id)) return
1508
+ seenIds.add(item.id)
1509
+ if (seenIds.size > 500) {
1510
+ const first = seenIds.values().next().value
1511
+ seenIds.delete(first)
1512
+ }
1476
1513
  player.queue.push(item)
1477
1514
  drainQueue()
1478
1515
  } 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
@@ -403,7 +403,7 @@ export function apply(ctx, config) {
403
403
  }
404
404
  }
405
405
  const providers = makeProviders(
406
- { resolveKey, fetchImpl: fetch, cfg },
406
+ { resolveKey, fetchImpl: fetch, cfg, modelManager },
407
407
  { text, lang: cfg.autoDetect ? detectLang(text) : cfg.language, signal, models, voices, role },
408
408
  )
409
409
  const out = await runChain(order, providers)
@@ -464,8 +464,15 @@ export function apply(ctx, config) {
464
464
 
465
465
  function enqueue(item) {
466
466
  pending.push(item)
467
- const max = live().maxQueue || 8
468
- while (pending.length > max) pending.shift()
467
+ const max = live().maxQueue || 32
468
+ while (pending.length > max) {
469
+ const dropIndex = pending.findIndex((x) => x.kind !== 'reserved')
470
+ if (dropIndex >= 0) {
471
+ pending.splice(dropIndex, 1)
472
+ } else {
473
+ pending.shift()
474
+ }
475
+ }
469
476
  if (live().streamingEnabled && item.kind === 'chime') {
470
477
  broadcastStream('chime', item)
471
478
  }
@@ -600,6 +607,13 @@ export function apply(ctx, config) {
600
607
  },
601
608
  }), 'dsh-tts: /stream route')
602
609
 
610
+ ctx.effect(() => () => {
611
+ for (const res of streamSubscribers) {
612
+ try { res.end() } catch {}
613
+ }
614
+ streamSubscribers.clear()
615
+ }, 'dsh-tts: cleanup stream subscribers')
616
+
603
617
  ctx.effect(() => ctx.webServer.register({
604
618
  kind: 'exact',
605
619
  path: '/dsh-tts/models/status',
@@ -696,8 +710,8 @@ export function apply(ctx, config) {
696
710
  if (!voiceInstalled) {
697
711
  try {
698
712
  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)) {
713
+ const rootNm = path.join(process.cwd(), 'node_modules', '@goodandready', 'dsh-voice')
714
+ if (fs.existsSync(rootDev) || fs.existsSync(rootNm)) {
701
715
  voiceInstalled = true
702
716
  }
703
717
  } catch {}
@@ -706,8 +720,8 @@ export function apply(ctx, config) {
706
720
  if (!messengerInstalled) {
707
721
  try {
708
722
  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)) {
723
+ const rootNm = path.join(process.cwd(), 'node_modules', '@goodandready', 'dsh-messenger-gateway')
724
+ if (fs.existsSync(rootDev) || fs.existsSync(rootNm)) {
711
725
  messengerInstalled = true
712
726
  }
713
727
  } catch {}
@@ -760,9 +774,8 @@ export function apply(ctx, config) {
760
774
  }
761
775
  let body = {}
762
776
  try {
763
- const chunks = []
764
- for await (const c of req) chunks.push(c)
765
- body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
777
+ const raw = await readBody(req, 64 * 1024)
778
+ body = JSON.parse(raw.toString('utf8') || '{}')
766
779
  } catch (e) {
767
780
  writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
768
781
  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.21",
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
  },