@goodandready/dsh-voice 0.9.28 → 0.9.30

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/README.md CHANGED
@@ -35,6 +35,9 @@
35
35
 
36
36
  ---
37
37
 
38
+ ## Requirements
39
+ ### Browser Microphone & Secure Context\nModern browsers require a **Secure Context** (`https://` or `http://localhost` / `http://127.0.0.1`) for microphone capture (`navigator.mediaDevices.getUserMedia`). When accessing DSH over a local network (e.g., `http://192.168.1.x:3080` via `dsh-lanmode`), browsers will block microphone access by default. Serve DSH behind an HTTPS reverse proxy or configure browser security flags (`chrome://flags/#unsafely-treat-insecure-origin-as-secure`) for LAN IP access.\n
40
+
38
41
  ## ⚡ Overview
39
42
 
40
43
  **`dsh-voice`** brings voice superpowers to the **DeepSeek Harness** Web UI. Whether you need hands-free real-time streaming dictation segmented on natural breath pauses or crisp voice notes with keyboard/mouse Push-to-Talk gestures, `dsh-voice` ensures your audio is never lost thanks to **automatic multi-provider fallback chains**.
package/README.ru.md CHANGED
@@ -35,6 +35,9 @@
35
35
 
36
36
  ---
37
37
 
38
+ ## Требования
39
+ ### Доступ к микрофону и Secure Context\nСовременные веб-браузеры требуют безопасный контекст (**Secure Context**: `https://` или `http://localhost` / `http://127.0.0.1`) для захвата аудио с микрофона (`navigator.mediaDevices.getUserMedia`). При открытии DSH в локальной сети по обычному HTTP (например, `http://192.168.1.x:3080` через `dsh-lanmode`) браузер блокирует микрофон. Для работы в LAN настройте HTTPS (через reverse proxy, например caddy/nginx) либо укажите адрес в настройках браузера (`chrome://flags/#unsafely-treat-insecure-origin-as-secure`).\n
40
+
38
41
  ## ⚡ Обзор
39
42
 
40
43
  **`dsh-voice`** добавляет полноценные голосовые возможности в веб-интерфейс **DeepSeek Harness**. Будь то непрерывная диктовка с нарезкой фраз по естественным паузам или голосовые сообщения с удобными жестами Push-to-Talk (мышь и клавиатура) — `dsh-voice` гарантирует сохранность каждой записи благодаря **автоматическим цепочкам отказоустойчивости**.
package/README.zh.md CHANGED
@@ -35,6 +35,9 @@
35
35
 
36
36
  ---
37
37
 
38
+ ## 运行要求
39
+ ### 浏览器麦克风与安全上下文 (Secure Context)\n现代浏览器要求在**安全上下文**(`https://` 或 `http://localhost` / `http://127.0.0.1`)下才允许调用麦克风接口(`navigator.mediaDevices.getUserMedia`)。当通过局域网以普通 HTTP 访问 DSH(如使用 `dsh-lanmode` 访问 `http://192.168.1.x:3080`)时,浏览器默认会阻止录音。在局域网环境下,请配置 HTTPS 反向代理,或在浏览器中启用标志(`chrome://flags/#unsafely-treat-insecure-origin-as-secure`)。\n
40
+
38
41
  ## ⚡ 插件概览
39
42
 
40
43
  **`dsh-voice`** 为 **DeepSeek Harness** Web 界面带来极速语音交互体验。无论是按自然停顿切分的流式听写,还是带撤回保护的语音消息以及键盘/鼠标 Push-to-Talk 对讲,`dsh-voice` 凭借**多服务商自动故障转移备用链**确保您的录音万无一失。
