@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.
- package/lib/client.js +444 -377
- package/lib/host-routes.js +186 -0
- package/lib/index.js +73 -281
- package/lib/local-daemon.js +130 -28
- package/lib/sensevoice-installer.js +24 -4
- package/lib/updater.js +27 -8
- package/package.json +1 -1
package/lib/local-daemon.js
CHANGED
|
@@ -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
|
-
}
|
|
47
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
}
|
|
139
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
236
|
-
const
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
207
|
+
// without renaming the canonical file away so live successors are never hidden (#205).
|
|
207
208
|
try {
|
|
208
|
-
const
|
|
209
|
-
if (
|
|
210
|
-
|
|
209
|
+
const currentPid = readLockPid(lockPath);
|
|
210
|
+
if (currentPid === pid) {
|
|
211
|
+
unlinkSync(lockPath);
|
|
212
|
+
return { locked: false, cleanedStale: true };
|
|
211
213
|
}
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|