@goodandready/dsh-voice 0.8.25 → 0.8.27

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
@@ -21,6 +21,16 @@
21
21
  <a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
22
22
  </p>
23
23
 
24
+ <table align="center">
25
+ <tr>
26
+ <td align="center">
27
+ ⭐ <strong>If you like this plugin, please star it on GitHub</strong> — it shows me that the plugin is useful to you and motivates me to keep developing it.
28
+ <br><br>
29
+ 🐛 <strong>If you find a bug or would like to request a feature</strong>, open a GitHub issue in any language — I will review your proposal and implement useful suggestions in a future plugin version.
30
+ </td>
31
+ </tr>
32
+ </table>
33
+
24
34
  </div>
25
35
 
26
36
  ---
package/README.ru.md CHANGED
@@ -21,6 +21,16 @@
21
21
  <a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
22
22
  </p>
23
23
 
24
+ <table align="center">
25
+ <tr>
26
+ <td align="center">
27
+ ⭐ <strong>Если вам нравится этот плагин, поставьте ему звезду на GitHub</strong> — это покажет мне, что плагин вам полезен, и будет мотивировать меня развивать его дальше.
28
+ <br><br>
29
+ 🐛 <strong>Если вы нашли баг или хотите предложить новый функционал</strong>, создайте issue на GitHub на любом языке — я рассмотрю ваше предложение и реализую полезные идеи в одной из следующих версий плагина.
30
+ </td>
31
+ </tr>
32
+ </table>
33
+
24
34
  </div>
25
35
 
26
36
  ---
package/README.zh.md CHANGED
@@ -21,6 +21,16 @@
21
21
  <a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
22
22
  </p>
23
23
 
24
+ <table align="center">
25
+ <tr>
26
+ <td align="center">
27
+ ⭐ <strong>如果您喜欢这个插件,请在 GitHub 上为它点亮 Star</strong> — 这能让我知道插件对您有用,并鼓励我继续开发和维护它。
28
+ <br><br>
29
+ 🐛 <strong>如果您发现 Bug 或希望增加功能</strong>,请使用任意语言在 GitHub 上提交 Issue — 我会评估您的建议,并在后续版本中实现有价值的改进。
30
+ </td>
31
+ </tr>
32
+ </table>
33
+
24
34
  </div>
25
35
 
26
36
  ---
