@goodandready/dsh-voice 0.8.18 → 0.8.20

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