@goodandready/dsh-voice 0.8.25 → 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.
@@ -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 }