@goodandready/dsh-tts 0.4.29 → 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,42 @@
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
+
27
+ ## 0.4.30
28
+
29
+ ### Fixed
30
+ - **Supply Chain Security & Updater** (#196, #199):
31
+ - Removed `--config.minimumReleaseAge=0` bypass in `lib/updater.js:installExact`, respecting pnpm supply-chain quarantine during updates.
32
+ - Consolidated updater endpoint to canonical `/api/dsh-tts/update`, removing legacy `/dsh-tts/updater` route alias.
33
+ - Implemented shared `updaterMutex` returning HTTP 409 `Install already in progress` across concurrent requests.
34
+ - **Theme Tokens & Design System** (#197):
35
+ - Replaced legacy CSS custom properties with canonical DSH core theme tokens (`--dsw-state-warn-primary`, `--dsw-brand-primary`, `--dsw-bg-layer-3`).
36
+ - **Client Networking & Timeout Safety** (#198):
37
+ - Wrapped all client `fetch` calls in `clientFetch(url, options)` utility with configurable 15-second `AbortController` timeout.
38
+ - Added error rejection handlers on fire-and-forget telemetry, settings, and player requests.
39
+
3
40
  ## 0.4.29
4
41
 
5
42
  ### 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,7 +51,69 @@ window.__ModuleLoader__.load({
51
51
  }
52
52
 
53
53
 
54
- function getModelHint(provider, t) {
54
+ function clientFetch(url, options) {
55
+ const opts = options || {}
56
+ const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : 15000
57
+ const controller = new AbortController()
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()
76
+
77
+ if (opts.signal) {
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
+ }
89
+ }
90
+
91
+ const fetchOpts = Object.assign({}, opts, { signal: controller.signal })
92
+ delete fetchOpts.timeoutMs
93
+
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
+ })
114
+ }
115
+
116
+ function getModelHint(provider, t) {
55
117
  if (typeof t === 'function') {
56
118
  const loc = t('modelHint_' + provider)
57
119
  if (loc && !loc.startsWith('modelHint_')) return loc
@@ -122,12 +184,12 @@ window.__ModuleLoader__.load({
122
184
  '.dts-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:12px 0}' +
123
185
  '.dts-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
124
186
  '.dts-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:13px;box-sizing:border-box}' +
125
- '.dts-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
187
+ '.dts-input:focus{outline:none;border-color:var(--dsw-alias-brand-primary, #6366f1)}' +
126
188
  '.dts-foot{border-top:1px solid var(--dsw-alias-border-l2);display:flex;justify-content:flex-end;align-items:center;gap:10px;padding:14px 0 4px}' +
127
189
  '.dts-save{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:6px 16px;font-size:13px;font-weight:500;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);transition:all .15s ease}' +
128
190
  '.dts-save:hover:not(:disabled){opacity:0.88}' +
129
191
  '.dts-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 12px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}' +
130
- '.dts-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}' +
192
+ '.dts-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-3, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}' +
131
193
  '.dts-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}' +
132
194
  '.dts-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 50%, transparent)}' +
133
195
  '.dts-entry{display:flex;flex-direction:column;gap:8px;padding:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}' +
@@ -135,18 +197,18 @@ window.__ModuleLoader__.load({
135
197
  '.dts-row .dts-model{flex:1;min-width:120px}' +
136
198
  '.dts-row .dts-key{flex:1;min-width:180px}' +
137
199
  '.dts-mini{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:6px;width:28px;height:28px;cursor:pointer;flex:none;display:inline-flex;align-items:center;justify-content:center;font-size:13px;transition:all .15s ease}' +
138
- '.dts-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2))}' +
200
+ '.dts-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-3, var(--dsw-alias-bg-layer-2))}' +
139
201
  '.dts-ok{font-size:12px;color:var(--dsw-alias-state-success-primary);font-weight:500}' +
140
202
  '.dts-bad{font-size:12px;color:var(--dsw-alias-state-error-primary);font-weight:500}' +
141
203
  '.dts-badge{font-size:11px;padding:3px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap;display:inline-flex;align-items:center;gap:4px;font-weight:500}' +
142
204
  '.dts-badge-on{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}' +
143
- '.dts-badge-warn{color:var(--dsw-alias-state-warning-primary);border-color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent)}' +
205
+ '.dts-badge-warn{color:var(--dsw-alias-state-warn-primary, #f59e0b);border-color:var(--dsw-alias-state-warn-primary, #f59e0b);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 8%, transparent)}' +
144
206
  '.dts-badge-bad{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}' +
145
207
  '.dts-link{background:none;border:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:12px;padding:0;text-decoration:underline}' +
146
208
  '.dts-link:hover:not(:disabled){color:var(--dsw-alias-label-primary)}' +
147
209
  '.dts-alert{padding:10px 14px;border-radius:8px;font-size:13px}' +
148
210
  '.dts-alert-err{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 20%, transparent)}' +
149
- '.dts-alert-warn{background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 10%, transparent);color:var(--dsw-alias-state-warning-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-warning-primary) 20%, transparent)}' +
211
+ '.dts-alert-warn{background:color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 10%, transparent);color:var(--dsw-alias-state-warn-primary, #f59e0b);border:1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 20%, transparent)}' +
150
212
  '.dts-alert-ok{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 20%, transparent)}' +
151
213
  '.dts-grow{flex:1;min-width:100px}'
152
214
 
@@ -457,7 +519,7 @@ window.__ModuleLoader__.load({
457
519
  const onSpeaking = (event) => {
458
520
  const phase = event && event.detail && event.detail.phase
459
521
  if (phase !== 'start') return
460
- if (!player.bargeIn) return
522
+ if (!player.bargeIn || player.vadBargeIn === false) return
461
523
  if (!player.audio && !player.queue.length && !player.busy) return
462
524
  stopPlayback()
463
525
  }
@@ -543,7 +605,7 @@ window.__ModuleLoader__.load({
543
605
 
544
606
  async function pollPending() {
545
607
  try {
546
- const st = await fetch('/dsh-tts/status', { cache: 'no-store' })
608
+ const st = await clientFetch('/dsh-tts/status', { cache: 'no-store', timeoutMs: 8000 })
547
609
  if (!st.ok) return
548
610
  const meta = await st.json()
549
611
  player.enabled = !!(meta && meta.speakReplies)
@@ -551,8 +613,9 @@ window.__ModuleLoader__.load({
551
613
  if (meta && meta.chime) player.chime = meta.chime
552
614
  if (meta && meta.roles) player.roles = meta.roles
553
615
  if (meta && typeof meta.bargeIn === 'boolean') player.bargeIn = meta.bargeIn
616
+ if (meta && typeof meta.vadBargeIn === 'boolean') player.vadBargeIn = meta.vadBargeIn
554
617
  if (!player.enabled) return
555
- const res = await fetch('/dsh-tts/pending?after=' + encodeURIComponent(player.after || ''), { cache: 'no-store' })
618
+ const res = await clientFetch('/dsh-tts/pending?after=' + encodeURIComponent(player.after || ''), { cache: 'no-store', timeoutMs: 8000 })
556
619
  if (!res.ok) return
557
620
  const data = await res.json()
558
621
  const items = data && Array.isArray(data.items) ? data.items : []
@@ -608,10 +671,11 @@ window.__ModuleLoader__.load({
608
671
  function playStandalone(text) {
609
672
  if (!text) return
610
673
  stopPlayback()
611
- fetch('/dsh-tts/speak', {
674
+ clientFetch('/dsh-tts/speak', {
612
675
  method: 'POST',
613
676
  headers: { 'Content-Type': 'application/json' },
614
677
  body: JSON.stringify({ text }),
678
+ timeoutMs: 15000,
615
679
  })
616
680
  .then((r) => r.json())
617
681
  .then((d) => {
@@ -623,15 +687,18 @@ window.__ModuleLoader__.load({
623
687
  audioBase64: d.audioBase64,
624
688
  })
625
689
  })
626
- .catch(() => {})
690
+ .catch((err) => {
691
+ console.warn('[dsh-tts] playStandalone fetch failed:', err)
692
+ })
627
693
  }
628
694
 
629
695
  function previewSpeech(provider, model, voice, text) {
630
696
  stopPlayback()
631
- return fetch('/dsh-tts/preview', {
697
+ return clientFetch('/dsh-tts/preview', {
632
698
  method: 'POST',
633
699
  headers: { 'Content-Type': 'application/json' },
634
700
  body: JSON.stringify({ provider, model, voice, text }),
701
+ timeoutMs: 15000,
635
702
  })
636
703
  .then((r) => r.json())
637
704
  .then((d) => {
@@ -657,10 +724,11 @@ window.__ModuleLoader__.load({
657
724
  if (list && list.items && list.items.length) phrase = list.items[0]
658
725
  }
659
726
  if (!phrase) return
660
- fetch('/dsh-tts/speak', {
727
+ clientFetch('/dsh-tts/speak', {
661
728
  method: 'POST',
662
729
  headers: { 'Content-Type': 'application/json' },
663
730
  body: JSON.stringify({ text: phrase }),
731
+ timeoutMs: 15000,
664
732
  })
665
733
  .then((r) => r.json())
666
734
  .then((d) => {
@@ -1219,8 +1287,8 @@ window.__ModuleLoader__.load({
1219
1287
  const [models, setModels] = React.useState({})
1220
1288
 
1221
1289
  const loadStatus = React.useCallback(() => {
1222
- if (typeof fetch === 'undefined') return
1223
- fetch('/dsh-tts/models/status')
1290
+ if (typeof clientFetch === 'undefined' && typeof fetch === 'undefined') return
1291
+ clientFetch('/dsh-tts/models/status', { timeoutMs: 8000 })
1224
1292
  .then((r) => r.json())
1225
1293
  .then((d) => { if (d && d.ok && d.models) setModels(d.models) })
1226
1294
  .catch(() => {}) // status poll best-effort
@@ -1236,11 +1304,14 @@ window.__ModuleLoader__.load({
1236
1304
  if (typeof window !== 'undefined' && window.confirm) {
1237
1305
  if (!window.confirm(t('confirmDeleteModel'))) return
1238
1306
  }
1239
- fetch('/dsh-tts/models/delete', {
1307
+ clientFetch('/dsh-tts/models/delete', {
1240
1308
  method: 'DELETE',
1241
1309
  headers: { 'content-type': 'application/json' },
1242
1310
  body: JSON.stringify({ engine }),
1243
- }).then(() => loadStatus()).catch(() => {})
1311
+ timeoutMs: 10000,
1312
+ }).then(() => loadStatus()).catch((err) => {
1313
+ console.warn('[dsh-tts] model delete failed:', err)
1314
+ })
1244
1315
  }
1245
1316
 
1246
1317
  const kokoro = models.kokoro || { installed: false, downloading: false, progress: 0 }
@@ -1480,7 +1551,7 @@ window.__ModuleLoader__.load({
1480
1551
  style: {
1481
1552
  padding: '10px 14px',
1482
1553
  borderRadius: '8px',
1483
- border: '1px solid var(--dsw-alias-state-warning-primary)',
1554
+ border: '1px solid var(--dsw-alias-state-warn-primary, #f59e0b)',
1484
1555
  background: 'var(--dsw-alias-bg-layer-2)',
1485
1556
  display: 'flex',
1486
1557
  flexDirection: 'column',
@@ -1488,7 +1559,7 @@ window.__ModuleLoader__.load({
1488
1559
  fontSize: '13px',
1489
1560
  },
1490
1561
  },
1491
- React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary)' } },
1562
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warn-primary, #f59e0b)' } },
1492
1563
  '⚠️ ' + t('voiceNotInstalledTitle')
1493
1564
  ),
1494
1565
  React.createElement('span', { className: 'dts-sub' },
@@ -1522,7 +1593,7 @@ window.__ModuleLoader__.load({
1522
1593
  style: {
1523
1594
  padding: '10px 14px',
1524
1595
  borderRadius: '8px',
1525
- border: '1px solid var(--dsw-alias-state-warning-primary)',
1596
+ border: '1px solid var(--dsw-alias-state-warn-primary, #f59e0b)',
1526
1597
  background: 'var(--dsw-alias-bg-layer-2)',
1527
1598
  display: 'flex',
1528
1599
  flexDirection: 'column',
@@ -1530,7 +1601,7 @@ window.__ModuleLoader__.load({
1530
1601
  fontSize: '13px',
1531
1602
  },
1532
1603
  },
1533
- React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary)' } },
1604
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warn-primary, #f59e0b)' } },
1534
1605
  '⚠️ ' + t('messengerNotInstalledTitle')
1535
1606
  ),
1536
1607
  React.createElement('span', { className: 'dts-sub' },
@@ -1565,8 +1636,9 @@ window.__ModuleLoader__.load({
1565
1636
  setLoading(true)
1566
1637
  setMsg(null)
1567
1638
  try {
1568
- const res = await fetch('/api/dsh-tts/update', {
1639
+ const res = await clientFetch('/api/dsh-tts/update', {
1569
1640
  headers: { 'x-dsh-plugin-update': '1' },
1641
+ timeoutMs: 10000,
1570
1642
  })
1571
1643
  if (!res.ok) throw new Error('HTTP ' + res.status)
1572
1644
  const data = await res.json()
@@ -1582,12 +1654,13 @@ window.__ModuleLoader__.load({
1582
1654
  setUpdating(true)
1583
1655
  setMsg(null)
1584
1656
  try {
1585
- const res = await fetch('/api/dsh-tts/update', {
1657
+ const res = await clientFetch('/api/dsh-tts/update', {
1586
1658
  method: 'POST',
1587
1659
  headers: {
1588
1660
  'x-dsh-plugin-update': '1',
1589
1661
  'content-type': 'application/json',
1590
1662
  },
1663
+ timeoutMs: 60000,
1591
1664
  })
1592
1665
  const data = await res.json()
1593
1666
  if (!res.ok) throw new Error((data && (data.error || data.message)) || 'HTTP ' + res.status)
@@ -1631,7 +1704,7 @@ window.__ModuleLoader__.load({
1631
1704
  {
1632
1705
  type: 'button',
1633
1706
  className: 'dts-btn',
1634
- style: { borderColor: 'var(--dsw-alias-state-warning-primary)', color: 'var(--dsw-alias-state-warning-primary)' },
1707
+ style: { borderColor: 'var(--dsw-alias-state-warn-primary, #f59e0b)', color: 'var(--dsw-alias-state-warn-primary, #f59e0b)' },
1635
1708
  onClick: onUpdateNow,
1636
1709
  disabled: updating || loading,
1637
1710
  },
@@ -1661,11 +1734,11 @@ window.__ModuleLoader__.load({
1661
1734
  const t = props.t || ((key) => key)
1662
1735
  const [data, setData] = React.useState(null)
1663
1736
  const loadStats = () => {
1664
- fetch('/dsh-tts/stats', { cache: 'no-store' }).then((r) => r.json()).then(setData).catch(() => {})
1737
+ clientFetch('/dsh-tts/stats', { cache: 'no-store', timeoutMs: 8000 }).then((r) => r.json()).then(setData).catch((err) => console.warn('[dsh-tts] loadStats failed:', err))
1665
1738
  }
1666
1739
  React.useEffect(() => { loadStats() }, [])
1667
1740
  const reset = () => {
1668
- fetch('/dsh-tts/stats', { method: 'DELETE' }).then(loadStats).catch(() => {})
1741
+ clientFetch('/dsh-tts/stats', { method: 'DELETE', timeoutMs: 8000 }).then(loadStats).catch((err) => console.warn('[dsh-tts] resetStats failed:', err))
1669
1742
  }
1670
1743
  return React.createElement('div', { className: 'dts-block' },
1671
1744
  React.createElement('span', { className: 'dts-sub' }, data
@@ -1751,18 +1824,18 @@ window.__ModuleLoader__.load({
1751
1824
 
1752
1825
  React.useEffect(() => {
1753
1826
  let alive = true
1754
- fetch('/dsh-tts/config', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
1827
+ clientFetch('/dsh-tts/config', { cache: 'no-store', timeoutMs: 8000 }).then((res) => res.json()).then((data) => {
1755
1828
  if (!alive) return
1756
1829
  applyPayload(data)
1757
1830
  }).catch((e) => { if (alive) setErr(String(e && e.message ? e.message : e)) })
1758
1831
 
1759
- fetch('/dsh-tts/integrations', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
1832
+ clientFetch('/dsh-tts/integrations', { cache: 'no-store', timeoutMs: 8000 }).then((res) => res.json()).then((data) => {
1760
1833
  if (!alive) return
1761
1834
  if (data && data.ok) setIntegrations(data.integrations || data)
1762
1835
  }).catch(() => {})
1763
1836
 
1764
1837
  const loadTelemetry = () => {
1765
- fetch('/dsh-tts/stats', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
1838
+ clientFetch('/dsh-tts/stats', { cache: 'no-store', timeoutMs: 8000 }).then((res) => res.json()).then((st) => {
1766
1839
  if (!alive || !st) return
1767
1840
  setTelemetry((prev) => Object.assign({}, prev, {
1768
1841
  cacheHits: st.cacheHits || 0,
@@ -1773,7 +1846,7 @@ window.__ModuleLoader__.load({
1773
1846
  }).catch(() => {
1774
1847
  if (alive) setTelemetry((prev) => Object.assign({}, prev, { online: false }))
1775
1848
  })
1776
- fetch('/dsh-tts/status', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
1849
+ clientFetch('/dsh-tts/status', { cache: 'no-store', timeoutMs: 8000 }).then((res) => res.json()).then((st) => {
1777
1850
  if (!alive || !st) return
1778
1851
  setTelemetry((prev) => Object.assign({}, prev, {
1779
1852
  sseClients: st.sseClients || 0,
@@ -1804,10 +1877,11 @@ window.__ModuleLoader__.load({
1804
1877
  const value = String(raw != null ? raw : ((keyDrafts && keyDrafts[provider]) || '')).trim()
1805
1878
  if (!value) return
1806
1879
  setErr('')
1807
- const res = await fetch('/dsh-tts/credential', {
1880
+ const res = await clientFetch('/dsh-tts/credential', {
1808
1881
  method: 'PUT',
1809
1882
  headers: { 'Content-Type': 'application/json' },
1810
1883
  body: JSON.stringify({ provider: provider, value: value }),
1884
+ timeoutMs: 10000,
1811
1885
  })
1812
1886
  const data = await res.json().catch(() => ({ /* parse error */ }))
1813
1887
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
@@ -1818,10 +1892,11 @@ window.__ModuleLoader__.load({
1818
1892
  const clearKey = async (provider) => {
1819
1893
  setErr('')
1820
1894
  try {
1821
- const res = await fetch('/dsh-tts/credential', {
1895
+ const res = await clientFetch('/dsh-tts/credential', {
1822
1896
  method: 'DELETE',
1823
1897
  headers: { 'Content-Type': 'application/json' },
1824
1898
  body: JSON.stringify({ provider: provider }),
1899
+ timeoutMs: 10000,
1825
1900
  })
1826
1901
  const data = await res.json().catch(() => ({ /* parse error */ }))
1827
1902
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
@@ -1843,10 +1918,11 @@ window.__ModuleLoader__.load({
1843
1918
  if (revision !== null && revision !== undefined) {
1844
1919
  bodyPayload.revision = revision
1845
1920
  }
1846
- const res = await fetch('/dsh-tts/config', {
1921
+ const res = await clientFetch('/dsh-tts/config', {
1847
1922
  method: 'PUT',
1848
1923
  headers: { 'Content-Type': 'application/json' },
1849
1924
  body: JSON.stringify(bodyPayload),
1925
+ timeoutMs: 10000,
1850
1926
  })
1851
1927
  const data = await res.json().catch(() => ({ /* parse error */ }))
1852
1928
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
@@ -1862,7 +1938,7 @@ window.__ModuleLoader__.load({
1862
1938
 
1863
1939
  const clearCacheNow = async () => {
1864
1940
  setErr('')
1865
- const res = await fetch('/dsh-tts/cache', { method: 'DELETE' })
1941
+ const res = await clientFetch('/dsh-tts/cache', { method: 'DELETE', timeoutMs: 10000 })
1866
1942
  if (!res.ok) throw new Error('HTTP ' + res.status)
1867
1943
  setSaved(true); setTimeout(() => setSaved(false), 2000)
1868
1944
  }
@@ -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' } })
@@ -495,15 +514,11 @@ export function registerHttpRoutes(ctx, api) {
495
514
  },
496
515
  }), 'dsh-tts: /speak route')
497
516
 
498
- ctx.effect(() => registerPluginUpdater(ctx, {
499
- endpoint: '/dsh-tts/updater',
500
- packageName: '@goodandready/dsh-tts',
501
- manifestUrl: new URL('../package.json', import.meta.url),
502
- }), 'dsh-tts: updater route /dsh-tts/updater')
503
-
517
+ const updaterMutex = { installing: false }
504
518
  ctx.effect(() => registerPluginUpdater(ctx, {
505
519
  endpoint: '/api/dsh-tts/update',
506
520
  packageName: '@goodandready/dsh-tts',
507
521
  manifestUrl: new URL('../package.json', import.meta.url),
522
+ mutex: updaterMutex,
508
523
  }), 'dsh-tts: updater route /api/dsh-tts/update')
509
524
  }
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/lib/updater.js CHANGED
@@ -225,7 +225,6 @@ async function installExact(target, packageSpec, options) {
225
225
  '--profile',
226
226
  target.profileName,
227
227
  'add',
228
- '--config.minimumReleaseAge=0',
229
228
  packageSpec,
230
229
  `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
231
230
  ],
@@ -268,7 +267,7 @@ function json(response, statusCode, value) {
268
267
  }
269
268
 
270
269
  export function registerPluginUpdater(ctx, options) {
271
- let installing = false
270
+ const mutex = options?.mutex || { installing: false }
272
271
  if (typeof ctx?.webServer?.register !== 'function') {
273
272
  ctx?.logger?.warn?.('[dsh-tts] webServer service unavailable, updater skipped')
274
273
  return () => {}
@@ -298,11 +297,11 @@ export function registerPluginUpdater(ctx, options) {
298
297
  json(response, 403, { error: 'Rejected non-local or cross-origin update request.' })
299
298
  return
300
299
  }
301
- if (installing) {
300
+ if (mutex.installing) {
302
301
  json(response, 409, { error: 'This plugin is already updating.' })
303
302
  return
304
303
  }
305
- installing = true
304
+ mutex.installing = true
306
305
  try {
307
306
  const before = await status(options, target)
308
307
  if (before.latestVersion === undefined) {
@@ -322,7 +321,7 @@ export function registerPluginUpdater(ctx, options) {
322
321
  } catch (err) {
323
322
  json(response, 503, { error: `Plugin update failed: ${String(err?.message || err)}` })
324
323
  } finally {
325
- installing = false
324
+ mutex.installing = false
326
325
  }
327
326
  } catch (error) {
328
327
  ctx?.logger?.warn?.(`[dsh-tts] plugin updater failed: ${String(error)}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.4.29",
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",