@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/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([])
|
|
@@ -205,7 +207,10 @@ export function apply(ctx, baseConfig) {
|
|
|
205
207
|
} catch { return false }
|
|
206
208
|
}
|
|
207
209
|
|
|
210
|
+
let startingWhisper = false
|
|
208
211
|
async function startWhisper() {
|
|
212
|
+
if (startingWhisper) return false
|
|
213
|
+
startingWhisper = true
|
|
209
214
|
const cfg = live()
|
|
210
215
|
if (!cfg.autoStart) return false
|
|
211
216
|
// Without a model path there is nothing to start: the package cannot know
|
|
@@ -226,6 +231,7 @@ export function apply(ctx, baseConfig) {
|
|
|
226
231
|
}
|
|
227
232
|
return false
|
|
228
233
|
} catch { return false }
|
|
234
|
+
finally { startingWhisper = false }
|
|
229
235
|
}
|
|
230
236
|
|
|
231
237
|
startWhisper().catch(() => {})
|
|
@@ -246,7 +252,10 @@ export function apply(ctx, baseConfig) {
|
|
|
246
252
|
} catch { return false }
|
|
247
253
|
}
|
|
248
254
|
|
|
255
|
+
let startingSensevoice = false
|
|
249
256
|
async function startSensevoice() {
|
|
257
|
+
if (startingSensevoice) return false
|
|
258
|
+
startingSensevoice = true
|
|
250
259
|
const cfg = live()
|
|
251
260
|
if (!cfg.sensevoiceAutostart) return false
|
|
252
261
|
if (!cfg.sensevoiceModel) return false
|
|
@@ -268,6 +277,7 @@ export function apply(ctx, baseConfig) {
|
|
|
268
277
|
}
|
|
269
278
|
return false
|
|
270
279
|
} catch { return false }
|
|
280
|
+
finally { startingSensevoice = false }
|
|
271
281
|
}
|
|
272
282
|
|
|
273
283
|
startSensevoice().catch(() => {})
|
package/lib/providers.js
CHANGED
|
@@ -140,6 +140,7 @@ export function makeProviders(deps, req) {
|
|
|
140
140
|
headers: { authorization: `Token ${key}`, 'content-type': mime },
|
|
141
141
|
body: bytes,
|
|
142
142
|
signal,
|
|
143
|
+
keepalive: true,
|
|
143
144
|
})
|
|
144
145
|
if (!res.ok) throw new Error(await readErrorDetail(res, 'Deepgram'))
|
|
145
146
|
let data
|
|
@@ -163,6 +164,7 @@ export function makeProviders(deps, req) {
|
|
|
163
164
|
headers: { authorization: `Bearer ${key}` },
|
|
164
165
|
body: form,
|
|
165
166
|
signal,
|
|
167
|
+
keepalive: true,
|
|
166
168
|
})
|
|
167
169
|
if (!res.ok) throw new Error(await readErrorDetail(res, 'Groq'))
|
|
168
170
|
let data
|
|
@@ -182,6 +184,7 @@ export function makeProviders(deps, req) {
|
|
|
182
184
|
headers: { authorization: `Bearer ${token}`, 'content-type': mime, 'x-wait-for-model': 'true' },
|
|
183
185
|
body: bytes,
|
|
184
186
|
signal,
|
|
187
|
+
keepalive: true,
|
|
185
188
|
})
|
|
186
189
|
if (!res.ok) throw new Error(await readErrorDetail(res, 'HF'))
|
|
187
190
|
let data
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.26",
|
|
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",
|
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
|
-
"lib
|
|
15
|
+
"lib/*.js",
|
|
16
16
|
"cordis.patch.yml",
|
|
17
17
|
"README.md",
|
|
18
|
+
"README.zh.md",
|
|
19
|
+
"README.ru.md",
|
|
18
20
|
"LICENSE"
|
|
19
21
|
],
|
|
20
22
|
"keywords": [
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
// dsh-voice — client half (browser).
|
|
2
|
-
//
|
|
3
|
-
// Two buttons in conversation.input.right:
|
|
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.
|
|
11
|
-
|
|
12
|
-
window.__ModuleLoader__.load({
|
|
13
|
-
id: '@goodandready/dsh-voice',
|
|
14
|
-
factory: (require) => {
|
|
15
|
-
var module = { exports: {} }
|
|
16
|
-
var exports = module.exports
|
|
17
|
-
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
18
|
-
let React = require('react')
|
|
19
|
-
|
|
20
|
-
const NS = 'dsh-voice'
|
|
21
|
-
|
|
22
|
-
// UI strings live in the locale registry so a separate package can
|
|
23
|
-
// translate them without touching this plugin. English is the source
|
|
24
|
-
// language, the default, and the fallback.
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
const en = {
|
|
2
|
-
'saveFailed': 'Some fields were not saved —',
|
|
3
|
-
'cardHint': 'Dictation and voice messages: providers, chains, local whisper',
|
|
4
|
-
'expand': 'Expand',
|
|
5
|
-
'collapse': 'Collapse',
|
|
6
|
-
'composerUnavailable': 'Composer unavailable',
|
|
7
|
-
'keySpace': 'Space',
|
|
8
|
-
'keyUnset': 'not set',
|
|
9
|
-
'recognitionError': 'recognition failed',
|
|
10
|
-
'micUnavailable': 'Microphone unavailable: needs HTTPS or localhost',
|
|
11
|
-
'noRecorder': 'MediaRecorder is not supported by this browser',
|
|
12
|
-
'browserFailed': 'Browser did not recognize: ',
|
|
13
|
-
'nothingHeard': 'Nothing was recognized',
|
|
14
|
-
'dictationBtn': 'Voice typing',
|
|
15
|
-
'messageBtn': 'Voice message — click or hold',
|
|
16
|
-
'dictationPill': 'Dictation — text is appended to the input',
|
|
17
|
-
'messagePill': 'Recording a voice message',
|
|
18
|
-
'cancel': 'Cancel',
|
|
19
|
-
'holdHint': 'Hold — release to send',
|
|
20
|
-
'listening': 'Listening in the browser…',
|
|
21
|
-
'stop': 'Stop',
|
|
22
|
-
'transcribing': 'Transcribing…',
|
|
23
|
-
'sendingIn': 'Sending to the agent in',
|
|
24
|
-
'secondsShort': ' s',
|
|
25
|
-
'keepPending': 'Do not send',
|
|
26
|
-
'hide': 'Hide',
|
|
27
|
-
'title': 'Voice',
|
|
28
|
-
'recordSlot': 'Voice recording',
|
|
29
|
-
'browserHint': 'the browser does the recognition, no key needed',
|
|
30
|
-
'openaiHint': 'whisper-1 · key OPENAI_API_KEY',
|
|
31
|
-
'siliconflowHint': 'FunAudioLLM/SenseVoiceSmall · key SILICONFLOW_API_KEY',
|
|
32
|
-
'deepinfraHint': 'openai/whisper-large-v3-turbo · key DEEPINFRA_API_KEY',
|
|
33
|
-
'fireworksHint': 'whisper-v3-turbo · key FIREWORKS_API_KEY',
|
|
34
|
-
'mistralHint': 'voxtral-mini-latest · key MISTRAL_API_KEY',
|
|
35
|
-
'openrouterHint': 'google/gemini-2.5-flash · key OPENROUTER_API_KEY',
|
|
36
|
-
'localHint': 'set when the server is started',
|
|
37
|
-
'up': 'Up',
|
|
38
|
-
'down': 'Down',
|
|
39
|
-
'remove': 'Remove',
|
|
40
|
-
'addProvider': 'Add provider',
|
|
41
|
-
'chainHint': 'Top to bottom is the order they are tried in',
|
|
42
|
-
'customName': 'name used in the chain',
|
|
43
|
-
'customModel': 'model',
|
|
44
|
-
'customKeyName': 'key name',
|
|
45
|
-
'customModelHint': 'model to request (empty means the built-in one)',
|
|
46
|
-
'addCustom': 'Add your own provider',
|
|
47
|
-
'loadingSettings': 'Loading settings…',
|
|
48
|
-
'notReady1': 'The harness has not announced this plugin’s settings yet. If it has just restarted, ',
|
|
49
|
-
'notReady2': 'the section will appear on its own in a few seconds.',
|
|
50
|
-
'hotkey': 'Voice message key',
|
|
51
|
-
'pressKey': 'Press a key…',
|
|
52
|
-
'clearKey': 'Clear the key',
|
|
53
|
-
'hotkeyHint1': 'Hold it to record, release to send to the agent, Esc cancels. ',
|
|
54
|
-
'hotkeyHint2': 'Any key will do: a letter, an F-key or a modifier.',
|
|
55
|
-
'language': 'Language',
|
|
56
|
-
'dictationHint': 'Speech is cut at pauses and the text is appended to the input.',
|
|
57
|
-
'pauseMs': 'Pause that ends a phrase, ms',
|
|
58
|
-
'pauseHint': 'Lower means more frequent chunks and faster text, but a higher risk of cutting a word',
|
|
59
|
-
'speaking': 'You are speaking…',
|
|
60
|
-
'silence': 'Pause…',
|
|
61
|
-
'normalizeTranscript': 'Normalize file transcripts',
|
|
62
|
-
'undo': 'Undo last insert',
|
|
63
|
-
'undone': 'Insert undone',
|
|
64
|
-
'nothingToUndo': 'Nothing to undo',
|
|
65
|
-
'beep': 'Beep on start/stop',
|
|
66
|
-
'localOnly': 'Local whisper only',
|
|
67
|
-
'localOnlyHint': 'Restrict both chains to the local whisper.cpp server: fully offline.',
|
|
68
|
-
'sendDelay': 'Dictation send delay (ms)',
|
|
69
|
-
'sendDelayHint': 'Wait before appending a dictated phrase, with a cancel window. 0 = off',
|
|
70
|
-
'mic': 'Microphone',
|
|
71
|
-
'micDefault': 'System default',
|
|
72
|
-
'vocabulary': 'Custom vocabulary (one word per line)',
|
|
73
|
-
'polish': 'Polish transcript with model',
|
|
74
|
-
'polishHint': 'Fix punctuation and fillers via the harness model before inserting',
|
|
75
|
-
'stream': 'Continuous dictation',
|
|
76
|
-
'streamHint': 'Cut phrases by a timer while you speak instead of waiting for a long pause',
|
|
77
|
-
'streamChunkMs': 'Stream chunk (ms)',
|
|
78
|
-
'vadAdapt': 'Adaptive silence',
|
|
79
|
-
'vadAdaptHint': 'Auto-tune the silence threshold to the pace of your speech. 0 = fixed',
|
|
80
|
-
'wakeWord': 'Wake word',
|
|
81
|
-
'wakeWordHint': 'Browser recognition starts recording when speech begins with this phrase. Empty = off',
|
|
82
|
-
'bargeIn': 'Barge-in',
|
|
83
|
-
'polishSend': 'Polish whole draft before sending',
|
|
84
|
-
'polishSendHint': 'Run the composed draft through the model right before sending',
|
|
85
|
-
'sessionCommands': 'Voice session commands',
|
|
86
|
-
'sessionCommandsHint': '"send", "cancel", "stop", "continue" act on the session instead of becoming text',
|
|
87
|
-
'polishBaseUrl': 'Offline polish endpoint',
|
|
88
|
-
'polishBaseUrlHint': 'OpenAI-compatible /chat/completions base URL, e.g. a local Ollama. Empty = harness model',
|
|
89
|
-
'polishModel': 'Offline polish model',
|
|
90
|
-
'polishKeyEnv': 'Offline polish key credential',
|
|
91
|
-
'voiceCommandsLabel': 'Voice edit commands ("new line", "paragraph")',
|
|
92
|
-
'normalizeTranscriptHint': 'transcribe_audio: spoken numbers to digits, tidy punctuation',
|
|
93
|
-
'messageTitle': 'Voice message',
|
|
94
|
-
'messageHint': 'One whole recording, sent to the agent once it is transcribed.',
|
|
95
|
-
'undoMs': 'Undo window, ms',
|
|
96
|
-
'undoHint': 'How long the automatic send can still be called off',
|
|
97
|
-
'customTitle': 'Your own providers',
|
|
98
|
-
'customHint': 'Any OpenAI-compatible API. The name becomes available in the chains above.',
|
|
99
|
-
'general': 'General',
|
|
100
|
-
'whisperEndpoint': 'Local whisper: endpoint',
|
|
101
|
-
'whisperEndpointHint': 'POST /inference of a whisper.cpp server',
|
|
102
|
-
'deepgramEndpoint': 'Deepgram: base URL',
|
|
103
|
-
'deepgramEndpointHint': 'Base URL for Deepgram or self-hosted deployment (default https://api.deepgram.com)',
|
|
104
|
-
'whisperBin': 'Local whisper: binary',
|
|
105
|
-
'whisperBinHint': 'used when autostart is on',
|
|
106
|
-
'whisperModel': 'Local whisper: model',
|
|
107
|
-
'whisperModelHint': 'Absolute path to the ggml model file',
|
|
108
|
-
'whisperAutostart': 'Autostart the local whisper',
|
|
109
|
-
'save': 'Save',
|
|
110
|
-
'saved': 'Saved ✓',
|
|
111
|
-
'openrouterWarning': 'OpenRouter has no /audio/transcriptions \u2014 use the openai-chat-audio template there',
|
|
112
|
-
'noiseSuppression': 'Hardware noise suppression',
|
|
113
|
-
'noiseSuppressionHint': 'Enable browser noise suppression, echo cancellation, and auto gain control',
|
|
114
|
-
'contextGlossary': 'Context glossary injection',
|
|
115
|
-
'contextGlossaryHint': 'Auto-extract code identifiers and terms from composer to improve STT accuracy',
|
|
116
|
-
'providerDashboard': 'Provider Latency & Health',
|
|
117
|
-
'avgLatency': 'Avg latency',
|
|
118
|
-
'successRate': 'Success',
|
|
119
|
-
'fast': 'Fast',
|
|
120
|
-
'normal': 'Normal',
|
|
121
|
-
'slow': 'Slow',
|
|
122
|
-
'error': 'Error',
|
|
123
|
-
'idle': 'No calls',
|
|
124
|
-
'play': 'Play',
|
|
125
|
-
'pause': 'Pause',
|
|
126
|
-
'listenBack': 'Listen back',
|
|
127
|
-
'lastRecording': 'Last voice note',
|
|
128
|
-
'sensevoiceHint': 'SenseVoice-ONNX / Sherpa-ONNX · ultra-fast local STT (~50ms)',
|
|
129
|
-
'sensevoiceEndpoint': 'SenseVoice: endpoint',
|
|
130
|
-
'sensevoiceEndpointHint': 'POST endpoint of sherpa-onnx or compatible server',
|
|
131
|
-
'sensevoiceBin': 'SenseVoice: binary',
|
|
132
|
-
'sensevoiceBinHint': 'used when autostart is on',
|
|
133
|
-
'sensevoiceModel': 'SenseVoice: model path',
|
|
134
|
-
'sensevoiceModelHint': 'Absolute path or identifier of the SenseVoice / sherpa-onnx model',
|
|
135
|
-
'sensevoiceAutostart': 'Autostart the SenseVoice server',
|
|
136
|
-
'visualizerStyle': 'Audio visualizer style',
|
|
137
|
-
'visualizerStyleHint': 'Waveform animation inside the recording pill',
|
|
138
|
-
'visLiquidWave': 'Liquid Wave',
|
|
139
|
-
'visDynamicOrb': 'Dynamic Orb',
|
|
140
|
-
'visBars': 'Classic Bars',
|
|
141
|
-
'visOff': 'Off',
|
|
142
|
-
'statusTitle': 'Connection & Engine Status',
|
|
143
|
-
'statusDesc': 'Current runtime connectivity to voice backends and recognition services.',
|
|
144
|
-
'badgeHostOnline': 'Host online ({ms} ms)',
|
|
145
|
-
'badgeHostOffline': 'Host unreachable',
|
|
146
|
-
'badgeWhisperActive': 'Whisper active',
|
|
147
|
-
'badgeWhisperInactive': 'Whisper offline',
|
|
148
|
-
'badgeSenseVoiceActive': 'SenseVoice active',
|
|
149
|
-
'badgeSenseVoiceInactive': 'SenseVoice offline',
|
|
150
|
-
'badgeProviders': '{count} providers ready',
|
|
151
|
-
'refreshStats': 'Refresh Stats',
|
|
152
|
-
'hardwareTitle': 'Hardware & Local Engines',
|
|
153
|
-
'hardwareDesc': 'Microphone device, local whisper.cpp and SenseVoice runtime settings.',
|
|
154
|
-
'uiError': 'Voice UI Error:',
|
|
155
|
-
'retry': 'Retry',
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Strings are also needed outside components — in recording handlers and
|
|
159
|
-
// slot labels — so the translator is module-level, not only via props.
|
|
160
|
-
let moduleT = (key) => key
|
|
161
|
-
const t = (key) => moduleT(key)
|
|
162
|
-
|
package/lib/client-src/20-css.js
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
// ------------------------------------------------------------------ css
|
|
2
|
-
const CSS =
|
|
3
|
-
'.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}' +
|
|
4
|
-
'.dvo-btn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}' +
|
|
5
|
-
'.dvo-btn[data-err="1"]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}' +
|
|
6
|
-
'.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}' +
|
|
7
|
-
'.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)}' +
|
|
8
|
-
'.dvo-pbtn:hover{background:var(--dsw-alias-bg-layer-2)}' +
|
|
9
|
-
'.dvo-wave{flex:1;min-width:0;max-width:100%;height:40px;width:100%;color:var(--dsw-alias-label-primary)}' +
|
|
10
|
-
'.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}' +
|
|
11
|
-
'.dvo-err{color:var(--dsw-alias-state-error-primary)}' +
|
|
12
|
-
'.dvo-count{font-variant-numeric:tabular-nums;font-size:13px;color:var(--dsw-alias-label-secondary)}' +
|
|
13
|
-
'.dvo-spin{animation:dvo-spin 1s linear infinite}' +
|
|
14
|
-
'@keyframes dvo-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
|
|
15
|
-
'.dvo-btn-active{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}' +
|
|
16
|
-
'.dvo-audio-wrap{display:flex;align-items:center;gap:8px;padding:2px 10px;background:var(--dsw-alias-bg-layer-2);border-radius:14px;border:1px solid var(--dsw-alias-border-l1)}' +
|
|
17
|
-
'.dvo-audio-play{display:flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);border:0;cursor:pointer;padding:0}' +
|
|
18
|
-
'.dvo-audio-play:hover{opacity:0.9}' +
|
|
19
|
-
'.dvo-audio-time{font-size:12px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}' +
|
|
20
|
-
'.dvo-dash{display:flex;flex-direction:column;gap:8px;margin-top:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:12px}' +
|
|
21
|
-
'.dvo-dash-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px;margin-top:4px}' +
|
|
22
|
-
'.dvo-dash-item{display:flex;flex-direction:column;gap:4px;padding:8px 10px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l1);border-radius:8px}' +
|
|
23
|
-
'.dvo-dash-name{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
|
|
24
|
-
'.dvo-dash-row{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--dsw-alias-label-secondary)}' +
|
|
25
|
-
'.dvo-badge{display:inline-flex;align-items:center;font-size:11px;padding:1px 6px;border-radius:6px;font-weight:600}' +
|
|
26
|
-
'.dvo-badge-fast{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-success-primary);border:1px solid var(--dsw-alias-state-success-primary)}' +
|
|
27
|
-
'.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)}' +
|
|
28
|
-
'.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)}' +
|
|
29
|
-
'.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)}' +
|
|
30
|
-
'.dvo-badge-idle{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary)}'
|
|
31
|
-
const cssId = 'dsh-voice/client.module.css'
|
|
32
|
-
if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + cssId + '"]')) {
|
|
33
|
-
const tag = document.createElement('style')
|
|
34
|
-
tag.textContent = CSS
|
|
35
|
-
tag.setAttribute('data-dsh-plugin', 'dsh-voice')
|
|
36
|
-
tag.dataset.pluginCss = cssId
|
|
37
|
-
document.head.appendChild(tag)
|
|
38
|
-
}
|
|
39
|
-
|
|
@@ -1,284 +0,0 @@
|
|
|
1
|
-
// ---------------------------------------------------------------- store
|
|
2
|
-
const voice = {
|
|
3
|
-
phase: 'idle', // idle | recording | processing | pending | error
|
|
4
|
-
mode: 'dictation', // dictation | message
|
|
5
|
-
error: '',
|
|
6
|
-
rec: null,
|
|
7
|
-
levels: [],
|
|
8
|
-
pending: null, // {text, leftMs} — message-mode cancel window
|
|
9
|
-
lastNote: null, // {blob, url, mime, text} — last recording
|
|
10
|
-
showPlayer: false,
|
|
11
|
-
inputActions: null,
|
|
12
|
-
input: null,
|
|
13
|
-
settings: { vadSilenceMs: 700, autoSendMs: 4000, stream: false, streamChunkMs: 1200, vadAdapt: 0, noiseSuppression: true, contextGlossary: true },
|
|
14
|
-
listeners: new Set(),
|
|
15
|
-
notify() { this.listeners.forEach((l) => l()) },
|
|
16
|
-
set(patch) { Object.assign(this, patch); this.notify() },
|
|
17
|
-
subscribe(l) { this.listeners.add(l); return () => this.listeners.delete(l) },
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function useVoice() {
|
|
21
|
-
const [, force] = React.useReducer((x) => x + 1, 0)
|
|
22
|
-
React.useEffect(() => voice.subscribe(force), [])
|
|
23
|
-
return voice
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// ---------------------------------------------------------------- icons
|
|
27
|
-
const ic = { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' }
|
|
28
|
-
const playIcon = () => React.createElement('svg', Object.assign({}, ic, { viewBox: '0 0 24 24' }),
|
|
29
|
-
React.createElement('polygon', { points: '6 4 20 12 6 20 6 4', fill: 'currentColor', stroke: 'none' }))
|
|
30
|
-
const pauseIcon = () => React.createElement('svg', Object.assign({}, ic, { viewBox: '0 0 24 24' }),
|
|
31
|
-
React.createElement('rect', { x: 6, y: 4, width: 4, height: 16, fill: 'currentColor', stroke: 'none' }),
|
|
32
|
-
React.createElement('rect', { x: 14, y: 4, width: 4, height: 16, fill: 'currentColor', stroke: 'none' }))
|
|
33
|
-
const micIcon = () => React.createElement('svg', ic,
|
|
34
|
-
React.createElement('path', { d: 'M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z' }),
|
|
35
|
-
React.createElement('path', { d: 'M19 10v2a7 7 0 0 1-14 0v-2' }),
|
|
36
|
-
React.createElement('line', { x1: 12, y1: 19, x2: 12, y2: 23 }))
|
|
37
|
-
const waveIcon = () => React.createElement('svg', ic,
|
|
38
|
-
React.createElement('line', { x1: 4, y1: 10, x2: 4, y2: 14 }),
|
|
39
|
-
React.createElement('line', { x1: 8, y1: 7, x2: 8, y2: 17 }),
|
|
40
|
-
React.createElement('line', { x1: 12, y1: 4, x2: 12, y2: 20 }),
|
|
41
|
-
React.createElement('line', { x1: 16, y1: 7, x2: 16, y2: 17 }),
|
|
42
|
-
React.createElement('line', { x1: 20, y1: 10, x2: 20, y2: 14 }))
|
|
43
|
-
const xIcon = () => React.createElement('svg', ic,
|
|
44
|
-
React.createElement('line', { x1: 18, y1: 6, x2: 6, y2: 18 }),
|
|
45
|
-
React.createElement('line', { x1: 6, y1: 6, x2: 18, y2: 18 }))
|
|
46
|
-
const stopIcon = () => React.createElement('svg', ic,
|
|
47
|
-
React.createElement('rect', { x: 7, y: 7, width: 10, height: 10, rx: 2.5, fill: 'currentColor', stroke: 'none' }))
|
|
48
|
-
const spinIcon = () => React.createElement('svg', Object.assign({}, ic, { className: 'dvo-spin' }),
|
|
49
|
-
React.createElement('path', { d: 'M21 12a9 9 0 1 1-6.219-8.56' }))
|
|
50
|
-
const warnIcon = () => React.createElement('svg', ic,
|
|
51
|
-
React.createElement('path', { d: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z' }),
|
|
52
|
-
React.createElement('line', { x1: 12, y1: 9, x2: 12, y2: 13 }),
|
|
53
|
-
React.createElement('line', { x1: 12, y1: 17, x2: 12.01, y2: 17 }))
|
|
54
|
-
const chevronIcon = () => React.createElement('svg', ic,
|
|
55
|
-
React.createElement('path', { d: 'M6 9l6 6 6-6' }))
|
|
56
|
-
|
|
57
|
-
// ------------------------------------------------------------ transport
|
|
58
|
-
function blobToBase64(blob) {
|
|
59
|
-
return new Promise((resolve, reject) => {
|
|
60
|
-
if (typeof FileReader === 'undefined') { reject(new Error('FileReader not supported')); return }
|
|
61
|
-
const fr = new FileReader()
|
|
62
|
-
fr.onload = () => { const s = String(fr.result || ''); resolve(s.indexOf(',') >= 0 ? s.slice(s.indexOf(',') + 1) : s) }
|
|
63
|
-
fr.onerror = () => reject(new Error('failed to read audio'))
|
|
64
|
-
fr.readAsDataURL(blob)
|
|
65
|
-
})
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function extractContextKeywords() {
|
|
69
|
-
if (!voice.settings || voice.settings.contextGlossary === false) return []
|
|
70
|
-
const text = (voice.input && typeof voice.input.draft === 'string') ? voice.input.draft : ''
|
|
71
|
-
if (!text || text.length < 3) return []
|
|
72
|
-
const matches = text.match(/\b[A-Za-z_][A-Za-z0-9_]{2,29}\b/g) || []
|
|
73
|
-
const stop = new Set(['the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'any', 'can', 'her', 'was', 'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how', 'man', 'new', 'now', 'old', 'see', 'two', 'way', 'who', 'boy', 'did', 'its', 'let', 'put', 'say', 'she', 'too', 'use'])
|
|
74
|
-
const words = []
|
|
75
|
-
const seen = new Set()
|
|
76
|
-
for (const m of matches) {
|
|
77
|
-
const lower = m.toLowerCase()
|
|
78
|
-
if (!stop.has(lower) && !seen.has(lower)) {
|
|
79
|
-
seen.add(lower)
|
|
80
|
-
words.push(m)
|
|
81
|
-
if (words.length >= 30) break
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return words
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async function sendAudio(blob, mime, mode) {
|
|
88
|
-
const dataBase64 = await blobToBase64(blob)
|
|
89
|
-
const payload = { dataBase64, mimeType: mime, mode }
|
|
90
|
-
const contextWords = extractContextKeywords()
|
|
91
|
-
if (contextWords && contextWords.length > 0) payload.contextWords = contextWords
|
|
92
|
-
const res = await fetch('/dsh-voice/transcribe', {
|
|
93
|
-
method: 'POST',
|
|
94
|
-
headers: { 'content-type': 'application/json' },
|
|
95
|
-
body: JSON.stringify(payload),
|
|
96
|
-
})
|
|
97
|
-
let parsed = null
|
|
98
|
-
try { parsed = await res.json() } catch (e) { /* not json */ }
|
|
99
|
-
if (!res.ok || !parsed || !parsed.ok) {
|
|
100
|
-
throw new Error((parsed && parsed.error && parsed.error.message) || `HTTP ${res.status}`)
|
|
101
|
-
}
|
|
102
|
-
if (parsed.command) return { command: parsed.command, text: '' }
|
|
103
|
-
return { text: String(parsed.text || '').trim(), command: null }
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function tidyPhrase(text) {
|
|
107
|
-
let s = String(text || '').trim()
|
|
108
|
-
if (!s) return s
|
|
109
|
-
s = s.replace(/\s*,\s*/g, ', ')
|
|
110
|
-
s = s.replace(/(^|[.!?\n]\s+)([a-zа-яё])/gi, (m, lead, ch) => lead + ch.toUpperCase())
|
|
111
|
-
return s
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Spoken edit commands (#37): "new line" / "с новой строки" -> \n and so on.
|
|
115
|
-
// Applied before normalization, only when enabled in settings.
|
|
116
|
-
// Russian phrases match RU STT output; English covers EN dictation.
|
|
117
|
-
const VOICE_COMMANDS = [
|
|
118
|
-
[/(^|[\s,.!?])с новой строки([\s,.!?]|$)/gi, '$1\n$2'],
|
|
119
|
-
[/(^|[\s,.!?])новая строка([\s,.!?]|$)/gi, '$1\n$2'],
|
|
120
|
-
[/(^|[\s,.!?])абзац([\s,.!?]|$)/gi, '$1\n\n$2'],
|
|
121
|
-
[/(^|\s)тире(\s|$)/gi, '$1—$2'],
|
|
122
|
-
[/(^|[\s,.!?])new line([\s,.!?]|$)/gi, '$1\n$2'],
|
|
123
|
-
[/(^|[\s,.!?])paragraph([\s,.!?]|$)/gi, '$1\n\n$2'],
|
|
124
|
-
]
|
|
125
|
-
|
|
126
|
-
function applyVoiceCommands(text) {
|
|
127
|
-
let s = text
|
|
128
|
-
for (const [re, to] of VOICE_COMMANDS) s = s.replace(re, to)
|
|
129
|
-
return s.replace(/[ \t]+/g, ' ').trim()
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Insert history for undo (#29-9). Browser-only storage.
|
|
133
|
-
const insertHistory = []
|
|
134
|
-
|
|
135
|
-
async function undoLastInsert() {
|
|
136
|
-
const last = insertHistory.pop()
|
|
137
|
-
if (!last) return t('nothingToUndo')
|
|
138
|
-
const actions = voice.inputActions
|
|
139
|
-
if (!actions || typeof actions.setDraft !== 'function') return t('composerUnavailable')
|
|
140
|
-
const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
|
|
141
|
-
if (draft === last.after) actions.setDraft(last.before)
|
|
142
|
-
else {
|
|
143
|
-
// Draft was edited manually — cut the last insert as a substring.
|
|
144
|
-
const i = draft.lastIndexOf(last.added)
|
|
145
|
-
if (i < 0) { insertHistory.push(last); return t('nothingToUndo') }
|
|
146
|
-
actions.setDraft((draft.slice(0, i) + draft.slice(i + last.added.length)).replace(/\s+$/, ''))
|
|
147
|
-
}
|
|
148
|
-
return t('undone')
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function appendDraft(text) {
|
|
152
|
-
const actions = voice.inputActions
|
|
153
|
-
if (!actions || typeof actions.setDraft !== 'function') {
|
|
154
|
-
voice.set({ phase: 'error', error: t('composerUnavailable') })
|
|
155
|
-
return
|
|
156
|
-
}
|
|
157
|
-
let clean = voice.settings.voiceCommands ? applyVoiceCommands(text) : tidyPhrase(text)
|
|
158
|
-
if (!clean) return
|
|
159
|
-
const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
|
|
160
|
-
const before = draft
|
|
161
|
-
actions.setDraft(draft ? draft + ' ' + clean : clean)
|
|
162
|
-
const limit = Number(voice.settings.historyLimit)
|
|
163
|
-
if (limit > 0) {
|
|
164
|
-
insertHistory.push({ before, added: draft ? ' ' + clean : clean, after: draft ? draft + ' ' + clean : clean })
|
|
165
|
-
while (insertHistory.length > limit) insertHistory.shift()
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Human-readable key name.
|
|
170
|
-
const KEY_LABELS = {
|
|
171
|
-
Control: 'Ctrl', Alt: 'Alt', Shift: 'Shift', Meta: 'Win',
|
|
172
|
-
Escape: 'Esc',
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function keyLabel(name) {
|
|
176
|
-
if (!name) return t('keyUnset')
|
|
177
|
-
if (name === 'Space') return t('keySpace')
|
|
178
|
-
if (KEY_LABELS[name]) return KEY_LABELS[name]
|
|
179
|
-
// Drop the Key/Digit prefix from codes like KeyR and Digit5.
|
|
180
|
-
return String(name).replace(/^Key/, '').replace(/^Digit/, '')
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// What to store on key press. Pure modifiers are remembered by name:
|
|
184
|
-
// left and right have different codes but the user means "either".
|
|
185
|
-
function keyFromEvent(event) {
|
|
186
|
-
if (['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)) return event.key
|
|
187
|
-
if (event.code) return event.code
|
|
188
|
-
return event.key || ''
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Announce that the user started speaking.
|
|
192
|
-
//
|
|
193
|
-
// Playback should mute immediately: listening and talking at once is
|
|
194
|
-
// impossible. There is no plugin-to-plugin API — this broadcasts a window
|
|
195
|
-
// event that anyone may hear, so either side works alone.
|
|
196
|
-
function announceVoice(phase) {
|
|
197
|
-
try {
|
|
198
|
-
window.dispatchEvent(new CustomEvent('dsh-voice:speaking', { detail: { phase } }))
|
|
199
|
-
} catch (noEvents) { /* no window — nobody to hear it */ }
|
|
200
|
-
if (voice.settings.beep) playBeep(phase === 'start' ? 880 : 660)
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Short WebAudio beep so start/stop is audible without looking (#29-6).
|
|
204
|
-
function playBeep(freq) {
|
|
205
|
-
try {
|
|
206
|
-
const AC = typeof AudioContext !== 'undefined' ? AudioContext
|
|
207
|
-
: (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
|
|
208
|
-
if (!AC) return
|
|
209
|
-
const ac = new AC()
|
|
210
|
-
const osc = ac.createOscillator()
|
|
211
|
-
const gain = ac.createGain()
|
|
212
|
-
osc.frequency.value = freq
|
|
213
|
-
osc.type = 'sine'
|
|
214
|
-
gain.gain.setValueAtTime(0.12, ac.currentTime)
|
|
215
|
-
gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.09)
|
|
216
|
-
osc.connect(gain); gain.connect(ac.destination)
|
|
217
|
-
osc.start(); osc.stop(ac.currentTime + 0.1)
|
|
218
|
-
osc.onended = () => { try { ac.close() } catch (e) { /* already closed */ } }
|
|
219
|
-
} catch (noAudio) { /* no audio — not critical */ }
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// ------------------------------------------------- browser recognition
|
|
223
|
-
//
|
|
224
|
-
// A separate leg unlike the others: the browser recognizes speech itself,
|
|
225
|
-
// nothing is uploaded to the host, no keys are needed, and text appears
|
|
226
|
-
// word by word while you speak.
|
|
227
|
-
//
|
|
228
|
-
// Cost: in Chrome audio goes to Google servers. This provider is never
|
|
229
|
-
// enabled automatically — only when placed explicitly in a chain.
|
|
230
|
-
function speechRecognitionCtor() {
|
|
231
|
-
if (typeof window === 'undefined') return null
|
|
232
|
-
return window.SpeechRecognition || window.webkitSpeechRecognition || null
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function browserRecognitionAvailable() {
|
|
236
|
-
return speechRecognitionCtor() !== null
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* @param options {{lang: string, continuous: boolean, onInterim, onFinal, onError}}
|
|
241
|
-
* @returns {{stop: Function, abort: Function}}
|
|
242
|
-
*/
|
|
243
|
-
function startBrowserRecognition(options) {
|
|
244
|
-
const Ctor = speechRecognitionCtor()
|
|
245
|
-
const recognition = new Ctor()
|
|
246
|
-
recognition.lang = options.lang && options.lang !== 'auto' ? options.lang : 'ru-RU'
|
|
247
|
-
recognition.continuous = options.continuous !== false
|
|
248
|
-
recognition.interimResults = true
|
|
249
|
-
let stopped = false
|
|
250
|
-
|
|
251
|
-
recognition.onresult = (event) => {
|
|
252
|
-
let interim = ''
|
|
253
|
-
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
254
|
-
const result = event.results[i]
|
|
255
|
-
const text = String(result[0] && result[0].transcript || '').trim()
|
|
256
|
-
if (!text) continue
|
|
257
|
-
if (result.isFinal) options.onFinal(text)
|
|
258
|
-
else interim += (interim ? ' ' : '') + text
|
|
259
|
-
}
|
|
260
|
-
options.onInterim(interim)
|
|
261
|
-
}
|
|
262
|
-
recognition.onerror = (event) => {
|
|
263
|
-
// no-speech and aborted are normal life, not failures.
|
|
264
|
-
const code = event && event.error
|
|
265
|
-
if (code === 'no-speech' || code === 'aborted') return
|
|
266
|
-
options.onError(code || t('recognitionError'))
|
|
267
|
-
}
|
|
268
|
-
// The browser ends recognition on its own (pauses, timeout). Restart
|
|
269
|
-
// until we stop it, otherwise dictation dies silently on the first pause.
|
|
270
|
-
recognition.onend = () => {
|
|
271
|
-
if (stopped) return
|
|
272
|
-
try { recognition.start() } catch (alreadyRunning) { /* already running */ }
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
try { recognition.start() } catch (cannotStart) {
|
|
276
|
-
options.onError(String(cannotStart && cannotStart.message || cannotStart))
|
|
277
|
-
}
|
|
278
|
-
return {
|
|
279
|
-
stop() { stopped = true; try { recognition.stop() } catch (already) { /* already stopped */ } },
|
|
280
|
-
abort() { stopped = true; try { recognition.abort() } catch (already) { /* already stopped */ } },
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Fetch the mode chain from the host once. Only needed to decide whether
|