@goodandready/dsh-voice 0.8.24 → 0.8.26

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
@@ -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',
@@ -177,20 +177,188 @@ window.__ModuleLoader__.load({
177
177
  'hardwareDesc': 'Microphone device, local whisper.cpp and SenseVoice runtime settings.',
178
178
  'uiError': 'Voice UI Error:',
179
179
  'retry': 'Retry',
180
+ 'testMic': 'Test Mic',
181
+ 'stopTest': 'Stop Test',
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': '关闭',
180
349
  }
181
350
 
182
351
  // Strings are also needed outside components — in recording handlers and
183
352
  // slot labels — so the translator is module-level, not only via props.
184
353
  let moduleT = (key) => key
185
354
  const t = (key) => moduleT(key)
186
-
187
355
  // ------------------------------------------------------------------ css
188
356
  const CSS =
189
- '.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}' +
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}' +
190
358
  '.dvo-btn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}' +
191
359
  '.dvo-btn[data-err="1"]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}' +
192
- '.dvo-pill{display:flex;align-items:center;gap:10px;height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px rgba(0,0,0,.18);box-sizing:border-box}' +
193
- '.dvo-pbtn{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary)}' +
360
+ '.dvo-pill{display:flex;align-items:center;gap:10px;height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px rgba(0,0,0,.18);box-sizing:border-box;user-select:none;-webkit-user-select:none}' +
361
+ '.dvo-pbtn{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);user-select:none;-webkit-user-select:none;touch-action:manipulation}' +
194
362
  '.dvo-pbtn:hover{background:var(--dsw-alias-bg-layer-2)}' +
195
363
  '.dvo-wave{flex:1;min-width:0;max-width:100%;height:40px;width:100%;color:var(--dsw-alias-label-primary)}' +
196
364
  '.dvo-status{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-secondary);font-size:13px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
@@ -213,7 +381,11 @@ window.__ModuleLoader__.load({
213
381
  '.dvo-badge-norm{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-warning-primary);border:1px solid var(--dsw-alias-state-warning-primary)}' +
214
382
  '.dvo-badge-slow{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-warning-primary);border:1px solid var(--dsw-alias-border-l2)}' +
215
383
  '.dvo-badge-err{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-error-primary);border:1px solid var(--dsw-alias-state-error-primary)}' +
216
- '.dvo-badge-idle{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary)}'
384
+ '.dvo-badge-idle{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary)}' +
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)}' +
386
+ '.dvo-meter-bar{flex:1;height:8px;background:var(--dsw-alias-bg-layer-3);border-radius:4px;overflow:hidden;position:relative}' +
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}'
217
389
  const cssId = 'dsh-voice/client.module.css'
