@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,395 +0,0 @@
1
- // to use browser recognition or record a file.
2
- let chainsPromise = null
3
- if (typeof window !== 'undefined') {
4
- window.addEventListener('dsh-voice:settings-saved', () => { chainsPromise = null })
5
- }
6
- function modeChain(mode) {
7
- if (!chainsPromise) {
8
- chainsPromise = fetch('/dsh-voice/status', { cache: 'no-store' })
9
- .then((res) => res.json())
10
- .then((data) => (data && data.modes) || {})
11
- .catch(() => ({}))
12
- }
13
- return chainsPromise.then((modes) => {
14
- const row = modes[mode] || {}
15
- return {
16
- chain: Array.isArray(row.chain) ? row.chain.map((e) => e && e.provider) : [],
17
- language: row.language || 'ru',
18
- }
19
- })
20
- }
21
-
22
- // ------------------------------------------------------------ recording
23
- function teardown(rec) {
24
- if (!rec) return
25
- try { rec.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
26
- if (rec.srcNode) { try { rec.srcNode.disconnect() } catch (e) { /* already disconnected */ } }
27
- if (rec.audioCtx) { try { rec.audioCtx.close() } catch (e) { /* already closed */ } }
28
- }
29
-
30
- function waitStop(recorder) {
31
- return new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true }))
32
- }
33
-
34
- async function openMic(mode) {
35
- if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
36
- throw new Error(t('micUnavailable'))
37
- }
38
- const ns = voice.settings.noiseSuppression !== false
39
- const audio = {
40
- channelCount: 1,
41
- echoCancellation: ns,
42
- noiseSuppression: ns,
43
- autoGainControl: ns,
44
- }
45
- if (voice.settings.micDeviceId) audio.deviceId = { exact: voice.settings.micDeviceId }
46
- const stream = await navigator.mediaDevices.getUserMedia({ audio })
47
- let mimeType = 'audio/webm;codecs=opus'
48
- if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = ''
49
- const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream)
50
- const rec = {
51
- recorder, stream, mode,
52
- chunks: [],
53
- mime: mimeType || recorder.mimeType || 'audio/webm',
54
- audioCtx: null, analyser: null,
55
- cutting: false, closing: false,
56
- silenceMs: 0, hadSpeech: false,
57
- streamMs: 0,
58
- }
59
- recorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) rec.chunks.push(e.data) }
60
- const AC = typeof AudioContext !== 'undefined' ? AudioContext
61
- : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
62
- if (AC) {
63
- rec.audioCtx = new AC()
64
- if (rec.audioCtx.state === 'suspended') { rec.audioCtx.resume().catch(() => {}) }
65
- const src = rec.audioCtx.createMediaStreamSource(stream)
66
- rec.srcNode = src
67
- rec.analyser = rec.audioCtx.createAnalyser()
68
- rec.analyser.fftSize = 128
69
- src.connect(rec.analyser)
70
- }
71
- // No timeslice: only then each stop() yields a standalone webm file.
72
- recorder.start()
73
- return rec
74
- }
75
-
76
- function currentLevel(rec) {
77
- if (!rec || !rec.analyser) return 0
78
- const data = new Uint8Array(rec.analyser.frequencyBinCount)
79
- rec.analyser.getByteFrequencyData(data)
80
- let sum = 0
81
- for (let i = 0; i < data.length; i++) sum += data[i]
82
- return Math.min(1, (sum / data.length / 255) * 2.2)
83
- }
84
-
85
- // Cut the current phrase: stop the recorder, send the finished file and
86
- // immediately start a new recording with the same recorder.
87
- let dictationQueue = Promise.resolve()
88
-
89
- function cutPhrase() {
90
- const rec = voice.rec
91
- if (!rec || rec.cutting || rec.closing) return
92
- rec.cutting = true
93
- const stopped = waitStop(rec.recorder)
94
- try { rec.recorder.stop() } catch (e) { /* already stopped */ }
95
- stopped.then(async () => {
96
- const blob = new Blob(rec.chunks, { type: rec.mime })
97
- rec.chunks = []
98
- rec.silenceMs = 0
99
- rec.hadSpeech = false
100
- rec.streamMs = 0
101
- if (!rec.closing) {
102
- try { rec.recorder.start() } catch (e) { /* stream already closed */ }
103
- }
104
- rec.cutting = false
105
- if (blob.size < 600) return // too short — not speech
106
- dictationQueue = dictationQueue.then(async () => {
107
- try {
108
- const out = await sendAudio(blob, rec.mime, 'dictation')
109
- const text = out && out.text ? out.text : ''
110
- const delay = Number(voice.settings.sendDelayMs) || 0
111
- if (text && delay > 0 && !voice.holding) {
112
- // Delayed insert with a cancel window (#29-5): text is already
113
- // inserted; the window only allows undoing it.
114
- appendDraft(text)
115
- voice.set({ phase: 'pending', pending: { text, undoOnly: true, leftMs: delay } })
116
- return
117
- }
118
- if (text) appendDraft(text)
119
- } catch (e) {
120
- voice.set({ error: String(e && e.message ? e.message : e) })
121
- }
122
- })
123
- })
124
- }
125
-
126
- // Browser recognition instead of file recording. Returns false when the
127
- // browser cannot do it — then fall back to the normal path.
128
- function startBrowserLeg(mode, language) {
129
- if (!browserRecognitionAvailable()) return false
130
- const finals = []
131
- voice.caption = ''
132
- voice.browser = startBrowserRecognition({
133
- lang: language,
134
- continuous: true,
135
- onInterim: (text) => {
136
- voice.caption = text; voice.notify()
137
- // Wake-word (#45): if interim starts with the trigger phrase, stop
138
- // browser listening and switch to normal recording.
139
- const ww = String(voice.settings.wakeWord || '').trim().toLowerCase()
140
- if (ww && mode === 'dictation' && !voice.rec) {
141
- const t = String(text || '').trim().toLowerCase()
142
- if (t.startsWith(ww)) {
143
- voice.browser = null
144
- voice.caption = ''
145
- openMic(mode)
146
- .then((rec) => {
147
- if (voice.phase !== 'recording') {
148
- teardown(rec)
149
- return
150
- }
151
- voice.rec = rec
152
- voice.notify()
153
- })
154
- .catch((err) => voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null }))
155
- }
156
- }
157
- },
158
- onFinal: (text) => {
159
- finals.push(text)
160
- voice.caption = ''
161
- // Dictation appends immediately; messages accumulate until release.
162
- if (mode === 'dictation') appendDraft(text)
163
- else voice.notify()
164
- },
165
- onError: (reason) => {
166
- // Browser failed after start — say so instead of pretending to listen.
167
- voice.browser = null
168
- voice.set({ phase: 'error', error: t('browserFailed') + reason })
169
- },
170
- })
171
- voice.browserFinals = finals
172
- voice.notify()
173
- return true
174
- }
175
-
176
- function startRecording(mode) {
177
- if (voice.phase !== 'idle' && voice.phase !== 'error') return
178
- // Announce before opening the mic: the sooner playback mutes, the less
179
- // of it ends up in the recording.
180
- announceVoice('start')
181
- dictationQueue = Promise.resolve()
182
- voice.set({ phase: 'recording', mode, error: '', levels: [], caption: '' })
183
- modeChain(mode).then((info) => {
184
- // Browser leg only when it is explicitly first in the chain.
185
- if (info.chain[0] === 'browser' && startBrowserLeg(mode, info.language)) return
186
- openMic(mode)
187
- .then((rec) => {
188
- if (voice.phase !== 'recording') {
189
- teardown(rec)
190
- return
191
- }
192
- voice.rec = rec
193
- voice.notify()
194
- })
195
- .catch((err) => voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null }))
196
- })
197
- }
198
-
199
- function startDictation() { startRecording('dictation') }
200
- function startMessage() { startRecording('message') }
201
-
202
- // --------------------------------------------------------- hold gesture
203
- //
204
- // Two gestures on one button: a short click toggles recording until the
205
- // next click; hold records only while pressed. Release sends.
206
- //
207
- // Distinguished by time: release before the threshold is a click.
208
- const HOLD_THRESHOLD_MS = 350
209
-
210
- const hold = { active: false, mode: null, startedAt: 0, armed: false }
211
-
212
- function beginHold(mode) {
213
- if (hold.armed || (voice.phase !== 'idle' && voice.phase !== 'error')) return
214
- hold.armed = true
215
- hold.mode = mode
216
- hold.startedAt = Date.now()
217
- hold.active = false
218
- // Start recording immediately: waiting for the threshold loses the first word.
219
- startRecording(mode)
220
- voice.holding = true
221
- voice.notify()
222
- }
223
-
224
- function endHold(cancelled) {
225
- if (!hold.armed) return
226
- const heldMs = Date.now() - hold.startedAt
227
- hold.armed = false
228
- hold.active = false
229
- voice.holding = false
230
- // Short press is a click: recording is already on, leave it running;
231
- // the second click will stop it.
232
- if (!cancelled && heldMs < HOLD_THRESHOLD_MS) { voice.notify(); return }
233
- if (cancelled) cancelCurrent()
234
- else stopCurrent()
235
- }
236
-
237
- // Hotkey: holding a key is easier than aiming the mouse. While the key is
238
- // down we record; Escape cancels.
239
- function hotkeyMatches(event, name) {
240
- if (name === 'Control') return event.key === 'Control'
241
- if (name === 'Alt') return event.key === 'Alt'
242
- if (name === 'Shift') return event.key === 'Shift'
243
- return event.code === name || event.key === name
244
- }
245
-
246
- function installHotkey(ctx, keyName, mode) {
247
- if (typeof document === 'undefined' || !keyName) return () => {}
248
- const down = (event) => {
249
- if (event.repeat) return
250
- // A modifier hotkey does not fight the text field: modifiers do not
251
- // type. A plain letter key must not be hijacked while typing.
252
- if (hotkeyMatches(event, keyName)) beginHold(mode)
253
- }
254
- const up = (event) => {
255
- if (hotkeyMatches(event, keyName)) endHold(false)
256
- else if (event.key === 'Escape' && hold.armed) endHold(true)
257
- }
258
- const blur = () => { if (hold.armed) endHold(true) }
259
- document.addEventListener('keydown', down, true)
260
- document.addEventListener('keyup', up, true)
261
- window.addEventListener('blur', blur)
262
- return () => {
263
- document.removeEventListener('keydown', down, true)
264
- document.removeEventListener('keyup', up, true)
265
- window.removeEventListener('blur', blur)
266
- }
267
- }
268
-
269
- function cancelCurrent() {
270
- announceVoice('end')
271
- if (voice.browser) {
272
- voice.browser.abort()
273
- voice.browser = null
274
- voice.browserFinals = null
275
- voice.set({ phase: 'idle', error: '', caption: '' })
276
- return
277
- }
278
- const rec = voice.rec
279
- voice.pending = null
280
- if (!rec) { voice.set({ phase: 'idle', error: '' }); return }
281
- rec.closing = true
282
- const stopped = waitStop(rec.recorder)
283
- try { rec.recorder.stop() } catch (e) { /* already stopped */ }
284
- stopped.then(() => { teardown(rec); voice.rec = null; voice.set({ phase: 'idle', error: '' }) })
285
- }
286
-
287
- // Stop on the second click: dictation flushes the tail; a voice message
288
- // sends the whole recording and opens the cancel window.
289
- function stopCurrent() {
290
- announceVoice('end')
291
- if (voice.browser) {
292
- const mode = voice.mode
293
- const said = (voice.browserFinals || []).join(' ').trim()
294
- voice.browser.stop()
295
- voice.browser = null
296
- voice.browserFinals = null
297
- voice.caption = ''
298
- if (!said) { voice.set({ phase: 'idle' }); return }
299
- if (mode === 'message') {
300
- appendDraft(said)
301
- voice.set({ phase: 'pending', pending: { text: said, leftMs: voice.settings.autoSendMs } })
302
- } else {
303
- // Dictation already appended while speaking — nothing extra to add.
304
- voice.set({ phase: 'idle' })
305
- }
306
- return
307
- }
308
- const rec = voice.rec
309
- if (!rec || rec.closing) return
310
- rec.closing = true
311
- const mode = rec.mode
312
- const stopped = waitStop(rec.recorder)
313
- try { rec.recorder.stop() } catch (e) { /* already stopped */ }
314
- stopped.then(async () => {
315
- const blob = new Blob(rec.chunks, { type: rec.mime })
316
- teardown(rec)
317
- voice.rec = null
318
- if (blob.size < 1200) { voice.set({ phase: 'idle' }); return }
319
- if (voice.lastNote && voice.lastNote.url) {
320
- try { URL.revokeObjectURL(voice.lastNote.url) } catch (e) { /* ignore */ }
321
- }
322
- let noteUrl = ''
323
- try { noteUrl = URL.createObjectURL(blob) } catch (e) { /* ignore */ }
324
- voice.lastNote = { blob, url: noteUrl, mime: rec.mime, text: '' }
325
- voice.set({ phase: 'processing' })
326
- try {
327
- const out = await sendAudio(blob, rec.mime, mode)
328
- if (out.command) { runSessionCommand(out.command); return }
329
- const text = out.text || ''
330
- if (!text) { voice.set({ phase: 'error', error: t('nothingHeard') }); return }
331
- appendDraft(text)
332
- if (voice.lastNote) voice.lastNote.text = text
333
- if (mode === 'message') {
334
- voice.set({ phase: 'pending', pending: { text: text, leftMs: voice.settings.autoSendMs, audioUrl: noteUrl } })
335
- } else {
336
- voice.set({ phase: 'idle' })
337
- }
338
- } catch (e) {
339
- voice.set({ phase: 'error', error: String(e && e.message ? e.message : e) })
340
- }
341
- })
342
- }
343
-
344
- function submitPending() {
345
- voice.pending = null
346
- voice.set({ phase: 'idle' })
347
- const actions = voice.inputActions
348
- if (!actions || typeof actions.submit !== 'function') return
349
- // Polish the whole draft before submit (#46). Errors do not block.
350
- if (voice.settings.polishSend) {
351
- const run = async () => {
352
- try {
353
- const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
354
- if (draft.trim()) {
355
- const res = await fetch('/dsh-voice/polish', {
356
- method: 'POST',
357
- headers: { 'content-type': 'application/json' },
358
- body: JSON.stringify({ text: draft }),
359
- })
360
- const parsed = await res.json().catch(() => null)
361
- if (parsed && parsed.ok && typeof parsed.text === 'string' && parsed.text.trim()) {
362
- actions.setDraft(parsed.text.trim())
363
- }
364
- }
365
- } catch (e) { /* polish is best-effort */ }
366
- }
367
- run().finally(() => setTimeout(() => { try { actions.submit() } catch (e) { /* busy */ } }, 0))
368
- return
369
- }
370
- setTimeout(() => { try { actions.submit() } catch (e) { /* composer busy */ } }, 0)
371
- }
372
-
373
- // Session voice commands (#48): clean "send/cancel/stop/continue".
374
- function runSessionCommand(cmd) {
375
- const actions = voice.inputActions
376
- voice.set({ phase: 'idle', pending: null })
377
- switch (cmd) {
378
- case 'send':
379
- case 'continue':
380
- if (actions && typeof actions.submit === 'function') actions.submit()
381
- break
382
- case 'cancel':
383
- case 'stop':
384
- // Clear wait/recording; leave the draft text intentionally untouched.
385
- break
386
- default:
387
- break
388
- }
389
- }
390
-
391
- function keepPending() {
392
- voice.pending = null
393
- voice.set({ phase: 'idle' })
394
- }
395
-
@@ -1,31 +0,0 @@
1
- // ----------------------------------------------------------- components
2
- function VoiceButtons(props) {
3
- const v = useVoice()
4
- voice.inputActions = props.inputActions
5
- voice.input = props.input
6
- if (v.phase !== 'idle' && v.phase !== 'error') return null
7
- const err = v.phase === 'error'
8
- const hk = voice.hotkey ? ` (${voice.hotkey})` : ''
9
- return React.createElement(React.Fragment, null,
10
- React.createElement('button', {
11
- type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
12
- title: err ? v.error : t('dictationBtn'), onClick: startDictation,
13
- }, micIcon()),
14
- React.createElement('button', {
15
- type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
16
- title: err ? v.error : (t('messageBtn') + hk),
17
- // Hold: record while pressed; leaving the control cancels.
18
- onPointerDown: (e) => { e.preventDefault(); beginHold('message') },
19
- onPointerUp: () => endHold(false),
20
- onPointerLeave: () => { if (hold.armed) endHold(true) },
21
- }, waveIcon()),
22
- voice.lastNote && voice.lastNote.url
23
- ? React.createElement('button', {
24
- type: 'button', className: 'dvo-btn' + (voice.showPlayer ? ' dvo-btn-active' : ''),
25
- title: t('listenBack'),
26
- onClick: () => voice.set({ showPlayer: !voice.showPlayer }),
27
- }, playIcon())
28
- : null,
29
- )
30
- }
31
-
@@ -1,121 +0,0 @@
1
- // ----------------------------------------------------------- visualizers
2
- function accentColor(fallback) {
3
- try {
4
- const root = document.documentElement
5
- const s = getComputedStyle(root)
6
- const pick = s.getPropertyValue('--dsw-alias-state-info-primary').trim()
7
- || s.getPropertyValue('--dsw-alias-label-primary').trim()
8
- if (pick) return pick
9
- } catch (noTheme) { /* canvas-only fallback */ }
10
- return fallback || voice.waveColor || 'currentColor'
11
- }
12
- function softColor(fallback) {
13
- try {
14
- const s = getComputedStyle(document.documentElement)
15
- const pick = s.getPropertyValue('--dsw-alias-bg-layer-3').trim()
16
- || s.getPropertyValue('--dsw-alias-label-primary').trim()
17
- if (pick) return pick
18
- } catch (noTheme) { /* fallback */ }
19
- return fallback || voice.waveColor || 'currentColor'
20
- }
21
- // 1. Liquid Wave: organic multi-layer wave
22
- function drawLiquidWave(g, w, h, levels, color, time) {
23
- const midY = h / 2
24
- const curLevel = levels.length ? levels[levels.length - 1] : 0
25
- const smoothLevel = Math.max(0.04, Math.min(1, curLevel * 1.6))
26
-
27
- const layers = [
28
- { amp: smoothLevel * (h * 0.42), freq: 0.024, speed: 0.08, alpha: 0.45 },
29
- { amp: smoothLevel * (h * 0.36), freq: 0.038, speed: -0.06, alpha: 0.75 },
30
- { amp: smoothLevel * (h * 0.28), freq: 0.052, speed: 0.11, alpha: 0.95 },
31
- ]
32
-
33
- for (let layerIdx = 0; layerIdx < layers.length; layerIdx++) {
34
- const lyr = layers[layerIdx]
35
- g.beginPath()
36
- g.globalAlpha = lyr.alpha
37
-
38
- const grad = g.createLinearGradient(0, 0, w, 0)
39
- grad.addColorStop(0, color)
40
- grad.addColorStop(0.5, accentColor(color))
41
- grad.addColorStop(1, color)
42
- g.strokeStyle = grad
43
- g.lineWidth = layerIdx === 2 ? 2.5 : 1.5
44
-
45
- g.moveTo(0, midY)
46
- const step = 6
47
- for (let x = 0; x <= w; x += step) {
48
- const edgeDist = Math.min(x, w - x) / (w * 0.25)
49
- const envelope = Math.min(1, Math.max(0, edgeDist))
50
- const phase = time * lyr.speed + x * lyr.freq
51
- const dy = Math.sin(phase) * lyr.amp * envelope + Math.cos(phase * 0.5) * (lyr.amp * 0.35) * envelope
52
- g.lineTo(x, midY + dy)
53
- }
54
- g.stroke()
55
- }
56
- g.globalAlpha = 1
57
- }
58
-
59
- // 2. Dynamic Orb: interactive pulsing sphere in the center
60
- function drawDynamicOrb(g, w, h, levels, color, time) {
61
- const cx = w / 2
62
- const cy = h / 2
63
- const curLevel = levels.length ? levels[levels.length - 1] : 0
64
- const smoothLevel = Math.max(0.05, Math.min(1, curLevel * 2.0))
65
-
66
- g.beginPath()
67
- g.globalAlpha = 0.25
68
- g.strokeStyle = color
69
- g.lineWidth = 1
70
- g.moveTo(10, cy)
71
- g.lineTo(cx - 35, cy)
72
- g.moveTo(cx + 35, cy)
73
- g.lineTo(w - 10, cy)
74
- g.stroke()
75
-
76
- const rRing = 14 + smoothLevel * 14 + Math.sin(time * 0.08) * 3
77
- g.beginPath()
78
- g.arc(cx, cy, rRing, 0, Math.PI * 2)
79
- g.strokeStyle = accentColor(color)
80
- g.globalAlpha = 0.35 + smoothLevel * 0.4
81
- g.lineWidth = 1.5
82
- g.stroke()
83
-
84
- if (smoothLevel > 0.25) {
85
- g.beginPath()
86
- g.arc(cx, cy, rRing + 7 + Math.cos(time * 0.06) * 4, 0, Math.PI * 2)
87
- g.strokeStyle = color
88
- g.globalAlpha = 0.2 + smoothLevel * 0.3
89
- g.lineWidth = 1
90
- g.stroke()
91
- }
92
-
93
- const rCore = 6 + smoothLevel * 8 + Math.sin(time * 0.12) * 1.5
94
- const radial = g.createRadialGradient(cx, cy, 1, cx, cy, rCore + 4)
95
- radial.addColorStop(0, softColor(color))
96
- radial.addColorStop(0.4, accentColor(color))
97
- radial.addColorStop(1, color)
98
- g.beginPath()
99
- g.arc(cx, cy, rCore, 0, Math.PI * 2)
100
- g.fillStyle = radial
101
- g.globalAlpha = 0.95
102
- g.fill()
103
-
104
- g.globalAlpha = 1
105
- }
106
-
107
- // 3. Classic Bars: classic vertical bars
108
- function drawClassicBars(g, w, h, levels, color) {
109
- const midY = h / 2
110
- for (let i = 0; i < levels.length && i * 7 < w; i++) {
111
- const level = levels[levels.length - 1 - i]
112
- const age = i / levels.length
113
- const x = w - 10 - i * 7
114
- const hh = Math.max(2.5, level * (h - 6) * 0.5 * (1 - age * 0.35))
115
- g.globalAlpha = 1 - age * 0.75
116
- g.fillStyle = color
117
- g.fillRect(x, midY - hh, 3.5, hh * 2)
118
- }
119
- g.globalAlpha = 1
120
- }
121
-