@goodandready/dsh-voice 0.9.43 → 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.
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': '正在导出报告…',
@@ -852,8 +845,7 @@ window.__ModuleLoader__.load({
852
845
  const text = chunks.join(' ')
853
846
  if (!text || text.length < 3) return []
854
847
  const matches = text.match(/[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{2,29}/g) || []
855
- // Linguistic filter: functional stop-word dictionary for vocabulary keyword extraction.
856
- // Language processing data for context analysis, distinct from user-facing UI text.
848
+ // Stop-word dictionary for vocabulary keyword extraction
857
849
  const stop = new Set([
858
850
  'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'any', 'can', 'her', 'was',
859
851
  'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how', 'man', 'new', 'now',
@@ -983,9 +975,7 @@ window.__ModuleLoader__.load({
983
975
  return lead + s + trail
984
976
  }
985
977
 
986
- // Functional speech edit commands (#37): spoken line breaks and punctuation -> symbols.
987
- // Linguistic data for audio transcript post-processing; not user-facing UI text.
988
- // Russian phrases match RU STT output; English covers EN dictation.
978
+ // Speech edit commands (#37): spoken line breaks and punctuation -> symbols
989
979
  const VOICE_COMMANDS = [
990
980
  [/(^|[\s,.!?])с новой строки([\s,.!?]|$)/gi, '$1\n$2'],
991
981
  [/(^|[\s,.!?])новая строка([\s,.!?]|$)/gi, '$1\n$2'],
@@ -1033,22 +1023,26 @@ window.__ModuleLoader__.load({
1033
1023
  function insertDraftText(text, target) {
1034
1024
  if (voice.disposed) return false
1035
1025
  const span = target && target.span
1026
+ const sIn = (target && target.input) || voice.input
1027
+ const sDraft = (sIn && typeof sIn.draft === 'string') ? sIn.draft : ''
1028
+ const spaced = (sDraft && !/\s$/.test(sDraft) && !/^\s/.test(text)
1029
+ && !/^[.,;:!?…»)\]}]/.test(text) && !/[\s(«"'\[]$/.test(sDraft)) ? ' ' + text : text
1036
1030
  if (span) {
1037
1031
  if (typeof span.insert === 'function') {
1038
1032
  try {
1039
- const r = span.insert(text)
1033
+ const r = span.insert(spaced)
1040
1034
  if (r !== false) return true
1041
1035
  return false
1042
1036
  } catch (_) {}
1043
1037
  } else if (typeof span.insertText === 'function') {
1044
1038
  try {
1045
- const r = span.insertText(text)
1039
+ const r = span.insertText(spaced)
1046
1040
  if (r !== false) return true
1047
1041
  return false
1048
1042
  } catch (_) {}
1049
1043
  } else if (typeof span === 'function') {
1050
1044
  try {
1051
- const r = span(text)
1045
+ const r = span(spaced)
1052
1046
  if (r !== false) return true
1053
1047
  return false
1054
1048
  } catch (_) {}
@@ -1062,7 +1056,7 @@ window.__ModuleLoader__.load({
1062
1056
  }
1063
1057
  if (typeof actions.insertText === 'function') {
1064
1058
  try {
1065
- const res = actions.insertText(text, span)
1059
+ const res = actions.insertText(spaced, span)
1066
1060
  if (res !== false) {
1067
1061
  if (target && typeof actions.captureInsertion === 'function') {
1068
1062
  try { target.span = actions.captureInsertion() } catch (_) {}
@@ -1124,14 +1118,7 @@ window.__ModuleLoader__.load({
1124
1118
  } catch (noAudio) { /* no audio — not critical */ }
1125
1119
  }
1126
1120
 
1127
- // ------------------------------------------------- browser recognition
1128
- //
1129
- // A separate leg unlike the others: the browser recognizes speech itself,
1130
- // nothing is uploaded to the host, no keys are needed, and text appears
1131
- // word by word while you speak.
1132
- //
1133
- // Cost: in Chrome audio goes to Google servers. This provider is never
1134
- // enabled automatically — only when placed explicitly in a chain.
1121
+ // browser recognition: in-browser, no upload, only enabled when in chain
1135
1122
  function speechRecognitionCtor() {
1136
1123
  if (typeof window === 'undefined') return null
1137
1124
  return window.SpeechRecognition || window.webkitSpeechRecognition || null
@@ -1190,8 +1177,7 @@ window.__ModuleLoader__.load({
1190
1177
  if (resolveStop) { resolveStop(); resolveStop = null }
1191
1178
  options.onError(code || t('recognitionError'))
1192
1179
  }
1193
- // The browser ends recognition on its own (pauses, timeout). Restart
1194
- // until we stop it, otherwise dictation dies silently on the first pause.
1180
+ // Restart browser recognition until explicitly stopped
1195
1181
  recognition.onend = () => {
1196
1182
  if (stopped) {
1197
1183
  if (resolveStop) { resolveStop(); resolveStop = null }
@@ -1225,8 +1211,7 @@ window.__ModuleLoader__.load({
1225
1211
  // Fetch the mode chain from the host once. Only needed to decide whether
1226
1212
 
1227
1213
  voice._core = { tidyPhrase, applyVoiceCommands, VOICE_COMMANDS, undoLastInsert, undoLastChunk: undoLastInsert, insertHistory, insertDraftText, appendDraft, resolveBrowserRecognitionLang }
1228
- // lib/client-src/35-webgpu.js — Client-side WebGPU/WASM Whisper offline recognition
1229
- // Detects WebGPU and WebAssembly availability in browser and provides offline transcription with graceful WASM fallback.
1214
+ // Client WebGPU/WASM Whisper offline recognition (#168)
1230
1215
 
1231
1216
  function isWebGpuSupported() {
1232
1217
  if (typeof navigator === 'undefined' || !navigator.gpu) return false
@@ -1332,8 +1317,7 @@ voice.webGpu = {
1332
1317
  load: loadWebGpuWhisper,
1333
1318
  clearCache: () => { _webGpuPipelines.clear() },
1334
1319
  }
1335
- // lib/client-src/36-silero-vad.js — Neural Silero VAD (WASM/ONNX) client module (#279)
1336
- // Provides neural voice activity detection to filter out mechanical keyboard clicks, sighs and fan noise.
1320
+ // Neural Silero VAD client module (#279)
1337
1321
 
1338
1322
  function isSileroVadSupported() {
1339
1323
  return typeof Float32Array !== 'undefined'
@@ -1379,8 +1363,7 @@ voice.sileroVad = {
1379
1363
  evaluate: evaluateSileroFrame,
1380
1364
  reset: () => { _sileroState = { h: null, c: null } },
1381
1365
  }
1382
- // lib/client-src/37-ws-stream.js — Ultra-low latency streaming WebSocket client (#282)
1383
- // Streams audio frames to local SenseVoice/Whisper servers with seamless HTTP fallback.
1366
+ // Streaming WebSocket client (#282)
1384
1367
 
1385
1368
  function isWsStreamingSupported() {
1386
1369
  return typeof WebSocket !== 'undefined'
@@ -1658,7 +1641,8 @@ voice._chainsPromise = null
1658
1641
  } else {
1659
1642
  rec.sileroSpeech = null
1660
1643
  }
1661
- if (rec.hadSpeech && raw > 0 && raw < 0.08) {
1644
+ const lowT = Number(voice.settings?.lowVolumeThreshold ?? 0.08)
1645
+ if (rec.hadSpeech && raw > 0 && raw < lowT) {
1662
1646
  rec.lowVolumeUntil = now + 800
1663
1647
  rec.hasLowVolume = true
1664
1648
  }
@@ -2151,9 +2135,7 @@ voice._chainsPromise = null
2151
2135
  return String(name).replace(/^Key/, '').replace(/^Digit/, '')
2152
2136
  }
2153
2137
 
2154
- // What to store on key press. Pure modifiers are remembered by name:
2155
- // left and right have different codes but the user means "either".
2156
- // Supports combos like Control+Space or Alt+KeyV.
2138
+ // Hotkey combo normalization
2157
2139
  function keyFromEvent(event) {
2158
2140
  if (!event) return ''
2159
2141
  const modifiers = []
@@ -2216,11 +2198,7 @@ voice._chainsPromise = null
2216
2198
  return event.code === name || event.key === name
2217
2199
  }
2218
2200
 
2219
- // Announce that the user started speaking.
2220
- //
2221
- // Playback should mute immediately: listening and talking at once is
2222
- // impossible. There is no plugin-to-plugin API — this broadcasts a window
2223
- // event that anyone may hear, so either side works alone.
2201
+ // Broadcast window event so active playback mutes when speaking.
2224
2202
 
2225
2203
  // --------------------------------------------------------- hold gesture
2226
2204
  const HOLD_THRESHOLD_MS = 350
@@ -2337,8 +2315,7 @@ voice._chainsPromise = null
2337
2315
  }
2338
2316
  return cleanup
2339
2317
  }
2340
- // ------------------------------------------------------------------ 43-preview
2341
- // Editable preview state machine, session pinning & UI component (#170).
2318
+ // Editable preview state machine (#170)
2342
2319
 
2343
2320
  function initPendingPreview(text, target, options = {}) {
2344
2321
  const autoMs = Number(options.autoSendMs) || 4000
@@ -2792,16 +2769,13 @@ function cycleDictationMode() {
2792
2769
  const adapt = Number(voice.settings.vadAdapt) || 0
2793
2770
  let effectiveVad = Number(voice.settings.vadSilenceMs) || 700
2794
2771
  if (adapt > 0 && rec.hadSpeech) {
2795
- // Adaptive threshold (#41): speech density over ~1s (20 samples).
2796
- // Dense speech -> lower threshold (cut more precisely);
2797
- // pause-heavy -> threshold rises toward base (do not cut on breaths).
2772
+ // Adaptive threshold (#41)
2798
2773
  const win = voice.levels.slice(-20)
2799
2774
  const density = win.length ? win.filter((v) => v > 0.06).length / win.length : 0
2800
2775
  const k = adapt * (density - 0.5) * 2
2801
2776
  effectiveVad = Math.max(150, Math.round(Number(voice.settings.vadSilenceMs) * (1 - k)))
2802
2777
  }
2803
- // Continuous mode (#40): cut on a timer while speech continues,
2804
- // without waiting for a long pause.
2778
+ // Continuous mode (#40)
2805
2779
  const stream = !!voice.settings.stream && rec.mode === 'dictation'
2806
2780
  if (stream && rec.hadSpeech && !rec.cutting) {
2807
2781
  rec.streamMs += tick
@@ -2893,8 +2867,7 @@ function cycleDictationMode() {
2893
2867
  const p = voice.pending
2894
2868
  if (!p) return
2895
2869
  if (p.undoOnly) {
2896
- // Undo-only mode (#29-5): when the window expires just hide the
2897
- // panel; the text either stayed or was already undone.
2870
+ // Undo-only mode (#29-5)
2898
2871
  p.leftMs -= tick
2899
2872
  if (p.leftMs <= 0) voice.set({ phase: 'idle' })
2900
2873
  else voice.notify()
@@ -2952,8 +2925,7 @@ function cycleDictationMode() {
2952
2925
  const inBrowser = !!voice.browser
2953
2926
  const rec = voice.rec
2954
2927
  const hint = v.mode === 'dictation' ? t('dictationPill') : t('messagePill')
2955
- // Live caption of what is heard right now. Until the browser emits a
2956
- // final chunk the text is interim and changes on screen.
2928
+ // Live caption: interim preview
2957
2929
  const caption = voice.caption || (inBrowser ? '' : null)
2958
2930
  const activeSubMode = (voice._activeTarget && voice._activeTarget.dictationMode)
2959
2931
  || voice.selectedDictationMode
@@ -3117,8 +3089,7 @@ function cycleDictationMode() {
3117
3089
 
3118
3090
  // --------------------------------------------------------------- slots
3119
3091
  function registerComposer(ctx) {
3120
- // The hotkey lives for as long as the plugin is applied and can change
3121
- // without restart: the settings card broadcasts and the composer reloads.
3092
+ // Hotkey listener with dynamic reload
3122
3093
  ctx.effect(() => {
3123
3094
  let dispose = () => {}
3124
3095
  let alive = true
@@ -3156,6 +3127,7 @@ function cycleDictationMode() {
3156
3127
  sessionCommands: !!(data && data.modes && data.modes.message && data.modes.message.sessionCommands),
3157
3128
  noiseSuppression: data && data.noiseSuppression !== false,
3158
3129
  noiseGateDb: Number(data && data.noiseGateDb !== undefined ? data.noiseGateDb : -45),
3130
+ lowVolumeThreshold: Number(data && data.lowVolumeThreshold !== undefined ? data.lowVolumeThreshold : 0.08),
3159
3131
  contextGlossary: data && data.contextGlossary !== false,
3160
3132
  visualizerStyle: (data && data.visualizerStyle) || 'liquid-wave',
3161
3133
  gatedTurnTaking: data && data.gatedTurnTaking !== false,
@@ -3180,9 +3152,7 @@ function cycleDictationMode() {
3180
3152
  }
3181
3153
  }, 'dsh-voice: hold hotkey')
3182
3154
 
3183
- // locale: NS is required — the core resolves `label` through the inject face,
3184
- // and without it the label throws "cannot get property t without inject" while
3185
- // the slot snapshot is built, breaking the whole client batch.
3155
+ // locale: NS required for core slot snapshot injection
3186
3156
  ctx.effect(() => {
3187
3157
  const offRight = ctx.slots.inject('conversation.input.right', () => ctx.slots.register(
3188
3158
  { name: 'conversation.input.right', id: '@goodandready/dsh-voice', order: 6, locale: NS, label: () => t('title') },
@@ -4462,11 +4432,7 @@ function MacrosSection(props) {
4462
4432
  function VoiceSection(props) {
4463
4433
  const t = (props && props.t) || moduleT
4464
4434
  const ctx = (props && props.ctx) || {}
4465
- // On a non-localhost page the kernel disables settings entirely: the
4466
- // shared document is not readable, every section gets "unavailable" and
4467
- // writes are dropped. The server does not share that restriction, and
4468
- // dsh-lanmode rebuilds the same mechanics on the same calls. Prefer its
4469
- // service when installed, otherwise the kernel one (fine on loopback).
4435
+ // Settings adapter: prefer dsh-lanmode when remote, otherwise kernel.
4470
4436
  const settingsService = (ctx.get && ctx.get('lanSettings')) || ctx.configForms || ctx.settingsScope
4471
4437
  const scope = settingsService && typeof settingsService.get === 'function'
4472
4438
  ? settingsService.get(NS)
@@ -4782,10 +4748,7 @@ function MacrosSection(props) {
4782
4748
  onOpenWizard: () => setShowWizard((v) => !v),
4783
4749
  }),
4784
4750
  showWizard ? h(FirstRunWizard, {
4785
- t: t,
4786
- draft: draft,
4787
- setTop: setTop,
4788
- writable: writable,
4751
+ t, draft, setTop, writable,
4789
4752
  onClose: () => setShowWizard(false),
4790
4753
  }) : null,
4791
4754
  h('div', { className: 'cb-section-card' },
@@ -4881,10 +4844,7 @@ function MacrosSection(props) {
4881
4844
  }),
4882
4845
  ),
4883
4846
  h(HardwareOptionsCard, {
4884
- t: t,
4885
- draft: draft,
4886
- setTop: setTop,
4887
- writable: writable,
4847
+ t, draft, setTop, writable,
4888
4848
  catching: catching,
4889
4849
  setCatching: setCatching,
4890
4850
  devices: devices,
@@ -4980,23 +4940,14 @@ function MacrosSection(props) {
4980
4940
  ),
4981
4941
  // Dictation Modes & Prompts (#264)
4982
4942
  h(DictationModesSection, {
4983
- t: t,
4984
- draft: draft,
4985
- setTop: setTop,
4986
- writable: writable,
4943
+ t, draft, setTop, writable,
4987
4944
  }),
4988
4945
  // Voice Action Macros (#167)
4989
4946
  h(MacrosSection, {
4990
- t: t,
4991
- draft: draft,
4992
- setTop: setTop,
4993
- writable: writable,
4947
+ t, draft, setTop, writable,
4994
4948
  }),
4995
4949
  h(JargonEditor, {
4996
- t: t,
4997
- draft: draft,
4998
- setTop: setTop,
4999
- writable: writable,
4950
+ t, draft, setTop, writable,
5000
4951
  jargonInput: jargonInput,
5001
4952
  setJargonInput: setJargonInput,
5002
4953
  }),
@@ -5034,6 +4985,16 @@ function MacrosSection(props) {
5034
4985
  const isSecure = typeof window !== 'undefined' ? (window.isSecureContext || window.location.protocol === 'https:' || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') : true
5035
4986
  const hasMediaDevices = typeof navigator !== 'undefined' && !!navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function'
5036
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
+ )
5037
4998
 
5038
4999
  const [micPermission, setMicPermission] = React.useState('prompt')
5039
5000
  const [devices, setDevices] = React.useState([])
@@ -5108,7 +5069,16 @@ function MacrosSection(props) {
5108
5069
  const constraints = {
5109
5070
  audio: selectedDev ? { deviceId: { exact: selectedDev } } : true,
5110
5071
  }
5111
- 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
+ }
5112
5082
  signalStreamRef.current = stream
5113
5083
  setMicPermission('granted')
5114
5084
  refreshDevices()
@@ -5161,7 +5131,15 @@ function MacrosSection(props) {
5161
5131
  const constraints = {
5162
5132
  audio: selectedDev ? { deviceId: { exact: selectedDev } } : true,
5163
5133
  }
5164
- 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
+ }
5165
5143
  let mime = 'audio/webm;codecs=opus'
5166
5144
  if (!MediaRecorder.isTypeSupported(mime)) mime = ''
5167
5145
  recorder = mime ? new MediaRecorder(testStream, { mimeType: mime }) : new MediaRecorder(testStream)
@@ -5245,10 +5223,19 @@ function MacrosSection(props) {
5245
5223
  }
5246
5224
 
5247
5225
  let nextStepHint = ''
5226
+ const isPermDenied = micPermission === 'denied' || (testError && /permission|denied|notallowed/i.test(testError))
5248
5227
  if (!isSecure) {
5249
5228
  nextStepHint = t('wizardHintHttps')
5250
- } else if (micPermission === 'denied') {
5251
- 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
+ }
5252
5239
  } else if (peakRms === 0 && signalTesting) {
5253
5240
  nextStepHint = t('wizardHintDevice')
5254
5241
  } else if (testError) {
@@ -5304,6 +5291,13 @@ function MacrosSection(props) {
5304
5291
  className: 'cb-btn',
5305
5292
  onClick: requestPermissionAndTestSignal,
5306
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,
5307
5301
  ),
5308
5302
  ),
5309
5303
  ),
@@ -5387,8 +5381,15 @@ function MacrosSection(props) {
5387
5381
  ),
5388
5382
 
5389
5383
  // Next Step Hint / Feedback
5390
- nextStepHint ? h('div', { className: 'dvs-wait', style: { fontStyle: 'italic' } },
5391
- 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,
5392
5393
  ) : null,
5393
5394
 
5394
5395
  // Wizard Bottom Actions
@@ -5488,9 +5489,7 @@ function MacrosSection(props) {
5488
5489
 
5489
5490
  exports.inject = ['timer', 'slots', 'configForms', 'locale']
5490
5491
  exports.apply = function apply(ctx) {
5491
- // English is the source and fallback language; Chinese is the built-in
5492
- // localized user dictionary. Other languages (like Russian) come from
5493
- // the separate translation plugin at runtime.
5492
+ // English source/fallback, Chinese built-in dictionary.
5494
5493
  const addLocale = (locale, dictionary) => {
5495
5494
  try {
5496
5495
  return ctx.locale.register(NS, locale, dictionary)
@@ -5522,6 +5521,7 @@ function MacrosSection(props) {
5522
5521
  cycleDictationMode,
5523
5522
  resolveBrowserRecognitionLang,
5524
5523
  VoiceSection,
5524
+ FirstRunWizard,
5525
5525
  cutPhrase,
5526
5526
  sendAudio,
5527
5527
  modeChain,
@@ -74,6 +74,7 @@ export function registerHostRoutes(ctx, deps) {
74
74
  localOnly: cfg.localOnly,
75
75
  micDeviceId: cfg.micDeviceId,
76
76
  noiseGateDb: cfg.noiseGateDb !== undefined ? cfg.noiseGateDb : -45,
77
+ lowVolumeThreshold: typeof cfg.lowVolumeThreshold === 'number' ? cfg.lowVolumeThreshold : 0.08,
77
78
  historyLimit: cfg.historyLimit,
78
79
  voiceCommands: cfg.voiceCommands,
79
80
  wakeWord: String(cfg.wakeWord || ''),
package/lib/schema.js CHANGED
@@ -106,6 +106,8 @@ export const BaseConfig = z.object({
106
106
  .description('Microphone device id for recording. Empty means the system default.'),
107
107
  noiseGateDb: z.number().default(-45)
108
108
  .description('Audio noise gate threshold in dB for client-side recording (-50 to -25 dB, or <= -90 to disable). Silence below this threshold cuts ambient background noise and prevents false VAD triggers.'),
109
+ lowVolumeThreshold: z.number().min(0).max(1).default(0.08)
110
+ .description('Recording level (0..1) below which the "Low signal / speak louder" hint appears while dictating. Lower it for quiet microphones; 0 disables the hint.'),
109
111
  vadEngine: z.union(['silero', 'amplitude']).default('silero')
110
112
  .description('Voice activity detection engine: neural Silero VAD (WASM/ONNX) or classic amplitude threshold.'),
111
113
  streamingWs: z.boolean().default(true)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.9.43",
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",