@goodandready/dsh-voice 0.9.43 → 0.9.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +95 -95
- package/lib/host-routes.js +1 -0
- package/lib/schema.js +2 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
// dsh-voice — client half (browser).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// mic — dictation: speech is cut on pauses, each chunk is recognized and
|
|
5
|
-
// appended to the composer; send stays with the user;
|
|
6
|
-
// wave — voice message: one whole recording; after recognition the text is
|
|
7
|
-
// sent to the agent when the cancel window expires.
|
|
8
|
-
//
|
|
9
|
-
// The recording pill lives in conversation.input.dock; settings are a card
|
|
10
|
-
// in settings.plugin.item.
|
|
3
|
+
// Dictation (mic), voice message (wave), dock pill and settings card.
|
|
11
4
|
|
|
12
5
|
window.__ModuleLoader__.load({
|
|
13
6
|
id: '@goodandready/dsh-voice',
|
|
@@ -32,9 +25,7 @@ window.__ModuleLoader__.load({
|
|
|
32
25
|
const ROW_ID = 'dsh-voice'
|
|
33
26
|
const ROW_CONFIG_KEY = PKG + '#' + ROW_ID
|
|
34
27
|
|
|
35
|
-
// UI strings
|
|
36
|
-
// translate them without touching this plugin. English is the source
|
|
37
|
-
// language, the default, and the fallback.
|
|
28
|
+
// UI strings in locale registry; English is source/default.
|
|
38
29
|
const en = {
|
|
39
30
|
'saveFailed': 'Some fields were not saved —',
|
|
40
31
|
'cardHint': 'Dictation and voice messages: providers, chains, local whisper',
|
|
@@ -320,6 +311,7 @@ window.__ModuleLoader__.load({
|
|
|
320
311
|
'wizardTestFailed': 'STT test failed: {error}',
|
|
321
312
|
'wizardNextStepHint': 'Next step: {hint}',
|
|
322
313
|
'wizardHintPermission': 'Click "Request Microphone Access" and allow browser permission prompt.', 'wizardHintDevice': 'Check microphone connection or select a different input device.', 'wizardHintHttps': 'Open DeepSeek Harness via HTTPS or localhost address.', 'wizardHintProvider': 'Configure STT engine credentials or install SenseVoice model below.',
|
|
314
|
+
'wizardHintWinDesktop': 'System microphone blocked. Open Windows Settings → Privacy & security → Microphone, and turn ON "Let desktop apps access your microphone".', 'wizardHintWinBrowser': 'Microphone permission denied. Allow access in your browser address bar or Windows Settings → Privacy & security → Microphone.', 'wizardHintMacDesktop': 'System microphone blocked. Open macOS System Settings → Privacy & Security → Microphone, and enable DSH Desktop.', 'wizardOpenWinMicSettings': 'Open Windows Mic Settings',
|
|
323
315
|
'wizardFinishSuccess': 'Setup & Verification Complete', 'wizardSkip': 'Skip Wizard',
|
|
324
316
|
'wizardStatusCompleted': 'Microphone and STT verified',
|
|
325
317
|
'exportDiagnosticsBtn': 'Export Diagnostics Report', 'exportingDiagnostics': 'Exporting report…',
|
|
@@ -612,6 +604,7 @@ window.__ModuleLoader__.load({
|
|
|
612
604
|
'wizardTestFailed': 'STT 测试失败:{error}',
|
|
613
605
|
'wizardNextStepHint': '建议下一步:{hint}',
|
|
614
606
|
'wizardHintPermission': '点击“请求麦克风权限”并在浏览器弹窗中允许访问。', 'wizardHintDevice': '检查麦克风硬件连接或切换选择其他输入设备。', 'wizardHintHttps': '请通过 HTTPS 或 localhost 访问 DeepSeek Harness。', 'wizardHintProvider': '请在下方配置 STT 密钥凭据或一键安装 SenseVoice 本地模型。',
|
|
607
|
+
'wizardHintWinDesktop': '系统麦克风被阻止。请在 Windows【设置】→【隐私和安全性】→【麦克风】中开启最下方的【允许桌面应用访问你的麦克风】。', 'wizardHintWinBrowser': '麦克风权限被拒绝。请在浏览器地址栏允许麦克风权限,或在 Windows【设置】→【隐私和安全性】→【麦克风】中开启访问。', 'wizardHintMacDesktop': '系统麦克风被阻止。请在 macOS【系统设置】→【隐私与安全性】→【麦克风】中勾选允许 DSH Desktop。', 'wizardOpenWinMicSettings': '打开系统麦克风设置',
|
|
615
608
|
'wizardFinishSuccess': '设置与验证已完成', 'wizardSkip': '跳过向导',
|
|
616
609
|
'wizardStatusCompleted': '麦克风与 STT 诊断已就绪',
|
|
617
610
|
'exportDiagnosticsBtn': '导出诊断报告', 'exportingDiagnostics': '正在导出报告…',
|
|
@@ -852,8 +845,7 @@ window.__ModuleLoader__.load({
|
|
|
852
845
|
const text = chunks.join(' ')
|
|
853
846
|
if (!text || text.length < 3) return []
|
|
854
847
|
const matches = text.match(/[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{2,29}/g) || []
|
|
855
|
-
//
|
|
856
|
-
// Language processing data for context analysis, distinct from user-facing UI text.
|
|
848
|
+
// Stop-word dictionary for vocabulary keyword extraction
|
|
857
849
|
const stop = new Set([
|
|
858
850
|
'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'any', 'can', 'her', 'was',
|
|
859
851
|
'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how', 'man', 'new', 'now',
|
|
@@ -983,9 +975,7 @@ window.__ModuleLoader__.load({
|
|
|
983
975
|
return lead + s + trail
|
|
984
976
|
}
|
|
985
977
|
|
|
986
|
-
//
|
|
987
|
-
// Linguistic data for audio transcript post-processing; not user-facing UI text.
|
|
988
|
-
// Russian phrases match RU STT output; English covers EN dictation.
|
|
978
|
+
// Speech edit commands (#37): spoken line breaks and punctuation -> symbols
|
|
989
979
|
const VOICE_COMMANDS = [
|
|
990
980
|
[/(^|[\s,.!?])с новой строки([\s,.!?]|$)/gi, '$1\n$2'],
|
|
991
981
|
[/(^|[\s,.!?])новая строка([\s,.!?]|$)/gi, '$1\n$2'],
|
|
@@ -1033,22 +1023,26 @@ window.__ModuleLoader__.load({
|
|
|
1033
1023
|
function insertDraftText(text, target) {
|
|
1034
1024
|
if (voice.disposed) return false
|
|
1035
1025
|
const span = target && target.span
|
|
1026
|
+
const sIn = (target && target.input) || voice.input
|
|
1027
|
+
const sDraft = (sIn && typeof sIn.draft === 'string') ? sIn.draft : ''
|
|
1028
|
+
const spaced = (sDraft && !/\s$/.test(sDraft) && !/^\s/.test(text)
|
|
1029
|
+
&& !/^[.,;:!?…»)\]}]/.test(text) && !/[\s(«"'\[]$/.test(sDraft)) ? ' ' + text : text
|
|
1036
1030
|
if (span) {
|
|
1037
1031
|
if (typeof span.insert === 'function') {
|
|
1038
1032
|
try {
|
|
1039
|
-
const r = span.insert(
|
|
1033
|
+
const r = span.insert(spaced)
|
|
1040
1034
|
if (r !== false) return true
|
|
1041
1035
|
return false
|
|
1042
1036
|
} catch (_) {}
|
|
1043
1037
|
} else if (typeof span.insertText === 'function') {
|
|
1044
1038
|
try {
|
|
1045
|
-
const r = span.insertText(
|
|
1039
|
+
const r = span.insertText(spaced)
|
|
1046
1040
|
if (r !== false) return true
|
|
1047
1041
|
return false
|
|
1048
1042
|
} catch (_) {}
|
|
1049
1043
|
} else if (typeof span === 'function') {
|
|
1050
1044
|
try {
|
|
1051
|
-
const r = span(
|
|
1045
|
+
const r = span(spaced)
|
|
1052
1046
|
if (r !== false) return true
|
|
1053
1047
|
return false
|
|
1054
1048
|
} catch (_) {}
|
|
@@ -1062,7 +1056,7 @@ window.__ModuleLoader__.load({
|
|
|
1062
1056
|
}
|
|
1063
1057
|
if (typeof actions.insertText === 'function') {
|
|
1064
1058
|
try {
|
|
1065
|
-
const res = actions.insertText(
|
|
1059
|
+
const res = actions.insertText(spaced, span)
|
|
1066
1060
|
if (res !== false) {
|
|
1067
1061
|
if (target && typeof actions.captureInsertion === 'function') {
|
|
1068
1062
|
try { target.span = actions.captureInsertion() } catch (_) {}
|
|
@@ -1124,14 +1118,7 @@ window.__ModuleLoader__.load({
|
|
|
1124
1118
|
} catch (noAudio) { /* no audio — not critical */ }
|
|
1125
1119
|
}
|
|
1126
1120
|
|
|
1127
|
-
//
|
|
1128
|
-
//
|
|
1129
|
-
// A separate leg unlike the others: the browser recognizes speech itself,
|
|
1130
|
-
// nothing is uploaded to the host, no keys are needed, and text appears
|
|
1131
|
-
// word by word while you speak.
|
|
1132
|
-
//
|
|
1133
|
-
// Cost: in Chrome audio goes to Google servers. This provider is never
|
|
1134
|
-
// enabled automatically — only when placed explicitly in a chain.
|
|
1121
|
+
// browser recognition: in-browser, no upload, only enabled when in chain
|
|
1135
1122
|
function speechRecognitionCtor() {
|
|
1136
1123
|
if (typeof window === 'undefined') return null
|
|
1137
1124
|
return window.SpeechRecognition || window.webkitSpeechRecognition || null
|
|
@@ -1190,8 +1177,7 @@ window.__ModuleLoader__.load({
|
|
|
1190
1177
|
if (resolveStop) { resolveStop(); resolveStop = null }
|
|
1191
1178
|
options.onError(code || t('recognitionError'))
|
|
1192
1179
|
}
|
|
1193
|
-
//
|
|
1194
|
-
// until we stop it, otherwise dictation dies silently on the first pause.
|
|
1180
|
+
// Restart browser recognition until explicitly stopped
|
|
1195
1181
|
recognition.onend = () => {
|
|
1196
1182
|
if (stopped) {
|
|
1197
1183
|
if (resolveStop) { resolveStop(); resolveStop = null }
|
|
@@ -1225,8 +1211,7 @@ window.__ModuleLoader__.load({
|
|
|
1225
1211
|
// Fetch the mode chain from the host once. Only needed to decide whether
|
|
1226
1212
|
|
|
1227
1213
|
voice._core = { tidyPhrase, applyVoiceCommands, VOICE_COMMANDS, undoLastInsert, undoLastChunk: undoLastInsert, insertHistory, insertDraftText, appendDraft, resolveBrowserRecognitionLang }
|
|
1228
|
-
//
|
|
1229
|
-
// Detects WebGPU and WebAssembly availability in browser and provides offline transcription with graceful WASM fallback.
|
|
1214
|
+
// Client WebGPU/WASM Whisper offline recognition (#168)
|
|
1230
1215
|
|
|
1231
1216
|
function isWebGpuSupported() {
|
|
1232
1217
|
if (typeof navigator === 'undefined' || !navigator.gpu) return false
|
|
@@ -1332,8 +1317,7 @@ voice.webGpu = {
|
|
|
1332
1317
|
load: loadWebGpuWhisper,
|
|
1333
1318
|
clearCache: () => { _webGpuPipelines.clear() },
|
|
1334
1319
|
}
|
|
1335
|
-
//
|
|
1336
|
-
// Provides neural voice activity detection to filter out mechanical keyboard clicks, sighs and fan noise.
|
|
1320
|
+
// Neural Silero VAD client module (#279)
|
|
1337
1321
|
|
|
1338
1322
|
function isSileroVadSupported() {
|
|
1339
1323
|
return typeof Float32Array !== 'undefined'
|
|
@@ -1379,8 +1363,7 @@ voice.sileroVad = {
|
|
|
1379
1363
|
evaluate: evaluateSileroFrame,
|
|
1380
1364
|
reset: () => { _sileroState = { h: null, c: null } },
|
|
1381
1365
|
}
|
|
1382
|
-
//
|
|
1383
|
-
// Streams audio frames to local SenseVoice/Whisper servers with seamless HTTP fallback.
|
|
1366
|
+
// Streaming WebSocket client (#282)
|
|
1384
1367
|
|
|
1385
1368
|
function isWsStreamingSupported() {
|
|
1386
1369
|
return typeof WebSocket !== 'undefined'
|
|
@@ -1658,7 +1641,8 @@ voice._chainsPromise = null
|
|
|
1658
1641
|
} else {
|
|
1659
1642
|
rec.sileroSpeech = null
|
|
1660
1643
|
}
|
|
1661
|
-
|
|
1644
|
+
const lowT = Number(voice.settings?.lowVolumeThreshold ?? 0.08)
|
|
1645
|
+
if (rec.hadSpeech && raw > 0 && raw < lowT) {
|
|
1662
1646
|
rec.lowVolumeUntil = now + 800
|
|
1663
1647
|
rec.hasLowVolume = true
|
|
1664
1648
|
}
|
|
@@ -2151,9 +2135,7 @@ voice._chainsPromise = null
|
|
|
2151
2135
|
return String(name).replace(/^Key/, '').replace(/^Digit/, '')
|
|
2152
2136
|
}
|
|
2153
2137
|
|
|
2154
|
-
//
|
|
2155
|
-
// left and right have different codes but the user means "either".
|
|
2156
|
-
// Supports combos like Control+Space or Alt+KeyV.
|
|
2138
|
+
// Hotkey combo normalization
|
|
2157
2139
|
function keyFromEvent(event) {
|
|
2158
2140
|
if (!event) return ''
|
|
2159
2141
|
const modifiers = []
|
|
@@ -2216,11 +2198,7 @@ voice._chainsPromise = null
|
|
|
2216
2198
|
return event.code === name || event.key === name
|
|
2217
2199
|
}
|
|
2218
2200
|
|
|
2219
|
-
//
|
|
2220
|
-
//
|
|
2221
|
-
// Playback should mute immediately: listening and talking at once is
|
|
2222
|
-
// impossible. There is no plugin-to-plugin API — this broadcasts a window
|
|
2223
|
-
// event that anyone may hear, so either side works alone.
|
|
2201
|
+
// Broadcast window event so active playback mutes when speaking.
|
|
2224
2202
|
|
|
2225
2203
|
// --------------------------------------------------------- hold gesture
|
|
2226
2204
|
const HOLD_THRESHOLD_MS = 350
|
|
@@ -2337,8 +2315,7 @@ voice._chainsPromise = null
|
|
|
2337
2315
|
}
|
|
2338
2316
|
return cleanup
|
|
2339
2317
|
}
|
|
2340
|
-
//
|
|
2341
|
-
// Editable preview state machine, session pinning & UI component (#170).
|
|
2318
|
+
// Editable preview state machine (#170)
|
|
2342
2319
|
|
|
2343
2320
|
function initPendingPreview(text, target, options = {}) {
|
|
2344
2321
|
const autoMs = Number(options.autoSendMs) || 4000
|
|
@@ -2792,16 +2769,13 @@ function cycleDictationMode() {
|
|
|
2792
2769
|
const adapt = Number(voice.settings.vadAdapt) || 0
|
|
2793
2770
|
let effectiveVad = Number(voice.settings.vadSilenceMs) || 700
|
|
2794
2771
|
if (adapt > 0 && rec.hadSpeech) {
|
|
2795
|
-
// Adaptive threshold (#41)
|
|
2796
|
-
// Dense speech -> lower threshold (cut more precisely);
|
|
2797
|
-
// pause-heavy -> threshold rises toward base (do not cut on breaths).
|
|
2772
|
+
// Adaptive threshold (#41)
|
|
2798
2773
|
const win = voice.levels.slice(-20)
|
|
2799
2774
|
const density = win.length ? win.filter((v) => v > 0.06).length / win.length : 0
|
|
2800
2775
|
const k = adapt * (density - 0.5) * 2
|
|
2801
2776
|
effectiveVad = Math.max(150, Math.round(Number(voice.settings.vadSilenceMs) * (1 - k)))
|
|
2802
2777
|
}
|
|
2803
|
-
// Continuous mode (#40)
|
|
2804
|
-
// without waiting for a long pause.
|
|
2778
|
+
// Continuous mode (#40)
|
|
2805
2779
|
const stream = !!voice.settings.stream && rec.mode === 'dictation'
|
|
2806
2780
|
if (stream && rec.hadSpeech && !rec.cutting) {
|
|
2807
2781
|
rec.streamMs += tick
|
|
@@ -2893,8 +2867,7 @@ function cycleDictationMode() {
|
|
|
2893
2867
|
const p = voice.pending
|
|
2894
2868
|
if (!p) return
|
|
2895
2869
|
if (p.undoOnly) {
|
|
2896
|
-
// Undo-only mode (#29-5)
|
|
2897
|
-
// panel; the text either stayed or was already undone.
|
|
2870
|
+
// Undo-only mode (#29-5)
|
|
2898
2871
|
p.leftMs -= tick
|
|
2899
2872
|
if (p.leftMs <= 0) voice.set({ phase: 'idle' })
|
|
2900
2873
|
else voice.notify()
|
|
@@ -2952,8 +2925,7 @@ function cycleDictationMode() {
|
|
|
2952
2925
|
const inBrowser = !!voice.browser
|
|
2953
2926
|
const rec = voice.rec
|
|
2954
2927
|
const hint = v.mode === 'dictation' ? t('dictationPill') : t('messagePill')
|
|
2955
|
-
// Live caption
|
|
2956
|
-
// final chunk the text is interim and changes on screen.
|
|
2928
|
+
// Live caption: interim preview
|
|
2957
2929
|
const caption = voice.caption || (inBrowser ? '' : null)
|
|
2958
2930
|
const activeSubMode = (voice._activeTarget && voice._activeTarget.dictationMode)
|
|
2959
2931
|
|| voice.selectedDictationMode
|
|
@@ -3117,8 +3089,7 @@ function cycleDictationMode() {
|
|
|
3117
3089
|
|
|
3118
3090
|
// --------------------------------------------------------------- slots
|
|
3119
3091
|
function registerComposer(ctx) {
|
|
3120
|
-
//
|
|
3121
|
-
// without restart: the settings card broadcasts and the composer reloads.
|
|
3092
|
+
// Hotkey listener with dynamic reload
|
|
3122
3093
|
ctx.effect(() => {
|
|
3123
3094
|
let dispose = () => {}
|
|
3124
3095
|
let alive = true
|
|
@@ -3156,6 +3127,7 @@ function cycleDictationMode() {
|
|
|
3156
3127
|
sessionCommands: !!(data && data.modes && data.modes.message && data.modes.message.sessionCommands),
|
|
3157
3128
|
noiseSuppression: data && data.noiseSuppression !== false,
|
|
3158
3129
|
noiseGateDb: Number(data && data.noiseGateDb !== undefined ? data.noiseGateDb : -45),
|
|
3130
|
+
lowVolumeThreshold: Number(data && data.lowVolumeThreshold !== undefined ? data.lowVolumeThreshold : 0.08),
|
|
3159
3131
|
contextGlossary: data && data.contextGlossary !== false,
|
|
3160
3132
|
visualizerStyle: (data && data.visualizerStyle) || 'liquid-wave',
|
|
3161
3133
|
gatedTurnTaking: data && data.gatedTurnTaking !== false,
|
|
@@ -3180,9 +3152,7 @@ function cycleDictationMode() {
|
|
|
3180
3152
|
}
|
|
3181
3153
|
}, 'dsh-voice: hold hotkey')
|
|
3182
3154
|
|
|
3183
|
-
// locale: NS
|
|
3184
|
-
// and without it the label throws "cannot get property t without inject" while
|
|
3185
|
-
// the slot snapshot is built, breaking the whole client batch.
|
|
3155
|
+
// locale: NS required for core slot snapshot injection
|
|
3186
3156
|
ctx.effect(() => {
|
|
3187
3157
|
const offRight = ctx.slots.inject('conversation.input.right', () => ctx.slots.register(
|
|
3188
3158
|
{ name: 'conversation.input.right', id: '@goodandready/dsh-voice', order: 6, locale: NS, label: () => t('title') },
|
|
@@ -4462,11 +4432,7 @@ function MacrosSection(props) {
|
|
|
4462
4432
|
function VoiceSection(props) {
|
|
4463
4433
|
const t = (props && props.t) || moduleT
|
|
4464
4434
|
const ctx = (props && props.ctx) || {}
|
|
4465
|
-
//
|
|
4466
|
-
// shared document is not readable, every section gets "unavailable" and
|
|
4467
|
-
// writes are dropped. The server does not share that restriction, and
|
|
4468
|
-
// dsh-lanmode rebuilds the same mechanics on the same calls. Prefer its
|
|
4469
|
-
// service when installed, otherwise the kernel one (fine on loopback).
|
|
4435
|
+
// Settings adapter: prefer dsh-lanmode when remote, otherwise kernel.
|
|
4470
4436
|
const settingsService = (ctx.get && ctx.get('lanSettings')) || ctx.configForms || ctx.settingsScope
|
|
4471
4437
|
const scope = settingsService && typeof settingsService.get === 'function'
|
|
4472
4438
|
? settingsService.get(NS)
|
|
@@ -4782,10 +4748,7 @@ function MacrosSection(props) {
|
|
|
4782
4748
|
onOpenWizard: () => setShowWizard((v) => !v),
|
|
4783
4749
|
}),
|
|
4784
4750
|
showWizard ? h(FirstRunWizard, {
|
|
4785
|
-
t
|
|
4786
|
-
draft: draft,
|
|
4787
|
-
setTop: setTop,
|
|
4788
|
-
writable: writable,
|
|
4751
|
+
t, draft, setTop, writable,
|
|
4789
4752
|
onClose: () => setShowWizard(false),
|
|
4790
4753
|
}) : null,
|
|
4791
4754
|
h('div', { className: 'cb-section-card' },
|
|
@@ -4881,10 +4844,7 @@ function MacrosSection(props) {
|
|
|
4881
4844
|
}),
|
|
4882
4845
|
),
|
|
4883
4846
|
h(HardwareOptionsCard, {
|
|
4884
|
-
t
|
|
4885
|
-
draft: draft,
|
|
4886
|
-
setTop: setTop,
|
|
4887
|
-
writable: writable,
|
|
4847
|
+
t, draft, setTop, writable,
|
|
4888
4848
|
catching: catching,
|
|
4889
4849
|
setCatching: setCatching,
|
|
4890
4850
|
devices: devices,
|
|
@@ -4980,23 +4940,14 @@ function MacrosSection(props) {
|
|
|
4980
4940
|
),
|
|
4981
4941
|
// Dictation Modes & Prompts (#264)
|
|
4982
4942
|
h(DictationModesSection, {
|
|
4983
|
-
t
|
|
4984
|
-
draft: draft,
|
|
4985
|
-
setTop: setTop,
|
|
4986
|
-
writable: writable,
|
|
4943
|
+
t, draft, setTop, writable,
|
|
4987
4944
|
}),
|
|
4988
4945
|
// Voice Action Macros (#167)
|
|
4989
4946
|
h(MacrosSection, {
|
|
4990
|
-
t
|
|
4991
|
-
draft: draft,
|
|
4992
|
-
setTop: setTop,
|
|
4993
|
-
writable: writable,
|
|
4947
|
+
t, draft, setTop, writable,
|
|
4994
4948
|
}),
|
|
4995
4949
|
h(JargonEditor, {
|
|
4996
|
-
t
|
|
4997
|
-
draft: draft,
|
|
4998
|
-
setTop: setTop,
|
|
4999
|
-
writable: writable,
|
|
4950
|
+
t, draft, setTop, writable,
|
|
5000
4951
|
jargonInput: jargonInput,
|
|
5001
4952
|
setJargonInput: setJargonInput,
|
|
5002
4953
|
}),
|
|
@@ -5034,6 +4985,16 @@ function MacrosSection(props) {
|
|
|
5034
4985
|
const isSecure = typeof window !== 'undefined' ? (window.isSecureContext || window.location.protocol === 'https:' || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') : true
|
|
5035
4986
|
const hasMediaDevices = typeof navigator !== 'undefined' && !!navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function'
|
|
5036
4987
|
const hasMediaRecorder = typeof MediaRecorder !== 'undefined'
|
|
4988
|
+
const isDesktop = typeof navigator !== 'undefined' && (
|
|
4989
|
+
/dsh-desktop|electron/i.test(navigator.userAgent || '') ||
|
|
4990
|
+
(typeof window !== 'undefined' && window.location.protocol === 'dsh-app:')
|
|
4991
|
+
)
|
|
4992
|
+
const isWindows = typeof navigator !== 'undefined' && (
|
|
4993
|
+
/win/i.test(navigator.platform || '') || /windows/i.test(navigator.userAgent || '')
|
|
4994
|
+
)
|
|
4995
|
+
const isMac = typeof navigator !== 'undefined' && (
|
|
4996
|
+
/mac/i.test(navigator.platform || '') || /macintosh|mac os x/i.test(navigator.userAgent || '')
|
|
4997
|
+
)
|
|
5037
4998
|
|
|
5038
4999
|
const [micPermission, setMicPermission] = React.useState('prompt')
|
|
5039
5000
|
const [devices, setDevices] = React.useState([])
|
|
@@ -5108,7 +5069,16 @@ function MacrosSection(props) {
|
|
|
5108
5069
|
const constraints = {
|
|
5109
5070
|
audio: selectedDev ? { deviceId: { exact: selectedDev } } : true,
|
|
5110
5071
|
}
|
|
5111
|
-
|
|
5072
|
+
let stream
|
|
5073
|
+
try {
|
|
5074
|
+
stream = await navigator.mediaDevices.getUserMedia(constraints)
|
|
5075
|
+
} catch (firstErr) {
|
|
5076
|
+
if (selectedDev) {
|
|
5077
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
5078
|
+
} else {
|
|
5079
|
+
throw firstErr
|
|
5080
|
+
}
|
|
5081
|
+
}
|
|
5112
5082
|
signalStreamRef.current = stream
|
|
5113
5083
|
setMicPermission('granted')
|
|
5114
5084
|
refreshDevices()
|
|
@@ -5161,7 +5131,15 @@ function MacrosSection(props) {
|
|
|
5161
5131
|
const constraints = {
|
|
5162
5132
|
audio: selectedDev ? { deviceId: { exact: selectedDev } } : true,
|
|
5163
5133
|
}
|
|
5164
|
-
|
|
5134
|
+
try {
|
|
5135
|
+
testStream = await navigator.mediaDevices.getUserMedia(constraints)
|
|
5136
|
+
} catch (firstErr) {
|
|
5137
|
+
if (selectedDev) {
|
|
5138
|
+
testStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
5139
|
+
} else {
|
|
5140
|
+
throw firstErr
|
|
5141
|
+
}
|
|
5142
|
+
}
|
|
5165
5143
|
let mime = 'audio/webm;codecs=opus'
|
|
5166
5144
|
if (!MediaRecorder.isTypeSupported(mime)) mime = ''
|
|
5167
5145
|
recorder = mime ? new MediaRecorder(testStream, { mimeType: mime }) : new MediaRecorder(testStream)
|
|
@@ -5245,10 +5223,19 @@ function MacrosSection(props) {
|
|
|
5245
5223
|
}
|
|
5246
5224
|
|
|
5247
5225
|
let nextStepHint = ''
|
|
5226
|
+
const isPermDenied = micPermission === 'denied' || (testError && /permission|denied|notallowed/i.test(testError))
|
|
5248
5227
|
if (!isSecure) {
|
|
5249
5228
|
nextStepHint = t('wizardHintHttps')
|
|
5250
|
-
} else if (
|
|
5251
|
-
|
|
5229
|
+
} else if (isPermDenied) {
|
|
5230
|
+
if (isDesktop && isWindows) {
|
|
5231
|
+
nextStepHint = t('wizardHintWinDesktop')
|
|
5232
|
+
} else if (isDesktop && isMac) {
|
|
5233
|
+
nextStepHint = t('wizardHintMacDesktop')
|
|
5234
|
+
} else if (isWindows) {
|
|
5235
|
+
nextStepHint = t('wizardHintWinBrowser')
|
|
5236
|
+
} else {
|
|
5237
|
+
nextStepHint = t('wizardHintPermission')
|
|
5238
|
+
}
|
|
5252
5239
|
} else if (peakRms === 0 && signalTesting) {
|
|
5253
5240
|
nextStepHint = t('wizardHintDevice')
|
|
5254
5241
|
} else if (testError) {
|
|
@@ -5304,6 +5291,13 @@ function MacrosSection(props) {
|
|
|
5304
5291
|
className: 'cb-btn',
|
|
5305
5292
|
onClick: requestPermissionAndTestSignal,
|
|
5306
5293
|
}, t('wizardRequestMic')),
|
|
5294
|
+
(isWindows && isPermDenied) ? h('a', {
|
|
5295
|
+
href: 'ms-settings:privacy-microphone',
|
|
5296
|
+
target: '_blank',
|
|
5297
|
+
rel: 'noopener noreferrer',
|
|
5298
|
+
className: 'cb-btn cb-btn-mini',
|
|
5299
|
+
style: { marginLeft: '8px', textDecoration: 'none', display: 'inline-flex', alignItems: 'center' },
|
|
5300
|
+
}, '⚙ ' + t('wizardOpenWinMicSettings')) : null,
|
|
5307
5301
|
),
|
|
5308
5302
|
),
|
|
5309
5303
|
),
|
|
@@ -5387,8 +5381,15 @@ function MacrosSection(props) {
|
|
|
5387
5381
|
),
|
|
5388
5382
|
|
|
5389
5383
|
// Next Step Hint / Feedback
|
|
5390
|
-
nextStepHint ? h('div', { className: 'dvs-wait', style: { fontStyle: 'italic' } },
|
|
5391
|
-
t('wizardNextStepHint').replace('{hint}', nextStepHint)
|
|
5384
|
+
nextStepHint ? h('div', { className: 'dvs-wait', style: { fontStyle: 'italic', display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px' } },
|
|
5385
|
+
h('span', null, t('wizardNextStepHint').replace('{hint}', nextStepHint)),
|
|
5386
|
+
(isWindows && isPermDenied) ? h('a', {
|
|
5387
|
+
href: 'ms-settings:privacy-microphone',
|
|
5388
|
+
target: '_blank',
|
|
5389
|
+
rel: 'noopener noreferrer',
|
|
5390
|
+
className: 'cb-btn cb-btn-mini',
|
|
5391
|
+
style: { textDecoration: 'none', fontStyle: 'normal' },
|
|
5392
|
+
}, '⚙ ' + t('wizardOpenWinMicSettings')) : null,
|
|
5392
5393
|
) : null,
|
|
5393
5394
|
|
|
5394
5395
|
// Wizard Bottom Actions
|
|
@@ -5488,9 +5489,7 @@ function MacrosSection(props) {
|
|
|
5488
5489
|
|
|
5489
5490
|
exports.inject = ['timer', 'slots', 'configForms', 'locale']
|
|
5490
5491
|
exports.apply = function apply(ctx) {
|
|
5491
|
-
// English
|
|
5492
|
-
// localized user dictionary. Other languages (like Russian) come from
|
|
5493
|
-
// the separate translation plugin at runtime.
|
|
5492
|
+
// English source/fallback, Chinese built-in dictionary.
|
|
5494
5493
|
const addLocale = (locale, dictionary) => {
|
|
5495
5494
|
try {
|
|
5496
5495
|
return ctx.locale.register(NS, locale, dictionary)
|
|
@@ -5522,6 +5521,7 @@ function MacrosSection(props) {
|
|
|
5522
5521
|
cycleDictationMode,
|
|
5523
5522
|
resolveBrowserRecognitionLang,
|
|
5524
5523
|
VoiceSection,
|
|
5524
|
+
FirstRunWizard,
|
|
5525
5525
|
cutPhrase,
|
|
5526
5526
|
sendAudio,
|
|
5527
5527
|
modeChain,
|
package/lib/host-routes.js
CHANGED
|
@@ -74,6 +74,7 @@ export function registerHostRoutes(ctx, deps) {
|
|
|
74
74
|
localOnly: cfg.localOnly,
|
|
75
75
|
micDeviceId: cfg.micDeviceId,
|
|
76
76
|
noiseGateDb: cfg.noiseGateDb !== undefined ? cfg.noiseGateDb : -45,
|
|
77
|
+
lowVolumeThreshold: typeof cfg.lowVolumeThreshold === 'number' ? cfg.lowVolumeThreshold : 0.08,
|
|
77
78
|
historyLimit: cfg.historyLimit,
|
|
78
79
|
voiceCommands: cfg.voiceCommands,
|
|
79
80
|
wakeWord: String(cfg.wakeWord || ''),
|
package/lib/schema.js
CHANGED
|
@@ -106,6 +106,8 @@ export const BaseConfig = z.object({
|
|
|
106
106
|
.description('Microphone device id for recording. Empty means the system default.'),
|
|
107
107
|
noiseGateDb: z.number().default(-45)
|
|
108
108
|
.description('Audio noise gate threshold in dB for client-side recording (-50 to -25 dB, or <= -90 to disable). Silence below this threshold cuts ambient background noise and prevents false VAD triggers.'),
|
|
109
|
+
lowVolumeThreshold: z.number().min(0).max(1).default(0.08)
|
|
110
|
+
.description('Recording level (0..1) below which the "Low signal / speak louder" hint appears while dictating. Lower it for quiet microphones; 0 disables the hint.'),
|
|
109
111
|
vadEngine: z.union(['silero', 'amplitude']).default('silero')
|
|
110
112
|
.description('Voice activity detection engine: neural Silero VAD (WASM/ONNX) or classic amplitude threshold.'),
|
|
111
113
|
streamingWs: z.boolean().default(true)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.45",
|
|
4
4
|
"description": "Voice input for DeepSeek Harness: dictation chunked by pauses and voice messages, each with its own provider fallback chain (Deepgram, Groq, HuggingFace, local whisper.cpp, plus any OpenAI-compatible API of your own).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|