@goodandready/dsh-voice 0.9.17 → 0.9.19

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.
@@ -6,6 +6,7 @@ export function createLocalDaemons({ live, ctx }) {
6
6
  let child = null
7
7
  let whisperError = null
8
8
  let startingWhisper = false
9
+ let disposed = false
9
10
 
10
11
  async function whisperAlive() {
11
12
  const rawUrl = String(live().whisperUrl || '').trim()
@@ -20,7 +21,7 @@ export function createLocalDaemons({ live, ctx }) {
20
21
  }
21
22
 
22
23
  async function startWhisper() {
23
- if (startingWhisper) return false
24
+ if (startingWhisper || disposed) return false
24
25
  startingWhisper = true
25
26
  try {
26
27
  const cfg = live()
@@ -36,15 +37,21 @@ export function createLocalDaemons({ live, ctx }) {
36
37
  }
37
38
  const command = `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
38
39
  + ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${isAutoLang(cfg.dictation.language) ? 'auto' : cfg.dictation.language}`
39
- const specOpts = { command, timeoutMs: 0, stdoutMaxBytes: 4 * 1024 * 1024 }
40
+ const specOpts = { command, onExpiry: 'none', timeoutMs: 0, stdoutMaxBytes: 4 * 1024 * 1024 }
40
41
  const spec = typeof ctx.shell?.resolve === 'function' ? ctx.shell.resolve(specOpts) : specOpts
41
42
 
42
43
  if (typeof ctx.shell?.execute === 'function') {
43
44
  child = await ctx.shell.execute(spec)
44
45
  } else if (typeof ctx.shell?.start === 'function') {
45
46
  child = await ctx.shell.start(spec)
46
- } else {
47
- child = null
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
48
55
  }
49
56
 
50
57
  if (child && typeof child.on === 'function') {
@@ -58,9 +65,30 @@ export function createLocalDaemons({ live, ctx }) {
58
65
  })
59
66
  }
60
67
  if (child && child.done && typeof child.done.then === 'function') {
61
- child.done.then((res) => {
62
- if (res && res.code !== 0 && res.code !== null && res.code !== undefined) {
63
- whisperError = `whisper server process exited with code ${res.code}`
68
+ child.done.then(async (res) => {
69
+ let code = res && res.code
70
+ let detail = ''
71
+ if (typeof child.result === 'function') {
72
+ try {
73
+ const r = await child.result()
74
+ if (r) {
75
+ if (r.exitCode !== undefined && r.exitCode !== null) code = r.exitCode
76
+ if (r.stderr) detail = String(r.stderr).trim()
77
+ }
78
+ } catch (_) {}
79
+ } else if (child.result && typeof child.result === 'object') {
80
+ code = child.result.exitCode ?? code
81
+ if (child.result.stderr) detail = String(child.result.stderr).trim()
82
+ }
83
+ if (child.status === 'killed') {
84
+ whisperError = detail ? `whisper server process was killed: ${detail}` : 'whisper server process was killed'
85
+ } else if (child.status === 'completed') {
86
+ code = code ?? child.exitCode ?? 0
87
+ if (code !== 0 && code !== null && code !== undefined) {
88
+ whisperError = `whisper server process exited with code ${code}${detail ? `: ${detail}` : ''}`
89
+ }
90
+ } else if (code !== 0 && code !== null && code !== undefined) {
91
+ whisperError = `whisper server process exited with code ${code}${detail ? `: ${detail}` : ''}`
64
92
  }
65
93
  }).catch((err) => {
66
94
  whisperError = err?.message || String(err)
@@ -69,18 +97,51 @@ export function createLocalDaemons({ live, ctx }) {
69
97
 
70
98
  // Wait for it to become healthy (up to 30s for cold model loading on Metal/CPU).
71
99
  for (let i = 0; i < 60; i++) {
100
+ if (disposed) return false
72
101
  await new Promise((r) => setTimeout(r, 500))
102
+ if (disposed) return false
73
103
  if (await whisperAlive()) {
74
104
  whisperError = null
75
105
  return true
76
106
  }
77
- if (child && child.exitCode !== null && child.exitCode !== undefined) {
78
- whisperError = `whisper server process exited prematurely with code ${child.exitCode}`
79
- return false
80
- }
81
- if (child && (child.status === 'exited' || child.status === 'failed')) {
82
- whisperError = `whisper server process exited prematurely with status ${child.status}`
83
- return false
107
+ if (child) {
108
+ let detail = ''
109
+ if (typeof child.read === 'function') {
110
+ try {
111
+ const out = child.read()
112
+ if (out && out.stderr) detail = String(out.stderr).trim()
113
+ } catch (_) {}
114
+ }
115
+ if (child.status === 'killed') {
116
+ whisperError = detail ? `whisper server process was killed: ${detail}` : 'whisper server process was killed'
117
+ return false
118
+ }
119
+ if (child.status === 'completed') {
120
+ let code = child.exitCode
121
+ if (typeof child.result === 'function') {
122
+ try {
123
+ const r = await child.result()
124
+ if (r) {
125
+ if (r.exitCode !== undefined && r.exitCode !== null) code = r.exitCode
126
+ if (r.stderr && !detail) detail = String(r.stderr).trim()
127
+ }
128
+ } catch (_) {}
129
+ } else if (child.result && typeof child.result === 'object') {
130
+ code = child.result.exitCode ?? code
131
+ if (child.result.stderr && !detail) detail = String(child.result.stderr).trim()
132
+ }
133
+ code = code ?? 0
134
+ whisperError = `whisper server process exited prematurely with code ${code}${detail ? `: ${detail}` : ''}`
135
+ return false
136
+ }
137
+ if (child.status === 'exited' || child.status === 'failed') {
138
+ whisperError = `whisper server process exited prematurely with status ${child.status}${detail ? `: ${detail}` : ''}`
139
+ return false
140
+ }
141
+ if (child.exitCode !== null && child.exitCode !== undefined) {
142
+ whisperError = `whisper server process exited prematurely with code ${child.exitCode}${detail ? `: ${detail}` : ''}`
143
+ return false
144
+ }
84
145
  }
85
146
  }
86
147
  whisperError = 'whisper server process started but failed health check on port 8001'
@@ -110,7 +171,7 @@ export function createLocalDaemons({ live, ctx }) {
110
171
  }
111
172
 
112
173
  async function startSensevoice() {
113
- if (startingSensevoice) return false
174
+ if (startingSensevoice || disposed) return false
114
175
  startingSensevoice = true
115
176
  try {
116
177
  const cfg = live()
@@ -128,15 +189,21 @@ export function createLocalDaemons({ live, ctx }) {
128
189
  try { port = new URL(cfg.sensevoiceUrl).port || '6006' } catch { /* invalid URL */ }
129
190
  const cmdArgs = buildSensevoiceArgs(cfg, port)
130
191
  const commandStr = `${JSON.stringify(cfg.sensevoiceBin)}${cmdArgs}`
131
- const specOpts = { command: commandStr, timeoutMs: 0, stdoutMaxBytes: 4 * 1024 * 1024 }
192
+ const specOpts = { command: commandStr, onExpiry: 'none', timeoutMs: 0, stdoutMaxBytes: 4 * 1024 * 1024 }
132
193
  const spec = typeof ctx.shell?.resolve === 'function' ? ctx.shell.resolve(specOpts) : specOpts
133
194
 
134
195
  if (typeof ctx.shell?.execute === 'function') {
135
196
  sensevoiceChild = await ctx.shell.execute(spec)
136
197
  } else if (typeof ctx.shell?.start === 'function') {
137
198
  sensevoiceChild = await ctx.shell.start(spec)
138
- } else {
139
- sensevoiceChild = null
199
+ }
200
+
201
+ if (disposed) {
202
+ if (sensevoiceChild && typeof sensevoiceChild.kill === 'function') {
203
+ try { sensevoiceChild.kill() } catch { /* ignore */ }
204
+ sensevoiceChild = null
205
+ }
206
+ return false
140
207
  }
141
208
 
142
209
  if (sensevoiceChild && typeof sensevoiceChild.on === 'function') {
@@ -150,9 +217,30 @@ export function createLocalDaemons({ live, ctx }) {
150
217
  })
151
218
  }
152
219
  if (sensevoiceChild && sensevoiceChild.done && typeof sensevoiceChild.done.then === 'function') {
153
- sensevoiceChild.done.then((res) => {
154
- if (res && res.code !== 0 && res.code !== null && res.code !== undefined) {
155
- sensevoiceError = `sensevoice process exited with code ${res.code}`
220
+ sensevoiceChild.done.then(async (res) => {
221
+ let code = res && res.code
222
+ let detail = ''
223
+ if (typeof sensevoiceChild.result === 'function') {
224
+ try {
225
+ const r = await sensevoiceChild.result()
226
+ if (r) {
227
+ if (r.exitCode !== undefined && r.exitCode !== null) code = r.exitCode
228
+ if (r.stderr) detail = String(r.stderr).trim()
229
+ }
230
+ } catch (_) {}
231
+ } else if (sensevoiceChild.result && typeof sensevoiceChild.result === 'object') {
232
+ code = sensevoiceChild.result.exitCode ?? code
233
+ if (sensevoiceChild.result.stderr) detail = String(sensevoiceChild.result.stderr).trim()
234
+ }
235
+ if (sensevoiceChild.status === 'killed') {
236
+ sensevoiceError = detail ? `sensevoice process was killed: ${detail}` : 'sensevoice process was killed'
237
+ } else if (sensevoiceChild.status === 'completed') {
238
+ code = code ?? sensevoiceChild.exitCode ?? 0
239
+ if (code !== 0 && code !== null && code !== undefined) {
240
+ sensevoiceError = `sensevoice process exited with code ${code}${detail ? `: ${detail}` : ''}`
241
+ }
242
+ } else if (code !== 0 && code !== null && code !== undefined) {
243
+ sensevoiceError = `sensevoice process exited with code ${code}${detail ? `: ${detail}` : ''}`
156
244
  }
157
245
  }).catch((err) => {
158
246
  sensevoiceError = err?.message || String(err)
@@ -161,18 +249,31 @@ export function createLocalDaemons({ live, ctx }) {
161
249
 
162
250
  // Wait for it to become healthy (up to 30s).
163
251
  for (let i = 0; i < 60; i++) {
252
+ if (disposed) return false
164
253
  await new Promise((r) => setTimeout(r, 500))
254
+ if (disposed) return false
165
255
  if (await sensevoiceAlive()) {
166
256
  sensevoiceError = null
167
257
  return true
168
258
  }
169
- if (sensevoiceChild && sensevoiceChild.exitCode !== null && sensevoiceChild.exitCode !== undefined) {
170
- sensevoiceError = `sensevoice process exited prematurely with code ${sensevoiceChild.exitCode}`
171
- return false
172
- }
173
- if (sensevoiceChild && (sensevoiceChild.status === 'exited' || sensevoiceChild.status === 'failed')) {
174
- sensevoiceError = `sensevoice process exited prematurely with status ${sensevoiceChild.status}`
175
- return false
259
+ if (sensevoiceChild) {
260
+ if (sensevoiceChild.status === 'killed') {
261
+ sensevoiceError = 'sensevoice process was killed'
262
+ return false
263
+ }
264
+ if (sensevoiceChild.status === 'completed') {
265
+ const code = sensevoiceChild.result?.exitCode ?? sensevoiceChild.exitCode ?? 0
266
+ sensevoiceError = `sensevoice process exited prematurely with code ${code}`
267
+ return false
268
+ }
269
+ if (sensevoiceChild.status === 'exited' || sensevoiceChild.status === 'failed') {
270
+ sensevoiceError = `sensevoice process exited prematurely with status ${sensevoiceChild.status}`
271
+ return false
272
+ }
273
+ if (sensevoiceChild.exitCode !== null && sensevoiceChild.exitCode !== undefined) {
274
+ sensevoiceError = `sensevoice process exited prematurely with code ${sensevoiceChild.exitCode}`
275
+ return false
276
+ }
176
277
  }
177
278
  }
178
279
  sensevoiceError = 'sensevoice process started but failed health check'
@@ -195,6 +296,7 @@ export function createLocalDaemons({ live, ctx }) {
195
296
  }
196
297
 
197
298
  function dispose() {
299
+ disposed = true
198
300
  if (child && typeof child.kill === 'function') {
199
301
  try { child.kill() } catch { /* ignore */ }
200
302
  child = null
@@ -232,16 +232,36 @@ export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateCo
232
232
  }
233
233
 
234
234
  export function buildSensevoiceArgs(cfg, port) {
235
- const modelDir = cfg?.sensevoiceModel ? path.dirname(cfg.sensevoiceModel) : getDefaultModelDir()
236
- const tokensPath = findTokensFile(modelDir)
235
+ let modelPath = String(cfg?.sensevoiceModel || '')
236
+ const modelDir = modelPath ? path.dirname(modelPath) : getDefaultModelDir()
237
+ const tokensPath = (cfg?.sensevoiceTokens && existsSync(cfg.sensevoiceTokens))
238
+ ? cfg.sensevoiceTokens
239
+ : findTokensFile(modelDir)
237
240
  const tokensArg = tokensPath ? ` --tokens=${JSON.stringify(tokensPath)}` : ''
238
241
  const rawProv = String(cfg?.sensevoiceProvider || '').trim().toLowerCase()
239
- const normProv = rawProv === 'rknpu' ? 'rknn' : rawProv
242
+ let normProv = rawProv === 'rknpu' ? 'rknn' : rawProv
243
+
244
+ if (normProv === 'rknn') {
245
+ const ext = path.extname(modelPath).toLowerCase()
246
+ if (ext === '.onnx') {
247
+ const rknnCandidate = path.join(modelDir, path.basename(modelPath, ext) + '.rknn')
248
+ const defaultRknn = path.join(modelDir, 'model.rknn')
249
+ if (existsSync(rknnCandidate)) {
250
+ modelPath = rknnCandidate
251
+ } else if (existsSync(defaultRknn)) {
252
+ modelPath = defaultRknn
253
+ } else {
254
+ // Guard: ordinary ONNX model cannot be loaded by sherpa-onnx RKNN runtime (#221)
255
+ normProv = 'cpu'
256
+ }
257
+ }
258
+ }
259
+
240
260
  const provider = (normProv && normProv !== 'cpu')
241
261
  ? ` --provider=${normProv}`
242
262
  : ''
243
263
  const threads = Number(cfg?.sensevoiceThreads) || 4
244
- return ` --sense-voice-model=${JSON.stringify(cfg?.sensevoiceModel || '')}`
264
+ return ` --sense-voice-model=${JSON.stringify(modelPath)}`
245
265
  + tokensArg
246
266
  + ` --port=${port} --num-threads=${threads}`
247
267
  + provider
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, statSync } 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,20 +202,39 @@ export function checkProfileLock(profileDir) {
202
202
  if (isProcessAlive(pid)) {
203
203
  return { locked: true, pid };
204
204
  }
205
+ // Stale lock: PID is not running.
205
206
  // Verify before unlinking: ensure the file content still holds the dead pid we read,
206
- // so we don't delete a successor's live lock acquired in the interim (#205).
207
+ // without renaming the canonical file away so live successors are never hidden (#205).
207
208
  try {
208
- const freshPid = readLockPid(lockPath);
209
- if (freshPid !== pid) {
210
- return { locked: true, pid: freshPid };
209
+ const currentPid = readLockPid(lockPath);
210
+ if (currentPid === pid) {
211
+ unlinkSync(lockPath);
212
+ return { locked: false, cleanedStale: true };
211
213
  }
212
- unlinkSync(lockPath);
213
- return { locked: false, cleanedStale: true };
214
+ if (currentPid !== null && isProcessAlive(currentPid)) {
215
+ return { locked: true, pid: currentPid };
216
+ }
217
+ return { locked: false };
214
218
  } catch {
219
+ if (!existsSync(lockPath)) return { locked: false };
220
+ const currentPid = readLockPid(lockPath);
221
+ if (currentPid !== null && isProcessAlive(currentPid)) {
222
+ return { locked: true, pid: currentPid };
223
+ }
215
224
  return { locked: false };
216
225
  }
217
226
  }
218
- return { locked: true, pid: null };
227
+ // Lockfile without valid PID: check age (> 10 min)
228
+ try {
229
+ const stat = statSync(lockPath);
230
+ if (Date.now() - stat.mtimeMs > 10 * 60_000) {
231
+ try { unlinkSync(lockPath); } catch {}
232
+ return { locked: false, cleanedStale: true };
233
+ }
234
+ return { locked: true, pid: null };
235
+ } catch {
236
+ return { locked: false };
237
+ }
219
238
  }
220
239
 
221
240
  async function installExact(target, packageSpec, options) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.9.17",
3
+ "version": "0.9.19",
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",