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