@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.
- package/lib/client.js +547 -394
- package/lib/host-routes.js +186 -0
- package/lib/index.js +75 -457
- package/lib/local-daemon.js +272 -0
- package/lib/schema.js +1 -1
- package/lib/sensevoice-installer.js +4 -2
- package/lib/updater.js +17 -3
- package/package.json +1 -1
|
@@ -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", "
|
|
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
|
|
239
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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.
|
|
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",
|