@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 +40 -0
- package/README.zh.md +6 -5
- package/lib/cache.js +62 -11
- package/lib/client.js +54 -8
- package/lib/engines/manager.js +44 -5
- package/lib/index.js +1 -0
- package/lib/keys.js +1 -0
- package/lib/routes.js +33 -4
- package/lib/stream-hub.js +1 -1
- package/package.json +1 -1
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
|
|
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 })
|
|
@@ -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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
await fs.promises.
|
|
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
|
-
|
|
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
|
|
@@ -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())
|
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,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
|
|
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 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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|