@goodandready/dsh-tts 0.4.24 → 0.4.26

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.26
4
+
5
+ ### Fixed
6
+ - **Cloud Timeout Cancellation Forwarding** (#145): Fixed unquoted `abort` identifier in `lib/providers.js:21` inside `withCloudTimeout()`. Passing an active `AbortSignal` in `synthesize()` now cleanly attaches cancellation listeners without throwing `ReferenceError`.
7
+ - **Settings Save Handler Runtime Crash** (#146): Added a defensive `settingsApi` adapter to `lib/index.js` and bound `getSettingsApi()` in `apply()`. `PUT /dsh-tts/config` now updates live configuration and persists changes without `ReferenceError: settingsApi is not defined`.
8
+ - **Client Bundle Parity Verification** (#147): Added non-destructive `--check` mode to `scripts/build-client.mjs`, registered `check:client` npm script, and added automated test in `test/client-redesign.test.mjs` to prevent bundle drift.
9
+
3
10
  ## 0.4.24
4
11
 
5
12
  ### Fixed
package/lib/index.js CHANGED
@@ -24,7 +24,7 @@ import {
24
24
 
25
25
  // Scoped identity matching package.json and cordis.patch.yml
26
26
  export const name = '@goodandready/dsh-tts'
27
- export const inject = ['tools', 'credentials', 'llm', 'webServer', 'settings']
27
+ export const inject = ['tools', 'credentials', 'llm', 'webServer']
28
28
 
29
29
  const NS = 'dsh-tts'
30
30
 
@@ -45,34 +45,43 @@ const RoleOverride = z.object({
45
45
  ssmlStyle: z.string().default(''),
46
46
  })
47
47
 
48
+ // Unwrap Volatile boxes before any caller reads a value. A volatile field holds a
49
+ // box rather than its value, and the Loader mutates those boxes in place.
50
+ export function plainConfig(value) {
51
+ if (value === null || typeof value !== 'object') return value
52
+ if (Array.isArray(value)) return value.map(plainConfig)
53
+ if (typeof value.get === 'function') return plainConfig(value.get())
54
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]))
55
+ }
56
+
48
57
  export const Config = z.object({
49
- speakReplies: z.boolean().default(false)
58
+ speakReplies: z.boolean().volatile().default(false)
50
59
  .description('When on, speak each finished agent reply in the Web UI.'),
51
- enableLocalEngines: z.boolean().default(false)
60
+ enableLocalEngines: z.boolean().volatile().default(false)
52
61
  .description('Enable offline local TTS engines (Kokoro / F5).'),
53
- kokoroEnabled: z.boolean().default(false)
62
+ kokoroEnabled: z.boolean().volatile().default(false)
54
63
  .description('Use local Kokoro-82M on CPU when installed.'),
55
- f5Enabled: z.boolean().default(false)
64
+ f5Enabled: z.boolean().volatile().default(false)
56
65
  .description('Use local F5-TTS on GPU when installed.'),
57
- streamingEnabled: z.boolean().default(true)
66
+ streamingEnabled: z.boolean().volatile().default(true)
58
67
  .description('Stream audio in real-time using AudioWorklet and SSE for sub-300ms latency.'),
59
- skipCode: z.boolean().default(true)
68
+ skipCode: z.boolean().volatile().default(true)
60
69
  .description('Replace fenced code blocks with a short spoken notice instead of reading them aloud.'),
61
- cache: z.boolean().default(true)
70
+ cache: z.boolean().volatile().default(true)
62
71
  .description('Reuse synthesized audio for repeated phrases instead of paying and waiting again.'),
63
- cacheMaxMb: z.number().default(100)
72
+ cacheMaxMb: z.number().volatile().default(100)
64
73
  .description('Disk limit for the synthesis cache; least recently used items are evicted first.'),
65
- narrateQuotesOnly: z.boolean().default(false)
74
+ narrateQuotesOnly: z.boolean().volatile().default(false)
66
75
  .description('Speak only quoted fragments.'),
67
- skipActions: z.boolean().default(false)
76
+ skipActions: z.boolean().volatile().default(false)
68
77
  .description('Drop *asterisk action* blocks instead of reading them.'),
69
- removeRegex: z.string().default('')
78
+ removeRegex: z.string().volatile().default('')
70
79
  .description('Custom global regex whose matches are removed before synthesis.'),
71
- autoDetect: z.boolean().default(false)
80
+ autoDetect: z.boolean().volatile().default(false)
72
81
  .description('Guess ru/en per spoken piece instead of using the language setting.'),
73
- customBaseUrl: z.string().default('')
82
+ customBaseUrl: z.string().volatile().default('')
74
83
  .description('Origin of an OpenAI-compatible /audio/speech endpoint; empty skips it.'),
75
- customKeyEnv: z.string().default('CUSTOM_TTS_API_KEY'),
84
+ customKeyEnv: z.string().volatile().default('CUSTOM_TTS_API_KEY'),
76
85
  pronunciation: z.array(z.object({
77
86
  from: z.string().default(''),
78
87
  to: z.string().default(''),
@@ -80,29 +89,29 @@ export const Config = z.object({
80
89
  lang: z.string().default(''),
81
90
  })).default([])
82
91
  .description('Say this instead of that. Applied top-down; /regex/ form allowed in from.'),
83
- enableItDictionary: z.boolean().default(true)
92
+ enableItDictionary: z.boolean().volatile().default(true)
84
93
  .description('Auto-correct common IT terminology pronunciation (SQL, Nginx, K8s, etc.).'),
85
- longReply: z.string().default('truncate')
94
+ longReply: z.string().volatile().default('truncate')
86
95
  .description('What to do when a reply exceeds the length limit.'),
87
- summaryModel: z.string().default('')
96
+ summaryModel: z.string().volatile().default('')
88
97
  .description('provider/model for the spoken retelling; empty uses the conversation default.'),
89
- summarySentences: z.number().default(3)
98
+ summarySentences: z.number().volatile().default(3)
90
99
  .description('Sentences in the spoken retelling.'),
91
- roles: z.dict(RoleOverride).default({
100
+ roles: z.dict(RoleOverride).volatile().default({
92
101
  reply: { provider: '', model: '', voice: '', chime: '', ssmlStyle: '' },
93
102
  approval: { provider: '', model: '', voice: '', chime: '', ssmlStyle: '' },
94
103
  error: { provider: '', model: '', voice: '', chime: '', ssmlStyle: '' },
95
104
  })
96
105
  .description('Per-role and subagent overrides; empty fields inherit the main chain.'),
97
- voiceDuplexEnabled: z.boolean().default(false)
106
+ voiceDuplexEnabled: z.boolean().volatile().default(false)
98
107
  .description('Full-duplex voice conversation with dsh-voice.'),
99
- vadBargeIn: z.boolean().default(true)
108
+ vadBargeIn: z.boolean().volatile().default(true)
100
109
  .description('Mute speech synthesis immediately when voice activity is detected.'),
101
- messengerTtsEnabled: z.boolean().default(false)
110
+ messengerTtsEnabled: z.boolean().volatile().default(false)
102
111
  .description('Send synthesized voice notes to messengers via @goodandready/dsh-messenger-gateway.'),
103
- autoDetectSubagent: z.boolean().default(true)
112
+ autoDetectSubagent: z.boolean().volatile().default(true)
104
113
  .description('Auto-detect subagent roles in multi-agent sessions and route to dedicated voice profiles.'),
105
- language: z.string().default('ru'),
114
+ language: z.string().volatile().default('ru'),
106
115
  speakAsItGoes: z
107
116
  .boolean()
108
117
  .description('Speak each reply as it lands instead of waiting for the whole turn to finish. '
@@ -138,43 +147,43 @@ export const Config = z.object({
138
147
  .string()
139
148
  .description('Short sound before an announcement: ding, beep or none.')
140
149
  .default('ding'),
141
- maxChars: z.number().default(4000)
150
+ maxChars: z.number().volatile().default(4000)
142
151
  .description('Longer replies are truncated before synthesis.'),
143
- chain: z.array(ChainEntry)
152
+ chain: z.array(ChainEntry).volatile()
144
153
  .default([
145
154
  { provider: 'edge', model: '', voice: 'ru-RU-SvetlanaNeural' },
146
155
  { provider: 'piper', model: '', voice: '' },
147
156
  { provider: 'espeak', model: '', voice: 'ru' },
148
157
  ])
149
158
  .description('Fallback chain. Order is the order of attempts. A provider without a key is skipped.'),
150
- openaiKeyEnv: z.string().default('OPENAI_API_KEY'),
151
- openaiBaseUrl: z.string().default('https://api.openai.com/v1'),
152
- elevenlabsKeyEnv: z.string().default('ELEVENLABS_API_KEY'),
153
- googleKeyEnv: z.string().default('GEMINI_API_KEY'),
154
- azureKeyEnv: z.string().default('AZURE_SPEECH_KEY'),
155
- azureRegion: z.string().default('')
159
+ openaiKeyEnv: z.string().volatile().default('OPENAI_API_KEY'),
160
+ openaiBaseUrl: z.string().volatile().default('https://api.openai.com/v1'),
161
+ elevenlabsKeyEnv: z.string().volatile().default('ELEVENLABS_API_KEY'),
162
+ googleKeyEnv: z.string().volatile().default('GEMINI_API_KEY'),
163
+ azureKeyEnv: z.string().volatile().default('AZURE_SPEECH_KEY'),
164
+ azureRegion: z.string().volatile().default('')
156
165
  .description('Azure Speech region, e.g. eastus. Required for the azure provider.'),
157
- mimoKeyEnv: z.string().description('Credential holding the Xiaomi MiMo key.').default('MIMO_API_KEY'),
158
- mimoBaseUrl: z.string().description('MiMo API root. Synthesis goes through its chat endpoint.').default('https://api.xiaomimimo.com/v1'),
159
- mimoFormat: z.string().description('MiMo output format: mp3 or wav.').default('mp3'),
160
- minimaxBin: z.string()
166
+ mimoKeyEnv: z.string().volatile().description('Credential holding the Xiaomi MiMo key.').default('MIMO_API_KEY'),
167
+ mimoBaseUrl: z.string().volatile().description('MiMo API root. Synthesis goes through its chat endpoint.').default('https://api.xiaomimimo.com/v1'),
168
+ mimoFormat: z.string().volatile().description('MiMo output format: mp3 or wav.').default('mp3'),
169
+ minimaxBin: z.string().volatile()
161
170
  .description('MiniMax CLI, looked up in PATH unless absolute. Install and log in separately; '
162
171
  + 'without it the provider declines and the chain moves on.')
163
172
  .default('mmx'),
164
- siliconflowKeyEnv: z.string().description('Credential holding the SiliconFlow key.').default('SILICONFLOW_API_KEY'),
165
- deepinfraKeyEnv: z.string().description('Credential holding the DeepInfra key.').default('DEEPINFRA_API_KEY'),
166
- fireworksKeyEnv: z.string().description('Credential holding the Fireworks key.').default('FIREWORKS_API_KEY'),
167
- groqKeyEnv: z.string().default('GROQ_API_KEY'),
168
- deepgramKeyEnv: z.string().default('DEEPGRAM_API_KEY'),
169
- openrouterKeyEnv: z.string().default('OPENROUTER_API_KEY'),
170
- edgeBin: z.string().default('edge-tts')
173
+ siliconflowKeyEnv: z.string().volatile().description('Credential holding the SiliconFlow key.').default('SILICONFLOW_API_KEY'),
174
+ deepinfraKeyEnv: z.string().volatile().description('Credential holding the DeepInfra key.').default('DEEPINFRA_API_KEY'),
175
+ fireworksKeyEnv: z.string().volatile().description('Credential holding the Fireworks key.').default('FIREWORKS_API_KEY'),
176
+ groqKeyEnv: z.string().volatile().default('GROQ_API_KEY'),
177
+ deepgramKeyEnv: z.string().volatile().default('DEEPGRAM_API_KEY'),
178
+ openrouterKeyEnv: z.string().volatile().default('OPENROUTER_API_KEY'),
179
+ edgeBin: z.string().volatile().default('edge-tts')
171
180
  .description('edge-tts CLI. Looked up in PATH unless an absolute path is given.'),
172
- piperBin: z.string().default('piper'),
173
- piperModel: z.string().default('')
181
+ piperBin: z.string().volatile().default('piper'),
182
+ piperModel: z.string().volatile().default('')
174
183
  .description('Path to a Piper ONNX model. The piper provider is skipped while this is empty.'),
175
- espeakBin: z.string().default('espeak-ng'),
176
- timeoutMs: z.number().default(60000),
177
- maxQueue: z.number().default(8),
184
+ espeakBin: z.string().volatile().default('espeak-ng'),
185
+ timeoutMs: z.number().volatile().default(60000),
186
+ maxQueue: z.number().volatile().default(8),
178
187
  })
179
188
 
180
189
  let utteranceSeq = 0
@@ -182,19 +191,31 @@ let utteranceSeq = 0
182
191
  export function apply(ctx, config) {
183
192
  const providerBreaker = createProviderBreaker()
184
193
 
185
- let getConfig = () => config
186
- const live = () => Config(structuredClone(getConfig() ?? {})) ?? config
187
-
188
- let settingsApi
189
- ctx.inject(['settings'], (sctx) => {
190
- const scope = sctx.settings.register(NS, Config, { base: config })
191
- settingsApi = scope
192
- getConfig = () => scope.get() ?? config
193
- sctx.effect(() => () => {
194
- settingsApi = undefined
195
- getConfig = () => config
196
- })
197
- })
194
+ let currentConfig = plainConfig(config)
195
+ let getConfig = () => currentConfig
196
+ const live = () => plainConfig(Config(structuredClone(getConfig() ?? {})) ?? config)
197
+
198
+ const settingsApi = {
199
+ get: () => live(),
200
+ replace: async (next) => {
201
+ const plain = plainConfig(next)
202
+ currentConfig = plain
203
+ const svc = ctx?.get?.('settings')
204
+ if (svc) {
205
+ const rev = svc.getSnapshot?.()?.revision ?? svc.getSnapshot?.()?.entries?.[NS]?.revision
206
+ if (typeof svc.replace === 'function') {
207
+ await svc.replace(NS, plain, rev)
208
+ } else if (typeof svc.update === 'function') {
209
+ await svc.update(NS, plain, rev)
210
+ }
211
+ }
212
+ return plain
213
+ },
214
+ }
215
+
216
+ // settings.register was removed before 0.1.7-rc.2. The host hands the profile entry
217
+ // config to apply() directly; live() below re-resolves it through the schema and
218
+ // unwraps, so the Loader's in-place box updates are picked up without a service.
198
219
 
199
220
  const pending = []
200
221
  const collectors = new Map()
package/lib/providers.js CHANGED
@@ -18,7 +18,7 @@ export function makeProviders(deps, req) {
18
18
  }, cloudTimeoutMs)
19
19
  if (parentSignal) {
20
20
  if (parentSignal.aborted) controller.abort(parentSignal.reason)
21
- else parentSignal.addEventListener(abort, () => controller.abort(parentSignal.reason), { once: true })
21
+ else parentSignal.addEventListener('abort', () => controller.abort(parentSignal.reason), { once: true })
22
22
  }
23
23
  return {
24
24
  signal: controller.signal,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.4.24",
3
+ "version": "0.4.26",
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",
@@ -45,7 +45,8 @@
45
45
  "scripts": {
46
46
  "test": "node --test test/*.test.mjs",
47
47
  "build:client": "node scripts/build-client.mjs",
48
- "pretest": "node scripts/build-client.mjs"
48
+ "pretest": "node scripts/build-client.mjs",
49
+ "check:client": "node scripts/build-client.mjs --check"
49
50
  },
50
51
  "dsh": {
51
52
  "bundle": {
@@ -67,7 +68,7 @@
67
68
  "@deepseek-ai/dsh-credentials": "^0.1.7-rc.2 || ^0.2.0-rc.1",
68
69
  "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2 || ^0.2.0-rc.1",
69
70
  "@deepseek-ai/dsh-settings": "^0.1.7-rc.2 || ^0.2.0-rc.1",
70
- "@deepseek-ai/schemastery": "^3.18.1"
71
+ "@deepseek-ai/schemastery": "^3.18.4"
71
72
  },
72
73
  "publishConfig": {
73
74
  "access": "public"