@goodandready/dsh-voice 0.9.44 → 0.9.45

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.
Files changed (2) hide show
  1. package/lib/client.js +81 -77
  2. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -1,13 +1,6 @@
1
1
  // dsh-voice — client half (browser).
2
2
  //
3
- // Two buttons in conversation.input.right:
4
- // mic — dictation: speech is cut on pauses, each chunk is recognized and
5
- // appended to the composer; send stays with the user;
6
- // wave — voice message: one whole recording; after recognition the text is
7
- // sent to the agent when the cancel window expires.
8
- //
9
- // The recording pill lives in conversation.input.dock; settings are a card
10
- // in settings.plugin.item.
3
+ // Dictation (mic), voice message (wave), dock pill and settings card.
11
4
 
12
5
  window.__ModuleLoader__.load({
13
6
  id: '@goodandready/dsh-voice',
@@ -32,9 +25,7 @@ window.__ModuleLoader__.load({
32
25
  const ROW_ID = 'dsh-voice'
33
26
  const ROW_CONFIG_KEY = PKG + '#' + ROW_ID
34
27
 
35
- // UI strings live in the locale registry so a separate package can
36
- // translate them without touching this plugin. English is the source
37
- // language, the default, and the fallback.
28
+ // UI strings in locale registry; English is source/default.
38
29
  const en = {
39
30
  'saveFailed': 'Some fields were not saved —',
40
31
  'cardHint': 'Dictation and voice messages: providers, chains, local whisper',
@@ -320,6 +311,7 @@ window.__ModuleLoader__.load({
320
311
  'wizardTestFailed': 'STT test failed: {error}',
321
312
  'wizardNextStepHint': 'Next step: {hint}',
322
313
  'wizardHintPermission': 'Click "Request Microphone Access" and allow browser permission prompt.', 'wizardHintDevice': 'Check microphone connection or select a different input device.', 'wizardHintHttps': 'Open DeepSeek Harness via HTTPS or localhost address.', 'wizardHintProvider': 'Configure STT engine credentials or install SenseVoice model below.',
314
+ 'wizardHintWinDesktop': 'System microphone blocked. Open Windows Settings → Privacy & security → Microphone, and turn ON "Let desktop apps access your microphone".', 'wizardHintWinBrowser': 'Microphone permission denied. Allow access in your browser address bar or Windows Settings → Privacy & security → Microphone.', 'wizardHintMacDesktop': 'System microphone blocked. Open macOS System Settings → Privacy & Security → Microphone, and enable DSH Desktop.', 'wizardOpenWinMicSettings': 'Open Windows Mic Settings',
323
315
  'wizardFinishSuccess': 'Setup & Verification Complete', 'wizardSkip': 'Skip Wizard',
324
316
  'wizardStatusCompleted': 'Microphone and STT verified',
325
317
  'exportDiagnosticsBtn': 'Export Diagnostics Report', 'exportingDiagnostics': 'Exporting report…',
@@ -612,6 +604,7 @@ window.__ModuleLoader__.load({
612
604
  'wizardTestFailed': 'STT 测试失败:{error}',
613
605
  'wizardNextStepHint': '建议下一步:{hint}',
614
606
  'wizardHintPermission': '点击“请求麦克风权限”并在浏览器弹窗中允许访问。', 'wizardHintDevice': '检查麦克风硬件连接或切换选择其他输入设备。', 'wizardHintHttps': '请通过 HTTPS 或 localhost 访问 DeepSeek Harness。', 'wizardHintProvider': '请在下方配置 STT 密钥凭据或一键安装 SenseVoice 本地模型。',
607
+ 'wizardHintWinDesktop': '系统麦克风被阻止。请在 Windows【设置】→【隐私和安全性】→【麦克风】中开启最下方的【允许桌面应用访问你的麦克风】。', 'wizardHintWinBrowser': '麦克风权限被拒绝。请在浏览器地址栏允许麦克风权限,或在 Windows【设置】→【隐私和安全性】→【麦克风】中开启访问。', 'wizardHintMacDesktop': '系统麦克风被阻止。请在 macOS【系统设置】→【隐私与安全性】→【麦克风】中勾选允许 DSH Desktop。', 'wizardOpenWinMicSettings': '打开系统麦克风设置',
615
608
  'wizardFinishSuccess': '设置与验证已完成', 'wizardSkip': '跳过向导',
616
609
  'wizardStatusCompleted': '麦克风与 STT 诊断已就绪',
617
610
  'exportDiagnosticsBtn': '导出诊断报告', 'exportingDiagnostics': '正在导出报告…',
@@ -1184,8 +1177,7 @@ window.__ModuleLoader__.load({
1184
1177
  if (resolveStop) { resolveStop(); resolveStop = null }
1185
1178
  options.onError(code || t('recognitionError'))
1186
1179
  }
1187
- // The browser ends recognition on its own (pauses, timeout). Restart
1188
- // until we stop it, otherwise dictation dies silently on the first pause.
1180
+ // Restart browser recognition until explicitly stopped
1189
1181
  recognition.onend = () => {
1190
1182
  if (stopped) {
1191
1183
  if (resolveStop) { resolveStop(); resolveStop = null }
@@ -1219,8 +1211,7 @@ window.__ModuleLoader__.load({
1219
1211
  // Fetch the mode chain from the host once. Only needed to decide whether
1220
1212
 
1221
1213
  voice._core = { tidyPhrase, applyVoiceCommands, VOICE_COMMANDS, undoLastInsert, undoLastChunk: undoLastInsert, insertHistory, insertDraftText, appendDraft, resolveBrowserRecognitionLang }
1222
- // lib/client-src/35-webgpu.js — Client-side WebGPU/WASM Whisper offline recognition
1223
- // Detects WebGPU and WebAssembly availability in browser and provides offline transcription with graceful WASM fallback.
1214
+ // Client WebGPU/WASM Whisper offline recognition (#168)
1224
1215
 
1225
1216
  function isWebGpuSupported() {
1226
1217
  if (typeof navigator === 'undefined' || !navigator.gpu) return false
@@ -1326,8 +1317,7 @@ voice.webGpu = {
1326
1317
  load: loadWebGpuWhisper,
1327
1318
  clearCache: () => { _webGpuPipelines.clear() },
1328
1319
  }
1329
- // lib/client-src/36-silero-vad.js — Neural Silero VAD (WASM/ONNX) client module (#279)
1330
- // Provides neural voice activity detection to filter out mechanical keyboard clicks, sighs and fan noise.
1320
+ // Neural Silero VAD client module (#279)
1331
1321
 
1332
1322
  function isSileroVadSupported() {
1333
1323
  return typeof Float32Array !== 'undefined'
@@ -1373,8 +1363,7 @@ voice.sileroVad = {
1373
1363
  evaluate: evaluateSileroFrame,
1374
1364
  reset: () => { _sileroState = { h: null, c: null } },
1375
1365
  }
1376
- // lib/client-src/37-ws-stream.js — Ultra-low latency streaming WebSocket client (#282)
1377
- // Streams audio frames to local SenseVoice/Whisper servers with seamless HTTP fallback.
1366
+ // Streaming WebSocket client (#282)
1378
1367
 
1379
1368
  function isWsStreamingSupported() {
1380
1369
  return typeof WebSocket !== 'undefined'
@@ -2146,9 +2135,7 @@ voice._chainsPromise = null
2146
2135
  return String(name).replace(/^Key/, '').replace(/^Digit/, '')
2147
2136
  }
2148
2137
 
2149
- // What to store on key press. Pure modifiers are remembered by name:
2150
- // left and right have different codes but the user means "either".
2151
- // Supports combos like Control+Space or Alt+KeyV.
2138
+ // Hotkey combo normalization
2152
2139
  function keyFromEvent(event) {
2153
2140
  if (!event) return ''
2154
2141
  const modifiers = []
@@ -2211,11 +2198,7 @@ voice._chainsPromise = null
2211
2198
  return event.code === name || event.key === name
2212
2199
  }
2213
2200
 
2214
- // Announce that the user started speaking.
2215
- //
2216
- // Playback should mute immediately: listening and talking at once is
2217
- // impossible. There is no plugin-to-plugin API — this broadcasts a window
2218
- // event that anyone may hear, so either side works alone.
2201
+ // Broadcast window event so active playback mutes when speaking.
2219
2202
 
2220
2203
  // --------------------------------------------------------- hold gesture
2221
2204
  const HOLD_THRESHOLD_MS = 350
@@ -2332,8 +2315,7 @@ voice._chainsPromise = null
2332
2315
  }
2333
2316
  return cleanup
2334
2317
  }
2335
- // ------------------------------------------------------------------ 43-preview
2336
- // Editable preview state machine, session pinning & UI component (#170).
2318
+ // Editable preview state machine (#170)
2337
2319
 
2338
2320
  function initPendingPreview(text, target, options = {}) {
2339
2321
  const autoMs = Number(options.autoSendMs) || 4000
@@ -2787,16 +2769,13 @@ function cycleDictationMode() {
2787
2769
  const adapt = Number(voice.settings.vadAdapt) || 0
2788
2770
  let effectiveVad = Number(voice.settings.vadSilenceMs) || 700
2789
2771
  if (adapt > 0 && rec.hadSpeech) {
2790
- // Adaptive threshold (#41): speech density over ~1s (20 samples).
2791
- // Dense speech -> lower threshold (cut more precisely);
2792
- // pause-heavy -> threshold rises toward base (do not cut on breaths).
2772
+ // Adaptive threshold (#41)
2793
2773
  const win = voice.levels.slice(-20)
2794
2774
  const density = win.length ? win.filter((v) => v > 0.06).length / win.length : 0
2795
2775
  const k = adapt * (density - 0.5) * 2
2796
2776
  effectiveVad = Math.max(150, Math.round(Number(voice.settings.vadSilenceMs) * (1 - k)))
2797
2777
  }
2798
- // Continuous mode (#40): cut on a timer while speech continues,
2799
- // without waiting for a long pause.
2778
+ // Continuous mode (#40)
2800
2779
  const stream = !!voice.settings.stream && rec.mode === 'dictation'
2801
2780
  if (stream && rec.hadSpeech && !rec.cutting) {
2802
2781
  rec.streamMs += tick
@@ -2888,8 +2867,7 @@ function cycleDictationMode() {
2888
2867
  const p = voice.pending
2889
2868
  if (!p) return
2890
2869
  if (p.undoOnly) {
2891
- // Undo-only mode (#29-5): when the window expires just hide the
2892
- // panel; the text either stayed or was already undone.
2870
+ // Undo-only mode (#29-5)
2893
2871
  p.leftMs -= tick
2894
2872
  if (p.leftMs <= 0) voice.set({ phase: 'idle' })
2895
2873
  else voice.notify()
@@ -2947,8 +2925,7 @@ function cycleDictationMode() {
2947
2925
  const inBrowser = !!voice.browser
2948
2926
  const rec = voice.rec
2949
2927
  const hint = v.mode === 'dictation' ? t('dictationPill') : t('messagePill')
2950
- // Live caption of what is heard right now. Until the browser emits a
2951
- // final chunk the text is interim and changes on screen.
2928
+ // Live caption: interim preview
2952
2929
  const caption = voice.caption || (inBrowser ? '' : null)
2953
2930
  const activeSubMode = (voice._activeTarget && voice._activeTarget.dictationMode)
2954
2931
  || voice.selectedDictationMode
@@ -3112,8 +3089,7 @@ function cycleDictationMode() {
3112
3089
 
3113
3090
  // --------------------------------------------------------------- slots
3114
3091
  function registerComposer(ctx) {
3115
- // The hotkey lives for as long as the plugin is applied and can change
3116
- // without restart: the settings card broadcasts and the composer reloads.
3092
+ // Hotkey listener with dynamic reload
3117
3093
  ctx.effect(() => {
3118
3094
  let dispose = () => {}
3119
3095
  let alive = true
@@ -3176,9 +3152,7 @@ function cycleDictationMode() {
3176
3152
  }
3177
3153
  }, 'dsh-voice: hold hotkey')
3178
3154
 
3179
- // locale: NS is required — the core resolves `label` through the inject face,
3180
- // and without it the label throws "cannot get property t without inject" while
3181
- // the slot snapshot is built, breaking the whole client batch.
3155
+ // locale: NS required for core slot snapshot injection
3182
3156
  ctx.effect(() => {
3183
3157
  const offRight = ctx.slots.inject('conversation.input.right', () => ctx.slots.register(
3184
3158
  { name: 'conversation.input.right', id: '@goodandready/dsh-voice', order: 6, locale: NS, label: () => t('title') },
@@ -4458,11 +4432,7 @@ function MacrosSection(props) {
4458
4432
  function VoiceSection(props) {
4459
4433
  const t = (props && props.t) || moduleT
4460
4434
  const ctx = (props && props.ctx) || {}
4461
- // On a non-localhost page the kernel disables settings entirely: the
4462
- // shared document is not readable, every section gets "unavailable" and
4463
- // writes are dropped. The server does not share that restriction, and
4464
- // dsh-lanmode rebuilds the same mechanics on the same calls. Prefer its
4465
- // service when installed, otherwise the kernel one (fine on loopback).
4435
+ // Settings adapter: prefer dsh-lanmode when remote, otherwise kernel.
4466
4436
  const settingsService = (ctx.get && ctx.get('lanSettings')) || ctx.configForms || ctx.settingsScope
4467
4437
  const scope = settingsService && typeof settingsService.get === 'function'
4468
4438
  ? settingsService.get(NS)
@@ -4778,10 +4748,7 @@ function MacrosSection(props) {
4778
4748
  onOpenWizard: () => setShowWizard((v) => !v),
4779
4749
  }),
4780
4750
  showWizard ? h(FirstRunWizard, {
4781
- t: t,
4782
- draft: draft,
4783
- setTop: setTop,
4784
- writable: writable,
4751
+ t, draft, setTop, writable,
4785
4752
  onClose: () => setShowWizard(false),
4786
4753
  }) : null,
4787
4754
  h('div', { className: 'cb-section-card' },
@@ -4877,10 +4844,7 @@ function MacrosSection(props) {
4877
4844
  }),
4878
4845
  ),
4879
4846
  h(HardwareOptionsCard, {
4880
- t: t,
4881
- draft: draft,
4882
- setTop: setTop,
4883
- writable: writable,
4847
+ t, draft, setTop, writable,
4884
4848
  catching: catching,
4885
4849
  setCatching: setCatching,
4886
4850
  devices: devices,
@@ -4976,23 +4940,14 @@ function MacrosSection(props) {
4976
4940
  ),
4977
4941
  // Dictation Modes & Prompts (#264)
4978
4942
  h(DictationModesSection, {
4979
- t: t,
4980
- draft: draft,
4981
- setTop: setTop,
4982
- writable: writable,
4943
+ t, draft, setTop, writable,
4983
4944
  }),
4984
4945
  // Voice Action Macros (#167)
4985
4946
  h(MacrosSection, {
4986
- t: t,
4987
- draft: draft,
4988
- setTop: setTop,
4989
- writable: writable,
4947
+ t, draft, setTop, writable,
4990
4948
  }),
4991
4949
  h(JargonEditor, {
4992
- t: t,
4993
- draft: draft,
4994
- setTop: setTop,
4995
- writable: writable,
4950
+ t, draft, setTop, writable,
4996
4951
  jargonInput: jargonInput,
4997
4952
  setJargonInput: setJargonInput,
4998
4953
  }),
@@ -5030,6 +4985,16 @@ function MacrosSection(props) {
5030
4985
  const isSecure = typeof window !== 'undefined' ? (window.isSecureContext || window.location.protocol === 'https:' || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') : true
5031
4986
  const hasMediaDevices = typeof navigator !== 'undefined' && !!navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function'
5032
4987
  const hasMediaRecorder = typeof MediaRecorder !== 'undefined'
4988
+ const isDesktop = typeof navigator !== 'undefined' && (
4989
+ /dsh-desktop|electron/i.test(navigator.userAgent || '') ||
4990
+ (typeof window !== 'undefined' && window.location.protocol === 'dsh-app:')
4991
+ )
4992
+ const isWindows = typeof navigator !== 'undefined' && (
4993
+ /win/i.test(navigator.platform || '') || /windows/i.test(navigator.userAgent || '')
4994
+ )
4995
+ const isMac = typeof navigator !== 'undefined' && (
4996
+ /mac/i.test(navigator.platform || '') || /macintosh|mac os x/i.test(navigator.userAgent || '')
4997
+ )
5033
4998
 
5034
4999
  const [micPermission, setMicPermission] = React.useState('prompt')
5035
5000
  const [devices, setDevices] = React.useState([])
@@ -5104,7 +5069,16 @@ function MacrosSection(props) {
5104
5069
  const constraints = {
5105
5070
  audio: selectedDev ? { deviceId: { exact: selectedDev } } : true,
5106
5071
  }
5107
- const stream = await navigator.mediaDevices.getUserMedia(constraints)
5072
+ let stream
5073
+ try {
5074
+ stream = await navigator.mediaDevices.getUserMedia(constraints)
5075
+ } catch (firstErr) {
5076
+ if (selectedDev) {
5077
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true })
5078
+ } else {
5079
+ throw firstErr
5080
+ }
5081
+ }
5108
5082
  signalStreamRef.current = stream
5109
5083
  setMicPermission('granted')
5110
5084
  refreshDevices()
@@ -5157,7 +5131,15 @@ function MacrosSection(props) {
5157
5131
  const constraints = {
5158
5132
  audio: selectedDev ? { deviceId: { exact: selectedDev } } : true,
5159
5133
  }
5160
- testStream = await navigator.mediaDevices.getUserMedia(constraints)
5134
+ try {
5135
+ testStream = await navigator.mediaDevices.getUserMedia(constraints)
5136
+ } catch (firstErr) {
5137
+ if (selectedDev) {
5138
+ testStream = await navigator.mediaDevices.getUserMedia({ audio: true })
5139
+ } else {
5140
+ throw firstErr
5141
+ }
5142
+ }
5161
5143
  let mime = 'audio/webm;codecs=opus'
5162
5144
  if (!MediaRecorder.isTypeSupported(mime)) mime = ''
5163
5145
  recorder = mime ? new MediaRecorder(testStream, { mimeType: mime }) : new MediaRecorder(testStream)
@@ -5241,10 +5223,19 @@ function MacrosSection(props) {
5241
5223
  }
5242
5224
 
5243
5225
  let nextStepHint = ''
5226
+ const isPermDenied = micPermission === 'denied' || (testError && /permission|denied|notallowed/i.test(testError))
5244
5227
  if (!isSecure) {
5245
5228
  nextStepHint = t('wizardHintHttps')
5246
- } else if (micPermission === 'denied') {
5247
- nextStepHint = t('wizardHintPermission')
5229
+ } else if (isPermDenied) {
5230
+ if (isDesktop && isWindows) {
5231
+ nextStepHint = t('wizardHintWinDesktop')
5232
+ } else if (isDesktop && isMac) {
5233
+ nextStepHint = t('wizardHintMacDesktop')
5234
+ } else if (isWindows) {
5235
+ nextStepHint = t('wizardHintWinBrowser')
5236
+ } else {
5237
+ nextStepHint = t('wizardHintPermission')
5238
+ }
5248
5239
  } else if (peakRms === 0 && signalTesting) {
5249
5240
  nextStepHint = t('wizardHintDevice')
5250
5241
  } else if (testError) {
@@ -5300,6 +5291,13 @@ function MacrosSection(props) {
5300
5291
  className: 'cb-btn',
5301
5292
  onClick: requestPermissionAndTestSignal,
5302
5293
  }, t('wizardRequestMic')),
5294
+ (isWindows && isPermDenied) ? h('a', {
5295
+ href: 'ms-settings:privacy-microphone',
5296
+ target: '_blank',
5297
+ rel: 'noopener noreferrer',
5298
+ className: 'cb-btn cb-btn-mini',
5299
+ style: { marginLeft: '8px', textDecoration: 'none', display: 'inline-flex', alignItems: 'center' },
5300
+ }, '⚙ ' + t('wizardOpenWinMicSettings')) : null,
5303
5301
  ),
5304
5302
  ),
5305
5303
  ),
@@ -5383,8 +5381,15 @@ function MacrosSection(props) {
5383
5381
  ),
5384
5382
 
5385
5383
  // Next Step Hint / Feedback
5386
- nextStepHint ? h('div', { className: 'dvs-wait', style: { fontStyle: 'italic' } },
5387
- t('wizardNextStepHint').replace('{hint}', nextStepHint)
5384
+ nextStepHint ? h('div', { className: 'dvs-wait', style: { fontStyle: 'italic', display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px' } },
5385
+ h('span', null, t('wizardNextStepHint').replace('{hint}', nextStepHint)),
5386
+ (isWindows && isPermDenied) ? h('a', {
5387
+ href: 'ms-settings:privacy-microphone',
5388
+ target: '_blank',
5389
+ rel: 'noopener noreferrer',
5390
+ className: 'cb-btn cb-btn-mini',
5391
+ style: { textDecoration: 'none', fontStyle: 'normal' },
5392
+ }, '⚙ ' + t('wizardOpenWinMicSettings')) : null,
5388
5393
  ) : null,
5389
5394
 
5390
5395
  // Wizard Bottom Actions
@@ -5484,9 +5489,7 @@ function MacrosSection(props) {
5484
5489
 
5485
5490
  exports.inject = ['timer', 'slots', 'configForms', 'locale']
5486
5491
  exports.apply = function apply(ctx) {
5487
- // English is the source and fallback language; Chinese is the built-in
5488
- // localized user dictionary. Other languages (like Russian) come from
5489
- // the separate translation plugin at runtime.
5492
+ // English source/fallback, Chinese built-in dictionary.
5490
5493
  const addLocale = (locale, dictionary) => {
5491
5494
  try {
5492
5495
  return ctx.locale.register(NS, locale, dictionary)
@@ -5518,6 +5521,7 @@ function MacrosSection(props) {
5518
5521
  cycleDictationMode,
5519
5522
  resolveBrowserRecognitionLang,
5520
5523
  VoiceSection,
5524
+ FirstRunWizard,
5521
5525
  cutPhrase,
5522
5526
  sendAudio,
5523
5527
  modeChain,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.9.44",
3
+ "version": "0.9.45",
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",