@goodandready/dsh-voice 0.9.15 → 0.9.17
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 +208 -95
- package/lib/index.js +11 -185
- package/lib/local-daemon.js +218 -0
- package/lib/schema.js +1 -1
- package/lib/sensevoice-installer.js +4 -2
- package/lib/updater.js +6 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -568,6 +568,7 @@ window.__ModuleLoader__.load({
|
|
|
568
568
|
window.addEventListener('beforeunload', onPageUnload)
|
|
569
569
|
|
|
570
570
|
return () => {
|
|
571
|
+
voice.disposed = true
|
|
571
572
|
window.removeEventListener('dsh:tts:start', onTtsStart)
|
|
572
573
|
window.removeEventListener('dsh:tts:stop', onTtsStop)
|
|
573
574
|
window.removeEventListener('dsh-voice:settings-saved', onSettingsSaved)
|
|
@@ -734,51 +735,80 @@ window.__ModuleLoader__.load({
|
|
|
734
735
|
|
|
735
736
|
async function sendAudio(blob, mime, mode) {
|
|
736
737
|
const activeChain = (voice._activeChain || [])
|
|
737
|
-
|
|
738
|
-
|
|
738
|
+
const gpuIdx = activeChain.indexOf('browser-webgpu')
|
|
739
|
+
const modelMap = voice._activeModelMap || {}
|
|
740
|
+
const chosenModel = modelMap['browser-webgpu'] || (voice.settings && voice.settings.webgpuModel)
|
|
741
|
+
|
|
742
|
+
const tryWebGpu = async () => {
|
|
743
|
+
const canGpu = voice.webGpu?.isSupported ? voice.webGpu.isSupported() : (typeof isWebGpuSupported === 'function' && isWebGpuSupported())
|
|
744
|
+
if (canGpu) {
|
|
739
745
|
try {
|
|
740
|
-
const
|
|
746
|
+
const fn = voice.webGpu?.transcribe || transcribeWithWebGpu
|
|
747
|
+
const out = await fn(blob, mime, {
|
|
741
748
|
language: voice._activeLang || (voice.settings && voice.settings.dictation && voice.settings.dictation.language) || '',
|
|
742
|
-
model:
|
|
749
|
+
model: chosenModel,
|
|
743
750
|
})
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
}
|
|
751
|
+
const text = (out && typeof out.text === 'string') ? tidyPhrase(out.text) : ''
|
|
752
|
+
if (text) return { text, command: null, action: null, provider: 'browser-webgpu' }
|
|
747
753
|
} catch (webGpuErr) {
|
|
748
|
-
|
|
749
|
-
console.warn('[dsh-voice] WebGPU Whisper failed, falling back to server chain:', webGpuErr)
|
|
754
|
+
console.warn('[dsh-voice] WebGPU Whisper failed:', webGpuErr)
|
|
750
755
|
}
|
|
751
756
|
}
|
|
757
|
+
return null
|
|
752
758
|
}
|
|
753
|
-
|
|
754
|
-
const
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
res =
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
759
|
+
|
|
760
|
+
const tryHost = async () => {
|
|
761
|
+
const dataBase64 = await blobToBase64(blob)
|
|
762
|
+
const payload = { dataBase64, mimeType: mime, mode }
|
|
763
|
+
const contextWords = extractContextKeywords()
|
|
764
|
+
if (contextWords && contextWords.length > 0) payload.contextWords = contextWords
|
|
765
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
|
|
766
|
+
const timer = controller ? setTimeout(() => controller.abort(), 60000) : null
|
|
767
|
+
let res = null
|
|
768
|
+
try {
|
|
769
|
+
res = await fetch('/dsh-voice/transcribe', {
|
|
770
|
+
method: 'POST',
|
|
771
|
+
headers: { 'content-type': 'application/json' },
|
|
772
|
+
body: JSON.stringify(payload),
|
|
773
|
+
signal: controller ? controller.signal : undefined,
|
|
774
|
+
})
|
|
775
|
+
} catch (e) {
|
|
776
|
+
if (e && e.name === 'AbortError') {
|
|
777
|
+
throw new Error('Transcription request timed out (60s)')
|
|
778
|
+
}
|
|
779
|
+
throw e
|
|
780
|
+
} finally {
|
|
781
|
+
if (timer) clearTimeout(timer)
|
|
770
782
|
}
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
if (
|
|
783
|
+
let parsed = null
|
|
784
|
+
try { parsed = await res.json() } catch (e) { /* not json */ }
|
|
785
|
+
if (!res.ok || !parsed || !parsed.ok) {
|
|
786
|
+
throw new Error((parsed && parsed.error && parsed.error.message) || `HTTP ${res.status}`)
|
|
787
|
+
}
|
|
788
|
+
if (parsed.command) return { command: parsed.command, text: '', action: null }
|
|
789
|
+
return { text: String(parsed.text || '').trim(), command: null, action: parsed.action || null }
|
|
774
790
|
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
791
|
+
|
|
792
|
+
if (gpuIdx === 0) {
|
|
793
|
+
const gpuOut = await tryWebGpu()
|
|
794
|
+
if (gpuOut && gpuOut.text) return gpuOut
|
|
795
|
+
if (activeChain.length > 1) return await tryHost()
|
|
796
|
+
return gpuOut || { text: '', command: null, action: null, provider: 'browser-webgpu' }
|
|
797
|
+
} else if (gpuIdx > 0) {
|
|
798
|
+
try {
|
|
799
|
+
const hostOut = await tryHost()
|
|
800
|
+
if (hostOut && (hostOut.text || hostOut.command || hostOut.action)) return hostOut
|
|
801
|
+
} catch (hostErr) {
|
|
802
|
+
const gpuOut = await tryWebGpu()
|
|
803
|
+
if (gpuOut && gpuOut.text) return gpuOut
|
|
804
|
+
throw hostErr
|
|
805
|
+
}
|
|
806
|
+
const gpuOut = await tryWebGpu()
|
|
807
|
+
if (gpuOut && gpuOut.text) return gpuOut
|
|
808
|
+
return { text: '', command: null, action: null }
|
|
779
809
|
}
|
|
780
|
-
|
|
781
|
-
return
|
|
810
|
+
|
|
811
|
+
return await tryHost()
|
|
782
812
|
}
|
|
783
813
|
|
|
784
814
|
function tidyPhrase(text) {
|
|
@@ -837,6 +867,7 @@ window.__ModuleLoader__.load({
|
|
|
837
867
|
}
|
|
838
868
|
|
|
839
869
|
function insertDraftText(text) {
|
|
870
|
+
if (voice.disposed) return false
|
|
840
871
|
const actions = voice.inputActions
|
|
841
872
|
if (!actions || (typeof actions.setDraft !== 'function' && typeof actions.insertText !== 'function')) {
|
|
842
873
|
voice.set({ phase: 'error', error: t('composerUnavailable') })
|
|
@@ -859,7 +890,7 @@ window.__ModuleLoader__.load({
|
|
|
859
890
|
}
|
|
860
891
|
|
|
861
892
|
function appendDraft(text) {
|
|
862
|
-
if (!text) return
|
|
893
|
+
if (voice.disposed || !text) return
|
|
863
894
|
let clean = voice.settings.voiceCommands ? applyVoiceCommands(text) : tidyPhrase(text)
|
|
864
895
|
if (!clean) return
|
|
865
896
|
insertDraftText(clean)
|
|
@@ -1087,34 +1118,41 @@ function isWebGpuSupported() {
|
|
|
1087
1118
|
return true
|
|
1088
1119
|
}
|
|
1089
1120
|
|
|
1090
|
-
|
|
1091
|
-
let
|
|
1121
|
+
const _webGpuPipelines = new Map()
|
|
1122
|
+
let _webGpuLoadingModel = null
|
|
1092
1123
|
|
|
1093
1124
|
async function loadWebGpuWhisper(modelName) {
|
|
1094
|
-
|
|
1095
|
-
if (
|
|
1096
|
-
|
|
1097
|
-
|
|
1125
|
+
const targetModel = modelName || (voice.settings && voice.settings.webgpuModel) || 'onnx-community/whisper-tiny'
|
|
1126
|
+
if (_webGpuPipelines.has(targetModel)) {
|
|
1127
|
+
return _webGpuPipelines.get(targetModel)
|
|
1128
|
+
}
|
|
1129
|
+
while (_webGpuLoadingModel) {
|
|
1130
|
+
if (_webGpuLoadingModel === targetModel) {
|
|
1131
|
+
await new Promise((r) => setTimeout(r, 60))
|
|
1132
|
+
if (_webGpuPipelines.has(targetModel)) return _webGpuPipelines.get(targetModel)
|
|
1133
|
+
} else {
|
|
1134
|
+
break
|
|
1098
1135
|
}
|
|
1099
|
-
if (_webGpuPipeline) return _webGpuPipeline
|
|
1100
1136
|
}
|
|
1101
|
-
|
|
1137
|
+
_webGpuLoadingModel = targetModel
|
|
1102
1138
|
try {
|
|
1103
1139
|
if (!isWebGpuSupported()) {
|
|
1104
1140
|
throw new Error('WebGPU is not supported in this browser environment')
|
|
1105
1141
|
}
|
|
1106
1142
|
// Dynamic import of transformers.js from ESM CDN
|
|
1107
|
-
const mod = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.3.3')
|
|
1143
|
+
const mod = typeof globalThis !== 'undefined' && globalThis.__mockTransformers ? globalThis.__mockTransformers : (await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.3.3'))
|
|
1108
1144
|
const pipeline = mod.pipeline || (mod.default && mod.default.pipeline)
|
|
1109
1145
|
if (!pipeline) throw new Error('Transformers pipeline not found in loaded bundle')
|
|
1110
|
-
const
|
|
1111
|
-
_webGpuPipeline = await pipeline('automatic-speech-recognition', targetModel, {
|
|
1146
|
+
const instance = await pipeline('automatic-speech-recognition', targetModel, {
|
|
1112
1147
|
device: 'webgpu',
|
|
1113
1148
|
dtype: 'fp32',
|
|
1114
1149
|
})
|
|
1115
|
-
|
|
1150
|
+
_webGpuPipelines.set(targetModel, instance)
|
|
1151
|
+
return instance
|
|
1116
1152
|
} finally {
|
|
1117
|
-
|
|
1153
|
+
if (_webGpuLoadingModel === targetModel) {
|
|
1154
|
+
_webGpuLoadingModel = null
|
|
1155
|
+
}
|
|
1118
1156
|
}
|
|
1119
1157
|
}
|
|
1120
1158
|
|
|
@@ -1150,13 +1188,23 @@ async function transcribeWithWebGpu(blob, mime, options) {
|
|
|
1150
1188
|
voice.webGpu = {
|
|
1151
1189
|
isSupported: isWebGpuSupported,
|
|
1152
1190
|
transcribe: transcribeWithWebGpu,
|
|
1191
|
+
load: loadWebGpuWhisper,
|
|
1192
|
+
clearCache: () => { _webGpuPipelines.clear() },
|
|
1153
1193
|
}
|
|
1154
1194
|
voice._chainsPromise = null
|
|
1155
1195
|
function modeChain(mode) {
|
|
1156
1196
|
if (!voice._chainsPromise) {
|
|
1157
1197
|
voice._chainsPromise = fetch('/dsh-voice/status', { cache: 'no-store' })
|
|
1158
1198
|
.then((res) => res.json())
|
|
1159
|
-
.then((data) =>
|
|
1199
|
+
.then((data) => {
|
|
1200
|
+
if (data && typeof data.localOnly === 'boolean') {
|
|
1201
|
+
voice.settings.localOnly = data.localOnly
|
|
1202
|
+
}
|
|
1203
|
+
if (data && data.webgpuModel) {
|
|
1204
|
+
voice.settings.webgpuModel = data.webgpuModel
|
|
1205
|
+
}
|
|
1206
|
+
return (data && data.modes) || {}
|
|
1207
|
+
})
|
|
1160
1208
|
.catch(() => {
|
|
1161
1209
|
voice._chainsPromise = null
|
|
1162
1210
|
return {}
|
|
@@ -1164,9 +1212,21 @@ voice.webGpu = {
|
|
|
1164
1212
|
}
|
|
1165
1213
|
return voice._chainsPromise.then((modes) => {
|
|
1166
1214
|
const row = modes[mode] || {}
|
|
1215
|
+
let chain = Array.isArray(row.chain) ? row.chain : []
|
|
1216
|
+
let providers = chain.map((e) => (typeof e === 'string' ? e : e && e.provider)).filter(Boolean)
|
|
1217
|
+
if (voice.settings && voice.settings.localOnly) {
|
|
1218
|
+
providers = providers.filter((p) => p !== 'browser')
|
|
1219
|
+
}
|
|
1220
|
+
const modelMap = {}
|
|
1221
|
+
for (const entry of chain) {
|
|
1222
|
+
if (entry && entry.provider && entry.model) {
|
|
1223
|
+
modelMap[entry.provider] = entry.model
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1167
1226
|
return {
|
|
1168
|
-
chain:
|
|
1227
|
+
chain: providers,
|
|
1169
1228
|
language: row.language !== undefined ? row.language : '',
|
|
1229
|
+
modelMap,
|
|
1170
1230
|
}
|
|
1171
1231
|
})
|
|
1172
1232
|
}
|
|
@@ -1184,7 +1244,7 @@ voice.webGpu = {
|
|
|
1184
1244
|
if (!recorder || recorder.state === 'inactive') return Promise.resolve()
|
|
1185
1245
|
return new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true }))
|
|
1186
1246
|
}
|
|
1187
|
-
async function openMic(mode) {
|
|
1247
|
+
async function openMic(mode, opId) {
|
|
1188
1248
|
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
1189
1249
|
throw new Error(t('micUnavailable'))
|
|
1190
1250
|
}
|
|
@@ -1202,6 +1262,7 @@ voice.webGpu = {
|
|
|
1202
1262
|
const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream)
|
|
1203
1263
|
const rec = {
|
|
1204
1264
|
recorder, stream, mode,
|
|
1265
|
+
opId: opId || (voice.activeOpId || 0),
|
|
1205
1266
|
chunks: [],
|
|
1206
1267
|
mime: mimeType || recorder.mimeType || 'audio/webm',
|
|
1207
1268
|
audioCtx: null, analyser: null,
|
|
@@ -1252,6 +1313,7 @@ voice.webGpu = {
|
|
|
1252
1313
|
function cutPhrase() {
|
|
1253
1314
|
const rec = voice.rec
|
|
1254
1315
|
if (!rec || rec.cutting || rec.closing) return
|
|
1316
|
+
const opId = rec.opId || voice.activeOpId
|
|
1255
1317
|
rec.cutting = true
|
|
1256
1318
|
const stopped = waitStop(rec.recorder)
|
|
1257
1319
|
try {
|
|
@@ -1263,7 +1325,7 @@ voice.webGpu = {
|
|
|
1263
1325
|
return
|
|
1264
1326
|
}
|
|
1265
1327
|
stopped.then(async () => {
|
|
1266
|
-
if (rec.cancelled || voice.activeOpId !== opId) return
|
|
1328
|
+
if (rec.cancelled || voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) return
|
|
1267
1329
|
const blob = new Blob(rec.chunks, { type: rec.mime })
|
|
1268
1330
|
rec.chunks = []
|
|
1269
1331
|
rec.silenceMs = 0
|
|
@@ -1277,42 +1339,42 @@ voice.webGpu = {
|
|
|
1277
1339
|
rec.cutting = false
|
|
1278
1340
|
if (rec.cancelled || blob.size < 600) return // cancelled or too short — not speech
|
|
1279
1341
|
dictationQueue = dictationQueue.then(async () => {
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1342
|
+
if (rec.cancelled || voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) return
|
|
1343
|
+
try {
|
|
1344
|
+
const out = await sendAudio(blob, rec.mime, 'dictation')
|
|
1345
|
+
if (rec.cancelled || voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) return
|
|
1346
|
+
const text = out && out.text ? out.text : ''
|
|
1347
|
+
const action = out && out.action ? out.action : null
|
|
1348
|
+
if (voice.settings.structuredPromptVoice !== false && text) {
|
|
1349
|
+
const promptInfo = findActiveStructuredPrompt()
|
|
1350
|
+
if (promptInfo && submitToStructuredPrompt(text, promptInfo)) {
|
|
1351
|
+
return
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
if (action === 'send' || action === 'clear' || action === 'newline') {
|
|
1355
|
+
executeVoiceAction(action, text)
|
|
1289
1356
|
return
|
|
1290
1357
|
}
|
|
1358
|
+
if (action === 'cancel') {
|
|
1359
|
+
cancelCurrent()
|
|
1360
|
+
return
|
|
1361
|
+
}
|
|
1362
|
+
const delay = Number(voice.settings.sendDelayMs) || 0
|
|
1363
|
+
if (text && delay > 0 && !voice.holding) {
|
|
1364
|
+
appendDraft(text)
|
|
1365
|
+
voice.set({ phase: 'pending', pending: { text, undoOnly: true, leftMs: delay } })
|
|
1366
|
+
return
|
|
1367
|
+
}
|
|
1368
|
+
if (text) appendDraft(text)
|
|
1369
|
+
} catch (e) {
|
|
1370
|
+
if (!rec.cancelled && voice.activeOpId === opId) {
|
|
1371
|
+
voice.set({ error: String(e && e.message ? e.message : e) })
|
|
1372
|
+
}
|
|
1291
1373
|
}
|
|
1292
|
-
if (action === 'send' || action === 'clear' || action === 'newline') {
|
|
1293
|
-
executeVoiceAction(action, text)
|
|
1294
|
-
return
|
|
1295
|
-
}
|
|
1296
|
-
if (action === 'cancel') {
|
|
1297
|
-
cancelCurrent()
|
|
1298
|
-
return
|
|
1299
|
-
}
|
|
1300
|
-
const delay = Number(voice.settings.sendDelayMs) || 0
|
|
1301
|
-
if (text && delay > 0 && !voice.holding) {
|
|
1302
|
-
appendDraft(text)
|
|
1303
|
-
voice.set({ phase: 'pending', pending: { text, undoOnly: true, leftMs: delay } })
|
|
1304
|
-
return
|
|
1305
|
-
}
|
|
1306
|
-
if (text) appendDraft(text)
|
|
1307
|
-
} catch (e) {
|
|
1308
|
-
if (!rec.cancelled) {
|
|
1309
|
-
voice.set({ error: String(e && e.message ? e.message : e) })
|
|
1310
|
-
}
|
|
1311
|
-
}
|
|
1312
1374
|
})
|
|
1313
1375
|
})
|
|
1314
1376
|
}
|
|
1315
|
-
function startBrowserLeg(mode, language) {
|
|
1377
|
+
function startBrowserLeg(mode, language, opId) {
|
|
1316
1378
|
if (voice.settings && voice.settings.localOnly) return false
|
|
1317
1379
|
if (!browserRecognitionAvailable()) return false
|
|
1318
1380
|
const finals = []
|
|
@@ -1321,6 +1383,7 @@ voice.webGpu = {
|
|
|
1321
1383
|
lang: language,
|
|
1322
1384
|
continuous: true,
|
|
1323
1385
|
onInterim: (text) => {
|
|
1386
|
+
if (voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) return
|
|
1324
1387
|
voice.caption = text
|
|
1325
1388
|
if (voice.settings.liveInterimPreview !== false) {
|
|
1326
1389
|
voice.interimTranscript = text
|
|
@@ -1336,20 +1399,25 @@ voice.webGpu = {
|
|
|
1336
1399
|
voice.browser = null
|
|
1337
1400
|
voice.caption = ''
|
|
1338
1401
|
voice.interimTranscript = ''
|
|
1339
|
-
openMic(mode)
|
|
1402
|
+
openMic(mode, opId)
|
|
1340
1403
|
.then((rec) => {
|
|
1341
|
-
if (voice.phase !== 'recording') {
|
|
1404
|
+
if (voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) {
|
|
1342
1405
|
teardown(rec)
|
|
1343
1406
|
return
|
|
1344
1407
|
}
|
|
1408
|
+
rec.opId = opId
|
|
1345
1409
|
voice.rec = rec
|
|
1346
1410
|
voice.notify()
|
|
1347
1411
|
})
|
|
1348
|
-
.catch((err) =>
|
|
1412
|
+
.catch((err) => {
|
|
1413
|
+
if (voice.activeOpId !== opId) return
|
|
1414
|
+
voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null })
|
|
1415
|
+
})
|
|
1349
1416
|
}
|
|
1350
1417
|
}
|
|
1351
1418
|
},
|
|
1352
1419
|
onFinal: (text) => {
|
|
1420
|
+
if (voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) return
|
|
1353
1421
|
finals.push(text)
|
|
1354
1422
|
voice.caption = ''
|
|
1355
1423
|
voice.interimTranscript = ''
|
|
@@ -1357,6 +1425,7 @@ voice.webGpu = {
|
|
|
1357
1425
|
else voice.notify()
|
|
1358
1426
|
},
|
|
1359
1427
|
onError: (reason) => {
|
|
1428
|
+
if (voice.activeOpId !== opId || voice.disposed) return
|
|
1360
1429
|
voice.browser = null
|
|
1361
1430
|
voice.interimTranscript = ''
|
|
1362
1431
|
voice.set({ phase: 'error', error: t('browserFailed') + reason })
|
|
@@ -1369,6 +1438,7 @@ voice.webGpu = {
|
|
|
1369
1438
|
function startRecording(mode) {
|
|
1370
1439
|
if (voice.phase !== 'idle' && voice.phase !== 'error') return
|
|
1371
1440
|
voice.activeOpId = (voice.activeOpId || 0) + 1
|
|
1441
|
+
const opId = voice.activeOpId
|
|
1372
1442
|
if (voice.isTtsSpeaking) {
|
|
1373
1443
|
if (voice.settings.bargeIn) {
|
|
1374
1444
|
triggerBargeIn()
|
|
@@ -1381,19 +1451,25 @@ voice.webGpu = {
|
|
|
1381
1451
|
dictationQueue = Promise.resolve()
|
|
1382
1452
|
voice.set({ phase: 'recording', mode, error: '', levels: [], caption: '', interimTranscript: '' })
|
|
1383
1453
|
modeChain(mode).then((info) => {
|
|
1454
|
+
if (voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) return
|
|
1384
1455
|
voice._activeChain = info.chain || []
|
|
1385
1456
|
voice._activeLang = info.language || ''
|
|
1386
|
-
|
|
1387
|
-
|
|
1457
|
+
voice._activeModelMap = info.modelMap || {}
|
|
1458
|
+
if (info.chain[0] === 'browser' && !voice.settings.localOnly && startBrowserLeg(mode, info.language, opId)) return
|
|
1459
|
+
openMic(mode, opId)
|
|
1388
1460
|
.then((rec) => {
|
|
1389
|
-
if (voice.phase !== 'recording') {
|
|
1461
|
+
if (voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) {
|
|
1390
1462
|
teardown(rec)
|
|
1391
1463
|
return
|
|
1392
1464
|
}
|
|
1465
|
+
rec.opId = opId
|
|
1393
1466
|
voice.rec = rec
|
|
1394
1467
|
voice.notify()
|
|
1395
1468
|
})
|
|
1396
|
-
.catch((err) =>
|
|
1469
|
+
.catch((err) => {
|
|
1470
|
+
if (voice.activeOpId !== opId) return
|
|
1471
|
+
voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null })
|
|
1472
|
+
})
|
|
1397
1473
|
})
|
|
1398
1474
|
}
|
|
1399
1475
|
function startDictation() { startRecording('dictation') }
|
|
@@ -1522,11 +1598,14 @@ voice.webGpu = {
|
|
|
1522
1598
|
announceVoice('end')
|
|
1523
1599
|
focusComposer()
|
|
1524
1600
|
if (voice.browser) {
|
|
1601
|
+
voice.activeOpId = (voice.activeOpId || 0) + 1
|
|
1602
|
+
const opId = voice.activeOpId
|
|
1525
1603
|
const mode = voice.mode
|
|
1526
1604
|
const b = voice.browser
|
|
1527
1605
|
voice.browser = null
|
|
1528
1606
|
voice.caption = ''
|
|
1529
1607
|
Promise.resolve(b.stop()).then(() => {
|
|
1608
|
+
if (voice.activeOpId !== opId || voice.phase === 'idle' || voice.disposed) return
|
|
1530
1609
|
const said = (voice.browserFinals || []).join(' ').trim()
|
|
1531
1610
|
voice.browserFinals = null
|
|
1532
1611
|
if (!said) { voice.set({ phase: 'idle' }); return }
|
|
@@ -2180,6 +2259,7 @@ voice.webGpu = {
|
|
|
2180
2259
|
Object.assign(voice.settings, {
|
|
2181
2260
|
beep: !!(data && data.beep),
|
|
2182
2261
|
localOnly: !!(data && data.localOnly),
|
|
2262
|
+
webgpuModel: String((data && data.webgpuModel) || (voice.settings && voice.settings.webgpuModel) || 'onnx-community/whisper-tiny'),
|
|
2183
2263
|
micDeviceId: String((data && data.micDeviceId) || ''),
|
|
2184
2264
|
historyLimit: Number(data && data.historyLimit),
|
|
2185
2265
|
voiceCommands: !!(data && data.voiceCommands),
|
|
@@ -2709,7 +2789,7 @@ voice.webGpu = {
|
|
|
2709
2789
|
style: { flex: 1 },
|
|
2710
2790
|
},
|
|
2711
2791
|
React.createElement('option', { value: 'cpu' }, 'CPU (Standard)'),
|
|
2712
|
-
React.createElement('option', { value: '
|
|
2792
|
+
React.createElement('option', { value: 'rknn' }, 'Rockchip RK3588 NPU (RKNN)'),
|
|
2713
2793
|
React.createElement('option', { value: 'openvino' }, 'Intel OpenVINO'),
|
|
2714
2794
|
React.createElement('option', { value: 'cuda' }, 'NVIDIA CUDA'),
|
|
2715
2795
|
),
|
|
@@ -3227,6 +3307,27 @@ voice.webGpu = {
|
|
|
3227
3307
|
try { return JSON.parse(JSON.stringify(obj)) } catch (e) { return Object.assign({}, obj) }
|
|
3228
3308
|
}
|
|
3229
3309
|
|
|
3310
|
+
const setTop = (key, val) => {
|
|
3311
|
+
setDraft((d) => Object.assign({}, d || {}, { [key]: val }))
|
|
3312
|
+
}
|
|
3313
|
+
const setIn = (mode, key, val) => {
|
|
3314
|
+
setDraft((d) => {
|
|
3315
|
+
const next = Object.assign({}, d || {})
|
|
3316
|
+
next[mode] = Object.assign({}, next[mode] || {}, { [key]: val })
|
|
3317
|
+
return next
|
|
3318
|
+
})
|
|
3319
|
+
}
|
|
3320
|
+
const textField = (key, label, hint) => React.createElement('div', { className: 'cb-field' },
|
|
3321
|
+
React.createElement('label', null, label),
|
|
3322
|
+
React.createElement('input', {
|
|
3323
|
+
type: 'text',
|
|
3324
|
+
value: (draft && draft[key]) ?? '',
|
|
3325
|
+
disabled: !writable,
|
|
3326
|
+
onChange: (e) => setTop(key, e.target.value),
|
|
3327
|
+
}),
|
|
3328
|
+
hint ? React.createElement('span', { className: 'dvs-sub' }, hint) : null,
|
|
3329
|
+
)
|
|
3330
|
+
|
|
3230
3331
|
React.useEffect(() => { if (ready && draft === null) setDraft(deepClone(value)) }, [ready, draft, value])
|
|
3231
3332
|
|
|
3232
3333
|
React.useEffect(() => {
|
|
@@ -3259,13 +3360,10 @@ voice.webGpu = {
|
|
|
3259
3360
|
techJargonCorrection: val.techJargonCorrection !== false,
|
|
3260
3361
|
liveInterimPreview: val.liveInterimPreview !== false,
|
|
3261
3362
|
structuredPromptVoice: val.structuredPromptVoice !== false,
|
|
3363
|
+
webgpuModel: String(val.webgpuModel || (voice.settings && voice.settings.webgpuModel) || 'onnx-community/whisper-tiny'),
|
|
3262
3364
|
})
|
|
3263
3365
|
}, [ready, value])
|
|
3264
3366
|
|
|
3265
|
-
const { sensevoiceState, installingSensevoice, triggerInstallSensevoice } = useSensevoiceInstaller(fetchStatus)
|
|
3266
|
-
|
|
3267
|
-
const { testingMic, testLevel, toggleTestMic, gateDbVal, gateThresholdPercent } = useMicTester(draft, value, setErr)
|
|
3268
|
-
|
|
3269
3367
|
const [statusData, setStatusData] = React.useState(null)
|
|
3270
3368
|
const [statusLatency, setStatusLatency] = React.useState(null)
|
|
3271
3369
|
const fetchStatus = React.useCallback(() => {
|
|
@@ -3279,6 +3377,9 @@ voice.webGpu = {
|
|
|
3279
3377
|
setStatusLatency(Math.round(performance.now() - start))
|
|
3280
3378
|
if (data && data.ok) {
|
|
3281
3379
|
setStatusData(data)
|
|
3380
|
+
if (data.webgpuModel && voice.settings) {
|
|
3381
|
+
voice.settings.webgpuModel = data.webgpuModel
|
|
3382
|
+
}
|
|
3282
3383
|
}
|
|
3283
3384
|
})
|
|
3284
3385
|
.catch(() => {
|
|
@@ -3291,6 +3392,10 @@ voice.webGpu = {
|
|
|
3291
3392
|
fetchStatus()
|
|
3292
3393
|
}, [fetchStatus])
|
|
3293
3394
|
|
|
3395
|
+
const { sensevoiceState, installingSensevoice, triggerInstallSensevoice } = useSensevoiceInstaller(fetchStatus)
|
|
3396
|
+
|
|
3397
|
+
const { testingMic, testLevel, toggleTestMic, gateDbVal, gateThresholdPercent } = useMicTester(draft, value, setErr)
|
|
3398
|
+
|
|
3294
3399
|
const { updaterStatus, updating, updaterMsg, updaterLoading, checkUpdater, onUpdateNow } = usePluginUpdater()
|
|
3295
3400
|
|
|
3296
3401
|
const save = async () => {
|
|
@@ -3783,6 +3888,14 @@ voice.webGpu = {
|
|
|
3783
3888
|
isHotkeyRelease,
|
|
3784
3889
|
extractContextKeywords,
|
|
3785
3890
|
resolveBrowserRecognitionLang,
|
|
3891
|
+
VoiceSection,
|
|
3892
|
+
cutPhrase,
|
|
3893
|
+
sendAudio,
|
|
3894
|
+
modeChain,
|
|
3895
|
+
startRecording,
|
|
3896
|
+
cancelCurrent,
|
|
3897
|
+
stopCurrent,
|
|
3898
|
+
loadWebGpuWhisper,
|
|
3786
3899
|
}
|
|
3787
3900
|
return module.exports
|
|
3788
3901
|
},
|
package/lib/index.js
CHANGED
|
@@ -31,6 +31,7 @@ import { registerConfigRoutes } from "./config-routes.js"
|
|
|
31
31
|
import { isAutoLang } from "./provider-http.js"
|
|
32
32
|
import { ChainEntry, CustomProvider, BaseConfig } from "./schema.js"
|
|
33
33
|
import { registerTranscribeAudioTool } from "./tool.js"
|
|
34
|
+
import { createLocalDaemons } from "./local-daemon.js"
|
|
34
35
|
export { ChainEntry, CustomProvider, BaseConfig }
|
|
35
36
|
|
|
36
37
|
export const name = '@goodandready/dsh-voice'
|
|
@@ -75,195 +76,18 @@ export function apply(ctx, baseConfig) {
|
|
|
75
76
|
return process.env[ref] || ''
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const res = await fetch(base + '/', { signal: AbortSignal.timeout(2000) })
|
|
84
|
-
return res.ok
|
|
85
|
-
} catch {
|
|
86
|
-
return false
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
let whisperError = null
|
|
91
|
-
let startingWhisper = false
|
|
92
|
-
async function startWhisper() {
|
|
93
|
-
if (startingWhisper) return false
|
|
94
|
-
startingWhisper = true
|
|
95
|
-
try {
|
|
96
|
-
const cfg = live()
|
|
97
|
-
if (!cfg.autoStart) return false
|
|
98
|
-
// Without a model path there is nothing to start: the package cannot know
|
|
99
|
-
// where a user's model lives and must not launch a random binary.
|
|
100
|
-
if (!cfg.whisperModel) return false
|
|
101
|
-
if (await whisperAlive()) {
|
|
102
|
-
whisperError = null
|
|
103
|
-
return true
|
|
104
|
-
}
|
|
105
|
-
if (child && typeof child.kill === 'function') {
|
|
106
|
-
try { child.kill() } catch { /* ignore */ }
|
|
107
|
-
child = null
|
|
108
|
-
}
|
|
109
|
-
const spec = typeof ctx.shell?.resolve === 'function'
|
|
110
|
-
? ctx.shell.resolve({
|
|
111
|
-
command: `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
|
|
112
|
-
+ ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${isAutoLang(cfg.dictation.language) ? 'auto' : cfg.dictation.language}`,
|
|
113
|
-
timeoutMs: 0,
|
|
114
|
-
stdoutMaxBytes: 4 * 1024 * 1024,
|
|
115
|
-
})
|
|
116
|
-
: {
|
|
117
|
-
command: `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
|
|
118
|
-
+ ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${isAutoLang(cfg.dictation.language) ? 'auto' : cfg.dictation.language}`,
|
|
119
|
-
timeoutMs: 0,
|
|
120
|
-
stdoutMaxBytes: 4 * 1024 * 1024,
|
|
121
|
-
}
|
|
122
|
-
child = typeof ctx.shell?.execute === 'function'
|
|
123
|
-
? ctx.shell.execute(spec)
|
|
124
|
-
: typeof ctx.shell?.start === 'function'
|
|
125
|
-
? ctx.shell.start(spec)
|
|
126
|
-
: null
|
|
127
|
-
if (child && typeof child.on === 'function') {
|
|
128
|
-
child.on('error', (err) => {
|
|
129
|
-
whisperError = err?.message || String(err)
|
|
130
|
-
})
|
|
131
|
-
child.on('exit', (code) => {
|
|
132
|
-
if (code !== 0 && code !== null) {
|
|
133
|
-
whisperError = `whisper server process exited with code ${code}`
|
|
134
|
-
}
|
|
135
|
-
})
|
|
136
|
-
}
|
|
137
|
-
if (child && child.done && typeof child.done.then === 'function') {
|
|
138
|
-
child.done.then((res) => {
|
|
139
|
-
if (res && res.code !== 0 && res.code !== null && res.code !== undefined) {
|
|
140
|
-
whisperError = `whisper server process exited with code ${res.code}`
|
|
141
|
-
}
|
|
142
|
-
}).catch((err) => {
|
|
143
|
-
whisperError = err?.message || String(err)
|
|
144
|
-
})
|
|
145
|
-
}
|
|
146
|
-
// Wait for it to become healthy (up to 30s for cold model loading on Metal/CPU).
|
|
147
|
-
for (let i = 0; i < 60; i++) {
|
|
148
|
-
await new Promise((r) => setTimeout(r, 500))
|
|
149
|
-
if (await whisperAlive()) {
|
|
150
|
-
whisperError = null
|
|
151
|
-
return true
|
|
152
|
-
}
|
|
153
|
-
if (child && child.exitCode !== null && child.exitCode !== undefined) {
|
|
154
|
-
whisperError = `whisper server process exited prematurely with code ${child.exitCode}`
|
|
155
|
-
return false
|
|
156
|
-
}
|
|
157
|
-
if (child && (child.status === 'exited' || child.status === 'failed')) {
|
|
158
|
-
whisperError = `whisper server process exited prematurely with status ${child.status}`
|
|
159
|
-
return false
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
whisperError = 'whisper server process started but failed health check on port 8001'
|
|
163
|
-
return false
|
|
164
|
-
} catch (err) {
|
|
165
|
-
whisperError = String(err && err.message ? err.message : err)
|
|
166
|
-
return false
|
|
167
|
-
}
|
|
168
|
-
finally { startingWhisper = false }
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// SenseVoice-ONNX / Sherpa-ONNX: autostart and health check.
|
|
172
|
-
let sensevoiceChild = null
|
|
173
|
-
let sensevoiceError = null
|
|
174
|
-
|
|
175
|
-
async function sensevoiceAlive() {
|
|
176
|
-
const cfg = live()
|
|
177
|
-
const base = String(cfg.sensevoiceUrl || '').replace(/\/api\/.*$/, '').replace(/\/v1\/.*$/, '').replace(/\/+$/, '')
|
|
178
|
-
if (!base) return false
|
|
179
|
-
try {
|
|
180
|
-
const res = await fetch(base + '/', { signal: AbortSignal.timeout(2000) })
|
|
181
|
-
return res.ok || res.status === 404 // sherpa-onnx answers 404 on / but is alive
|
|
182
|
-
} catch {
|
|
183
|
-
return false
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
let startingSensevoice = false
|
|
188
|
-
async function startSensevoice() {
|
|
189
|
-
if (startingSensevoice) return false
|
|
190
|
-
startingSensevoice = true
|
|
191
|
-
try {
|
|
192
|
-
const cfg = live()
|
|
193
|
-
if (!cfg.sensevoiceAutostart) return false
|
|
194
|
-
if (!cfg.sensevoiceModel) return false
|
|
195
|
-
if (await sensevoiceAlive()) {
|
|
196
|
-
sensevoiceError = null
|
|
197
|
-
return true
|
|
198
|
-
}
|
|
199
|
-
if (sensevoiceChild && typeof sensevoiceChild.kill === 'function') {
|
|
200
|
-
try { sensevoiceChild.kill() } catch { /* ignore */ }
|
|
201
|
-
sensevoiceChild = null
|
|
202
|
-
}
|
|
203
|
-
let port = '6006'
|
|
204
|
-
try { port = new URL(cfg.sensevoiceUrl).port || '6006' } catch { /* invalid URL */ }
|
|
205
|
-
const cmdArgs = buildSensevoiceArgs(cfg, port)
|
|
206
|
-
const commandStr = `${JSON.stringify(cfg.sensevoiceBin)}${cmdArgs}`
|
|
207
|
-
const specOpts = { command: commandStr, timeoutMs: 0, stdoutMaxBytes: 4 * 1024 * 1024 }
|
|
208
|
-
const spec = typeof ctx.shell?.resolve === 'function' ? ctx.shell.resolve(specOpts) : specOpts
|
|
209
|
-
sensevoiceChild = typeof ctx.shell?.execute === 'function'
|
|
210
|
-
? ctx.shell.execute(spec)
|
|
211
|
-
: typeof ctx.shell?.start === 'function'
|
|
212
|
-
? ctx.shell.start(spec)
|
|
213
|
-
: null
|
|
214
|
-
if (sensevoiceChild && typeof sensevoiceChild.on === 'function') {
|
|
215
|
-
sensevoiceChild.on('error', (err) => {
|
|
216
|
-
sensevoiceError = err?.message || String(err)
|
|
217
|
-
})
|
|
218
|
-
sensevoiceChild.on('exit', (code) => {
|
|
219
|
-
if (code !== 0 && code !== null) {
|
|
220
|
-
sensevoiceError = `sensevoice process exited with code ${code}`
|
|
221
|
-
}
|
|
222
|
-
})
|
|
223
|
-
}
|
|
224
|
-
if (sensevoiceChild && sensevoiceChild.done && typeof sensevoiceChild.done.then === 'function') {
|
|
225
|
-
sensevoiceChild.done.then((res) => {
|
|
226
|
-
if (res && res.code !== 0 && res.code !== null && res.code !== undefined) {
|
|
227
|
-
sensevoiceError = `sensevoice process exited with code ${res.code}`
|
|
228
|
-
}
|
|
229
|
-
}).catch((err) => {
|
|
230
|
-
sensevoiceError = err?.message || String(err)
|
|
231
|
-
})
|
|
232
|
-
}
|
|
233
|
-
// Wait for it to become healthy (up to 30s).
|
|
234
|
-
for (let i = 0; i < 60; i++) {
|
|
235
|
-
await new Promise((r) => setTimeout(r, 500))
|
|
236
|
-
if (await sensevoiceAlive()) {
|
|
237
|
-
sensevoiceError = null
|
|
238
|
-
return true
|
|
239
|
-
}
|
|
240
|
-
if (sensevoiceChild && sensevoiceChild.exitCode !== null && sensevoiceChild.exitCode !== undefined) {
|
|
241
|
-
sensevoiceError = `sensevoice process exited prematurely with code ${sensevoiceChild.exitCode}`
|
|
242
|
-
return false
|
|
243
|
-
}
|
|
244
|
-
if (sensevoiceChild && (sensevoiceChild.status === 'exited' || sensevoiceChild.status === 'failed')) {
|
|
245
|
-
sensevoiceError = `sensevoice process exited prematurely with status ${sensevoiceChild.status}`
|
|
246
|
-
return false
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
sensevoiceError = 'sensevoice process started but failed health check'
|
|
250
|
-
return false
|
|
251
|
-
} catch (err) {
|
|
252
|
-
sensevoiceError = String(err && err.message ? err.message : err)
|
|
253
|
-
return false
|
|
254
|
-
}
|
|
255
|
-
finally { startingSensevoice = false }
|
|
256
|
-
}
|
|
79
|
+
const daemons = createLocalDaemons({ live, ctx })
|
|
80
|
+
const {
|
|
81
|
+
whisperAlive, startWhisper, getWhisperError,
|
|
82
|
+
sensevoiceAlive, startSensevoice, getSensevoiceError,
|
|
83
|
+
} = daemons
|
|
257
84
|
|
|
258
85
|
function triggerAutostart() {
|
|
259
|
-
|
|
260
|
-
ctx.logger?.debug?.('whisper autostart failed: %s', err?.message || err)
|
|
261
|
-
})
|
|
262
|
-
startSensevoice().catch((err) => {
|
|
263
|
-
ctx.logger?.debug?.('sensevoice autostart failed: %s', err?.message || err)
|
|
264
|
-
})
|
|
86
|
+
daemons.triggerAutostart()
|
|
265
87
|
}
|
|
266
88
|
|
|
89
|
+
ctx.effect(() => () => daemons.dispose(), 'dsh-voice: daemon cleanup')
|
|
90
|
+
|
|
267
91
|
ctx.inject(['settings'], (sctx) => {
|
|
268
92
|
settingsService = sctx.settings
|
|
269
93
|
try {
|
|
@@ -345,6 +169,8 @@ export function apply(ctx, baseConfig) {
|
|
|
345
169
|
const cfg = live()
|
|
346
170
|
const whisperOk = await whisperAlive()
|
|
347
171
|
const sensevoiceOk = await sensevoiceAlive()
|
|
172
|
+
const whisperError = whisperOk ? null : getWhisperError()
|
|
173
|
+
const sensevoiceError = sensevoiceOk ? null : getSensevoiceError()
|
|
348
174
|
writeJson(res, 200, {
|
|
349
175
|
ok: true,
|
|
350
176
|
whisperRunning: whisperOk,
|
|
@@ -0,0 +1,218 @@
|
|
|
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
|
+
|
|
10
|
+
async function whisperAlive() {
|
|
11
|
+
const rawUrl = String(live().whisperUrl || '').trim()
|
|
12
|
+
const base = rawUrl.includes('/inference') ? rawUrl.split('/inference')[0] : rawUrl.replace(/\/+$/, '')
|
|
13
|
+
if (!base) return false
|
|
14
|
+
try {
|
|
15
|
+
const res = await fetch(base + '/', { signal: AbortSignal.timeout(2000) })
|
|
16
|
+
return res.ok
|
|
17
|
+
} catch {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function startWhisper() {
|
|
23
|
+
if (startingWhisper) return false
|
|
24
|
+
startingWhisper = true
|
|
25
|
+
try {
|
|
26
|
+
const cfg = live()
|
|
27
|
+
if (!cfg.autoStart) return false
|
|
28
|
+
if (!cfg.whisperModel) return false
|
|
29
|
+
if (await whisperAlive()) {
|
|
30
|
+
whisperError = null
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
if (child && typeof child.kill === 'function') {
|
|
34
|
+
try { child.kill() } catch { /* ignore */ }
|
|
35
|
+
child = null
|
|
36
|
+
}
|
|
37
|
+
const command = `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
|
|
38
|
+
+ ` --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 spec = typeof ctx.shell?.resolve === 'function' ? ctx.shell.resolve(specOpts) : specOpts
|
|
41
|
+
|
|
42
|
+
if (typeof ctx.shell?.execute === 'function') {
|
|
43
|
+
child = await ctx.shell.execute(spec)
|
|
44
|
+
} else if (typeof ctx.shell?.start === 'function') {
|
|
45
|
+
child = await ctx.shell.start(spec)
|
|
46
|
+
} else {
|
|
47
|
+
child = null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (child && typeof child.on === 'function') {
|
|
51
|
+
child.on('error', (err) => {
|
|
52
|
+
whisperError = err?.message || String(err)
|
|
53
|
+
})
|
|
54
|
+
child.on('exit', (code) => {
|
|
55
|
+
if (code !== 0 && code !== null) {
|
|
56
|
+
whisperError = `whisper server process exited with code ${code}`
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
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}`
|
|
64
|
+
}
|
|
65
|
+
}).catch((err) => {
|
|
66
|
+
whisperError = err?.message || String(err)
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Wait for it to become healthy (up to 30s for cold model loading on Metal/CPU).
|
|
71
|
+
for (let i = 0; i < 60; i++) {
|
|
72
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
73
|
+
if (await whisperAlive()) {
|
|
74
|
+
whisperError = null
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
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
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
whisperError = 'whisper server process started but failed health check on port 8001'
|
|
87
|
+
return false
|
|
88
|
+
} catch (err) {
|
|
89
|
+
whisperError = String(err && err.message ? err.message : err)
|
|
90
|
+
return false
|
|
91
|
+
} finally {
|
|
92
|
+
startingWhisper = false
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let sensevoiceChild = null
|
|
97
|
+
let sensevoiceError = null
|
|
98
|
+
let startingSensevoice = false
|
|
99
|
+
|
|
100
|
+
async function sensevoiceAlive() {
|
|
101
|
+
const cfg = live()
|
|
102
|
+
const base = String(cfg.sensevoiceUrl || '').replace(/\/api\/.*$/, '').replace(/\/v1\/.*$/, '').replace(/\/+$/, '')
|
|
103
|
+
if (!base) return false
|
|
104
|
+
try {
|
|
105
|
+
const res = await fetch(base + '/', { signal: AbortSignal.timeout(2000) })
|
|
106
|
+
return res.ok || res.status === 404
|
|
107
|
+
} catch {
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function startSensevoice() {
|
|
113
|
+
if (startingSensevoice) return false
|
|
114
|
+
startingSensevoice = true
|
|
115
|
+
try {
|
|
116
|
+
const cfg = live()
|
|
117
|
+
if (!cfg.sensevoiceAutostart) return false
|
|
118
|
+
if (!cfg.sensevoiceModel) return false
|
|
119
|
+
if (await sensevoiceAlive()) {
|
|
120
|
+
sensevoiceError = null
|
|
121
|
+
return true
|
|
122
|
+
}
|
|
123
|
+
if (sensevoiceChild && typeof sensevoiceChild.kill === 'function') {
|
|
124
|
+
try { sensevoiceChild.kill() } catch { /* ignore */ }
|
|
125
|
+
sensevoiceChild = null
|
|
126
|
+
}
|
|
127
|
+
let port = '6006'
|
|
128
|
+
try { port = new URL(cfg.sensevoiceUrl).port || '6006' } catch { /* invalid URL */ }
|
|
129
|
+
const cmdArgs = buildSensevoiceArgs(cfg, port)
|
|
130
|
+
const commandStr = `${JSON.stringify(cfg.sensevoiceBin)}${cmdArgs}`
|
|
131
|
+
const specOpts = { command: commandStr, timeoutMs: 0, stdoutMaxBytes: 4 * 1024 * 1024 }
|
|
132
|
+
const spec = typeof ctx.shell?.resolve === 'function' ? ctx.shell.resolve(specOpts) : specOpts
|
|
133
|
+
|
|
134
|
+
if (typeof ctx.shell?.execute === 'function') {
|
|
135
|
+
sensevoiceChild = await ctx.shell.execute(spec)
|
|
136
|
+
} else if (typeof ctx.shell?.start === 'function') {
|
|
137
|
+
sensevoiceChild = await ctx.shell.start(spec)
|
|
138
|
+
} else {
|
|
139
|
+
sensevoiceChild = null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (sensevoiceChild && typeof sensevoiceChild.on === 'function') {
|
|
143
|
+
sensevoiceChild.on('error', (err) => {
|
|
144
|
+
sensevoiceError = err?.message || String(err)
|
|
145
|
+
})
|
|
146
|
+
sensevoiceChild.on('exit', (code) => {
|
|
147
|
+
if (code !== 0 && code !== null) {
|
|
148
|
+
sensevoiceError = `sensevoice process exited with code ${code}`
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
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}`
|
|
156
|
+
}
|
|
157
|
+
}).catch((err) => {
|
|
158
|
+
sensevoiceError = err?.message || String(err)
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Wait for it to become healthy (up to 30s).
|
|
163
|
+
for (let i = 0; i < 60; i++) {
|
|
164
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
165
|
+
if (await sensevoiceAlive()) {
|
|
166
|
+
sensevoiceError = null
|
|
167
|
+
return true
|
|
168
|
+
}
|
|
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
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
sensevoiceError = 'sensevoice process started but failed health check'
|
|
179
|
+
return false
|
|
180
|
+
} catch (err) {
|
|
181
|
+
sensevoiceError = String(err && err.message ? err.message : err)
|
|
182
|
+
return false
|
|
183
|
+
} finally {
|
|
184
|
+
startingSensevoice = false
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function triggerAutostart() {
|
|
189
|
+
startWhisper().catch((err) => {
|
|
190
|
+
ctx.logger?.debug?.('whisper autostart failed: %s', err?.message || err)
|
|
191
|
+
})
|
|
192
|
+
startSensevoice().catch((err) => {
|
|
193
|
+
ctx.logger?.debug?.('sensevoice autostart failed: %s', err?.message || err)
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function dispose() {
|
|
198
|
+
if (child && typeof child.kill === 'function') {
|
|
199
|
+
try { child.kill() } catch { /* ignore */ }
|
|
200
|
+
child = null
|
|
201
|
+
}
|
|
202
|
+
if (sensevoiceChild && typeof sensevoiceChild.kill === 'function') {
|
|
203
|
+
try { sensevoiceChild.kill() } catch { /* ignore */ }
|
|
204
|
+
sensevoiceChild = null
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
whisperAlive,
|
|
210
|
+
startWhisper,
|
|
211
|
+
getWhisperError: () => whisperError,
|
|
212
|
+
sensevoiceAlive,
|
|
213
|
+
startSensevoice,
|
|
214
|
+
getSensevoiceError: () => sensevoiceError,
|
|
215
|
+
triggerAutostart,
|
|
216
|
+
dispose,
|
|
217
|
+
}
|
|
218
|
+
}
|
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
|
@@ -202,7 +202,13 @@ 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
|
+
// so we don't delete a successor's live lock acquired in the interim (#205).
|
|
205
207
|
try {
|
|
208
|
+
const freshPid = readLockPid(lockPath);
|
|
209
|
+
if (freshPid !== pid) {
|
|
210
|
+
return { locked: true, pid: freshPid };
|
|
211
|
+
}
|
|
206
212
|
unlinkSync(lockPath);
|
|
207
213
|
return { locked: false, cleanedStale: true };
|
|
208
214
|
} catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.17",
|
|
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",
|