@goodandready/dsh-tts 0.4.30 → 0.4.31

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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.31
4
+
5
+ ### Fixed
6
+ - **Settings & Revision Safety** (#151):
7
+ - Stripped `revision` from config payload before calling `settingsApi.replace(parsed, revision)`, eliminating `Config field "revision" is not volatile` validation errors in core SettingsForms.
8
+ - Reordered save sequence so provider API keys are only persisted to credential store after settings replacement succeeds, preventing mutation on 409 revision conflict.
9
+ - **Messenger & Duplex Route Guards** (#165):
10
+ - Added `vadBargeIn` property to `/dsh-tts/status` and connected client player to check `vadBargeIn` during active speech.
11
+ - Enforced 403 rejection on external messenger `/dsh-tts/speak` requests when `messengerTtsEnabled` is disabled.
12
+ - **Model Integrity & Immutable Sources** (#179):
13
+ - Updated Kokoro-82M ONNX model weights and voices URLs to official release assets from `thewh1teagle/kokoro-onnx` v0.19.
14
+ - Pinned F5-TTS model weights URL to immutable upstream commit hash `84e5a410d9cead4de2f847e7c9369a6440bdfaca`.
15
+ - Upgraded `validateFileIntegrity` to reject HTML error pages, repetitive dummy byte sequences, and malformed JSON files.
16
+ - **SSE Hub Backpressure & Resource Protection** (#186):
17
+ - Implemented backpressure buffer checks in `safeWrite`, immediately dropping backpressured subscribers when buffer cap is exceeded while preserving healthy subscribers.
18
+ - **Client Timeout Stream Wrapping** (#198):
19
+ - Wrapped response body methods (`json`, `text`, `blob`, `arrayBuffer`) in `clientFetch` so `AbortController` timeout remains active until the body settles.
20
+ - **Synthesis Cache Race Elimination** (#202):
21
+ - Added generation tracking and in-flight write synchronization to `lib/cache.js`, preventing `ENOTEMPTY` errors during cache clearing.
22
+ - **Documentation & Packaging Hygiene** (#181, #183, #184):
23
+ - Aligned `README.zh.md` architecture and tables to correctly classify Edge TTS as cloud-free online and Piper/eSpeak as local/offline.
24
+ - Removed `AGENTS.md` and `index.md` from `.gitignore` while ensuring strict exclusion from published npm packages.
25
+ - Added comprehensive test suites verifying synthesis deduplication, player SSE contracts, and cache clear atomicity.
26
+
3
27
  ## 0.4.30
4
28
 
5
29
  ### Fixed
package/README.zh.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  <div align="center">
4
4
 
5
- <h3>DeepSeek Harness 多引擎语音合成:云端与系统离线引擎、流式音频、IT 术语词典与即时通讯集成</h3>
5
+ <h3>DeepSeek Harness 多引擎语音合成:云端与本地引擎、流式音频、IT 术语词典与即时通讯集成</h3>
6
6
 
7
7
  <p align="center">
8
8
  <a href="https://www.npmjs.com/package/@goodandready/dsh-tts"><img src="https://img.shields.io/npm/v/@goodandready/dsh-tts.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
@@ -39,7 +39,7 @@
39
39
 
40
40
  **`dsh-tts`** 为 **DeepSeek Harness** Web 界面提供高保真智能体回复语音朗读服务。开启 **朗读智能体回复** 后,每条生成的助手回复或实时流式片段均由服务端合成并即时推流至浏览器播放。
41
41
 
42
- API 密钥绝不暴露给前端:音频合成全程在服务端通过**多服务商独立备用链**执行,包括系统离线引擎(Edge TTS、Piper、eSpeak)。Kokoro/F5 仅可下载权重,**推理未捆绑**。
42
+ API 密钥绝不暴露给前端:音频合成全程在服务端通过**多服务商独立备用链**执行,包括本地系统与在线引擎(Edge TTS 在线免 Key,Piper 与 eSpeak 本地离线)。Kokoro/F5 仅可下载权重,**推理未捆绑**。
43
43
 
44
44
  ```mermaid
45
45
  graph LR
@@ -59,7 +59,7 @@ graph LR
59
59
 
60
60
  subgraph Fallback [TTS 引擎备用链]
61
61
  LRU -->|未命中| Chain{生效备用链}
62
- Chain -->|离线| P1[Edge TTS / Piper / eSpeak]
62
+ Chain -->|在线 / 本地| P1[Edge TTS / Piper / eSpeak]
63
63
  Chain -.->|云端| P2[OpenAI / ElevenLabs / Google / Azure / Groq]
64
64
  Chain -.->|免费云端| P3[EdgeTTS / SiliconFlow]
65
65
  Chain -.->|系统兜底| P4[本地 Piper / eSpeak NG]
@@ -85,8 +85,9 @@ graph LR
85
85
 
86
86
  ## 🚀 核心功能
87
87
 
88
- ### 1. 📴 离线系统引擎与诚实的神经引擎状态
89
- * **Edge TTS / Piper / eSpeak**:可用的离线/系统合成,无需云 API Key。
88
+ ### 1. 📴 本地系统与在线引擎状态
89
+ * **Piper / eSpeak**:完全离线的本地系统合成,无需云 API 密钥。
90
+ * **Edge TTS**:免费的微软在线语音合成,无需 API 密钥(需要网络连接与 edge-tts 工具)。
90
91
  * **Kokoro-82M / F5-TTS**:仅权重下载与状态展示。**推理未捆绑在本包中**,provider 会明确失败并继续 fallback。
91
92
  * **ModelManager 管理界面**:在设置中手动安装模型,实时显示下载进度条、SHA-256 校验和一键删除。无任何静默或自动下载。
92
93
 
package/lib/cache.js CHANGED
@@ -19,6 +19,15 @@ export function createSpeechCache({ root, maxBytes }) {
19
19
  return ready
20
20
  }
21
21
 
22
+ let generation = 0
23
+ const pendingWrites = new Set()
24
+
25
+ function trackWrite(p) {
26
+ pendingWrites.add(p)
27
+ p.finally(() => pendingWrites.delete(p)).catch(() => {})
28
+ return p
29
+ }
30
+
22
31
  // L1 In-Memory RAM Cache (max 50 entries or 10MB total buffer size)
23
32
  const l1Map = new Map()
24
33
  const L1_MAX_ITEMS = 50
@@ -102,9 +111,21 @@ export function createSpeechCache({ root, maxBytes }) {
102
111
  await ensureIndex()
103
112
  const buf = Buffer.isBuffer(audio) ? audio : Buffer.from(audio)
104
113
  putL1(key, mime, buf)
105
- await fs.promises.writeFile(path.join(dir, key + AUDIO_EXT), buf)
106
114
  const now = Date.now()
107
- await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime, at: now }))
115
+ const currentGen = generation
116
+ const writePromise = (async () => {
117
+ if (currentGen !== generation) return
118
+ await fs.promises.writeFile(path.join(dir, key + AUDIO_EXT), buf)
119
+ await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime, at: now }))
120
+ if (currentGen !== generation) {
121
+ await fs.promises.rm(path.join(dir, key + AUDIO_EXT), { force: true }).catch(() => {})
122
+ await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
123
+ }
124
+ })()
125
+ trackWrite(writePromise)
126
+ await writePromise
127
+ if (currentGen !== generation) return
128
+
108
129
  const prev = diskEntries.get(key)
109
130
  if (prev) diskTotalBytes -= prev.size
110
131
  diskEntries.set(key, { size: buf.length, at: now })
@@ -124,7 +145,15 @@ export function createSpeechCache({ root, maxBytes }) {
124
145
  l1Map.set(key, { ...hit, at: now })
125
146
  const d = diskEntries.get(key)
126
147
  if (d) d.at = now
127
- fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: hit.mime, at: now })).catch(() => {})
148
+ const currentGen = generation
149
+ const writePromise = (async () => {
150
+ if (currentGen !== generation) return
151
+ await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: hit.mime, at: now }))
152
+ if (currentGen !== generation) {
153
+ await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
154
+ }
155
+ })().catch(() => {})
156
+ trackWrite(writePromise)
128
157
  return { mime: hit.mime || 'audio/mpeg', audio: hit.audio }
129
158
  }
130
159
 
@@ -137,7 +166,15 @@ export function createSpeechCache({ root, maxBytes }) {
137
166
  const now = Date.now()
138
167
  const d = diskEntries.get(key)
139
168
  if (d) d.at = now
140
- fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: meta.mime, at: now })).catch(() => {})
169
+ const currentGen = generation
170
+ const writePromise = (async () => {
171
+ if (currentGen !== generation) return
172
+ await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: meta.mime, at: now }))
173
+ if (currentGen !== generation) {
174
+ await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
175
+ }
176
+ })().catch(() => {})
177
+ trackWrite(writePromise)
141
178
  return { mime: meta.mime || 'audio/mpeg', audio }
142
179
  } catch (miss) {
143
180
  return null
@@ -145,12 +182,23 @@ export function createSpeechCache({ root, maxBytes }) {
145
182
  },
146
183
 
147
184
  async clear() {
185
+ generation++
148
186
  l1Map.clear()
187
+ if (pendingWrites.size > 0) {
188
+ await Promise.allSettled(Array.from(pendingWrites))
189
+ }
149
190
  await ensureIndex()
150
191
  const count = diskEntries.size
151
- for (const key of diskEntries.keys()) {
152
- await fs.promises.rm(path.join(dir, key + AUDIO_EXT), { force: true }).catch(() => {})
153
- await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
192
+ let files = []
193
+ try {
194
+ files = await fs.promises.readdir(dir)
195
+ } catch {
196
+ files = []
197
+ }
198
+ for (const name of files) {
199
+ if (name.endsWith(AUDIO_EXT) || name.endsWith('.json')) {
200
+ await fs.promises.rm(path.join(dir, name), { force: true }).catch(() => {})
201
+ }
154
202
  }
155
203
  diskEntries.clear()
156
204
  diskTotalBytes = 0
package/lib/client.js CHANGED
@@ -51,21 +51,66 @@ window.__ModuleLoader__.load({
51
51
  }
52
52
 
53
53
 
54
- function clientFetch(url, options) {
54
+ function clientFetch(url, options) {
55
55
  const opts = options || {}
56
56
  const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : 15000
57
57
  const controller = new AbortController()
58
- const timer = setTimeout(() => controller.abort(), timeoutMs)
58
+ let timer = null
59
+ let parentCleanup = null
60
+
61
+ const cleanup = () => {
62
+ if (timer) {
63
+ clearTimeout(timer)
64
+ timer = null
65
+ }
66
+ if (parentCleanup) {
67
+ parentCleanup()
68
+ parentCleanup = null
69
+ }
70
+ }
71
+
72
+ timer = setTimeout(() => {
73
+ try { controller.abort() } catch { /* already aborted */ }
74
+ }, timeoutMs)
75
+ if (timer && typeof timer.unref === 'function') timer.unref()
59
76
 
60
77
  if (opts.signal) {
61
- if (opts.signal.aborted) controller.abort()
62
- else opts.signal.addEventListener('abort', () => controller.abort(), { once: true })
78
+ if (opts.signal.aborted) {
79
+ controller.abort()
80
+ } else {
81
+ const onAbort = () => {
82
+ try { controller.abort() } catch { /* already aborted */ }
83
+ }
84
+ opts.signal.addEventListener('abort', onAbort, { once: true })
85
+ parentCleanup = () => {
86
+ try { opts.signal.removeEventListener('abort', onAbort) } catch { /* listener already removed */ }
87
+ }
88
+ }
63
89
  }
64
90
 
65
91
  const fetchOpts = Object.assign({}, opts, { signal: controller.signal })
66
92
  delete fetchOpts.timeoutMs
67
93
 
68
- return fetch(url, fetchOpts).finally(() => clearTimeout(timer))
94
+ return fetch(url, fetchOpts).then((res) => {
95
+ if (!res.body || res.bodyUsed) {
96
+ cleanup()
97
+ return res
98
+ }
99
+ const wrap = (orig) => {
100
+ if (typeof orig !== 'function') return orig
101
+ return function (...args) {
102
+ return orig.apply(res, args).finally(cleanup)
103
+ }
104
+ }
105
+ res.json = wrap(res.json)
106
+ res.text = wrap(res.text)
107
+ res.blob = wrap(res.blob)
108
+ res.arrayBuffer = wrap(res.arrayBuffer)
109
+ return res
110
+ }, (err) => {
111
+ cleanup()
112
+ throw err
113
+ })
69
114
  }
70
115
 
71
116
  function getModelHint(provider, t) {
@@ -474,7 +519,7 @@ window.__ModuleLoader__.load({
474
519
  const onSpeaking = (event) => {
475
520
  const phase = event && event.detail && event.detail.phase
476
521
  if (phase !== 'start') return
477
- if (!player.bargeIn) return
522
+ if (!player.bargeIn || player.vadBargeIn === false) return
478
523
  if (!player.audio && !player.queue.length && !player.busy) return
479
524
  stopPlayback()
480
525
  }
@@ -568,6 +613,7 @@ window.__ModuleLoader__.load({
568
613
  if (meta && meta.chime) player.chime = meta.chime
569
614
  if (meta && meta.roles) player.roles = meta.roles
570
615
  if (meta && typeof meta.bargeIn === 'boolean') player.bargeIn = meta.bargeIn
616
+ if (meta && typeof meta.vadBargeIn === 'boolean') player.vadBargeIn = meta.vadBargeIn
571
617
  if (!player.enabled) return
572
618
  const res = await clientFetch('/dsh-tts/pending?after=' + encodeURIComponent(player.after || ''), { cache: 'no-store', timeoutMs: 8000 })
573
619
  if (!res.ok) return
@@ -11,12 +11,12 @@ export const MODEL_SPECS = {
11
11
  files: [
12
12
  {
13
13
  name: 'kokoro-v0_19.onnx',
14
- url: 'https://huggingface.co/hexgrad/Kokoro-82M/resolve/f905c3175b9f7a77d4c1b9be894ff99ff7e9a8f2/kokoro-v0_19.onnx',
14
+ url: 'https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files/kokoro-v0_19.onnx',
15
15
  minBytes: 1024,
16
16
  },
17
17
  {
18
18
  name: 'voices.json',
19
- url: 'https://huggingface.co/hexgrad/Kokoro-82M/resolve/f905c3175b9f7a77d4c1b9be894ff99ff7e9a8f2/voices.json',
19
+ url: 'https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files/voices.json',
20
20
  minBytes: 1024,
21
21
  },
22
22
  ],
@@ -29,7 +29,7 @@ export const MODEL_SPECS = {
29
29
  files: [
30
30
  {
31
31
  name: 'model_1200000.safetensors',
32
- url: 'https://huggingface.co/SWivid/F5-TTS/resolve/main/F5TTS_Base/model_1200000.safetensors',
32
+ url: 'https://huggingface.co/SWivid/F5-TTS/resolve/84e5a410d9cead4de2f847e7c9369a6440bdfaca/F5TTS_Base/model_1200000.safetensors',
33
33
  minBytes: 1024,
34
34
  },
35
35
  ],
@@ -50,13 +50,44 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
50
50
  const st = await fs.promises.stat(filePath)
51
51
  if (!st.isFile() || st.size < minBytes) return false
52
52
  const fd = await fs.promises.open(filePath, 'r')
53
- const buf = Buffer.alloc(Math.min(st.size, 1024))
54
- await fd.read(buf, 0, buf.length, 0)
53
+ const sampleLen = Math.min(st.size, 2048)
54
+ const buf = Buffer.alloc(sampleLen)
55
+ await fd.read(buf, 0, sampleLen, 0)
55
56
  await fd.close()
57
+
56
58
  const str = buf.toString('utf8').trim().toLowerCase()
57
59
  if (str.startsWith('<!doctype') || str.startsWith('<html') || str.startsWith('<head') || str.includes('<html')) {
58
60
  return false
59
61
  }
62
+ if (str.startsWith('404') || str.includes('entry not found') || str.includes('invalid rev id')) {
63
+ return false
64
+ }
65
+
66
+ // Reject repetitive dummy bytes (e.g. 2048 bytes of 0x2a)
67
+ if (buf.length >= 2048 && (buf[0] === 0x2a || buf[0] === 0x00)) {
68
+ let allSame = true
69
+ for (let i = 1; i < buf.length; i++) {
70
+ if (buf[i] !== buf[0]) {
71
+ allSame = false
72
+ break
73
+ }
74
+ }
75
+ if (allSame) return false
76
+ }
77
+
78
+ // Validate JSON files
79
+ if (filePath.endsWith('.json')) {
80
+ const rawStr = buf.toString('utf8').trim()
81
+ if (rawStr.startsWith('{') || rawStr.startsWith('[') || rawStr.includes('{')) {
82
+ try {
83
+ const fullContent = await fs.promises.readFile(filePath, 'utf8')
84
+ JSON.parse(fullContent)
85
+ } catch {
86
+ return false
87
+ }
88
+ }
89
+ }
90
+
60
91
  return true
61
92
  } catch (_err) {
62
93
  return false
package/lib/index.js CHANGED
@@ -248,6 +248,7 @@ export function apply(ctx, config) {
248
248
  get: () => live(),
249
249
  replace: async (next, expectedRevision) => {
250
250
  const plain = plainConfig(next)
251
+ delete plain.revision
251
252
  const svc = ctx?.get?.('settings') ?? ctx?.settings ?? ctx?.get?.('settingsForms') ?? ctx?.settingsForms
252
253
  if (!svc) {
253
254
  throw new Error('Settings service is unavailable')
package/lib/keys.js CHANGED
@@ -59,6 +59,7 @@ export function stripSecretsFromConfig(payload) {
59
59
  const config = { ...payload }
60
60
  delete config.keys
61
61
  delete config.credentials
62
+ delete config.revision
62
63
  for (const key of Object.keys(config)) {
63
64
  if (/KeyEnv$/.test(key)) continue
64
65
  if (/(^|.*)(apiKey|api_key|secret|token|password)$/i.test(key) || /Key$/.test(key)) {
package/lib/routes.js CHANGED
@@ -49,6 +49,7 @@ export function registerHttpRoutes(ctx, api) {
49
49
  rate: cfg.rate,
50
50
  chime: cfg.chime,
51
51
  bargeIn: cfg.bargeIn && cfg.vadBargeIn !== false,
52
+ vadBargeIn: cfg.vadBargeIn !== false,
52
53
  voiceDuplexEnabled: cfg.voiceDuplexEnabled !== false,
53
54
  messengerTtsEnabled: cfg.messengerTtsEnabled !== false,
54
55
  language: cfg.language,
@@ -368,23 +369,35 @@ export function registerHttpRoutes(ctx, api) {
368
369
  }
369
370
  const revision = payload?.revision ?? (typeof payload?.config === 'object' ? payload.config?.revision : undefined)
370
371
  if (payload && typeof payload.config === 'object') {
371
- payload = { ...payload.config, keys: payload.keys, revision: payload.revision ?? payload.config.revision }
372
+ payload = { ...payload.config, keys: payload.keys }
373
+ }
374
+ if (payload && typeof payload === 'object') {
375
+ delete payload.revision
376
+ if (payload.config && typeof payload.config === 'object') {
377
+ delete payload.config.revision
378
+ }
372
379
  }
373
380
  const keys = pendingKeyWrites(payload && payload.keys)
374
381
  const stripped = stripSecretsFromConfig(payload)
382
+ delete stripped.revision
375
383
  let parsed
376
384
  try { parsed = validateConfig(stripped) } catch (e) {
377
385
  writeJson(res, 400, { ok: false, error: { code: 'schema', message: String(e && e.message || e) } })
378
386
  return
379
387
  }
388
+ if (parsed && typeof parsed === 'object') {
389
+ delete parsed.revision
390
+ }
380
391
  try {
392
+ await settingsApi.replace(parsed, revision)
381
393
  for (const item of keys) {
382
394
  await storeProviderKey(item.provider, item.value)
383
395
  }
384
- await settingsApi.replace(parsed, revision)
385
396
  writeJson(res, 200, await configResponse())
386
397
  } catch (e) {
387
- const isConflict = e?.code === 'conflict' || e?.name === 'SettingsConflictError' || /conflict|revision/i.test(e?.message || '')
398
+ const isConflict = e?.code === 'conflict' || e?.name === 'SettingsConflictError' || (
399
+ /conflict/i.test(e?.message || '') || (/revision/i.test(e?.message || '') && !/not volatile/i.test(e?.message || ''))
400
+ )
388
401
  const statusCode = isConflict ? 409 : 500
389
402
  const code = isConflict ? 'conflict' : 'save'
390
403
  writeJson(res, statusCode, { ok: false, error: { code, message: String(e && e.message || e) } })
@@ -467,6 +480,12 @@ export function registerHttpRoutes(ctx, api) {
467
480
  const rawText = String(payload.text || '')
468
481
  if (!rawText.trim()) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
469
482
  const cfg = live()
483
+ const isVoice = payload.channel === 'voice' || payload.source === 'voice' || req.headers['x-dsh-channel'] === 'voice'
484
+ if (isVoice && !cfg.voiceDuplexEnabled) {
485
+ writeJson(res, 403, { ok: false, error: { code: 'disabled', message: 'voice duplex tts is disabled' } })
486
+ return
487
+ }
488
+ const isWeb = payload.channel === 'web' || payload.source === 'web' || req.headers['x-dsh-channel'] === 'web'
470
489
  const isMessenger = payload.channel === 'messenger' || payload.source === 'messenger' || req.headers['x-dsh-channel'] === 'messenger'
471
490
  if (isMessenger && !cfg.messengerTtsEnabled) {
472
491
  writeJson(res, 403, { ok: false, error: { code: 'disabled', message: 'messenger tts is disabled' } })
package/lib/stream-hub.js CHANGED
@@ -25,7 +25,7 @@ function safeWrite(res, payload) {
25
25
  ? res.writableLength
26
26
  : (res.__dtsBufferedBytes || 0)
27
27
 
28
- if (res.__dtsBackpressured && currentBuffered + payloadBytes > MAX_SUBSCRIBER_BUFFER) {
28
+ if (res.__dtsBackpressured && (currentBuffered + payloadBytes > MAX_SUBSCRIBER_BUFFER)) {
29
29
  subscribers.delete(res)
30
30
  detach(res)
31
31
  try { res.end() } catch (_err) { /* already ended */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.4.30",
3
+ "version": "0.4.31",
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",