@goodandready/dsh-tts 0.4.30 → 0.4.32

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,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.32
4
+
5
+ ### Fixed
6
+ - **Transactional Rollback on Credential Failure** (#151):
7
+ - Added transactional rollback to previous config in `/dsh-tts/config` PUT handler if credential persistence throws an error after settings replacement, ensuring consistent state on aborted saves.
8
+ - **Messenger Gateway / Speak Guard Strictness** (#165):
9
+ - Enforced 403 rejection for unclassified / external gateway requests lacking channel metadata when `messengerTtsEnabled` is disabled, while preserving internal web UI and voice duplex traffic.
10
+ - **Strict Multi-Format Model Integrity Validation** (#179):
11
+ - Enhanced `validateFileIntegrity` to reject uniform repeating byte sequences of any value, strictly validate JSON parsing for voice mappings, enforce ONNX protobuf header magic (`0x08`), and validate safetensors header length bounds.
12
+ - **Non-Tautological Test Suite Enforcement** (#181):
13
+ - Replaced tautological and assertion-free test fixtures in deduplication, client prebuffering, and SSE player suites with strict assertions verifying exact execution counts (`synthCalls === 1`), factory invocation, and queue limits.
14
+ - **Immediate Pre-Write SSE Chunk Cap** (#186):
15
+ - Added immediate buffer cap rejection before `res.write` in `safeWrite`, preventing oversized payloads (>512 KB) from entering the socket buffer even on the initial chunk.
16
+ - **Cache L2 Clear Concurrency Guard** (#202):
17
+ - Captured generation token at the immediate start of `get()`, preventing post-clear metadata recreation when disk reads are interleaved with cache clearing.
18
+
19
+ ## 0.4.31
20
+
21
+ ### Fixed
22
+ - **Settings & Revision Safety** (#151):
23
+ - Stripped `revision` from config payload before calling `settingsApi.replace(parsed, revision)`, eliminating `Config field "revision" is not volatile` validation errors in core SettingsForms.
24
+ - Reordered save sequence so provider API keys are only persisted to credential store after settings replacement succeeds, preventing mutation on 409 revision conflict.
25
+ - **Messenger & Duplex Route Guards** (#165):
26
+ - Added `vadBargeIn` property to `/dsh-tts/status` and connected client player to check `vadBargeIn` during active speech.
27
+ - Enforced 403 rejection on external messenger `/dsh-tts/speak` requests when `messengerTtsEnabled` is disabled.
28
+ - **Model Integrity & Immutable Sources** (#179):
29
+ - Updated Kokoro-82M ONNX model weights and voices URLs to official release assets from `thewh1teagle/kokoro-onnx` v0.19.
30
+ - Pinned F5-TTS model weights URL to immutable upstream commit hash `84e5a410d9cead4de2f847e7c9369a6440bdfaca`.
31
+ - Upgraded `validateFileIntegrity` to reject HTML error pages, repetitive dummy byte sequences, and malformed JSON files.
32
+ - **SSE Hub Backpressure & Resource Protection** (#186):
33
+ - Implemented backpressure buffer checks in `safeWrite`, immediately dropping backpressured subscribers when buffer cap is exceeded while preserving healthy subscribers.
34
+ - **Client Timeout Stream Wrapping** (#198):
35
+ - Wrapped response body methods (`json`, `text`, `blob`, `arrayBuffer`) in `clientFetch` so `AbortController` timeout remains active until the body settles.
36
+ - **Synthesis Cache Race Elimination** (#202):
37
+ - Added generation tracking and in-flight write synchronization to `lib/cache.js`, preventing `ENOTEMPTY` errors during cache clearing.
38
+ - **Documentation & Packaging Hygiene** (#181, #183, #184):
39
+ - Aligned `README.zh.md` architecture and tables to correctly classify Edge TTS as cloud-free online and Piper/eSpeak as local/offline.
40
+ - Removed `AGENTS.md` and `index.md` from `.gitignore` while ensuring strict exclusion from published npm packages.
41
+ - Added comprehensive test suites verifying synthesis deduplication, player SSE contracts, and cache clear atomicity.
42
+
3
43
  ## 0.4.30
4
44
 
5
45
  ### 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 })
@@ -116,6 +137,7 @@ export function createSpeechCache({ root, maxBytes }) {
116
137
  },
117
138
 
118
139
  async get(key) {
140
+ const currentGen = generation
119
141
  // 1. L1 RAM Hit
120
142
  if (l1Map.has(key)) {
121
143
  const hit = l1Map.get(key)
@@ -124,7 +146,16 @@ export function createSpeechCache({ root, maxBytes }) {
124
146
  l1Map.set(key, { ...hit, at: now })
125
147
  const d = diskEntries.get(key)
126
148
  if (d) d.at = now
127
- fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: hit.mime, at: now })).catch(() => {})
149
+ if (currentGen === generation) {
150
+ const writePromise = (async () => {
151
+ if (currentGen !== generation) return
152
+ await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: hit.mime, at: now }))
153
+ if (currentGen !== generation) {
154
+ await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
155
+ }
156
+ })().catch(() => {})
157
+ trackWrite(writePromise)
158
+ }
128
159
  return { mime: hit.mime || 'audio/mpeg', audio: hit.audio }
129
160
  }
130
161
 
@@ -133,11 +164,20 @@ export function createSpeechCache({ root, maxBytes }) {
133
164
  try {
134
165
  const meta = JSON.parse(await fs.promises.readFile(path.join(dir, key + '.json'), 'utf8'))
135
166
  const audio = await fs.promises.readFile(path.join(dir, key + AUDIO_EXT))
136
- putL1(key, meta.mime, audio)
137
- const now = Date.now()
138
- const d = diskEntries.get(key)
139
- if (d) d.at = now
140
- fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: meta.mime, at: now })).catch(() => {})
167
+ if (currentGen === generation) {
168
+ putL1(key, meta.mime, audio)
169
+ const now = Date.now()
170
+ const d = diskEntries.get(key)
171
+ if (d) d.at = now
172
+ const writePromise = (async () => {
173
+ if (currentGen !== generation) return
174
+ await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: meta.mime, at: now }))
175
+ if (currentGen !== generation) {
176
+ await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
177
+ }
178
+ })().catch(() => {})
179
+ trackWrite(writePromise)
180
+ }
141
181
  return { mime: meta.mime || 'audio/mpeg', audio }
142
182
  } catch (miss) {
143
183
  return null
@@ -145,12 +185,23 @@ export function createSpeechCache({ root, maxBytes }) {
145
185
  },
146
186
 
147
187
  async clear() {
188
+ generation++
148
189
  l1Map.clear()
190
+ if (pendingWrites.size > 0) {
191
+ await Promise.allSettled(Array.from(pendingWrites))
192
+ }
149
193
  await ensureIndex()
150
194
  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(() => {})
195
+ let files = []
196
+ try {
197
+ files = await fs.promises.readdir(dir)
198
+ } catch {
199
+ files = []
200
+ }
201
+ for (const name of files) {
202
+ if (name.endsWith(AUDIO_EXT) || name.endsWith('.json')) {
203
+ await fs.promises.rm(path.join(dir, name), { force: true }).catch(() => {})
204
+ }
154
205
  }
155
206
  diskEntries.clear()
156
207
  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
@@ -628,7 +674,7 @@ window.__ModuleLoader__.load({
628
674
  clientFetch('/dsh-tts/speak', {
629
675
  method: 'POST',
630
676
  headers: { 'Content-Type': 'application/json' },
631
- body: JSON.stringify({ text }),
677
+ body: JSON.stringify({ text, channel: 'web' }),
632
678
  timeoutMs: 15000,
633
679
  })
634
680
  .then((r) => r.json())
@@ -681,7 +727,7 @@ window.__ModuleLoader__.load({
681
727
  clientFetch('/dsh-tts/speak', {
682
728
  method: 'POST',
683
729
  headers: { 'Content-Type': 'application/json' },
684
- body: JSON.stringify({ text: phrase }),
730
+ body: JSON.stringify({ text: phrase, channel: 'web' }),
685
731
  timeoutMs: 15000,
686
732
  })
687
733
  .then((r) => r.json())
@@ -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,52 @@ 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 0x41, 0x2a, etc.)
67
+ if (buf.length >= 2048) {
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
+ // Format-specific verification
79
+ if (filePath.endsWith('.json')) {
80
+ try {
81
+ const content = await fs.promises.readFile(filePath, 'utf8')
82
+ const parsed = JSON.parse(content)
83
+ if (!parsed || typeof parsed !== 'object') return false
84
+ } catch {
85
+ return false
86
+ }
87
+ } else if (filePath.endsWith('.safetensors')) {
88
+ if (st.size < 16) return false
89
+ const headerLen = buf.readBigUInt64LE(0)
90
+ if (headerLen <= 0n || headerLen > 100n * 1024n * 1024n || BigInt(st.size) < 8n + headerLen) {
91
+ return false
92
+ }
93
+ } else if (filePath.endsWith('.onnx')) {
94
+ if (buf[0] !== 0x08) {
95
+ return false
96
+ }
97
+ }
98
+
60
99
  return true
61
100
  } catch (_err) {
62
101
  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,45 @@ 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
+ }
391
+ let replaceSucceeded = false
392
+ const prevConfig = typeof settingsApi.get === 'function' ? settingsApi.get() : null
380
393
  try {
394
+ await settingsApi.replace(parsed, revision)
395
+ replaceSucceeded = true
381
396
  for (const item of keys) {
382
397
  await storeProviderKey(item.provider, item.value)
383
398
  }
384
- await settingsApi.replace(parsed, revision)
385
399
  writeJson(res, 200, await configResponse())
386
400
  } catch (e) {
387
- const isConflict = e?.code === 'conflict' || e?.name === 'SettingsConflictError' || /conflict|revision/i.test(e?.message || '')
401
+ if (replaceSucceeded && prevConfig) {
402
+ try {
403
+ await settingsApi.replace(prevConfig)
404
+ } catch (_rollbackErr) {
405
+ // best-effort rollback on credential storage failure
406
+ }
407
+ }
408
+ const isConflict = e?.code === 'conflict' || e?.name === 'SettingsConflictError' || (
409
+ /conflict/i.test(e?.message || '') || (/revision/i.test(e?.message || '') && !/not volatile/i.test(e?.message || ''))
410
+ )
388
411
  const statusCode = isConflict ? 409 : 500
389
412
  const code = isConflict ? 'conflict' : 'save'
390
413
  writeJson(res, statusCode, { ok: false, error: { code, message: String(e && e.message || e) } })
@@ -467,7 +490,13 @@ export function registerHttpRoutes(ctx, api) {
467
490
  const rawText = String(payload.text || '')
468
491
  if (!rawText.trim()) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
469
492
  const cfg = live()
470
- const isMessenger = payload.channel === 'messenger' || payload.source === 'messenger' || req.headers['x-dsh-channel'] === 'messenger'
493
+ const isVoice = payload.channel === 'voice' || payload.source === 'voice' || req.headers['x-dsh-channel'] === 'voice'
494
+ if (isVoice && !cfg.voiceDuplexEnabled) {
495
+ writeJson(res, 403, { ok: false, error: { code: 'disabled', message: 'voice duplex tts is disabled' } })
496
+ return
497
+ }
498
+ const isWeb = payload.channel === 'web' || payload.source === 'web' || req.headers['x-dsh-channel'] === 'web'
499
+ const isMessenger = payload.channel === 'messenger' || payload.source === 'messenger' || req.headers['x-dsh-channel'] === 'messenger' || (!isVoice && !isWeb)
471
500
  if (isMessenger && !cfg.messengerTtsEnabled) {
472
501
  writeJson(res, 403, { ok: false, error: { code: 'disabled', message: 'messenger tts is disabled' } })
473
502
  return
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 (payloadBytes > MAX_SUBSCRIBER_BUFFER || (res.__dtsBackpressured && currentBuffered + payloadBytes > MAX_SUBSCRIBER_BUFFER) || currentBuffered + payloadBytes > MAX_SUBSCRIBER_BUFFER * 2) {
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.32",
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",