package/lib/client.js CHANGED
@@ -132,7 +132,7 @@ window.__ModuleLoader__.load({
132
132
  'whisperAutostart': 'Autostart the local whisper',
133
133
  'save': 'Save',
134
134
  'saved': 'Saved ✓',
135
- 'openrouterWarning': 'OpenRouter has no /audio/transcriptions \u2014 use the openai-chat-audio template there',
135
+ 'openrouterWarning': 'OpenRouter has no /audio/transcriptions use the openai-chat-audio template there',
136
136
  'noiseSuppression': 'Hardware noise suppression',
137
137
  'noiseSuppressionHint': 'Enable browser noise suppression, echo cancellation, and auto gain control',
138
138
  'contextGlossary': 'Context glossary injection',
@@ -180,13 +180,178 @@ window.__ModuleLoader__.load({
180
180
  'testMic': 'Test Mic',
181
181
  'stopTest': 'Stop Test',
182
182
  'micLevel': 'Mic level',
183
+ 'noiseGate': 'Noise Gate (dB)',
184
+ 'noiseGateHint': 'Mute background fan hum and keyboard clicks below threshold. Off = disabled',
185
+ 'noiseGateOff': 'Off',
186
+ }
187
+
188
+ const zh = {
189
+ 'saveFailed': '部分字段未保存 —',
190
+ 'cardHint': '听写与语音消息:服务商、备用链、本地 whisper',
191
+ 'expand': '展开',
192
+ 'collapse': '收起',
193
+ 'composerUnavailable': '输入框不可用',
194
+ 'keySpace': '空格',
195
+ 'keyUnset': '未设置',
196
+ 'recognitionError': '识别失败',
197
+ 'micUnavailable': '麦克风不可用:需要 HTTPS 或 localhost',
198
+ 'noRecorder': '当前浏览器不支持 MediaRecorder',
199
+ 'browserFailed': '浏览器未能识别:',
200
+ 'nothingHeard': '未识别到任何内容',
201
+ 'dictationBtn': '语音输入',
202
+ 'messageBtn': '语音消息 — 点击或按住',
203
+ 'dictationPill': '听写 — 文本将追加至输入框',
204
+ 'messagePill': '正在录制语音消息',
205
+ 'cancel': '取消',
206
+ 'holdHint': '按住录音 — 松开即发送',
207
+ 'listening': '浏览器正在倾听…',
208
+ 'stop': '停止',
209
+ 'transcribing': '正在转写…',
210
+ 'sendingIn': '发送至智能体倒计时',
211
+ 'secondsShort': ' 秒',
212
+ 'keepPending': '不发送',
213
+ 'hide': '隐藏',
214
+ 'title': '语音',
215
+ 'recordSlot': '语音录制',
216
+ 'browserHint': '浏览器直接识别,无需密钥',
217
+ 'openaiHint': 'whisper-1 · 密钥 OPENAI_API_KEY',
218
+ 'siliconflowHint': 'FunAudioLLM/SenseVoiceSmall · 密钥 SILICONFLOW_API_KEY',
219
+ 'deepinfraHint': 'openai/whisper-large-v3-turbo · 密钥 DEEPINFRA_API_KEY',
220
+ 'fireworksHint': 'whisper-v3-turbo · 密钥 FIREWORKS_API_KEY',
221
+ 'mistralHint': 'voxtral-mini-latest · 密钥 MISTRAL_API_KEY',
222
+ 'openrouterHint': 'google/gemini-2.5-flash · 密钥 OPENROUTER_API_KEY',
223
+ 'localHint': '服务器启动时配置',
224
+ 'up': '上移',
225
+ 'down': '下移',
226
+ 'remove': '移除',
227
+ 'addProvider': '添加服务商',
228
+ 'chainHint': '自上而下为故障转移尝试顺序',
229
+ 'customName': '备用链中使用的名称',
230
+ 'customModel': '模型',
231
+ 'customKeyName': '密钥名称',
232
+ 'customModelHint': '请求的模型(留空使用内置默认值)',
233
+ 'addCustom': '添加自定义服务商',
234
+ 'loadingSettings': '正在加载设置…',
235
+ 'notReady1': 'Harness 尚未同步此插件的设置。如果刚重启,',
236
+ 'notReady2': '设置项将在数秒内自动呈现。',
237
+ 'hotkey': '语音消息快捷键',
238
+ 'pressKey': '请按下按键…',
239
+ 'clearKey': '清除按键',
240
+ 'hotkeyHint1': '按住录音,松开发送给智能体,按 Esc 取消。',
241
+ 'hotkeyHint2': '支持任意按键:字母键、功能键 (F1-F12) 或修饰键。',
242
+ 'language': '识别语言',
243
+ 'dictationHint': '语音按停顿自动切句,文字实时追加至输入框。',
244
+ 'pauseMs': '断句静音停顿时长(毫秒)',
245
+ 'pauseHint': '数值越小切分越频繁、出字越快,但可能切断未说完的词',
246
+ 'speaking': '正在说话…',
247
+ 'silence': '停顿…',
248
+ 'normalizeTranscript': '规范化转写文本',
249
+ 'undo': '撤销上次插入',
250
+ 'undone': '插入已撤销',
251
+ 'nothingToUndo': '没有可撤销的内容',
252
+ 'beep': '启停提示音',
253
+ 'localOnly': '仅使用本地 whisper',
254
+ 'localOnlyHint': '将双链限制为仅使用本地 whisper.cpp 服务:纯离线运行。',
255
+ 'sendDelay': '听写延迟追加 (ms)',
256
+ 'sendDelayHint': '追加听写文本前的缓冲等待时间,允许撤回。0 为关闭',
257
+ 'mic': '麦克风设备',
258
+ 'micDefault': '系统默认',
259
+ 'vocabulary': '自定义词汇表(每行一个词)',
260
+ 'polish': '模型润色转写文本',
261
+ 'polishHint': '插入前通过模型修正标点符号并消除语气口头禅',
262
+ 'stream': '连续流式听写',
263
+ 'streamHint': '说话期间按定时器切分短句,无需等待长时间停顿',
264
+ 'streamChunkMs': '流式切片时长 (ms)',
265
+ 'vadAdapt': '自适应静音阈值',
266
+ 'vadAdaptHint': '根据语速动态调整停顿静音阈值。0 为固定阈值',
267
+ 'wakeWord': '语音唤醒词',
268
+ 'wakeWordHint': '说话以此短语开头时自动触发录音。留空为关闭',
269
+ 'bargeIn': '语音插话打断 (Barge-in)',
270
+ 'polishSend': '发送前润色整篇草稿',
271
+ 'polishSendHint': '发送给智能体前对整个输入框草稿进行一次整体润色',
272
+ 'sessionCommands': '会话语音控制指令',
273
+ 'sessionCommandsHint': '"send"、"cancel"、"stop"、"continue" 执行操作而非转为文本',
274
+ 'polishBaseUrl': '离线润色服务端点',
275
+ 'polishBaseUrlHint': '兼容 OpenAI /chat/completions 的基础地址(如本地 Ollama)。留空使用 harness 模型',
276
+ 'polishModel': '离线润色模型',
277
+ 'polishKeyEnv': '离线润色密钥凭据名',
278
+ 'voiceCommandsLabel': '语音编辑指令("换行"、"段落")',
279
+ 'normalizeTranscriptHint': 'transcribe_audio:口语数字转阿拉伯数字,整理标点符号',
280
+ 'messageTitle': '语音消息',
281
+ 'messageHint': '录制单条完整语音,转写完成后自动发送给智能体。',
282
+ 'undoMs': '撤回窗口时长(毫秒)',
283
+ 'undoHint': '转写完成后多久以内可以随时取消发送',
284
+ 'customTitle': '自定义语音服务商',
285
+ 'customHint': '兼容 OpenAI 的任意音频 API。配置名称将出现在上方备用链中。',
286
+ 'general': '通用设置',
287
+ 'whisperEndpoint': '本地 whisper:端点地址',
288
+ 'whisperEndpointHint': 'whisper.cpp 服务器的 POST /inference 接口',
289
+ 'deepgramEndpoint': 'Deepgram:基础地址',
290
+ 'deepgramEndpointHint': 'Deepgram 或私有化部署的基础地址(默认 https://api.deepgram.com)',
291
+ 'whisperBin': '本地 whisper:可执行文件',
292
+ 'whisperBinHint': '开启自动启动时使用',
293
+ 'whisperModel': '本地 whisper:模型文件',
294
+ 'whisperModelHint': 'ggml 模型文件的绝对路径',
295
+ 'whisperAutostart': '自动启动本地 whisper',
296
+ 'save': '保存',
297
+ 'saved': '已保存 ✓',
298
+ 'openrouterWarning': 'OpenRouter 无 /audio/transcriptions 端点 — 请选用 openai-chat-audio 模板',
299
+ 'noiseSuppression': '硬件级降噪',
300
+ 'noiseSuppressionHint': '启用浏览器硬件降噪、回声消除与自动增益控制',
301
+ 'contextGlossary': '上下文专业词汇注入',
302
+ 'contextGlossaryHint': '自动从输入草稿提取代码变量名与术语以提升语音识别准确率',
303
+ 'providerDashboard': '服务商延迟与健康监控',
304
+ 'avgLatency': '平均延迟',
305
+ 'successRate': '成功率',
306
+ 'fast': '极速',
307
+ 'normal': '正常',
308
+ 'slow': '较慢',
309
+ 'error': '异常',
310
+ 'idle': '无调用记录',
311
+ 'play': '播放',
312
+ 'pause': '暂停',
313
+ 'listenBack': '试听回放',
314
+ 'lastRecording': '最近一条语音记录',
315
+ 'sensevoiceHint': 'SenseVoice-ONNX / Sherpa-ONNX · 极速本地语音识别 (~50ms)',
316
+ 'sensevoiceEndpoint': 'SenseVoice:端点地址',
317
+ 'sensevoiceEndpointHint': 'sherpa-onnx 或兼容服务器的 POST 端点',
318
+ 'sensevoiceBin': 'SenseVoice:可执行文件',
319
+ 'sensevoiceBinHint': '开启自动启动时使用',
320
+ 'sensevoiceModel': 'SenseVoice:模型路径',
321
+ 'sensevoiceModelHint': 'SenseVoice / sherpa-onnx 模型的绝对路径或标识符',
322
+ 'sensevoiceAutostart': '自动启动 SenseVoice 服务',
323
+ 'visualizerStyle': '音频动态波形样式',
324
+ 'visualizerStyleHint': '录音胶囊面板内的动态音频波形动画效果',
325
+ 'visLiquidWave': '流体水波 (Liquid Wave)',
326
+ 'visDynamicOrb': '动态脉冲球体 (Dynamic Orb)',
327
+ 'visBars': '经典频条 (Classic Bars)',
328
+ 'visOff': '关闭',
329
+ 'statusTitle': '连接与引擎状态看板',
330
+ 'statusDesc': '当前语音后端与识别服务的实时网络与进程就绪状态。',
331
+ 'badgeHostOnline': '服务端在线 ({ms} ms)',
332
+ 'badgeHostOffline': '服务端不可达',
333
+ 'badgeWhisperActive': 'Whisper 就绪',
334
+ 'badgeWhisperInactive': 'Whisper 未就绪',
335
+ 'badgeSenseVoiceActive': 'SenseVoice 就绪',
336
+ 'badgeSenseVoiceInactive': 'SenseVoice 未就绪',
337
+ 'badgeProviders': '{count} 个服务商已就绪',
338
+ 'refreshStats': '刷新状态',
339
+ 'hardwareTitle': '硬件与本地引擎',
340
+ 'hardwareDesc': '麦克风输入设备、本地 whisper.cpp 与 SenseVoice 运行时配置。',
341
+ 'uiError': '语音界面错误:',
342
+ 'retry': '重试',
343
+ 'testMic': '测试麦克风',
344
+ 'stopTest': '停止测试',
345
+ 'micLevel': '麦克风电平',
346
+ 'noiseGate': '噪声门 (dB)',
347
+ 'noiseGateHint': '静音切除低于阈值的风扇嗡嗡声与机械键盘敲击声。Off = 关闭',
348
+ 'noiseGateOff': '关闭',
183
349
  }
184
350
 
185
351
  // Strings are also needed outside components — in recording handlers and
186
352
  // slot labels — so the translator is module-level, not only via props.
187
353
  let moduleT = (key) => key
188
354
  const t = (key) => moduleT(key)
189
-
190
355
  // ------------------------------------------------------------------ css
191
356
  const CSS =
192
357
  '.dvo-btn{display:flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:0;box-sizing:border-box;user-select:none;-webkit-user-select:none;touch-action:manipulation}' +
@@ -219,7 +384,8 @@ window.__ModuleLoader__.load({
219
384
  '.dvo-badge-idle{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary)}' +
220
385
  '.dvo-mic-test{display:flex;align-items:center;gap:10px;margin-top:8px;padding:6px 12px;background:var(--dsw-alias-bg-layer-2);border-radius:8px;border:1px solid var(--dsw-alias-border-l1)}' +
221
386
  '.dvo-meter-bar{flex:1;height:8px;background:var(--dsw-alias-bg-layer-3);border-radius:4px;overflow:hidden;position:relative}' +
222
- '.dvo-meter-fill{height:100%;background:var(--dsw-alias-state-success-primary);border-radius:4px;transition:width 60ms ease-out}'
387
+ '.dvo-meter-fill{height:100%;background:var(--dsw-alias-state-success-primary);border-radius:4px;transition:width 60ms ease-out}' +
388
+ '.dvo-meter-gate{position:absolute;top:0;bottom:0;width:2px;background:var(--dsw-alias-state-warning-primary);box-shadow:0 0 4px var(--dsw-alias-state-warning-primary);z-index:2;pointer-events:none}'
223
389
  const cssId = 'dsh-voice/client.module.css'
224
390
  if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + cssId + '"]')) {
225
391
  const tag = document.createElement('style')
@@ -228,7 +394,6 @@ window.__ModuleLoader__.load({
228
394
  tag.dataset.pluginCss = cssId
229
395
  document.head.appendChild(tag)
230
396
  }
231
-
232
397
  // ---------------------------------------------------------------- store
233
398
  const voice = {
234
399
  phase: 'idle', // idle | recording | processing | pending | error
@@ -300,8 +465,14 @@ window.__ModuleLoader__.load({
300
465
  if (!voice.settings || voice.settings.contextGlossary === false) return []
301
466
  const text = (voice.input && typeof voice.input.draft === 'string') ? voice.input.draft : ''
302
467
  if (!text || text.length < 3) return []
303
- const matches = text.match(/\b[A-Za-z_][A-Za-z0-9_]{2,29}\b/g) || []
304
- const stop = new Set(['the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'any', 'can', 'her', 'was', 'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how', 'man', 'new', 'now', 'old', 'see', 'two', 'way', 'who', 'boy', 'did', 'its', 'let', 'put', 'say', 'she', 'too', 'use'])
468
+ const matches = text.match(/[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{2,29}/g) || []
469
+ const stop = new Set([
470
+ 'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'any', 'can', 'her', 'was',
471
+ 'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how', 'man', 'new', 'now',
472
+ 'old', 'see', 'two', 'way', 'who', 'boy', 'did', 'its', 'let', 'put', 'say', 'she',
473
+ 'too', 'use', 'это', 'как', 'что', 'для', 'или', 'если', 'все', 'при', 'так', 'уже',
474
+ 'был', 'быть', 'только', 'тоже', 'под', 'над', 'без', 'нет', 'даже', 'где', 'чем',
475
+ ])
305
476
  const words = []
306
477
  const seen = new Set()
307
478
  for (const m of matches) {
@@ -541,10 +712,13 @@ window.__ModuleLoader__.load({
541
712
  if (!rec) return
542
713
  try { rec.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
543
714
  if (rec.srcNode) { try { rec.srcNode.disconnect() } catch (e) { /* already disconnected */ } }
715
+ if (rec.filterNode) { try { rec.filterNode.disconnect() } catch (e) { /* already disconnected */ } }
716
+ if (rec.analyser) { try { rec.analyser.disconnect() } catch (e) { /* already disconnected */ } }
544
717
  if (rec.audioCtx) { try { rec.audioCtx.close() } catch (e) { /* already closed */ } }
545
718
  }
546
719
 
547
720
  function waitStop(recorder) {
721
+ if (!recorder || recorder.state === 'inactive') return Promise.resolve()
548
722
  return new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true }))
549
723
  }
550
724
 
@@ -581,9 +755,21 @@ window.__ModuleLoader__.load({
581
755
  if (rec.audioCtx.state === 'suspended') { rec.audioCtx.resume().catch(() => {}) }
582
756
  const src = rec.audioCtx.createMediaStreamSource(stream)
583
757
  rec.srcNode = src
758
+
759
+ let lastNode = src
760
+ try {
761
+ const filter = rec.audioCtx.createBiquadFilter()
762
+ filter.type = 'highpass'
763
+ filter.frequency.value = 80
764
+ filter.Q.value = 0.707
765
+ src.connect(filter)
766
+ lastNode = filter
767
+ rec.filterNode = filter
768
+ } catch (e) {}
769
+
584
770
  rec.analyser = rec.audioCtx.createAnalyser()
585
771
  rec.analyser.fftSize = 128
586
- src.connect(rec.analyser)
772
+ lastNode.connect(rec.analyser)
587
773
  }
588
774
  // No timeslice: only then each stop() yields a standalone webm file.
589
775
  recorder.start()
@@ -596,7 +782,14 @@ window.__ModuleLoader__.load({
596
782
  rec.analyser.getByteFrequencyData(data)
597
783
  let sum = 0
598
784
  for (let i = 0; i < data.length; i++) sum += data[i]
599
- return Math.min(1, (sum / data.length / 255) * 2.2)
785
+ const raw = Math.min(1, (sum / data.length / 255) * 2.2)
786
+
787
+ const gateDb = Number(voice.settings && voice.settings.noiseGateDb !== undefined ? voice.settings.noiseGateDb : -45)
788
+ if (gateDb > -90) {
789
+ const gateAmp = Math.pow(10, gateDb / 20) * 2.2
790
+ if (raw < gateAmp) return 0
791
+ }
792
+ return raw
600
793
  }
601
794
 
602
795
  // Cut the current phrase: stop the recorder, send the finished file and
@@ -608,7 +801,14 @@ window.__ModuleLoader__.load({
608
801
  if (!rec || rec.cutting || rec.closing) return
609
802
  rec.cutting = true
610
803
  const stopped = waitStop(rec.recorder)
611
- try { rec.recorder.stop() } catch (e) { /* already stopped */ }
804
+ try {
805
+ if (rec.recorder && rec.recorder.state !== 'inactive') {
806
+ rec.recorder.stop()
807
+ }
808
+ } catch (e) {
809
+ rec.cutting = false
810
+ return
811
+ }
612
812
  stopped.then(async () => {
613
813
  const blob = new Blob(rec.chunks, { type: rec.mime })
614
814
  rec.chunks = []
@@ -616,7 +816,9 @@ window.__ModuleLoader__.load({
616
816
  rec.hadSpeech = false
617
817
  rec.streamMs = 0
618
818
  if (!rec.closing) {
619
- try { rec.recorder.start() } catch (e) { /* stream already closed */ }
819
+ try {
820
+ if (rec.recorder && rec.recorder.state === 'inactive') rec.recorder.start()
821
+ } catch (e) { /* stream already closed */ }
620
822
  }
621
823
  rec.cutting = false
622
824
  if (blob.size < 600) return // too short — not speech
@@ -797,7 +999,9 @@ window.__ModuleLoader__.load({
797
999
  if (!rec) { voice.set({ phase: 'idle', error: '' }); return }
798
1000
  rec.closing = true
799
1001
  const stopped = waitStop(rec.recorder)
800
- try { rec.recorder.stop() } catch (e) { /* already stopped */ }
1002
+ try {
1003
+ if (rec.recorder && rec.recorder.state !== 'inactive') rec.recorder.stop()
1004
+ } catch (e) { /* already stopped */ }
801
1005
  stopped.then(() => { teardown(rec); voice.rec = null; voice.set({ phase: 'idle', error: '' }) })
802
1006
  }
803
1007
 
@@ -827,7 +1031,9 @@ window.__ModuleLoader__.load({
827
1031
  rec.closing = true
828
1032
  const mode = rec.mode
829
1033
  const stopped = waitStop(rec.recorder)
830
- try { rec.recorder.stop() } catch (e) { /* already stopped */ }
1034
+ try {
1035
+ if (rec.recorder && rec.recorder.state !== 'inactive') rec.recorder.stop()
1036
+ } catch (e) { /* already stopped */ }
831
1037
  stopped.then(async () => {
832
1038
  const blob = new Blob(rec.chunks, { type: rec.mime })
833
1039
  teardown(rec)
@@ -1344,6 +1550,7 @@ window.__ModuleLoader__.load({
1344
1550
  polishSend: !!(data && data.modes && data.modes.message && data.modes.message.polishSend),
1345
1551
  sessionCommands: !!(data && data.modes && data.modes.message && data.modes.message.sessionCommands),
1346
1552
  noiseSuppression: data && data.noiseSuppression !== false,
1553
+ noiseGateDb: Number(data && data.noiseGateDb !== undefined ? data.noiseGateDb : -45),
1347
1554
  contextGlossary: data && data.contextGlossary !== false,
1348
1555
  visualizerStyle: (data && data.visualizerStyle) || 'liquid-wave',
1349
1556
  })
@@ -1641,7 +1848,7 @@ window.__ModuleLoader__.load({
1641
1848
  const timer = setInterval(() => {
1642
1849
  if (tries >= 15) { clearInterval(timer); return }
1643
1850
  tries += 1
1644
- try { ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).describe().load() } catch (e) { /* service not up yet */ }
1851
+ try { ctx.settingsScope.describe().load() } catch (e) { /* service not up yet */ }
1645
1852
  }, 1000)
1646
1853
  return () => clearInterval(timer)
1647
1854
  }, [ready])
@@ -1674,12 +1881,13 @@ window.__ModuleLoader__.load({
1674
1881
  sessionCommands: !!(value && value.message && value.message.sessionCommands),
1675
1882
  polishBaseUrl: String((value && value.polishBaseUrl) || ''),
1676
1883
  noiseSuppression: value.noiseSuppression !== false,
1884
+ noiseGateDb: Number((value && value.noiseGateDb) !== undefined ? value.noiseGateDb : -45),
1677
1885
  contextGlossary: value.contextGlossary !== false,
1678
1886
  visualizerStyle: (value && value.visualizerStyle) || 'liquid-wave',
1679
1887
  })
1680
1888
  }, [ready, value])
1681
1889
 
1682
- // Interactive microphone tester
1890
+ // Interactive microphone tester
1683
1891
  const [testingMic, setTestingMic] = React.useState(false)
1684
1892
  const [testLevel, setTestLevel] = React.useState(0)
1685
1893
  const testRef = React.useRef(null)
@@ -1706,9 +1914,18 @@ window.__ModuleLoader__.load({
1706
1914
  const audioCtx = new AC()
1707
1915
  if (audioCtx.state === 'suspended') await audioCtx.resume().catch(() => {})
1708
1916
  const src = audioCtx.createMediaStreamSource(stream)
1917
+ let lastNode = src
1918
+ try {
1919
+ const filter = audioCtx.createBiquadFilter()
1920
+ filter.type = 'highpass'
1921
+ filter.frequency.value = 80
1922
+ filter.Q.value = 0.707
1923
+ src.connect(filter)
1924
+ lastNode = filter
1925
+ } catch (e) {}
1709
1926
  const analyser = audioCtx.createAnalyser()
1710
1927
  analyser.fftSize = 128
1711
- src.connect(analyser)
1928
+ lastNode.connect(analyser)
1712
1929
  testRef.current = { stream, audioCtx, analyser, active: true }
1713
1930
  setTestingMic(true)
1714
1931
 
@@ -1807,6 +2024,7 @@ window.__ModuleLoader__.load({
1807
2024
  streamChunkMs: Number(draft.dictation && draft.dictation.streamChunkMs) || 1200,
1808
2025
  vadAdapt: Number(draft.dictation && draft.dictation.vadAdapt) || 0,
1809
2026
  noiseSuppression: draft.noiseSuppression !== false,
2027
+ noiseGateDb: Number(draft.noiseGateDb !== undefined ? draft.noiseGateDb : -45),
1810
2028
  contextGlossary: draft.contextGlossary !== false,
1811
2029
  visualizerStyle: draft.visualizerStyle || 'liquid-wave',
1812
2030
  })
@@ -1843,6 +2061,9 @@ window.__ModuleLoader__.load({
1843
2061
  React.createElement('span', { className: 'dvs-sub' }, t('hotkeyHint1') + t('hotkeyHint2')),
1844
2062
  )
1845
2063
 
2064
+ const gateDbVal = Number((draft && draft.noiseGateDb) !== undefined ? draft.noiseGateDb : (value && value.noiseGateDb) !== undefined ? value.noiseGateDb : -45)
2065
+ const gateThresholdPercent = gateDbVal <= -90 ? 0 : Math.min(100, Math.max(0, Math.round(((gateDbVal + 60) / 40) * 100)))
2066
+
1846
2067
  const micField = () => React.createElement('div', { className: 'cb-field' },
1847
2068
  React.createElement('label', null, t('mic')),
1848
2069
  React.createElement('div', { className: 'cb-row' },
@@ -1866,9 +2087,25 @@ window.__ModuleLoader__.load({
1866
2087
  React.createElement('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, t('micLevel') + ':'),
1867
2088
  React.createElement('div', { className: 'dvo-meter-bar' },
1868
2089
  React.createElement('div', { className: 'dvo-meter-fill', style: { width: `${testLevel}%` } }),
2090
+ gateThresholdPercent > 0 ? React.createElement('div', { className: 'dvo-meter-gate', style: { left: `${gateThresholdPercent}%` }, title: `${gateDbVal} dB` }) : null,
1869
2091
  ),
1870
2092
  React.createElement('span', { style: { fontSize: '11px', fontVariantNumeric: 'tabular-nums', width: '32px', textAlign: 'right' } }, `${testLevel}%`),
1871
2093
  ) : null,
2094
+ React.createElement('div', { className: 'cb-row', style: { marginTop: '6px' } },
2095
+ React.createElement('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)', minWidth: '120px' } }, t('noiseGate') + ':'),
2096
+ React.createElement('select', {
2097
+ value: String(gateDbVal), disabled: !writable,
2098
+ onChange: (e) => setTop('noiseGateDb', Number(e.target.value)),
2099
+ style: { flex: 1 },
2100
+ },
2101
+ React.createElement('option', { value: '-999' }, t('noiseGateOff')),
2102
+ React.createElement('option', { value: '-50' }, '-50 dB (Low / Gentle)'),
2103
+ React.createElement('option', { value: '-45' }, '-45 dB (Standard Default)'),
2104
+ React.createElement('option', { value: '-35' }, '-35 dB (Medium Room)'),
2105
+ React.createElement('option', { value: '-25' }, '-25 dB (High / Mechanical Clicks)'),
2106
+ ),
2107
+ ),
2108
+ React.createElement('span', { className: 'dvs-sub' }, t('noiseGateHint')),
1872
2109
  )
1873
2110
 
1874
2111
  const langField = (mode) => React.createElement('div', { className: 'cb-field' },
@@ -2180,7 +2417,6 @@ window.__ModuleLoader__.load({
2180
2417
  ),
2181
2418
  )
2182
2419
  }
2183
-
2184
2420
  // Card in Settings → Plugins → Plugin settings (#18)
2185
2421
  function PluginCard(props) {
2186
2422
  const [open, setOpen] = React.useState(false)
@@ -2221,9 +2457,9 @@ window.__ModuleLoader__.load({
2221
2457
 
2222
2458
  exports.inject = ['timer', 'slots', 'settingsScope', 'locale']
2223
2459
  exports.apply = function apply(ctx) {
2224
- // English is the source language. Other languages come from the
2225
- // separate translation plugin at runtime. Re-registering the same
2226
- // namespace+language throws, so tolerate a pre-registered dictionary.
2460
+ // English is the source and fallback language; Chinese is the built-in
2461
+ // localized user dictionary. Other languages (like Russian) come from
2462
+ // the separate translation plugin at runtime.
2227
2463
  const addLocale = (locale, dictionary) => {
2228
2464
  try {
2229
2465
  return ctx.locale.register(NS, locale, dictionary)
@@ -2232,7 +2468,7 @@ window.__ModuleLoader__.load({
2232
2468
  }
2233
2469
  }
2234
2470
  ctx.effect(() => {
2235
- const undo = [addLocale('en', en)]
2471
+ const undo = [addLocale('en', en), addLocale('zh', zh)]
2236
2472
  return () => { for (const off of undo) off() }
2237
2473
  }, 'dsh-voice: locale dictionaries')
2238
2474
  moduleT = ctx.locale.bind(NS)
package/lib/index.js CHANGED
@@ -126,6 +126,8 @@ export const Config = z.object({
126
126
  .description('Restrict both chains to local-whisper only: fully offline, no cloud providers.'),
127
127
  micDeviceId: z.string().default('')
128
128
  .description('Microphone device id for recording. Empty means the system default.'),
129
+ noiseGateDb: z.number().default(-45)
130
+ .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.'),
129
131
  historyLimit: z.number().default(20)
130
132
  .description('How many recent dictation inserts to keep for undo in the browser. 0 disables history.'),
131
133
  vocabulary: z.array(z.string()).default([])
@@ -301,7 +303,7 @@ export function apply(ctx, baseConfig) {
301
303
  : (Array.isArray(cfg.vocabulary) ? cfg.vocabulary : [])
302
304
 
303
305
  const providers = makeProviders(
304
- { resolveKey, fetchImpl: fetch, cfg, toWav: (b) => toWav16k(b, cfg.ffmpegBin) },
306
+ { resolveKey, fetchImpl: fetch, cfg, toWav: (b, sig) => toWav16k(b, cfg.ffmpegBin, sig || signal) },
305
307
  { bytes, mime, lang: modeCfg.language, signal, models, vocabulary: vocab },
306
308
  )
307
309
  return runChain(order, providers, statsTracker.record)
@@ -354,6 +356,7 @@ export function apply(ctx, baseConfig) {
354
356
  beep: cfg.beep,
355
357
  localOnly: cfg.localOnly,
356
358
  micDeviceId: cfg.micDeviceId,
359
+ noiseGateDb: cfg.noiseGateDb !== undefined ? cfg.noiseGateDb : -45,
357
360
  historyLimit: cfg.historyLimit,
358
361
  voiceCommands: cfg.voiceCommands,
359
362
  wakeWord: String(cfg.wakeWord || ''),
package/lib/providers.js CHANGED
@@ -158,6 +158,7 @@ export function makeProviders(deps, req) {
158
158
  form.append('file', new Blob([bytes], { type: mime }), fileName(mime))
159
159
  form.append('model', pickModel(models, 'groq'))
160
160
  if (!isAutoLang(lang)) form.append('language', lang)
161
+ if (vocab) form.append('prompt', vocab)
161
162
  form.append('response_format', 'json')
162
163
  const res = await fetchImpl('https://api.groq.com/openai/v1/audio/transcriptions', {
163
164
  method: 'POST',
@@ -205,7 +206,7 @@ export function makeProviders(deps, req) {
205
206
  return { ok: false, provider: 'local-whisper', reason: 'local whisper needs WAV, no converter configured' }
206
207
  }
207
208
  try {
208
- sendBytes = await deps.toWav(bytes)
209
+ sendBytes = await deps.toWav(bytes, signal)
209
210
  sendMime = 'audio/wav'
210
211
  } catch (e) {
211
212
  return { ok: false, provider: 'local-whisper', reason: `local whisper: ${String(e && e.message || e)}` }
@@ -249,7 +250,7 @@ export function makeProviders(deps, req) {
249
250
  return { ok: false, provider: 'sensevoice', reason: 'sensevoice needs WAV, no converter configured' }
250
251
  }
251
252
  try {
252
- sendBytes = await deps.toWav(bytes)
253
+ sendBytes = await deps.toWav(bytes, signal)
253
254
  sendMime = 'audio/wav'
254
255
  } catch (e) {
255
256
  return { ok: false, provider: 'sensevoice', reason: `sensevoice: ${String(e && e.message || e)}` }
@@ -259,12 +260,11 @@ export function makeProviders(deps, req) {
259
260
  form.append('file', new Blob([sendBytes], { type: sendMime }), fileName(sendMime))
260
261
  const isOpenAI = url.includes('/transcriptions')
261
262
  if (isOpenAI) {
262
- const model = pickModel(models, 'sensevoice') || 'SenseVoiceSmall'
263
- form.append('model', model)
263
+ form.append('model', pickModel(models, 'sensevoice'))
264
+ if (!isAutoLang(lang)) form.append('language', lang)
265
+ if (vocab) form.append('prompt', vocab)
264
266
  form.append('response_format', 'json')
265
267
  }
266
- if (!isAutoLang(lang)) form.append('language', lang)
267
- if (vocab) form.append('prompt', vocab)
268
268
 
269
269
  let res
270
270
  try {
@@ -316,6 +316,7 @@ export function makeProviders(deps, req) {
316
316
  form.append('file', new Blob([bytes], { type: mime }), fileName(mime))
317
317
  form.append('model', model)
318
318
  if (lang && lang !== 'auto') form.append('language', lang)
319
+ if (vocab) form.append('prompt', vocab)
319
320
  form.append('response_format', 'json')
320
321
  const res = await fetchImpl(`${base}/audio/transcriptions`, {
321
322
  method: 'POST', headers, body: form, signal,
@@ -330,9 +331,9 @@ export function makeProviders(deps, req) {
330
331
  let format = chatAudioFormat(mime)
331
332
  if (!format) {
332
333
  if (typeof deps.toWav !== 'function') {
333
- throw new Error(`${label} needs wav or mp3, no converter configured`)
334
+ throw new Error(`${label}: chat template needs wav or mp3, unsupported format ${mime}`)
334
335
  }
335
- sendBytes = await deps.toWav(bytes)
336
+ sendBytes = await deps.toWav(bytes, signal)
336
337
  format = 'wav'
337
338
  }
338
339
  const ask = (spec.prompt || CHAT_AUDIO_PROMPT)
package/lib/wav.js CHANGED
@@ -11,8 +11,12 @@ import { spawn } from 'node:child_process'
11
11
  * @param ffmpegBin {string} ffmpeg binary path
12
12
  * @returns {Promise<Buffer>} 16 kHz mono WAV
13
13
  */
14
- export function toWav16k(bytes, ffmpegBin = 'ffmpeg') {
14
+ export function toWav16k(bytes, ffmpegBin = 'ffmpeg', signal = null) {
15
15
  return new Promise((resolve, reject) => {
16
+ if (signal?.aborted) {
17
+ reject(new Error('ffmpeg conversion aborted before start'))
18
+ return
19
+ }
16
20
  const proc = spawn(ffmpegBin, [
17
21
  '-hide_banner', '-loglevel', 'error',
18
22
  '-i', 'pipe:0',
@@ -21,10 +25,37 @@ export function toWav16k(bytes, ffmpegBin = 'ffmpeg') {
21
25
  ])
22
26
  const out = []
23
27
  const err = []
28
+ let done = false
29
+
30
+ const timeout = setTimeout(() => {
31
+ cleanup()
32
+ try { proc.kill('SIGKILL') } catch {}
33
+ reject(new Error('ffmpeg conversion timed out after 30s'))
34
+ }, 30000)
35
+
36
+ const onAbort = () => {
37
+ cleanup()
38
+ try { proc.kill('SIGKILL') } catch {}
39
+ reject(new Error('ffmpeg conversion aborted'))
40
+ }
41
+
42
+ if (signal) signal.addEventListener('abort', onAbort, { once: true })
43
+
44
+ function cleanup() {
45
+ if (done) return
46
+ done = true
47
+ clearTimeout(timeout)
48
+ if (signal) signal.removeEventListener('abort', onAbort)
49
+ }
50
+
24
51
  proc.stdout.on('data', (c) => out.push(c))
25
52
  proc.stderr.on('data', (c) => err.push(c))
26
- proc.on('error', (e) => reject(new Error(`ffmpeg unavailable: ${e.message}`)))
53
+ proc.on('error', (e) => {
54
+ cleanup()
55
+ reject(new Error(`ffmpeg unavailable: ${e.message}`))
56
+ })
27
57
  proc.on('close', (code) => {
58
+ cleanup()
28
59
  if (code !== 0) {
29
60
  reject(new Error(`ffmpeg exit ${code}: ${Buffer.concat(err).toString('utf8').slice(0, 200)}`))
30
61
  return