@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.
- package/README.md +20 -0
- package/README.ru.md +20 -0
- package/README.zh.md +16 -0
- package/lib/chain.js +4 -4
- package/lib/client-src/00-open.js +24 -0
- package/lib/client-src/10-locale.js +162 -0
- package/lib/client-src/20-css.js +39 -0
- package/lib/client-src/30-core.js +284 -0
- package/lib/client-src/40-recording.js +384 -0
- package/lib/client-src/41-buttons.js +30 -0
- package/lib/client-src/50-visualizers.js +121 -0
- package/lib/client-src/60-composer.js +285 -0
- package/lib/client-src/70-settings.js +765 -0
- package/lib/client-src/90-close.js +23 -0
- package/lib/client.js +541 -518
- package/lib/index.js +42 -43
- package/lib/normalize.js +2 -2
- package/lib/providers.js +39 -43
- package/lib/stats.js +2 -2
- package/lib/wav.js +8 -8
- package/package.json +4 -2
|
@@ -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
|
+
|