package/lib/client.js CHANGED
@@ -1080,39 +1080,73 @@ window.__ModuleLoader__.load({
1080
1080
  // Fetch the mode chain from the host once. Only needed to decide whether
1081
1081
 
1082
1082
  voice._core = { tidyPhrase, applyVoiceCommands, VOICE_COMMANDS, undoLastInsert, insertHistory, insertDraftText, appendDraft, resolveBrowserRecognitionLang }
1083
- // lib/client-src/35-webgpu.js — Client-side WebGPU Whisper offline recognition
1084
- // Detects WebGPU availability in browser and provides offline transcription with graceful fallback.
1083
+ // lib/client-src/35-webgpu.js — Client-side WebGPU/WASM Whisper offline recognition
1084
+ // Detects WebGPU and WebAssembly availability in browser and provides offline transcription with graceful WASM fallback.
1085
1085
 
1086
1086
  function isWebGpuSupported() {
1087
1087
  if (typeof navigator === 'undefined' || !navigator.gpu) return false
1088
1088
  return true
1089
1089
  }
1090
1090
 
1091
+ function isWasmSupported() {
1092
+ return typeof WebAssembly !== 'undefined'
1093
+ }
1094
+
1095
+ function isOfflineSupported() {
1096
+ return isWebGpuSupported() || isWasmSupported()
1097
+ }
1098
+
1091
1099
  const _webGpuPipelines = new Map()
1092
1100
 
1093
- function loadWebGpuWhisper(modelName) {
1101
+ function loadWebGpuWhisper(modelName, options) {
1094
1102
  const targetModel = modelName || (voice.settings && voice.settings.webgpuModel) || 'onnx-community/whisper-tiny'
1095
- if (_webGpuPipelines.has(targetModel)) {
1096
- return _webGpuPipelines.get(targetModel)
1103
+ const forceDevice = options?.device
1104
+ const key = `${targetModel}:${forceDevice || 'auto'}`
1105
+ if (_webGpuPipelines.has(key)) {
1106
+ return _webGpuPipelines.get(key)
1097
1107
  }
1098
1108
  const promise = (async () => {
1099
- if (!isWebGpuSupported()) {
1100
- throw new Error('WebGPU is not supported in this browser environment')
1109
+ if (!isOfflineSupported()) {
1110
+ throw new Error('Neither WebGPU nor WebAssembly is supported in this browser environment')
1101
1111
  }
1102
1112
  const mod = typeof globalThis !== 'undefined' && globalThis.__mockTransformers
1103
1113
  ? globalThis.__mockTransformers
1104
1114
  : (await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.3.3'))
1105
1115
  const pipeline = mod.pipeline || (mod.default && mod.default.pipeline)
1106
1116
  if (!pipeline) throw new Error('Transformers pipeline not found in loaded bundle')
1107
- return pipeline('automatic-speech-recognition', targetModel, {
1108
- device: 'webgpu',
1109
- dtype: 'fp32',
1110
- })
1117
+
1118
+ // Prefer WebGPU if available and not forced to wasm; fall back to WASM on error or when WebGPU is absent
1119
+ const canGpu = forceDevice === 'webgpu' || (!forceDevice && isWebGpuSupported())
1120
+ if (canGpu) {
1121
+ try {
1122
+ return await pipeline('automatic-speech-recognition', targetModel, {
1123
+ device: 'webgpu',
1124
+ dtype: 'fp32',
1125
+ })
1126
+ } catch (gpuErr) {
1127
+ if (isWasmSupported()) {
1128
+ return await pipeline('automatic-speech-recognition', targetModel, {
1129
+ device: 'wasm',
1130
+ dtype: 'q8',
1131
+ })
1132
+ }
1133
+ throw gpuErr
1134
+ }
1135
+ }
1136
+
1137
+ if (isWasmSupported()) {
1138
+ return await pipeline('automatic-speech-recognition', targetModel, {
1139
+ device: 'wasm',
1140
+ dtype: 'q8',
1141
+ })
1142
+ }
1143
+
1144
+ throw new Error('Offline speech recognition failed: no WebGPU or WebAssembly available')
1111
1145
  })().catch((err) => {
1112
- _webGpuPipelines.delete(targetModel)
1146
+ _webGpuPipelines.delete(key)
1113
1147
  throw err
1114
1148
  })
1115
- _webGpuPipelines.set(targetModel, promise)
1149
+ _webGpuPipelines.set(key, promise)
1116
1150
  return promise
1117
1151
  }
1118
1152
 
@@ -1131,10 +1165,10 @@ async function audioBlobToFloat32Array(blob) {
1131
1165
  }
1132
1166
 
1133
1167
  async function transcribeWithWebGpu(blob, mime, options) {
1134
- if (!isWebGpuSupported()) {
1135
- throw new Error('WebGPU unavailable in browser')
1168
+ if (!isOfflineSupported()) {
1169
+ throw new Error('Offline recognition (WebGPU/WASM) unavailable in browser')
1136
1170
  }
1137
- const transcriber = await loadWebGpuWhisper(options && options.model)
1171
+ const transcriber = await loadWebGpuWhisper(options && options.model, options)
1138
1172
  const audioData = await audioBlobToFloat32Array(blob)
1139
1173
  const generateArgs = {}
1140
1174
  if (options && options.language && options.language !== 'auto') {
@@ -1146,7 +1180,9 @@ async function transcribeWithWebGpu(blob, mime, options) {
1146
1180
  }
1147
1181
 
1148
1182
  voice.webGpu = {
1149
- isSupported: isWebGpuSupported,
1183
+ isSupported: isOfflineSupported,
1184
+ isWebGpuSupported,
1185
+ isWasmSupported,
1150
1186
  transcribe: transcribeWithWebGpu,
1151
1187
  load: loadWebGpuWhisper,
1152
1188
  clearCache: () => { _webGpuPipelines.clear() },
@@ -3051,11 +3087,13 @@ voice._chainsPromise = null
3051
3087
  const [updaterStatus, setUpdaterStatus] = React.useState(null)
3052
3088
  const [updating, setUpdating] = React.useState(false)
3053
3089
  const [updaterMsg, setUpdaterMsg] = React.useState('')
3090
+ const [updaterMsgKind, setUpdaterMsgKind] = React.useState('')
3054
3091
  const [updaterLoading, setUpdaterLoading] = React.useState(false)
3055
3092
 
3056
3093
  const checkUpdater = React.useCallback(async () => {
3057
3094
  setUpdaterLoading(true)
3058
3095
  setUpdaterMsg('')
3096
+ setUpdaterMsgKind('')
3059
3097
  try {
3060
3098
  const res = await fetch('/api/dsh-voice/update', {
3061
3099
  headers: { accept: 'application/json' },
@@ -3066,10 +3104,12 @@ voice._chainsPromise = null
3066
3104
  setUpdaterStatus(data)
3067
3105
  if (data && data.latestCheckFailed) {
3068
3106
  setUpdaterMsg(t('updateCheckFailed'))
3107
+ setUpdaterMsgKind('err')
3069
3108
  }
3070
3109
  } catch (e) {
3071
3110
  setUpdaterStatus(null)
3072
3111
  setUpdaterMsg(t('updateCheckFailed') + ': ' + (e?.message || String(e)))
3112
+ setUpdaterMsgKind('err')
3073
3113
  } finally {
3074
3114
  setUpdaterLoading(false)
3075
3115
  }
@@ -3079,6 +3119,7 @@ voice._chainsPromise = null
3079
3119
  if (updating) return
3080
3120
  setUpdating(true)
3081
3121
  setUpdaterMsg('')
3122
+ setUpdaterMsgKind('')
3082
3123
  try {
3083
3124
  const res = await fetch('/api/dsh-voice/update', {
3084
3125
  method: 'POST',
@@ -3104,15 +3145,17 @@ voice._chainsPromise = null
3104
3145
  throw new Error(data.error || `HTTP ${res.status}`)
3105
3146
  }
3106
3147
  setUpdaterMsg(data.message || t('updateSuccess'))
3148
+ setUpdaterMsgKind('ok')
3107
3149
  checkUpdater()
3108
3150
  } catch (err) {
3109
3151
  setUpdaterMsg(err && err.message ? err.message : String(err))
3152
+ setUpdaterMsgKind('err')
3110
3153
  } finally {
3111
3154
  setUpdating(false)
3112
3155
  }
3113
3156
  }
3114
3157
 
3115
- return { updaterStatus, updating, updaterMsg, updaterLoading, checkUpdater, onUpdateNow }
3158
+ return { updaterStatus, updating, updaterMsg, updaterMsgKind, updaterLoading, checkUpdater, onUpdateNow }
3116
3159
  }
3117
3160
  // Developer Lexicon & IT Jargon
3118
3161
  function JargonEditor(props) {
@@ -3249,6 +3292,7 @@ voice._chainsPromise = null
3249
3292
  const updaterLoading = props.updaterLoading
3250
3293
  const updating = props.updating
3251
3294
  const updaterMsg = props.updaterMsg
3295
+ const updaterMsgKind = props.updaterMsgKind
3252
3296
  const onCheck = props.onCheck
3253
3297
  const onUpdateNow = props.onUpdateNow
3254
3298
 
@@ -3292,7 +3336,7 @@ voice._chainsPromise = null
3292
3336
  style: {
3293
3337
  marginTop: '8px',
3294
3338
  fontSize: '12px',
3295
- color: updaterMsg.includes('fail') || updaterMsg.includes('失败')
3339
+ color: (updaterMsgKind === 'err' || (!updaterMsgKind && (updaterMsg.includes('fail') || updaterMsg.includes('失败'))))
3296
3340
  ? 'var(--dsw-alias-state-warning-primary)'
3297
3341
  : 'var(--dsw-alias-state-success-primary)',
3298
3342
  },
@@ -3495,7 +3539,7 @@ voice._chainsPromise = null
3495
3539
 
3496
3540
  const { testingMic, testLevel, toggleTestMic, gateDbVal, gateThresholdPercent } = useMicTester(draft, value, setErr)
3497
3541
 
3498
- const { updaterStatus, updating, updaterMsg, updaterLoading, checkUpdater, onUpdateNow } = usePluginUpdater()
3542
+ const { updaterStatus, updating, updaterMsg, updaterMsgKind, updaterLoading, checkUpdater, onUpdateNow } = usePluginUpdater()
3499
3543
 
3500
3544
  const save = async () => {
3501
3545
  setErr(''); setSaved(false)
@@ -3869,6 +3913,7 @@ voice._chainsPromise = null
3869
3913
  updaterLoading: updaterLoading,
3870
3914
  updating: updating,
3871
3915
  updaterMsg: updaterMsg,
3916
+ updaterMsgKind: updaterMsgKind,
3872
3917
  onCheck: checkUpdater,
3873
3918
  onUpdateNow: onUpdateNow,
3874
3919
  }),
@@ -3997,6 +4042,9 @@ voice._chainsPromise = null
3997
4042
  stopCurrent,
3998
4043
  submitPending,
3999
4044
  loadWebGpuWhisper,
4045
+ isWebGpuSupported,
4046
+ isWasmSupported,
4047
+ isOfflineSupported,
4000
4048
  waitStop,
4001
4049
  currentLevel,
4002
4050
  accentColor,
@@ -37,7 +37,7 @@ export function createLocalDaemons({ live, ctx }) {
37
37
  }
38
38
  const command = `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
39
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 }
40
+ const specOpts = { command, onExpiry: 'none', timeoutMs: 60000, stdoutMaxBytes: 4 * 1024 * 1024 }
41
41
  const spec = typeof ctx.shell?.resolve === 'function' ? ctx.shell.resolve(specOpts) : specOpts
42
42
 
43
43
  if (typeof ctx.shell?.execute === 'function') {
@@ -216,7 +216,7 @@ export function createLocalDaemons({ live, ctx }) {
216
216
  try { port = new URL(cfg.sensevoiceUrl).port || '6006' } catch { /* invalid URL */ }
217
217
  const cmdArgs = buildSensevoiceArgs(cfg, port)
218
218
  const commandStr = `${JSON.stringify(cfg.sensevoiceBin)}${cmdArgs}`
219
- const specOpts = { command: commandStr, onExpiry: 'none', timeoutMs: 0, stdoutMaxBytes: 4 * 1024 * 1024 }
219
+ const specOpts = { command: commandStr, onExpiry: 'none', timeoutMs: 60000, stdoutMaxBytes: 4 * 1024 * 1024 }
220
220
  const spec = typeof ctx.shell?.resolve === 'function' ? ctx.shell.resolve(specOpts) : specOpts
221
221
 
222
222
  if (typeof ctx.shell?.execute === 'function') {
@@ -249,8 +249,13 @@ export function resolveSensevoiceProvider(cfg) {
249
249
  return 'cpu'
250
250
  }
251
251
  if (ext === '.rknn') {
252
- return 'rknn'
252
+ if (modelPath && existsSync(modelPath)) {
253
+ return 'rknn'
254
+ }
255
+ // Guard: missing .rknn model file cannot be claimed as running accelerator (#168)
256
+ return 'cpu'
253
257
  }
258
+ return 'cpu'
254
259
  }
255
260
 
256
261
  return normProv || 'cpu'
@@ -263,10 +268,9 @@ export function buildSensevoiceArgs(cfg, port) {
263
268
  ? cfg.sensevoiceTokens
264
269
  : findTokensFile(modelDir)
265
270
  const tokensArg = tokensPath ? ` --tokens=${JSON.stringify(tokensPath)}` : ''
266
- const rawProv = String(cfg?.sensevoiceProvider || '').trim().toLowerCase()
267
- let normProv = rawProv === 'rknpu' ? 'rknn' : rawProv
271
+ const effectiveProv = resolveSensevoiceProvider(cfg)
268
272
 
269
- if (normProv === 'rknn') {
273
+ if (effectiveProv === 'rknn') {
270
274
  const ext = path.extname(modelPath).toLowerCase()
271
275
  if (ext === '.onnx') {
272
276
  const rknnCandidate = path.join(modelDir, path.basename(modelPath, ext) + '.rknn')
@@ -275,15 +279,12 @@ export function buildSensevoiceArgs(cfg, port) {
275
279
  modelPath = rknnCandidate
276
280
  } else if (existsSync(defaultRknn)) {
277
281
  modelPath = defaultRknn
278
- } else {
279
- // Guard: ordinary ONNX model cannot be loaded by sherpa-onnx RKNN runtime (#221)
280
- normProv = 'cpu'
281
282
  }
282
283
  }
283
284
  }
284
285
 
285
- const provider = (normProv && normProv !== 'cpu')
286
- ? ` --provider=${normProv}`
286
+ const provider = (effectiveProv && effectiveProv !== 'cpu')
287
+ ? ` --provider=${effectiveProv}`
287
288
  : ''
288
289
  const threads = Number(cfg?.sensevoiceThreads) || 4
289
290
  return ` --sense-voice-model=${JSON.stringify(modelPath)}`
package/lib/updater.js CHANGED
@@ -18,6 +18,17 @@ function header(request, name) {
18
18
  return Array.isArray(value) ? value[0] : value;
19
19
  }
20
20
 
21
+ function isPrivateLan(value) {
22
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, '');
23
+ if (!address) return false;
24
+ const ipv4 = address.startsWith('::ffff:') ? address.slice(7) : address;
25
+ return (
26
+ ipv4.startsWith('192.168.') ||
27
+ ipv4.startsWith('10.') ||
28
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ipv4)
29
+ );
30
+ }
31
+
21
32
  function isLoopback(value) {
22
33
  const address = value?.toLowerCase().replace(/^\[|\]$/g, '');
23
34
  return address === 'localhost' || address === 'localhost.' || address === '::1'
@@ -27,7 +38,8 @@ function isLoopback(value) {
27
38
 
28
39
  export function isTrustedUpdateRequest(request) {
29
40
  if (header(request, UPDATE_HEADER) !== '1') return false;
30
- if (!isLoopback(request.socket?.remoteAddress)) return false;
41
+ const remote = request.socket?.remoteAddress;
42
+ if (!isLoopback(remote) && !isPrivateLan(remote)) return false;
31
43
  const site = header(request, 'sec-fetch-site');
32
44
  if (site !== undefined && site !== 'same-origin') return false;
33
45
  const origin = header(request, 'origin');
@@ -35,8 +47,11 @@ export function isTrustedUpdateRequest(request) {
35
47
  if (origin === undefined || host === undefined) return false;
36
48
  try {
37
49
  const url = new URL(origin);
38
- return (url.protocol === 'http:' || url.protocol === 'https:')
39
- && isLoopback(url.hostname) && url.host === host;
50
+ return (
51
+ (url.protocol === 'http:' || url.protocol === 'https:') &&
52
+ (isLoopback(url.hostname) || isPrivateLan(url.hostname)) &&
53
+ url.host === host
54
+ );
40
55
  } catch {
41
56
  return false;
42
57
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.9.28",
3
+ "version": "0.9.30",
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",