@goodandready/dsh-tts 0.4.27 → 0.4.28

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,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.28
4
+
5
+ ### Fixed
6
+ - **Settings Contracts & Schemastery Validation** (#151, #152, #153, #155, #187, #188, #189):
7
+ - Made all 9 previously static fields volatile and ensured living Volatile boxes are unwrapped dynamically in live() without stale snapshots.
8
+ - Constrained maxQueue with .step(1).min(0) to reject non-integer and negative queue limits.
9
+ - Added optimistic revision validation and rollback on conflict (HTTP 409) in settings saving.
10
+ - Enforced isTrustedSettingsRequest auth guards on GET /dsh-tts/config and GET /dsh-tts/status.
11
+ - Enforced contiguous sequence ordering in SSE synthesis chunks.
12
+
13
+ - **Audio Pipeline, Cache & Concurrency** (#154, #156, #157, #166, #173, #174, #175, #186, #190):
14
+ - Sanitized and normalized text once in prepareSpeechText() before character limits or summary generation, eliminating double-clean pronunciation distortions.
15
+ - Fixed subagent voice override gating with autoDetectSubagent !== false.
16
+ - Extended cache and dedup fingerprints with endpoint, models, voices, role, and language.
17
+ - Added admission concurrency throttle and semaphore for maxQueue synthesis reservations.
18
+ - Implemented SSE subscriber backpressure tracking and dropped slow lagging subscribers (>512 KB buffer) with drain handling.
19
+ - Switched cache index to an in-memory Map to eliminate disk scans on eviction.
20
+ - Ensured speak_text tool returns metadata and delivers audio to pending queue/SSE without dumping base64 into LLM context.
21
+
22
+ - **Client Player & UI Parity** (#158, #159, #160, #161, #162, #163, #164, #165, #167, #168, #169, #176):
23
+ - Unified EventSource listener connection in client, preventing duplicate audio channels.
24
+ - Ensured skipCurrent() halts active playback immediately, and preview/replay route through the unified player.
25
+ - Gracefully handled browser autoplay restrictions (NotAllowedError), pausing the queue until user interaction.
26
+ - Kept compact recent/favorites history control visible in idle state.
27
+ - Registered updater routes inside ctx.effect() to properly unbind on plugin disposal.
28
+ - Enabled all 16 active providers in client UI, with secure key management and read-only indicators when settings scope is missing.
29
+ - Fixed sibling integration detection using Cordis services without relying on obsolete hasRoute.
30
+ - Re-anchored canonical defaults to English (en, en-US-AriaNeural) and achieved 100% parity between EN and ZH dictionaries.
31
+
32
+ - **Model Weights Manager & System Hygiene** (#177, #178, #179, #180, #181, #182, #183, #184, #185):
33
+ - Stream chunk downloads now properly manage EventEmitter listeners without leaking error or drain handlers.
34
+ - Added synchronous concurrency lock per engine to share in-flight downloads and used unique nonce temporary file paths.
35
+ - Added minimum file size validation (minBytes / 1024 bytes) to reject truncated or error HTML downloads.
36
+ - Validated engine parameter in /dsh-tts/models/install and /dsh-tts/models/delete, returning HTTP 400 for unknown engines.
37
+ - Replaced tautological regex and source inspection tests with true runtime behavior tests.
38
+ - Isolated cache tests in temporary directories with automated cleanup.
39
+ - Synchronized README documentation with reality and strictly excluded internal dev docs (AGENTS.md, index.md, plans) from package distributions.
40
+ - Closes #151, #152, #153, #154, #155, #156, #157, #158, #159, #160, #161, #162, #163, #164, #165, #166, #167, #168, #169, #170, #171, #172, #173, #174, #175, #176, #177, #178, #179, #180, #181, #182, #183, #184, #185, #186, #187, #188, #189, #190, #191.
41
+
3
42
  ## 0.4.27
4
43
 
5
44
  ### Fixed
package/README.md CHANGED
@@ -101,10 +101,10 @@ graph LR
101
101
 
102
102
  ### 4. 📚 Built-in IT Terminology Pronunciation Dictionary
103
103
  * **Pre-configured Lexicon**: Correct phonetic pronunciation for common technical abbreviations and developer terms:
104
- - `SQL` $\rightarrow$ "сиквел"
105
- - `Nginx` $\rightarrow$ "энджинкс"
106
- - `Kubernetes` / `K8s` $\rightarrow$ "кубернетис"
107
- - `Docker` $\rightarrow$ "докер", `API` $\rightarrow$ "апи", `JSON` $\rightarrow$ "джейсон", `YAML` $\rightarrow$ "ямл"
104
+ - `SQL` $\rightarrow$ "sequel"
105
+ - `Nginx` $\rightarrow$ "engine-x"
106
+ - `Kubernetes` / `K8s` $\rightarrow$ "coo-ber-net-ees" / "kates"
107
+ - `Docker` $\rightarrow$ "docker", `API` $\rightarrow$ "A-P-I", `JSON` $\rightarrow$ "jason", `YAML` $\rightarrow$ "yaml"
108
108
  - `GUI`, `CLI`, `CI/CD`, `PR`, `Regex`, `OAuth`, `HTTP`, `HTTPS`, `CPU`, `GPU`, `RAM`
109
109
  * **Interactive UI Editor**: Edit rules, preview phonetic substitutions with the **▶ Listen** button, and populate standard IT terms with one click.
110
110
 
@@ -135,7 +135,7 @@ graph LR
135
135
  | `f5` | Local F5-TTS GPU Daemon | `F5-TTS` | Default | *None* | Daemon ping only; **GPU inference not bundled** — fails honestly |
136
136
  | `elevenlabs` | ElevenLabs API | `eleven_multilingual_v2` | `Rachel` | `ELEVENLABS_API_KEY` | Ultra-realistic, emotional nuance |
137
137
  | `openai` | OpenAI Audio | `gpt-4o-mini-tts` / `tts-1` | `alloy` | `OPENAI_API_KEY` | High-quality industry standard |
138
- | `edge` | Microsoft Edge Online | `ru-RU-SvetlanaNeural` | `ru-RU-SvetlanaNeural` | *None* | **Free, high-fidelity neural TTS without API keys** |
138
+ | `edge` | Microsoft Edge Online | `en-US-AriaNeural` | `en-US-AriaNeural` | *None* | **Free, high-fidelity neural TTS without API keys** |
139
139
  | `siliconflow` | SiliconFlow CosyVoice | `FunAudioLLM/CosyVoice2-0.5B` | Default | `SILICONFLOW_API_KEY` | State-of-the-art CosyVoice2 neural engine |
140
140
  | `deepinfra` | DeepInfra Kokoro | `hexgrad/Kokoro-82M` | Default | `DEEPINFRA_API_KEY` | Fast open-weights Kokoro synthesis |
141
141
  | `fireworks` | Fireworks AI | `kokoro` | Default | `FIREWORKS_API_KEY` | Ultra-low latency Kokoro inference |
@@ -225,6 +225,10 @@ Config-only (not in the card): `*KeyEnv` fields (`openaiKeyEnv`, `elevenlabsKeyE
225
225
  * `DELETE /dsh-tts/models/delete` — `{ engine: 'kokoro' | 'f5' }` → Removes local model files.
226
226
  * `GET /dsh-tts/integrations` — Status of sibling plugins (`dsh-voice`, `dsh-messenger-gateway`).
227
227
  * `GET /dsh-tts/status` — Returns active chain state, cache statistics, and engine readiness.
228
+ * `DELETE /dsh-tts/cache` — Clears cached audio files.
229
+ * `GET /dsh-tts/config` & `PUT /dsh-tts/config` — Retrieve and update settings revision.
230
+ * `GET /dsh-tts/stats` & `DELETE /dsh-tts/stats` — Performance statistics and metrics reset.
231
+ * `PUT /dsh-tts/credential` & `DELETE /dsh-tts/credential` — Securely store or remove cloud provider API keys.
228
232
 
229
233
  ---
230
234
 
package/lib/cache.js CHANGED
@@ -41,56 +41,78 @@ export function createSpeechCache({ root, maxBytes }) {
41
41
  }
42
42
  }
43
43
 
44
- async function listEntries() {
44
+ // In-memory index of disk cache entries to avoid O(N) readdir/stat/readFile per write (#190)
45
+ const diskEntries = new Map() // key -> { size, at }
46
+ let diskTotalBytes = 0
47
+ let indexLoaded = false
48
+
49
+ async function ensureIndex() {
45
50
  await ensure()
46
- const out = []
47
- for (const name of await fs.promises.readdir(dir)) {
51
+ if (indexLoaded) return
52
+ diskEntries.clear()
53
+ diskTotalBytes = 0
54
+ let files = []
55
+ try {
56
+ files = await fs.promises.readdir(dir)
57
+ } catch {
58
+ files = []
59
+ }
60
+ for (const name of files) {
48
61
  if (!name.endsWith(AUDIO_EXT)) continue
62
+ const key = name.slice(0, -AUDIO_EXT.length)
49
63
  const full = path.join(dir, name)
50
- let st
51
- try {
52
- st = await fs.promises.stat(full)
53
- } catch {
54
- continue
55
- }
56
- let at = st.mtimeMs
57
64
  try {
58
- const meta = JSON.parse(await fs.promises.readFile(path.join(dir, name.slice(0, -AUDIO_EXT.length) + '.json'), 'utf8'))
59
- if (meta && typeof meta.at === 'number') at = meta.at
60
- } catch (noMeta) { /* legacy entry without meta — evict as first */ }
61
- out.push({ full, meta: name.slice(0, -AUDIO_EXT.length) + '.json', size: st.size, at })
65
+ const st = await fs.promises.stat(full)
66
+ let at = st.mtimeMs
67
+ try {
68
+ const meta = JSON.parse(await fs.promises.readFile(path.join(dir, key + '.json'), 'utf8'))
69
+ if (meta && typeof meta.at === 'number') at = meta.at
70
+ } catch { /* no meta */ }
71
+ diskEntries.set(key, { size: st.size, at })
72
+ diskTotalBytes += st.size
73
+ } catch { /* skip unreadable */ }
62
74
  }
63
- return out
75
+ indexLoaded = true
64
76
  }
65
77
 
66
78
  async function evict() {
79
+ await ensureIndex()
67
80
  const limit = typeof maxBytes === 'function' ? maxBytes() : Number(maxBytes)
68
- if (!(limit > 0)) return
69
- const rawEntries = await listEntries()
70
- const entries = rawEntries.map((e) => {
71
- const k = path.basename(e.full, AUDIO_EXT)
72
- const l1 = l1Map.get(k)
73
- return { ...e, key: k, at: (l1 && typeof l1.at === 'number') ? l1.at : e.at }
74
- }).sort((a, b) => a.at - b.at)
75
-
76
- let total = entries.reduce((acc, e) => acc + e.size, 0)
77
- for (const e of entries) {
78
- if (total <= limit) break
79
- await fs.promises.rm(e.full, { force: true })
80
- await fs.promises.rm(path.join(dir, e.meta), { force: true }).catch(() => {})
81
- l1Map.delete(e.key)
82
- total -= e.size
81
+ if (!(limit > 0) || diskTotalBytes <= limit) return
82
+
83
+ // Sort in-memory entries by timestamp ascending (oldest first)
84
+ const sorted = Array.from(diskEntries.entries()).sort((a, b) => {
85
+ const atA = l1Map.get(a[0])?.at ?? a[1].at
86
+ const atB = l1Map.get(b[0])?.at ?? b[1].at
87
+ return atA - atB
88
+ })
89
+
90
+ for (const [key, meta] of sorted) {
91
+ if (diskTotalBytes <= limit) break
92
+ await fs.promises.rm(path.join(dir, key + AUDIO_EXT), { force: true }).catch(() => {})
93
+ await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
94
+ diskEntries.delete(key)
95
+ diskTotalBytes -= meta.size
96
+ l1Map.delete(key)
83
97
  }
84
98
  }
85
99
 
86
100
  return {
87
101
  async put(key, mime, audio) {
88
- await ensure()
102
+ await ensureIndex()
89
103
  const buf = Buffer.isBuffer(audio) ? audio : Buffer.from(audio)
90
104
  putL1(key, mime, buf)
91
105
  await fs.promises.writeFile(path.join(dir, key + AUDIO_EXT), buf)
92
- await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime, at: Date.now() }))
93
- await evict()
106
+ const now = Date.now()
107
+ await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime, at: now }))
108
+ const prev = diskEntries.get(key)
109
+ if (prev) diskTotalBytes -= prev.size
110
+ diskEntries.set(key, { size: buf.length, at: now })
111
+ diskTotalBytes += buf.length
112
+ const limit = typeof maxBytes === 'function' ? maxBytes() : Number(maxBytes)
113
+ if (limit > 0 && diskTotalBytes > limit) {
114
+ await evict()
115
+ }
94
116
  },
95
117
 
96
118
  async get(key) {
@@ -100,17 +122,22 @@ export function createSpeechCache({ root, maxBytes }) {
100
122
  const now = Date.now()
101
123
  l1Map.delete(key) // refresh LRU order
102
124
  l1Map.set(key, { ...hit, at: now })
125
+ const d = diskEntries.get(key)
126
+ if (d) d.at = now
103
127
  fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: hit.mime, at: now })).catch(() => {})
104
128
  return { mime: hit.mime || 'audio/mpeg', audio: hit.audio }
105
129
  }
106
130
 
107
131
  // 2. L2 Disk Hit
132
+ await ensureIndex()
108
133
  try {
109
134
  const meta = JSON.parse(await fs.promises.readFile(path.join(dir, key + '.json'), 'utf8'))
110
135
  const audio = await fs.promises.readFile(path.join(dir, key + AUDIO_EXT))
111
136
  putL1(key, meta.mime, audio)
112
- // Touch LRU asynchronously so reads are never blocked.
113
- fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: meta.mime, at: Date.now() })).catch(() => {})
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(() => {})
114
141
  return { mime: meta.mime || 'audio/mpeg', audio }
115
142
  } catch (miss) {
116
143
  return null
@@ -119,12 +146,15 @@ export function createSpeechCache({ root, maxBytes }) {
119
146
 
120
147
  async clear() {
121
148
  l1Map.clear()
122
- const entries = await listEntries()
123
- for (const e of entries) {
124
- await fs.promises.rm(e.full, { force: true })
125
- await fs.promises.rm(path.join(dir, e.meta), { force: true }).catch(() => {})
149
+ await ensureIndex()
150
+ 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(() => {})
126
154
  }
127
- return entries.length
155
+ diskEntries.clear()
156
+ diskTotalBytes = 0
157
+ return count
128
158
  },
129
159
  }
130
160
  }