@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.
- package/README.md +10 -0
- package/README.ru.md +10 -0
- package/README.zh.md +10 -0
- package/lib/client.js +216 -12
- package/lib/index.js +2 -0
- package/package.json +4 -2
- package/lib/client-src/00-open.js +0 -24
- package/lib/client-src/10-locale.js +0 -165
- package/lib/client-src/20-css.js +0 -42
- package/lib/client-src/30-core.js +0 -286
- package/lib/client-src/40-recording.js +0 -395
- package/lib/client-src/41-buttons.js +0 -31
- package/lib/client-src/50-visualizers.js +0 -121
- package/lib/client-src/60-composer.js +0 -308
- package/lib/client-src/70-settings-base.js +0 -126
- package/lib/client-src/71-chains.js +0 -88
- package/lib/client-src/72-voice-section.js +0 -597
- package/lib/client-src/73-plugin-card.js +0 -38
- package/lib/client-src/90-close.js +0 -23
package/lib/client-src/20-css.js
DELETED
|
@@ -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 }
|