218
390
  if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + cssId + '"]')) {
219
391
  const tag = document.createElement('style')
@@ -222,7 +394,6 @@ window.__ModuleLoader__.load({
222
394
  tag.dataset.pluginCss = cssId
223
395
  document.head.appendChild(tag)
224
396
  }
225
-
226
397
  // ---------------------------------------------------------------- store
227
398
  const voice = {
228
399
  phase: 'idle', // idle | recording | processing | pending | error
@@ -351,7 +522,7 @@ window.__ModuleLoader__.load({
351
522
  function applyVoiceCommands(text) {
352
523
  let s = text
353
524
  for (const [re, to] of VOICE_COMMANDS) s = s.replace(re, to)
354
- return s.replace(/[ \t]+/g, ' ').trim()
525
+ return s.replace(/[ \t]*\n[ \t]*/g, '\n').replace(/[ \t]+/g, ' ').trim()
355
526
  }
356
527
 
357
528
  // Insert history for undo (#29-9). Browser-only storage.
@@ -507,6 +678,8 @@ window.__ModuleLoader__.load({
507
678
  }
508
679
 
509
680
  // Fetch the mode chain from the host once. Only needed to decide whether
681
+
682
+ voice._core = { tidyPhrase, applyVoiceCommands, VOICE_COMMANDS, undoLastInsert, insertHistory }
510
683
  // to use browser recognition or record a file.
511
684
  let chainsPromise = null
512
685
  if (typeof window !== 'undefined') {
@@ -532,6 +705,7 @@ window.__ModuleLoader__.load({
532
705
  function teardown(rec) {
533
706
  if (!rec) return
534
707
  try { rec.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
708
+ if (rec.srcNode) { try { rec.srcNode.disconnect() } catch (e) { /* already disconnected */ } }
535
709
  if (rec.audioCtx) { try { rec.audioCtx.close() } catch (e) { /* already closed */ } }
536
710
  }
537
711
 
@@ -569,10 +743,24 @@ window.__ModuleLoader__.load({
569
743
  : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
570
744
  if (AC) {
571
745
  rec.audioCtx = new AC()
746
+ if (rec.audioCtx.state === 'suspended') { rec.audioCtx.resume().catch(() => {}) }
572
747
  const src = rec.audioCtx.createMediaStreamSource(stream)
748
+ rec.srcNode = src
749
+
750
+ let lastNode = src
751
+ try {
752
+ const filter = rec.audioCtx.createBiquadFilter()
753
+ filter.type = 'highpass'
754
+ filter.frequency.value = 80
755
+ filter.Q.value = 0.707
756
+ src.connect(filter)
757
+ lastNode = filter
758
+ rec.filterNode = filter
759
+ } catch (e) {}
760
+
573
761
  rec.analyser = rec.audioCtx.createAnalyser()
574
762
  rec.analyser.fftSize = 128
575
- src.connect(rec.analyser)
763
+ lastNode.connect(rec.analyser)
576
764
  }
577
765
  // No timeslice: only then each stop() yields a standalone webm file.
578
766
  recorder.start()
@@ -585,11 +773,20 @@ window.__ModuleLoader__.load({
585
773
  rec.analyser.getByteFrequencyData(data)
586
774
  let sum = 0
587
775
  for (let i = 0; i < data.length; i++) sum += data[i]
588
- return Math.min(1, (sum / data.length / 255) * 2.2)
776
+ const raw = Math.min(1, (sum / data.length / 255) * 2.2)
777
+
778
+ const gateDb = Number(voice.settings && voice.settings.noiseGateDb !== undefined ? voice.settings.noiseGateDb : -45)
779
+ if (gateDb > -90) {
780
+ const gateAmp = Math.pow(10, gateDb / 20) * 2.2
781
+ if (raw < gateAmp) return 0
782
+ }
783
+ return raw
589
784
  }
590
785
 
591
786
  // Cut the current phrase: stop the recorder, send the finished file and
592
787
  // immediately start a new recording with the same recorder.
788
+ let dictationQueue = Promise.resolve()
789
+
593
790
  function cutPhrase() {
594
791
  const rec = voice.rec
595
792
  if (!rec || rec.cutting || rec.closing) return
@@ -606,7 +803,8 @@ window.__ModuleLoader__.load({
606
803
  try { rec.recorder.start() } catch (e) { /* stream already closed */ }
607
804
  }
608
805
  rec.cutting = false
609
- if (blob.size < 1200) return // too short — not speech
806
+ if (blob.size < 600) return // too short — not speech
807
+ dictationQueue = dictationQueue.then(async () => {
610
808
  try {
611
809
  const out = await sendAudio(blob, rec.mime, 'dictation')
612
810
  const text = out && out.text ? out.text : ''
@@ -622,6 +820,7 @@ window.__ModuleLoader__.load({
622
820
  } catch (e) {
623
821
  voice.set({ error: String(e && e.message ? e.message : e) })
624
822
  }
823
+ })
625
824
  })
626
825
  }
627
826
 
@@ -680,6 +879,7 @@ window.__ModuleLoader__.load({
680
879
  // Announce before opening the mic: the sooner playback mutes, the less
681
880
  // of it ends up in the recording.
682
881
  announceVoice('start')
882
+ dictationQueue = Promise.resolve()
683
883
  voice.set({ phase: 'recording', mode, error: '', levels: [], caption: '' })
684
884
  modeChain(mode).then((info) => {
685
885
  // Browser leg only when it is explicitly first in the chain.
@@ -901,6 +1101,7 @@ window.__ModuleLoader__.load({
901
1101
  voice.input = props.input
902
1102
  if (v.phase !== 'idle' && v.phase !== 'error') return null
903
1103
  const err = v.phase === 'error'
1104
+ const hk = voice.hotkey ? ` (${voice.hotkey})` : ''
904
1105
  return React.createElement(React.Fragment, null,
905
1106
  React.createElement('button', {
906
1107
  type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
@@ -908,7 +1109,7 @@ window.__ModuleLoader__.load({
908
1109
  }, micIcon()),
909
1110
  React.createElement('button', {
910
1111
  type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
911
- title: err ? v.error : t('messageBtn'),
1112
+ title: err ? v.error : (t('messageBtn') + hk),
912
1113
  // Hold: record while pressed; leaving the control cancels.
913
1114
  onPointerDown: (e) => { e.preventDefault(); beginHold('message') },
914
1115
  onPointerUp: () => endHold(false),
@@ -1097,45 +1298,67 @@ window.__ModuleLoader__.load({
1097
1298
  return () => dispose()
1098
1299
  }, [v.phase])
1099
1300
 
1100
- // Waveform / visualizer animation.
1301
+ // Waveform / visualizer animation: requestAnimationFrame with HiDPI scaling
1101
1302
  React.useEffect(() => {
1102
1303
  if (v.phase !== 'recording') return
1103
1304
  let frame = 0
1104
- const dispose = ctx.interval(() => {
1305
+ let animId = null
1306
+ let running = true
1307
+
1308
+ const render = () => {
1309
+ if (!running) return
1105
1310
  const canvas = canvasRef.current
1106
- if (!canvas) return
1107
- const g = canvas.getContext('2d')
1108
- const w = canvas.width, h = canvas.height
1109
- g.clearRect(0, 0, w, h)
1110
- if (!voice.waveColor) {
1111
- try { voice.waveColor = getComputedStyle(canvas).color || '#fff' } catch (e) { voice.waveColor = '#fff' }
1112
- }
1113
- const levels = voice.levels
1114
- const style = (voice.settings && voice.settings.visualizerStyle) || 'liquid-wave'
1115
- frame++
1116
-
1117
- if (style === 'off') {
1118
- g.beginPath()
1119
- g.globalAlpha = 0.25
1120
- g.strokeStyle = voice.waveColor
1121
- g.lineWidth = 1
1122
- g.moveTo(10, h / 2)
1123
- g.lineTo(w - 10, h / 2)
1124
- g.stroke()
1125
- g.globalAlpha = 1
1126
- return
1127
- }
1311
+ if (canvas) {
1312
+ const g = canvas.getContext('2d')
1313
+ const dpr = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1
1314
+ const rect = canvas.getBoundingClientRect()
1315
+ const cssW = rect.width || 720
1316
+ const cssH = rect.height || 40
1317
+ const targetW = Math.max(1, Math.round(cssW * dpr))
1318
+ const targetH = Math.max(1, Math.round(cssH * dpr))
1319
+
1320
+ if (canvas.width !== targetW || canvas.height !== targetH) {
1321
+ canvas.width = targetW
1322
+ canvas.height = targetH
1323
+ }
1128
1324
 
1129
- if (style === 'dynamic-orb') {
1130
- drawDynamicOrb(g, w, h, levels, voice.waveColor, frame)
1131
- } else if (style === 'bars') {
1132
- drawClassicBars(g, w, h, levels, voice.waveColor)
1133
- } else {
1134
- // default: liquid-wave
1135
- drawLiquidWave(g, w, h, levels, voice.waveColor, frame)
1325
+ g.save()
1326
+ g.scale(dpr, dpr)
1327
+ g.clearRect(0, 0, cssW, cssH)
1328
+
1329
+ if (!voice.waveColor) {
1330
+ try { voice.waveColor = getComputedStyle(canvas).color || '#fff' } catch (e) { voice.waveColor = '#fff' }
1331
+ }
1332
+ const levels = voice.levels
1333
+ const style = (voice.settings && voice.settings.visualizerStyle) || 'liquid-wave'
1334
+ frame++
1335
+
1336
+ if (style === 'off') {
1337
+ g.beginPath()
1338
+ g.globalAlpha = 0.25
1339
+ g.strokeStyle = voice.waveColor
1340
+ g.lineWidth = 1
1341
+ g.moveTo(10, cssH / 2)
1342
+ g.lineTo(cssW - 10, cssH / 2)
1343
+ g.stroke()
1344
+ g.globalAlpha = 1
1345
+ } else if (style === 'dynamic-orb') {
1346
+ drawDynamicOrb(g, cssW, cssH, levels, voice.waveColor, frame)
1347
+ } else if (style === 'bars') {
1348
+ drawClassicBars(g, cssW, cssH, levels, voice.waveColor)
1349
+ } else {
1350
+ drawLiquidWave(g, cssW, cssH, levels, voice.waveColor, frame)
1351
+ }
1352
+ g.restore()
1136
1353
  }
1137
- }, 33)
1138
- return () => dispose()
1354
+ animId = requestAnimationFrame(render)
1355
+ }
1356
+
1357
+ animId = requestAnimationFrame(render)
1358
+ return () => {
1359
+ running = false
1360
+ if (animId) cancelAnimationFrame(animId)
1361
+ }
1139
1362
  }, [v.phase])
1140
1363
 
1141
1364
  // Message-mode cancel window.
@@ -1288,6 +1511,7 @@ window.__ModuleLoader__.load({
1288
1511
  dispose()
1289
1512
  dispose = () => {}
1290
1513
  const key = data && data.hotkey
1514
+ voice.hotkey = key || ''
1291
1515
  if (key) dispose = installHotkey(ctx, key, 'message')
1292
1516
  // The composer needs fresh settings without opening the card.
1293
1517
  Object.assign(voice.settings, {
@@ -1620,23 +1844,85 @@ window.__ModuleLoader__.load({
1620
1844
  voice.settings = Object.assign({}, voice.settings, {
1621
1845
  vadSilenceMs: Number(value && value.dictation && value.dictation.vadSilenceMs) || 700,
1622
1846
  autoSendMs: Number(value && value.message && value.message.autoSendMs) || 4000,
1623
- beep: !!(snap && snap.beep),
1624
- micDeviceId: String((snap && snap.micDeviceId) || ''),
1625
- historyLimit: Number(snap && snap.historyLimit),
1626
- voiceCommands: !!(snap && snap.voiceCommands),
1847
+ beep: !!(value && value.beep),
1848
+ micDeviceId: String((value && value.micDeviceId) || ''),
1849
+ historyLimit: Number(value && value.historyLimit) || 20,
1850
+ voiceCommands: !!(value && value.voiceCommands),
1627
1851
  sendDelayMs: Number(value && value.dictation && value.dictation.sendDelayMs) || 0,
1628
1852
  stream: !!(value && value.dictation && value.dictation.stream),
1629
1853
  streamChunkMs: Number(value && value.dictation && value.dictation.streamChunkMs) || 1200,
1630
1854
  vadAdapt: Number(value && value.dictation && value.dictation.vadAdapt) || 0,
1631
- wakeWord: String((snap && snap.wakeWord) || ''),
1632
- bargeIn: !!(snap && snap.bargeIn),
1855
+ wakeWord: String((value && value.wakeWord) || ''),
1856
+ bargeIn: !!(value && value.bargeIn),
1633
1857
  polishSend: !!(value && value.message && value.message.polishSend),
1634
1858
  sessionCommands: !!(value && value.message && value.message.sessionCommands),
1635
- polishBaseUrl: String((snap && snap.polishBaseUrl) || ''),
1859
+ polishBaseUrl: String((value && value.polishBaseUrl) || ''),
1636
1860
  noiseSuppression: value.noiseSuppression !== false,
1861
+ noiseGateDb: Number((value && value.noiseGateDb) !== undefined ? value.noiseGateDb : -45),
1637
1862
  contextGlossary: value.contextGlossary !== false,
1863
+ visualizerStyle: (value && value.visualizerStyle) || 'liquid-wave',
1638
1864
  })
1639
- }, [ready, value, snap])
1865
+ }, [ready, value])
1866
+
1867
+ // Interactive microphone tester
1868
+ const [testingMic, setTestingMic] = React.useState(false)
1869
+ const [testLevel, setTestLevel] = React.useState(0)
1870
+ const testRef = React.useRef(null)
1871
+
1872
+ const toggleTestMic = async () => {
1873
+ if (testingMic) {
1874
+ if (testRef.current) {
1875
+ testRef.current.active = false
1876
+ try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) {}
1877
+ if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) {} }
1878
+ testRef.current = null
1879
+ }
1880
+ setTestingMic(false)
1881
+ setTestLevel(0)
1882
+ return
1883
+ }
1884
+ try {
1885
+ const devId = (draft && draft.micDeviceId) || (value && value.micDeviceId) || ''
1886
+ const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
1887
+ if (devId) audio.deviceId = { exact: devId }
1888
+ const stream = await navigator.mediaDevices.getUserMedia({ audio })
1889
+ const AC = typeof AudioContext !== 'undefined' ? AudioContext : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
1890
+ if (!AC) return
1891
+ const audioCtx = new AC()
1892
+ if (audioCtx.state === 'suspended') await audioCtx.resume().catch(() => {})
1893
+ const src = audioCtx.createMediaStreamSource(stream)
1894
+ const analyser = audioCtx.createAnalyser()
1895
+ analyser.fftSize = 128
1896
+ src.connect(analyser)
1897
+ testRef.current = { stream, audioCtx, analyser, active: true }
1898
+ setTestingMic(true)
1899
+
1900
+ const check = () => {
1901
+ if (!testRef.current || !testRef.current.active) return
1902
+ const data = new Uint8Array(analyser.frequencyBinCount)
1903
+ analyser.getByteFrequencyData(data)
1904
+ let sum = 0
1905
+ for (let i = 0; i < data.length; i++) sum += data[i]
1906
+ const lvl = Math.min(100, Math.round((sum / data.length / 255) * 220))
1907
+ setTestLevel(lvl)
1908
+ requestAnimationFrame(check)
1909
+ }
1910
+ requestAnimationFrame(check)
1911
+ } catch (e) {
1912
+ setErr(String(e && e.message ? e.message : e))
1913
+ }
1914
+ }
1915
+
1916
+ React.useEffect(() => {
1917
+ return () => {
1918
+ if (testRef.current) {
1919
+ testRef.current.active = false
1920
+ try { testRef.current.stream.getTracks().forEach((t) => t.stop()) } catch (e) {}
1921
+ if (testRef.current.audioCtx) { try { testRef.current.audioCtx.close() } catch (e) {} }
1922
+ testRef.current = null
1923
+ }
1924
+ }
1925
+ }, [])
1640
1926
 
1641
1927
  const [statusData, setStatusData] = React.useState(null)
1642
1928
  const [statusLatency, setStatusLatency] = React.useState(null)
@@ -1706,6 +1992,7 @@ window.__ModuleLoader__.load({
1706
1992
  streamChunkMs: Number(draft.dictation && draft.dictation.streamChunkMs) || 1200,
1707
1993
  vadAdapt: Number(draft.dictation && draft.dictation.vadAdapt) || 0,
1708
1994
  noiseSuppression: draft.noiseSuppression !== false,
1995
+ noiseGateDb: Number(draft.noiseGateDb !== undefined ? draft.noiseGateDb : -45),
1709
1996
  contextGlossary: draft.contextGlossary !== false,
1710
1997
  visualizerStyle: draft.visualizerStyle || 'liquid-wave',
1711
1998
  })
@@ -1742,16 +2029,51 @@ window.__ModuleLoader__.load({
1742
2029
  React.createElement('span', { className: 'dvs-sub' }, t('hotkeyHint1') + t('hotkeyHint2')),
1743
2030
  )
1744
2031
 
2032
+ const gateDbVal = Number((draft && draft.noiseGateDb) !== undefined ? draft.noiseGateDb : (value && value.noiseGateDb) !== undefined ? value.noiseGateDb : -45)
2033
+ const gateThresholdPercent = gateDbVal <= -90 ? 0 : Math.min(100, Math.max(0, Math.round(((gateDbVal + 60) / 40) * 100)))
2034
+
1745
2035
  const micField = () => React.createElement('div', { className: 'cb-field' },
1746
2036
  React.createElement('label', null, t('mic')),
1747
- React.createElement('select', {
1748
- value: String((snap && snap.micDeviceId) || ''), disabled: !writable,
1749
- onChange: (e) => setTop('micDeviceId', e.target.value),
1750
- },
1751
- React.createElement('option', { value: '' }, t('micDefault')),
1752
- devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
1753
- d.label || d.deviceId.slice(0, 12))),
2037
+ React.createElement('div', { className: 'cb-row' },
2038
+ React.createElement('select', {
2039
+ value: String((draft && draft.micDeviceId) ?? (value && value.micDeviceId) ?? ''), disabled: !writable || testingMic,
2040
+ onChange: (e) => setTop('micDeviceId', e.target.value),
2041
+ style: { flex: 1 },
2042
+ },
2043
+ React.createElement('option', { value: '' }, t('micDefault')),
2044
+ devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
2045
+ d.label || d.deviceId.slice(0, 12))),
2046
+ ),
2047
+ React.createElement('button', {
2048
+ type: 'button',
2049
+ className: 'cb-btn ' + (testingMic ? 'cb-btn-primary' : 'cb-btn-secondary'),
2050
+ onClick: toggleTestMic,
2051
+ style: { flex: 'none' },
2052
+ }, testingMic ? t('stopTest') : t('testMic')),
2053
+ ),
2054
+ testingMic ? React.createElement('div', { className: 'dvo-mic-test' },
2055
+ React.createElement('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, t('micLevel') + ':'),
2056
+ React.createElement('div', { className: 'dvo-meter-bar' },
2057
+ React.createElement('div', { className: 'dvo-meter-fill', style: { width: `${testLevel}%` } }),
2058
+ gateThresholdPercent > 0 ? React.createElement('div', { className: 'dvo-meter-gate', style: { left: `${gateThresholdPercent}%` }, title: `${gateDbVal} dB` }) : null,
2059
+ ),
2060
+ React.createElement('span', { style: { fontSize: '11px', fontVariantNumeric: 'tabular-nums', width: '32px', textAlign: 'right' } }, `${testLevel}%`),
2061
+ ) : null,
2062
+ React.createElement('div', { className: 'cb-row', style: { marginTop: '6px' } },
2063
+ React.createElement('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)', minWidth: '120px' } }, t('noiseGate') + ':'),
2064
+ React.createElement('select', {
2065
+ value: String(gateDbVal), disabled: !writable,
2066
+ onChange: (e) => setTop('noiseGateDb', Number(e.target.value)),
2067
+ style: { flex: 1 },
2068
+ },
2069
+ React.createElement('option', { value: '-999' }, t('noiseGateOff')),
2070
+ React.createElement('option', { value: '-50' }, '-50 dB (Low / Gentle)'),
2071
+ React.createElement('option', { value: '-45' }, '-45 dB (Standard Default)'),
2072
+ React.createElement('option', { value: '-35' }, '-35 dB (Medium Room)'),
2073
+ React.createElement('option', { value: '-25' }, '-25 dB (High / Mechanical Clicks)'),
2074
+ ),
1754
2075
  ),
2076
+ React.createElement('span', { className: 'dvs-sub' }, t('noiseGateHint')),
1755
2077
  )
1756
2078
 
1757
2079
  const langField = (mode) => React.createElement('div', { className: 'cb-field' },
@@ -2063,7 +2385,6 @@ window.__ModuleLoader__.load({
2063
2385
  ),
2064
2386
  )
2065
2387
  }
2066
-
2067
2388
  // Card in Settings → Plugins → Plugin settings (#18)
2068
2389
  function PluginCard(props) {
2069
2390
  const [open, setOpen] = React.useState(false)
@@ -2104,9 +2425,9 @@ window.__ModuleLoader__.load({
2104
2425
 
2105
2426
  exports.inject = ['timer', 'slots', 'settingsScope', 'locale']
2106
2427
  exports.apply = function apply(ctx) {
2107
- // English is the source language. Other languages come from the
2108
- // separate translation plugin at runtime. Re-registering the same
2109
- // namespace+language throws, so tolerate a pre-registered dictionary.
2428
+ // English is the source and fallback language; Chinese is the built-in
2429
+ // localized user dictionary. Other languages (like Russian) come from
2430
+ // the separate translation plugin at runtime.
2110
2431
  const addLocale = (locale, dictionary) => {
2111
2432
  try {
2112
2433
  return ctx.locale.register(NS, locale, dictionary)
@@ -2115,7 +2436,7 @@ window.__ModuleLoader__.load({
2115
2436
  }
2116
2437
  }
2117
2438
  ctx.effect(() => {
2118
- const undo = [addLocale('en', en)]
2439
+ const undo = [addLocale('en', en), addLocale('zh', zh)]
2119
2440
  return () => { for (const off of undo) off() }
2120
2441
  }, 'dsh-voice: locale dictionaries')
2121
2442
  moduleT = ctx.locale.bind(NS)