@goodandready/dsh-voice 0.9.16 → 0.9.18

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.
@@ -0,0 +1,272 @@
1
+ // lib/local-daemon.js — Local background process daemon management for whisper and sensevoice
2
+ import { isAutoLang } from './provider-http.js'
3
+ import { buildSensevoiceArgs } from './sensevoice-installer.js'
4
+
5
+ export function createLocalDaemons({ live, ctx }) {
6
+ let child = null
7
+ let whisperError = null
8
+ let startingWhisper = false
9
+ let disposed = false
10
+
11
+ async function whisperAlive() {
12
+ const rawUrl = String(live().whisperUrl || '').trim()
13
+ const base = rawUrl.includes('/inference') ? rawUrl.split('/inference')[0] : rawUrl.replace(/\/+$/, '')
14
+ if (!base) return false
15
+ try {
16
+ const res = await fetch(base + '/', { signal: AbortSignal.timeout(2000) })
17
+ return res.ok
18
+ } catch {
19
+ return false
20
+ }
21
+ }
22
+
23
+ async function startWhisper() {
24
+ if (startingWhisper || disposed) return false
25
+ startingWhisper = true
26
+ try {
27
+ const cfg = live()
28
+ if (!cfg.autoStart) return false
29
+ if (!cfg.whisperModel) return false
30
+ if (await whisperAlive()) {
31
+ whisperError = null
32
+ return true
33
+ }
34
+ if (child && typeof child.kill === 'function') {
35
+ try { child.kill() } catch { /* ignore */ }
36
+ child = null
37
+ }
38
+ const command = `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
39
+ + ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${isAutoLang(cfg.dictation.language) ? 'auto' : cfg.dictation.language}`
40
+ const specOpts = { command, onExpiry: 'none', timeoutMs: 0, stdoutMaxBytes: 4 * 1024 * 1024 }
41
+ const spec = typeof ctx.shell?.resolve === 'function' ? ctx.shell.resolve(specOpts) : specOpts
42
+
43
+ if (typeof ctx.shell?.execute === 'function') {
44
+ child = await ctx.shell.execute(spec)
45
+ } else if (typeof ctx.shell?.start === 'function') {
46
+ child = await ctx.shell.start(spec)
47
+ }
48
+
49
+ if (disposed) {
50
+ if (child && typeof child.kill === 'function') {
51
+ try { child.kill() } catch { /* ignore */ }
52
+ child = null
53
+ }
54
+ return false
55
+ }
56
+
57
+ if (child && typeof child.on === 'function') {
58
+ child.on('error', (err) => {
59
+ whisperError = err?.message || String(err)
60
+ })
61
+ child.on('exit', (code) => {
62
+ if (code !== 0 && code !== null) {
63
+ whisperError = `whisper server process exited with code ${code}`
64
+ }
65
+ })
66
+ }
67
+ if (child && child.done && typeof child.done.then === 'function') {
68
+ child.done.then((res) => {
69
+ if (child.status === 'killed') {
70
+ whisperError = 'whisper server process was killed'
71
+ } else if (child.status === 'completed') {
72
+ const code = child.result?.exitCode ?? (res && res.code)
73
+ if (code !== 0 && code !== null && code !== undefined) {
74
+ whisperError = `whisper server process exited with code ${code}`
75
+ }
76
+ } else if (res && res.code !== 0 && res.code !== null && res.code !== undefined) {
77
+ whisperError = `whisper server process exited with code ${res.code}`
78
+ }
79
+ }).catch((err) => {
80
+ whisperError = err?.message || String(err)
81
+ })
82
+ }
83
+
84
+ // Wait for it to become healthy (up to 30s for cold model loading on Metal/CPU).
85
+ for (let i = 0; i < 60; i++) {
86
+ if (disposed) return false
87
+ await new Promise((r) => setTimeout(r, 500))
88
+ if (disposed) return false
89
+ if (await whisperAlive()) {
90
+ whisperError = null
91
+ return true
92
+ }
93
+ if (child) {
94
+ if (child.status === 'killed') {
95
+ whisperError = 'whisper server process was killed'
96
+ return false
97
+ }
98
+ if (child.status === 'completed') {
99
+ const code = child.result?.exitCode ?? child.exitCode ?? 0
100
+ whisperError = `whisper server process exited prematurely with code ${code}`
101
+ return false
102
+ }
103
+ if (child.status === 'exited' || child.status === 'failed') {
104
+ whisperError = `whisper server process exited prematurely with status ${child.status}`
105
+ return false
106
+ }
107
+ if (child.exitCode !== null && child.exitCode !== undefined) {
108
+ whisperError = `whisper server process exited prematurely with code ${child.exitCode}`
109
+ return false
110
+ }
111
+ }
112
+ }
113
+ whisperError = 'whisper server process started but failed health check on port 8001'
114
+ return false
115
+ } catch (err) {
116
+ whisperError = String(err && err.message ? err.message : err)
117
+ return false
118
+ } finally {
119
+ startingWhisper = false
120
+ }
121
+ }
122
+
123
+ let sensevoiceChild = null
124
+ let sensevoiceError = null
125
+ let startingSensevoice = false
126
+
127
+ async function sensevoiceAlive() {
128
+ const cfg = live()
129
+ const base = String(cfg.sensevoiceUrl || '').replace(/\/api\/.*$/, '').replace(/\/v1\/.*$/, '').replace(/\/+$/, '')
130
+ if (!base) return false
131
+ try {
132
+ const res = await fetch(base + '/', { signal: AbortSignal.timeout(2000) })
133
+ return res.ok || res.status === 404
134
+ } catch {
135
+ return false
136
+ }
137
+ }
138
+
139
+ async function startSensevoice() {
140
+ if (startingSensevoice || disposed) return false
141
+ startingSensevoice = true
142
+ try {
143
+ const cfg = live()
144
+ if (!cfg.sensevoiceAutostart) return false
145
+ if (!cfg.sensevoiceModel) return false
146
+ if (await sensevoiceAlive()) {
147
+ sensevoiceError = null
148
+ return true
149
+ }
150
+ if (sensevoiceChild && typeof sensevoiceChild.kill === 'function') {
151
+ try { sensevoiceChild.kill() } catch { /* ignore */ }
152
+ sensevoiceChild = null
153
+ }
154
+ let port = '6006'
155
+ try { port = new URL(cfg.sensevoiceUrl).port || '6006' } catch { /* invalid URL */ }
156
+ const cmdArgs = buildSensevoiceArgs(cfg, port)
157
+ const commandStr = `${JSON.stringify(cfg.sensevoiceBin)}${cmdArgs}`
158
+ const specOpts = { command: commandStr, onExpiry: 'none', timeoutMs: 0, stdoutMaxBytes: 4 * 1024 * 1024 }
159
+ const spec = typeof ctx.shell?.resolve === 'function' ? ctx.shell.resolve(specOpts) : specOpts
160
+
161
+ if (typeof ctx.shell?.execute === 'function') {
162
+ sensevoiceChild = await ctx.shell.execute(spec)
163
+ } else if (typeof ctx.shell?.start === 'function') {
164
+ sensevoiceChild = await ctx.shell.start(spec)
165
+ }
166
+
167
+ if (disposed) {
168
+ if (sensevoiceChild && typeof sensevoiceChild.kill === 'function') {
169
+ try { sensevoiceChild.kill() } catch { /* ignore */ }
170
+ sensevoiceChild = null
171
+ }
172
+ return false
173
+ }
174
+
175
+ if (sensevoiceChild && typeof sensevoiceChild.on === 'function') {
176
+ sensevoiceChild.on('error', (err) => {
177
+ sensevoiceError = err?.message || String(err)
178
+ })
179
+ sensevoiceChild.on('exit', (code) => {
180
+ if (code !== 0 && code !== null) {
181
+ sensevoiceError = `sensevoice process exited with code ${code}`
182
+ }
183
+ })
184
+ }
185
+ if (sensevoiceChild && sensevoiceChild.done && typeof sensevoiceChild.done.then === 'function') {
186
+ sensevoiceChild.done.then((res) => {
187
+ if (sensevoiceChild.status === 'killed') {
188
+ sensevoiceError = 'sensevoice process was killed'
189
+ } else if (sensevoiceChild.status === 'completed') {
190
+ const code = sensevoiceChild.result?.exitCode ?? (res && res.code)
191
+ if (code !== 0 && code !== null && code !== undefined) {
192
+ sensevoiceError = `sensevoice process exited with code ${code}`
193
+ }
194
+ } else if (res && res.code !== 0 && res.code !== null && res.code !== undefined) {
195
+ sensevoiceError = `sensevoice process exited with code ${res.code}`
196
+ }
197
+ }).catch((err) => {
198
+ sensevoiceError = err?.message || String(err)
199
+ })
200
+ }
201
+
202
+ // Wait for it to become healthy (up to 30s).
203
+ for (let i = 0; i < 60; i++) {
204
+ if (disposed) return false
205
+ await new Promise((r) => setTimeout(r, 500))
206
+ if (disposed) return false
207
+ if (await sensevoiceAlive()) {
208
+ sensevoiceError = null
209
+ return true
210
+ }
211
+ if (sensevoiceChild) {
212
+ if (sensevoiceChild.status === 'killed') {
213
+ sensevoiceError = 'sensevoice process was killed'
214
+ return false
215
+ }
216
+ if (sensevoiceChild.status === 'completed') {
217
+ const code = sensevoiceChild.result?.exitCode ?? sensevoiceChild.exitCode ?? 0
218
+ sensevoiceError = `sensevoice process exited prematurely with code ${code}`
219
+ return false
220
+ }
221
+ if (sensevoiceChild.status === 'exited' || sensevoiceChild.status === 'failed') {
222
+ sensevoiceError = `sensevoice process exited prematurely with status ${sensevoiceChild.status}`
223
+ return false
224
+ }
225
+ if (sensevoiceChild.exitCode !== null && sensevoiceChild.exitCode !== undefined) {
226
+ sensevoiceError = `sensevoice process exited prematurely with code ${sensevoiceChild.exitCode}`
227
+ return false
228
+ }
229
+ }
230
+ }
231
+ sensevoiceError = 'sensevoice process started but failed health check'
232
+ return false
233
+ } catch (err) {
234
+ sensevoiceError = String(err && err.message ? err.message : err)
235
+ return false
236
+ } finally {
237
+ startingSensevoice = false
238
+ }
239
+ }
240
+
241
+ function triggerAutostart() {
242
+ startWhisper().catch((err) => {
243
+ ctx.logger?.debug?.('whisper autostart failed: %s', err?.message || err)
244
+ })
245
+ startSensevoice().catch((err) => {
246
+ ctx.logger?.debug?.('sensevoice autostart failed: %s', err?.message || err)
247
+ })
248
+ }
249
+
250
+ function dispose() {
251
+ disposed = true
252
+ if (child && typeof child.kill === 'function') {
253
+ try { child.kill() } catch { /* ignore */ }
254
+ child = null
255
+ }
256
+ if (sensevoiceChild && typeof sensevoiceChild.kill === 'function') {
257
+ try { sensevoiceChild.kill() } catch { /* ignore */ }
258
+ sensevoiceChild = null
259
+ }
260
+ }
261
+
262
+ return {
263
+ whisperAlive,
264
+ startWhisper,
265
+ getWhisperError: () => whisperError,
266
+ sensevoiceAlive,
267
+ startSensevoice,
268
+ getSensevoiceError: () => sensevoiceError,
269
+ triggerAutostart,
270
+ dispose,
271
+ }
272
+ }
package/lib/schema.js CHANGED
@@ -134,7 +134,7 @@ export const BaseConfig = z.object({
134
134
  sensevoiceAutostart: z.boolean().default(false)
135
135
  .description('Start the local SenseVoice / sherpa-onnx server process automatically on startup.'),
136
136
  sensevoiceProvider: z.string().default('cpu')
137
- .description('Execution provider for sherpa-onnx: "cpu", "rknpu" (Rockchip RK3588/RK3568 NPU on ARM SBC), "openvino", "cuda".'),
137
+ .description('Execution provider for sherpa-onnx: "cpu", "rknn" (Rockchip RK3588/RK3568 NPU on ARM SBC with RKNN models), "openvino", "cuda".'),
138
138
  sensevoiceThreads: z.number().default(4)
139
139
  .description('Execution thread count for local SenseVoice / sherpa-onnx on CPU/SBC.'),
140
140
  webgpuModel: z.string().default('onnx-community/whisper-tiny')
@@ -235,8 +235,10 @@ export function buildSensevoiceArgs(cfg, port) {
235
235
  const modelDir = cfg?.sensevoiceModel ? path.dirname(cfg.sensevoiceModel) : getDefaultModelDir()
236
236
  const tokensPath = findTokensFile(modelDir)
237
237
  const tokensArg = tokensPath ? ` --tokens=${JSON.stringify(tokensPath)}` : ''
238
- const provider = (cfg?.sensevoiceProvider && cfg.sensevoiceProvider !== 'cpu')
239
- ? ` --provider=${cfg.sensevoiceProvider}`
238
+ const rawProv = String(cfg?.sensevoiceProvider || '').trim().toLowerCase()
239
+ const normProv = rawProv === 'rknpu' ? 'rknn' : rawProv
240
+ const provider = (normProv && normProv !== 'cpu')
241
+ ? ` --provider=${normProv}`
240
242
  : ''
241
243
  const threads = Number(cfg?.sensevoiceThreads) || 4
242
244
  return ` --sense-voice-model=${JSON.stringify(cfg?.sensevoiceModel || '')}`
package/lib/updater.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // Implements canonical DSH plugin-updater specification.
3
3
 
4
4
  import { spawn } from 'node:child_process';
5
- import { existsSync, readFileSync, unlinkSync } from 'node:fs';
5
+ import { existsSync, readFileSync, unlinkSync, renameSync } from 'node:fs';
6
6
  import { readFile } from 'node:fs/promises';
7
7
  import { homedir } from 'node:os';
8
8
  import { basename, dirname, isAbsolute, resolve } from 'node:path';
@@ -202,10 +202,24 @@ export function checkProfileLock(profileDir) {
202
202
  if (isProcessAlive(pid)) {
203
203
  return { locked: true, pid };
204
204
  }
205
+ // Verify before unlinking: ensure the file content still holds the dead pid we read,
206
+ // using atomic rename coordinator so a successor's live lock is never deleted (#205).
205
207
  try {
206
- unlinkSync(lockPath);
207
- return { locked: false, cleanedStale: true };
208
+ const staleName = `${lockPath}.stale.${Date.now()}.${Math.random().toString(36).slice(2)}`;
209
+ renameSync(lockPath, staleName);
210
+ const renamedPid = readLockPid(staleName);
211
+ if (renamedPid === pid) {
212
+ try { unlinkSync(staleName); } catch {}
213
+ return { locked: false, cleanedStale: true };
214
+ }
215
+ try { renameSync(staleName, lockPath); } catch {}
216
+ return { locked: true, pid: renamedPid };
208
217
  } catch {
218
+ if (!existsSync(lockPath)) return { locked: false };
219
+ const currentPid = readLockPid(lockPath);
220
+ if (currentPid !== null && isProcessAlive(currentPid)) {
221
+ return { locked: true, pid: currentPid };
222
+ }
209
223
  return { locked: false };
210
224
  }
211
225
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.9.16",
3
+ "version": "0.9.18",
4
4
  "description": "Voice input for DeepSeek Harness: dictation chunked by pauses and voice messages, each with its own provider fallback chain (Deepgram, Groq, HuggingFace, local whisper.cpp, plus any OpenAI-compatible API of your own).",
5
5
  "license": "MIT",
6
6
  "type": "module",