@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/README.md +10 -0
- package/README.ru.md +10 -0
- package/README.zh.md +10 -0
- package/lib/client.js +386 -65
- package/lib/index.js +10 -0
- package/lib/providers.js +3 -0
- package/package.json +4 -2
- package/lib/client-src/00-open.js +0 -24
- package/lib/client-src/10-locale.js +0 -162
- package/lib/client-src/20-css.js +0 -39
- package/lib/client-src/30-core.js +0 -284
- package/lib/client-src/40-recording.js +0 -387
- package/lib/client-src/41-buttons.js +0 -30
- package/lib/client-src/50-visualizers.js +0 -121
- package/lib/client-src/60-composer.js +0 -285
- 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 -520
- package/lib/client-src/73-plugin-card.js +0 -38
- package/lib/client-src/90-close.js +0 -23
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',
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 <
|
|
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
|
-
|
|
1305
|
+
let animId = null
|
|
1306
|
+
let running = true
|
|
1307
|
+
|
|
1308
|
+
const render = () => {
|
|
1309
|
+
if (!running) return
|
|
1105
1310
|
const canvas = canvasRef.current
|
|
1106
|
-
if (
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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
|
-
|
|
1138
|
-
|
|
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: !!(
|
|
1624
|
-
micDeviceId: String((
|
|
1625
|
-
historyLimit: Number(
|
|
1626
|
-
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((
|
|
1632
|
-
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((
|
|
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
|
|
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('
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
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
|
|
2108
|
-
//
|
|
2109
|
-
//
|
|
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)
|