@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 +24 -0
- package/README.zh.md +6 -5
- package/lib/cache.js +55 -7
- package/lib/client.js +52 -6
- package/lib/engines/manager.js +36 -5
- package/lib/index.js +1 -0
- package/lib/keys.js +1 -0
- package/lib/routes.js +22 -3
- package/lib/stream-hub.js +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
-
* **
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
await fs.promises.
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
62
|
-
|
|
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).
|
|
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
|
package/lib/engines/manager.js
CHANGED
|
@@ -11,12 +11,12 @@ export const MODEL_SPECS = {
|
|
|
11
11
|
files: [
|
|
12
12
|
{
|
|
13
13
|
name: 'kokoro-v0_19.onnx',
|
|
14
|
-
url: 'https://
|
|
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://
|
|
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/
|
|
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
|
|
54
|
-
|
|
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
|
|
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' ||
|
|
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.
|
|
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",
|