@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 +10 -0
- package/README.ru.md +10 -0
- package/README.zh.md +10 -0
- package/lib/client.js +256 -20
- package/lib/index.js +4 -1
- package/lib/providers.js +9 -8
- package/lib/wav.js +33 -2
- package/package.json +4 -2
- package/lib/client-src/00-open.js +0 -24
- package/lib/client-src/10-locale.js +0 -165
- package/lib/client-src/20-css.js +0 -42
- package/lib/client-src/30-core.js +0 -286
- package/lib/client-src/40-recording.js +0 -395
- package/lib/client-src/41-buttons.js +0 -31
- package/lib/client-src/50-visualizers.js +0 -121
- package/lib/client-src/60-composer.js +0 -308
- package/lib/client-src/70-settings-base.js +0 -126
- package/lib/client-src/71-chains.js +0 -88
- package/lib/client-src/72-voice-section.js +0 -597
- package/lib/client-src/73-plugin-card.js +0 -38
- package/lib/client-src/90-close.js +0 -23
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
|
|
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(
|
|
304
|
-
const stop = new Set([
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2225
|
-
//
|
|
2226
|
-
//
|
|
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
|
-
|
|
263
|
-
form.append('
|
|
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,
|
|
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) =>
|
|
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
|