@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 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
- if (activeChain.includes('browser-webgpu')) {
738
- if (typeof isWebGpuSupported === 'function' && isWebGpuSupported()) {
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 out = await transcribeWithWebGpu(blob, mime, {
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: voice.settings && voice.settings.webgpuModel,
749
+ model: chosenModel,
743
750
  })
744
- if (out && typeof out.text === 'string') {
745
- return { text: tidyPhrase(out.text), command: null, action: null, provider: 'browser-webgpu' }
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
- // Graceful fallback to server chain
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
- const dataBase64 = await blobToBase64(blob)
754
- const payload = { dataBase64, mimeType: mime, mode }
755
- const contextWords = extractContextKeywords()
756
- if (contextWords && contextWords.length > 0) payload.contextWords = contextWords
757
- const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
758
- const timer = controller ? setTimeout(() => controller.abort(), 60000) : null
759
- let res = null
760
- try {
761
- res = await fetch('/dsh-voice/transcribe', {
762
- method: 'POST',
763
- headers: { 'content-type': 'application/json' },
764
- body: JSON.stringify(payload),
765
- signal: controller ? controller.signal : undefined,
766
- })
767
- } catch (e) {
768
- if (e && e.name === 'AbortError') {
769
- throw new Error('Transcription request timed out (60s)')
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
- throw e
772
- } finally {
773
- if (timer) clearTimeout(timer)
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
- let parsed = null
776
- try { parsed = await res.json() } catch (e) { /* not json */ }
777
- if (!res.ok || !parsed || !parsed.ok) {
778
- throw new Error((parsed && parsed.error && parsed.error.message) || `HTTP ${res.status}`)
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
- if (parsed.command) return { command: parsed.command, text: '', action: null }
781
- return { text: String(parsed.text || '').trim(), command: null, action: parsed.action || null }
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
- let _webGpuPipeline = null
1091
- let _webGpuLoading = false
1121
+ const _webGpuPipelines = new Map()
1122
+ let _webGpuLoadingModel = null
1092
1123
 
1093
1124
  async function loadWebGpuWhisper(modelName) {
1094
- if (_webGpuPipeline) return _webGpuPipeline
1095
- if (_webGpuLoading) {
1096
- while (_webGpuLoading) {
1097
- await new Promise((r) => setTimeout(r, 80))
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
- _webGpuLoading = true
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 targetModel = modelName || (voice.settings && voice.settings.webgpuModel) || 'onnx-community/whisper-tiny'
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
- return _webGpuPipeline
1150
+ _webGpuPipelines.set(targetModel, instance)
1151
+ return instance
1116
1152
  } finally {
1117
- _webGpuLoading = false
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) => (data && data.modes) || {})
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: Array.isArray(row.chain) ? row.chain.map((e) => e && e.provider) : [],
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
- if (rec.cancelled) return
1281
- try {
1282
- const out = await sendAudio(blob, rec.mime, 'dictation')
1283
- if (rec.cancelled) return
1284
- const text = out && out.text ? out.text : ''
1285
- const action = out && out.action ? out.action : null
1286
- if (voice.settings.structuredPromptVoice !== false && text) {
1287
- const promptInfo = findActiveStructuredPrompt()
1288
- if (promptInfo && submitToStructuredPrompt(text, promptInfo)) {
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) => voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null }))
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
- if (info.chain[0] === 'browser' && startBrowserLeg(mode, info.language)) return
1387
- openMic(mode)
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) => voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null }))
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: 'rknpu' }, 'Rockchip RK3588 NPU (rknpu)'),
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
- async function whisperAlive() {
79
- const rawUrl = String(live().whisperUrl || '').trim()
80
- const base = rawUrl.includes('/inference') ? rawUrl.split('/inference')[0] : rawUrl.replace(/\/+$/, '')
81
- if (!base) return false
82
- try {
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
- startWhisper().catch((err) => {
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", "rknpu" (Rockchip RK3588/RK3568 NPU on ARM SBC), "openvino", "cuda".'),
137
+ .description('Execution provider for sherpa-onnx: "cpu", "rknn" (Rockchip RK3588/RK3568 NPU on ARM SBC with RKNN models), "openvino", "cuda".'),
138
138
  sensevoiceThreads: z.number().default(4)
139
139
  .description('Execution thread count for local SenseVoice / sherpa-onnx on CPU/SBC.'),
140
140
  webgpuModel: z.string().default('onnx-community/whisper-tiny')
@@ -235,8 +235,10 @@ export function buildSensevoiceArgs(cfg, port) {
235
235
  const modelDir = cfg?.sensevoiceModel ? path.dirname(cfg.sensevoiceModel) : getDefaultModelDir()
236
236
  const tokensPath = findTokensFile(modelDir)
237
237
  const tokensArg = tokensPath ? ` --tokens=${JSON.stringify(tokensPath)}` : ''
238
- const provider = (cfg?.sensevoiceProvider && cfg.sensevoiceProvider !== 'cpu')
239
- ? ` --provider=${cfg.sensevoiceProvider}`
238
+ const rawProv = String(cfg?.sensevoiceProvider || '').trim().toLowerCase()
239
+ const normProv = rawProv === 'rknpu' ? 'rknn' : rawProv
240
+ const provider = (normProv && normProv !== 'cpu')
241
+ ? ` --provider=${normProv}`
240
242
  : ''
241
243
  const threads = Number(cfg?.sensevoiceThreads) || 4
242
244
  return ` --sense-voice-model=${JSON.stringify(cfg?.sensevoiceModel || '')}`
package/lib/updater.js CHANGED
@@ -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.15",
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",