@goodandready/dsh-voice 0.8.25 → 0.8.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.8.25",
3
+ "version": "0.8.27",
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,165 +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
- 'testMic': 'Test Mic',
157
- 'stopTest': 'Stop Test',
158
- 'micLevel': 'Mic level',
159
- }
160
-
161
- // Strings are also needed outside components — in recording handlers and
162
- // slot labels — so the translator is module-level, not only via props.
163
- let moduleT = (key) => key
164
- const t = (key) => moduleT(key)
165
-
@@ -1,42 +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;user-select:none;-webkit-user-select:none;touch-action:manipulation}' +
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;user-select:none;-webkit-user-select:none}' +
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);user-select:none;-webkit-user-select:none;touch-action:manipulation}' +
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
- '.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)}' +
32
- '.dvo-meter-bar{flex:1;height:8px;background:var(--dsw-alias-bg-layer-3);border-radius:4px;overflow:hidden;position:relative}' +
33
- '.dvo-meter-fill{height:100%;background:var(--dsw-alias-state-success-primary);border-radius:4px;transition:width 60ms ease-out}'
34
- const cssId = 'dsh-voice/client.module.css'
35
- if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + cssId + '"]')) {
36
- const tag = document.createElement('style')
37
- tag.textContent = CSS
38
- tag.setAttribute('data-dsh-plugin', 'dsh-voice')
39
- tag.dataset.pluginCss = cssId
40
- document.head.appendChild(tag)
41
- }
42
-
@@ -1,286 +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]*\n[ \t]*/g, '\n').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
285
-
286
- voice._core = { tidyPhrase, applyVoiceCommands, VOICE_COMMANDS, undoLastInsert, insertHistory }