@goodandready/dsh-voice 0.9.10 → 0.9.11
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/lib/client.js +198 -160
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -702,11 +702,15 @@ window.__ModuleLoader__.load({
|
|
|
702
702
|
}
|
|
703
703
|
|
|
704
704
|
function tidyPhrase(text) {
|
|
705
|
-
|
|
706
|
-
if (
|
|
705
|
+
if (!text) return ''
|
|
706
|
+
if (/^\n+$/.test(text)) return text
|
|
707
|
+
const lead = (text.match(/^\n+/) || [''])[0]
|
|
708
|
+
const trail = (text.match(/\n+$/) || [''])[0]
|
|
709
|
+
let s = String(text).trim()
|
|
710
|
+
if (!s) return lead || trail || ''
|
|
707
711
|
s = s.replace(/\s*,\s*/g, ', ')
|
|
708
|
-
s = s.replace(/(^|[.!?\n]\s+)([a-zа-яё])/gi, (m,
|
|
709
|
-
return s
|
|
712
|
+
s = s.replace(/(^|[.!?\n]\s+)([a-zа-яё])/gi, (m, l, ch) => l + ch.toUpperCase())
|
|
713
|
+
return lead + s + trail
|
|
710
714
|
}
|
|
711
715
|
|
|
712
716
|
// Functional speech edit commands (#37): spoken line breaks and punctuation -> symbols.
|
|
@@ -724,7 +728,12 @@ window.__ModuleLoader__.load({
|
|
|
724
728
|
function applyVoiceCommands(text) {
|
|
725
729
|
let s = text
|
|
726
730
|
for (const [re, to] of VOICE_COMMANDS) s = s.replace(re, to)
|
|
727
|
-
|
|
731
|
+
if (/^\n+$/.test(s)) return s
|
|
732
|
+
const lead = (s.match(/^\n+/) || [''])[0]
|
|
733
|
+
const trail = (s.match(/\n+$/) || [''])[0]
|
|
734
|
+
const trimmed = s.replace(/[ \t]*\n[ \t]*/g, '\n').replace(/[ \t]+/g, ' ').trim()
|
|
735
|
+
if (!trimmed) return lead || trail || ''
|
|
736
|
+
return lead + trimmed + trail
|
|
728
737
|
}
|
|
729
738
|
|
|
730
739
|
// Insert history for undo (#29-9). Browser-only storage.
|
|
@@ -747,22 +756,33 @@ window.__ModuleLoader__.load({
|
|
|
747
756
|
return t('undone')
|
|
748
757
|
}
|
|
749
758
|
|
|
750
|
-
function
|
|
759
|
+
function insertDraftText(text) {
|
|
751
760
|
const actions = voice.inputActions
|
|
752
|
-
if (!actions || typeof actions.setDraft !== 'function') {
|
|
761
|
+
if (!actions || (typeof actions.setDraft !== 'function' && typeof actions.insertText !== 'function')) {
|
|
753
762
|
voice.set({ phase: 'error', error: t('composerUnavailable') })
|
|
754
|
-
return
|
|
763
|
+
return false
|
|
764
|
+
}
|
|
765
|
+
if (typeof actions.insertText === 'function') {
|
|
766
|
+
try { actions.insertText(text); return true } catch (_) {}
|
|
755
767
|
}
|
|
756
|
-
let clean = voice.settings.voiceCommands ? applyVoiceCommands(text) : tidyPhrase(text)
|
|
757
|
-
if (!clean) return
|
|
758
768
|
const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
|
|
759
769
|
const before = draft
|
|
760
|
-
|
|
770
|
+
const sep = (!draft || draft.endsWith('\n') || text.startsWith('\n')) ? '' : ' '
|
|
771
|
+
const after = draft + sep + text
|
|
772
|
+
actions.setDraft(after)
|
|
761
773
|
const limit = Number(voice.settings.historyLimit)
|
|
762
774
|
if (limit > 0) {
|
|
763
|
-
insertHistory.push({ before, added:
|
|
775
|
+
insertHistory.push({ before, added: sep + text, after })
|
|
764
776
|
while (insertHistory.length > limit) insertHistory.shift()
|
|
765
777
|
}
|
|
778
|
+
return true
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function appendDraft(text) {
|
|
782
|
+
if (!text) return
|
|
783
|
+
let clean = voice.settings.voiceCommands ? applyVoiceCommands(text) : tidyPhrase(text)
|
|
784
|
+
if (!clean) return
|
|
785
|
+
insertDraftText(clean)
|
|
766
786
|
}
|
|
767
787
|
|
|
768
788
|
// Human-readable key name.
|
|
@@ -862,6 +882,8 @@ window.__ModuleLoader__.load({
|
|
|
862
882
|
recognition.continuous = options.continuous !== false
|
|
863
883
|
recognition.interimResults = true
|
|
864
884
|
let stopped = false
|
|
885
|
+
let stopPromise = null
|
|
886
|
+
let resolveStop = null
|
|
865
887
|
|
|
866
888
|
recognition.onresult = (event) => {
|
|
867
889
|
let interim = ''
|
|
@@ -878,12 +900,18 @@ window.__ModuleLoader__.load({
|
|
|
878
900
|
// no-speech and aborted are normal life, not failures.
|
|
879
901
|
const code = event && event.error
|
|
880
902
|
if (code === 'no-speech' || code === 'aborted') return
|
|
903
|
+
stopped = true
|
|
904
|
+
try { recognition.abort() } catch (_) {}
|
|
905
|
+
if (resolveStop) { resolveStop(); resolveStop = null }
|
|
881
906
|
options.onError(code || t('recognitionError'))
|
|
882
907
|
}
|
|
883
908
|
// The browser ends recognition on its own (pauses, timeout). Restart
|
|
884
909
|
// until we stop it, otherwise dictation dies silently on the first pause.
|
|
885
910
|
recognition.onend = () => {
|
|
886
|
-
if (stopped)
|
|
911
|
+
if (stopped) {
|
|
912
|
+
if (resolveStop) { resolveStop(); resolveStop = null }
|
|
913
|
+
return
|
|
914
|
+
}
|
|
887
915
|
try { recognition.start() } catch (alreadyRunning) { /* already running */ }
|
|
888
916
|
}
|
|
889
917
|
|
|
@@ -891,15 +919,27 @@ window.__ModuleLoader__.load({
|
|
|
891
919
|
options.onError(String(cannotStart && cannotStart.message || cannotStart))
|
|
892
920
|
}
|
|
893
921
|
return {
|
|
894
|
-
stop() {
|
|
895
|
-
|
|
922
|
+
stop() {
|
|
923
|
+
if (stopPromise) return stopPromise
|
|
924
|
+
stopped = true
|
|
925
|
+
stopPromise = new Promise((resolve) => {
|
|
926
|
+
resolveStop = resolve
|
|
927
|
+
try { recognition.stop() } catch (already) { resolve() }
|
|
928
|
+
setTimeout(resolve, 800)
|
|
929
|
+
})
|
|
930
|
+
return stopPromise
|
|
931
|
+
},
|
|
932
|
+
abort() {
|
|
933
|
+
stopped = true
|
|
934
|
+
if (resolveStop) { resolveStop(); resolveStop = null }
|
|
935
|
+
try { recognition.abort() } catch (already) { /* already stopped */ }
|
|
936
|
+
},
|
|
896
937
|
}
|
|
897
938
|
}
|
|
898
939
|
|
|
899
940
|
// Fetch the mode chain from the host once. Only needed to decide whether
|
|
900
941
|
|
|
901
|
-
voice._core = { tidyPhrase, applyVoiceCommands, VOICE_COMMANDS, undoLastInsert, insertHistory }
|
|
902
|
-
// to use browser recognition or record a file.
|
|
942
|
+
voice._core = { tidyPhrase, applyVoiceCommands, VOICE_COMMANDS, undoLastInsert, insertHistory, insertDraftText, appendDraft }
|
|
903
943
|
let chainsPromise = null
|
|
904
944
|
if (typeof window !== 'undefined') {
|
|
905
945
|
window.addEventListener('dsh-voice:settings-saved', () => { chainsPromise = null })
|
|
@@ -992,7 +1032,6 @@ window.__ModuleLoader__.load({
|
|
|
992
1032
|
rec.analyser.fftSize = 128
|
|
993
1033
|
lastNode.connect(rec.analyser)
|
|
994
1034
|
}
|
|
995
|
-
// No timeslice: only then each stop() yields a standalone webm file.
|
|
996
1035
|
recorder.start()
|
|
997
1036
|
return rec
|
|
998
1037
|
}
|
|
@@ -1010,8 +1049,6 @@ window.__ModuleLoader__.load({
|
|
|
1010
1049
|
}
|
|
1011
1050
|
return raw
|
|
1012
1051
|
}
|
|
1013
|
-
// Cut the current phrase: stop the recorder, send the finished file and
|
|
1014
|
-
// immediately start a new recording with the same recorder.
|
|
1015
1052
|
let dictationQueue = Promise.resolve()
|
|
1016
1053
|
function cutPhrase() {
|
|
1017
1054
|
const rec = voice.rec
|
|
@@ -1047,40 +1084,22 @@ window.__ModuleLoader__.load({
|
|
|
1047
1084
|
if (rec.cancelled) return
|
|
1048
1085
|
const text = out && out.text ? out.text : ''
|
|
1049
1086
|
const action = out && out.action ? out.action : null
|
|
1050
|
-
|
|
1051
1087
|
if (voice.settings.structuredPromptVoice !== false && text) {
|
|
1052
1088
|
const promptInfo = findActiveStructuredPrompt()
|
|
1053
1089
|
if (promptInfo && submitToStructuredPrompt(text, promptInfo)) {
|
|
1054
1090
|
return
|
|
1055
1091
|
}
|
|
1056
1092
|
}
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
if (text) appendDraft(text)
|
|
1060
|
-
if (voice.inputActions && typeof voice.inputActions.send === 'function') {
|
|
1061
|
-
voice.inputActions.send()
|
|
1062
|
-
}
|
|
1093
|
+
if (action === 'send' || action === 'clear' || action === 'newline') {
|
|
1094
|
+
executeVoiceAction(action, text)
|
|
1063
1095
|
return
|
|
1064
1096
|
}
|
|
1065
1097
|
if (action === 'cancel') {
|
|
1066
1098
|
cancelCurrent()
|
|
1067
1099
|
return
|
|
1068
1100
|
}
|
|
1069
|
-
if (action === 'clear') {
|
|
1070
|
-
if (voice.inputActions && typeof voice.inputActions.clear === 'function') {
|
|
1071
|
-
voice.inputActions.clear()
|
|
1072
|
-
}
|
|
1073
|
-
return
|
|
1074
|
-
}
|
|
1075
|
-
if (action === 'newline') {
|
|
1076
|
-
appendDraft(text ? (text + '\n') : '\n')
|
|
1077
|
-
return
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
1101
|
const delay = Number(voice.settings.sendDelayMs) || 0
|
|
1081
1102
|
if (text && delay > 0 && !voice.holding) {
|
|
1082
|
-
// Delayed insert with a cancel window (#29-5): text is already
|
|
1083
|
-
// inserted; the window only allows undoing it.
|
|
1084
1103
|
appendDraft(text)
|
|
1085
1104
|
voice.set({ phase: 'pending', pending: { text, undoOnly: true, leftMs: delay } })
|
|
1086
1105
|
return
|
|
@@ -1094,9 +1113,6 @@ window.__ModuleLoader__.load({
|
|
|
1094
1113
|
})
|
|
1095
1114
|
})
|
|
1096
1115
|
}
|
|
1097
|
-
|
|
1098
|
-
// Browser recognition instead of file recording. Returns false when the
|
|
1099
|
-
// browser cannot do it — then fall back to the normal path.
|
|
1100
1116
|
function startBrowserLeg(mode, language) {
|
|
1101
1117
|
if (voice.settings && voice.settings.localOnly) return false
|
|
1102
1118
|
if (!browserRecognitionAvailable()) return false
|
|
@@ -1111,12 +1127,13 @@ window.__ModuleLoader__.load({
|
|
|
1111
1127
|
voice.interimTranscript = text
|
|
1112
1128
|
}
|
|
1113
1129
|
voice.notify()
|
|
1114
|
-
// Wake-word (#45): if interim starts with the trigger phrase, stop
|
|
1115
|
-
// browser listening and switch to normal recording.
|
|
1116
1130
|
const ww = String(voice.settings.wakeWord || '').trim().toLowerCase()
|
|
1117
1131
|
if (ww && mode === 'dictation' && !voice.rec) {
|
|
1118
1132
|
const t = String(text || '').trim().toLowerCase()
|
|
1119
1133
|
if (t.startsWith(ww)) {
|
|
1134
|
+
if (voice.browser && typeof voice.browser.abort === 'function') {
|
|
1135
|
+
try { voice.browser.abort() } catch (_) {}
|
|
1136
|
+
}
|
|
1120
1137
|
voice.browser = null
|
|
1121
1138
|
voice.caption = ''
|
|
1122
1139
|
voice.interimTranscript = ''
|
|
@@ -1137,12 +1154,10 @@ window.__ModuleLoader__.load({
|
|
|
1137
1154
|
finals.push(text)
|
|
1138
1155
|
voice.caption = ''
|
|
1139
1156
|
voice.interimTranscript = ''
|
|
1140
|
-
// Dictation appends immediately; messages accumulate until release.
|
|
1141
1157
|
if (mode === 'dictation') appendDraft(text)
|
|
1142
1158
|
else voice.notify()
|
|
1143
1159
|
},
|
|
1144
1160
|
onError: (reason) => {
|
|
1145
|
-
// Browser failed after start — say so instead of pretending to listen.
|
|
1146
1161
|
voice.browser = null
|
|
1147
1162
|
voice.interimTranscript = ''
|
|
1148
1163
|
voice.set({ phase: 'error', error: t('browserFailed') + reason })
|
|
@@ -1152,7 +1167,6 @@ window.__ModuleLoader__.load({
|
|
|
1152
1167
|
voice.notify()
|
|
1153
1168
|
return true
|
|
1154
1169
|
}
|
|
1155
|
-
|
|
1156
1170
|
function startRecording(mode) {
|
|
1157
1171
|
if (voice.phase !== 'idle' && voice.phase !== 'error') return
|
|
1158
1172
|
voice.activeOpId = (voice.activeOpId || 0) + 1
|
|
@@ -1168,7 +1182,6 @@ window.__ModuleLoader__.load({
|
|
|
1168
1182
|
dictationQueue = Promise.resolve()
|
|
1169
1183
|
voice.set({ phase: 'recording', mode, error: '', levels: [], caption: '', interimTranscript: '' })
|
|
1170
1184
|
modeChain(mode).then((info) => {
|
|
1171
|
-
// Browser leg only when it is explicitly first in the chain.
|
|
1172
1185
|
if (info.chain[0] === 'browser' && startBrowserLeg(mode, info.language)) return
|
|
1173
1186
|
openMic(mode)
|
|
1174
1187
|
.then((rec) => {
|
|
@@ -1182,47 +1195,50 @@ window.__ModuleLoader__.load({
|
|
|
1182
1195
|
.catch((err) => voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null }))
|
|
1183
1196
|
})
|
|
1184
1197
|
}
|
|
1185
|
-
|
|
1186
1198
|
function startDictation() { startRecording('dictation') }
|
|
1187
1199
|
function startMessage() { startRecording('message') }
|
|
1188
|
-
|
|
1189
1200
|
// --------------------------------------------------------- hold gesture
|
|
1190
|
-
//
|
|
1191
|
-
// Two gestures on one button: a short click toggles recording until the
|
|
1192
|
-
// next click; hold records only while pressed. Release sends.
|
|
1193
|
-
//
|
|
1194
|
-
// Distinguished by time: release before the threshold is a click.
|
|
1195
1201
|
const HOLD_THRESHOLD_MS = 350
|
|
1196
|
-
|
|
1197
1202
|
const hold = { active: false, mode: null, startedAt: 0, armed: false }
|
|
1198
|
-
|
|
1199
|
-
function beginHold(mode) {
|
|
1203
|
+
let activePointerCleanup = null
|
|
1204
|
+
function beginHold(mode, pointerEvent) {
|
|
1200
1205
|
if (hold.armed || (voice.phase !== 'idle' && voice.phase !== 'error')) return
|
|
1201
1206
|
hold.armed = true
|
|
1202
1207
|
hold.mode = mode
|
|
1203
1208
|
hold.startedAt = Date.now()
|
|
1204
1209
|
hold.active = false
|
|
1205
|
-
|
|
1210
|
+
if (pointerEvent?.target?.setPointerCapture && pointerEvent.pointerId !== undefined) {
|
|
1211
|
+
try { pointerEvent.target.setPointerCapture(pointerEvent.pointerId) } catch (e) {}
|
|
1212
|
+
}
|
|
1213
|
+
if (typeof window !== 'undefined') {
|
|
1214
|
+
if (activePointerCleanup) { try { activePointerCleanup() } catch (e) {} }
|
|
1215
|
+
const onUp = () => endHold(false)
|
|
1216
|
+
const onCancel = () => endHold(true)
|
|
1217
|
+
window.addEventListener('pointerup', onUp, true)
|
|
1218
|
+
window.addEventListener('pointercancel', onCancel, true)
|
|
1219
|
+
window.addEventListener('blur', onCancel, true)
|
|
1220
|
+
activePointerCleanup = () => {
|
|
1221
|
+
window.removeEventListener('pointerup', onUp, true)
|
|
1222
|
+
window.removeEventListener('pointercancel', onCancel, true)
|
|
1223
|
+
window.removeEventListener('blur', onCancel, true)
|
|
1224
|
+
activePointerCleanup = null
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1206
1227
|
startRecording(mode)
|
|
1207
1228
|
voice.holding = true
|
|
1208
1229
|
voice.notify()
|
|
1209
1230
|
}
|
|
1210
|
-
|
|
1211
1231
|
function endHold(cancelled) {
|
|
1212
1232
|
if (!hold.armed) return
|
|
1233
|
+
if (activePointerCleanup) { try { activePointerCleanup() } catch (e) {} }
|
|
1213
1234
|
const heldMs = Date.now() - hold.startedAt
|
|
1214
1235
|
hold.armed = false
|
|
1215
1236
|
hold.active = false
|
|
1216
1237
|
voice.holding = false
|
|
1217
|
-
// Short press is a click: recording is already on, leave it running;
|
|
1218
|
-
// the second click will stop it.
|
|
1219
1238
|
if (!cancelled && heldMs < HOLD_THRESHOLD_MS) { voice.notify(); return }
|
|
1220
1239
|
if (cancelled) cancelCurrent()
|
|
1221
1240
|
else stopCurrent()
|
|
1222
1241
|
}
|
|
1223
|
-
|
|
1224
|
-
// Hotkey: holding a key is easier than aiming the mouse. While the key is
|
|
1225
|
-
// down we record; Escape cancels.
|
|
1226
1242
|
function hotkeyMatches(event, name) {
|
|
1227
1243
|
if (!event || !name) return false
|
|
1228
1244
|
if (typeof name === 'string' && name.includes('+')) {
|
|
@@ -1244,7 +1260,6 @@ window.__ModuleLoader__.load({
|
|
|
1244
1260
|
if (name === 'Meta') return event.key === 'Meta'
|
|
1245
1261
|
return event.code === name || event.key === name
|
|
1246
1262
|
}
|
|
1247
|
-
|
|
1248
1263
|
function isAllowedInEditable(name) {
|
|
1249
1264
|
if (!name) return false
|
|
1250
1265
|
if (['Control', 'Alt', 'Shift', 'Meta'].includes(name)) return true
|
|
@@ -1253,9 +1268,7 @@ window.__ModuleLoader__.load({
|
|
|
1253
1268
|
if (['Tab', 'Pause', 'ScrollLock', 'Insert'].includes(name)) return true
|
|
1254
1269
|
return false
|
|
1255
1270
|
}
|
|
1256
|
-
|
|
1257
1271
|
let activeHotkeyCleanup = null
|
|
1258
|
-
|
|
1259
1272
|
function clearGlobalHotkey() {
|
|
1260
1273
|
if (typeof activeHotkeyCleanup === 'function') {
|
|
1261
1274
|
try { activeHotkeyCleanup() } catch (_) { /* ignore */ }
|
|
@@ -1266,31 +1279,34 @@ window.__ModuleLoader__.load({
|
|
|
1266
1279
|
window.__dsh_voice_hotkey_cleanup = null
|
|
1267
1280
|
}
|
|
1268
1281
|
}
|
|
1269
|
-
|
|
1270
1282
|
function installHotkey(ctx, keyName, mode) {
|
|
1271
1283
|
clearGlobalHotkey()
|
|
1272
1284
|
if (typeof document === 'undefined' || !keyName) return () => {}
|
|
1273
1285
|
const down = (event) => {
|
|
1274
1286
|
if (event.repeat) return
|
|
1275
|
-
// A modifier, function key or combo does not fight the text field:
|
|
1276
|
-
// modifiers/combos do not type plain text. A plain letter key must not be hijacked while typing.
|
|
1277
1287
|
const target = event.target
|
|
1278
|
-
const isEditable = target && (
|
|
1279
|
-
target.tagName === 'INPUT' ||
|
|
1280
|
-
target.tagName === 'TEXTAREA' ||
|
|
1281
|
-
target.isContentEditable ||
|
|
1282
|
-
(typeof target.getAttribute === 'function' && target.getAttribute('contenteditable') === 'true')
|
|
1283
|
-
)
|
|
1288
|
+
const isEditable = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || (typeof target.getAttribute === 'function' && target.getAttribute('contenteditable') === 'true'))
|
|
1284
1289
|
if (isEditable && !isAllowedInEditable(keyName)) return
|
|
1285
|
-
|
|
1286
1290
|
if (hotkeyMatches(event, keyName)) {
|
|
1287
1291
|
event.preventDefault()
|
|
1288
1292
|
event.stopPropagation()
|
|
1289
1293
|
beginHold(mode)
|
|
1290
1294
|
}
|
|
1291
1295
|
}
|
|
1296
|
+
function isHotkeyRelease(event, name) {
|
|
1297
|
+
if (!event || !name) return false
|
|
1298
|
+
if (typeof name === 'string' && name.includes('+')) {
|
|
1299
|
+
const parts = name.split('+')
|
|
1300
|
+
if (event.code === parts[parts.length - 1] || event.key === parts[parts.length - 1]) return true
|
|
1301
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
1302
|
+
if (event.key === parts[i] || event.code === parts[i]) return true
|
|
1303
|
+
}
|
|
1304
|
+
return false
|
|
1305
|
+
}
|
|
1306
|
+
return event.code === name || event.key === name
|
|
1307
|
+
}
|
|
1292
1308
|
const up = (event) => {
|
|
1293
|
-
if (
|
|
1309
|
+
if (hold.armed && isHotkeyRelease(event, keyName)) {
|
|
1294
1310
|
event.preventDefault()
|
|
1295
1311
|
endHold(false)
|
|
1296
1312
|
} else if (event.key === 'Escape' && hold.armed) {
|
|
@@ -1317,7 +1333,6 @@ window.__ModuleLoader__.load({
|
|
|
1317
1333
|
}
|
|
1318
1334
|
return cleanup
|
|
1319
1335
|
}
|
|
1320
|
-
|
|
1321
1336
|
function cancelCurrent() {
|
|
1322
1337
|
voice.activeOpId = (voice.activeOpId || 0) + 1
|
|
1323
1338
|
announceVoice('end')
|
|
@@ -1340,27 +1355,25 @@ window.__ModuleLoader__.load({
|
|
|
1340
1355
|
} catch (e) { /* already stopped */ }
|
|
1341
1356
|
stopped.then(() => { teardown(rec); voice.rec = null; voice.set({ phase: 'idle', error: '' }) })
|
|
1342
1357
|
}
|
|
1343
|
-
|
|
1344
|
-
// Stop on the second click: dictation flushes the tail; a voice message
|
|
1345
|
-
// sends the whole recording and opens the cancel window.
|
|
1346
1358
|
function stopCurrent() {
|
|
1347
1359
|
announceVoice('end')
|
|
1348
1360
|
focusComposer()
|
|
1349
1361
|
if (voice.browser) {
|
|
1350
1362
|
const mode = voice.mode
|
|
1351
|
-
const
|
|
1352
|
-
voice.browser.stop()
|
|
1363
|
+
const b = voice.browser
|
|
1353
1364
|
voice.browser = null
|
|
1354
|
-
voice.browserFinals = null
|
|
1355
1365
|
voice.caption = ''
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
voice.set({ phase: '
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1366
|
+
Promise.resolve(b.stop()).then(() => {
|
|
1367
|
+
const said = (voice.browserFinals || []).join(' ').trim()
|
|
1368
|
+
voice.browserFinals = null
|
|
1369
|
+
if (!said) { voice.set({ phase: 'idle' }); return }
|
|
1370
|
+
if (mode === 'message') {
|
|
1371
|
+
appendDraft(said)
|
|
1372
|
+
voice.set({ phase: 'pending', pending: { text: said, leftMs: voice.settings.autoSendMs } })
|
|
1373
|
+
} else {
|
|
1374
|
+
voice.set({ phase: 'idle' })
|
|
1375
|
+
}
|
|
1376
|
+
})
|
|
1364
1377
|
return
|
|
1365
1378
|
}
|
|
1366
1379
|
const rec = voice.rec
|
|
@@ -1377,67 +1390,101 @@ window.__ModuleLoader__.load({
|
|
|
1377
1390
|
const blob = new Blob(rec.chunks, { type: rec.mime })
|
|
1378
1391
|
teardown(rec)
|
|
1379
1392
|
voice.rec = null
|
|
1380
|
-
if (blob.size <
|
|
1393
|
+
if (blob.size < (mode === 'dictation' ? 600 : 1200)) {
|
|
1394
|
+
if (mode === 'dictation') { try { await dictationQueue } catch (_) {} }
|
|
1395
|
+
voice.set({ phase: 'idle' })
|
|
1396
|
+
return
|
|
1397
|
+
}
|
|
1381
1398
|
if (voice.lastNote && voice.lastNote.url) {
|
|
1382
|
-
try { URL.revokeObjectURL(voice.lastNote.url) } catch (e) {
|
|
1399
|
+
try { URL.revokeObjectURL(voice.lastNote.url) } catch (e) {}
|
|
1383
1400
|
}
|
|
1384
1401
|
let noteUrl = ''
|
|
1385
|
-
try { noteUrl = URL.createObjectURL(blob) } catch (e) {
|
|
1402
|
+
try { noteUrl = URL.createObjectURL(blob) } catch (e) {}
|
|
1386
1403
|
voice.lastNote = { blob, url: noteUrl, mime: rec.mime, text: '' }
|
|
1387
1404
|
voice.set({ phase: 'processing' })
|
|
1388
|
-
|
|
1389
|
-
const out = await sendAudio(blob, rec.mime, mode)
|
|
1405
|
+
const processTail = async () => {
|
|
1390
1406
|
if (rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
voice.set({ phase: 'idle' })
|
|
1407
|
+
try {
|
|
1408
|
+
const out = await sendAudio(blob, rec.mime, mode)
|
|
1409
|
+
if (rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
|
|
1410
|
+
if (out.command) { runSessionCommand(out.command); return }
|
|
1411
|
+
const text = out.text || ''
|
|
1412
|
+
const action = out.action || null
|
|
1413
|
+
if (!text && !action) {
|
|
1414
|
+
if (mode === 'message') voice.set({ phase: 'error', error: t('nothingHeard') })
|
|
1415
|
+
else voice.set({ phase: 'idle' })
|
|
1400
1416
|
return
|
|
1401
1417
|
}
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
}
|
|
1409
|
-
if (action === 'cancel') {
|
|
1410
|
-
cancelCurrent()
|
|
1411
|
-
return
|
|
1412
|
-
}
|
|
1413
|
-
if (action === 'clear') {
|
|
1414
|
-
if (voice.inputActions && typeof voice.inputActions.clear === 'function') {
|
|
1415
|
-
voice.inputActions.clear()
|
|
1418
|
+
if (voice.settings.structuredPromptVoice !== false && text) {
|
|
1419
|
+
const promptInfo = findActiveStructuredPrompt()
|
|
1420
|
+
if (promptInfo && submitToStructuredPrompt(text, promptInfo)) {
|
|
1421
|
+
voice.set({ phase: 'idle' })
|
|
1422
|
+
return
|
|
1423
|
+
}
|
|
1416
1424
|
}
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1425
|
+
if (action === 'send' || action === 'clear' || action === 'newline') {
|
|
1426
|
+
executeVoiceAction(action, text)
|
|
1427
|
+
return
|
|
1428
|
+
}
|
|
1429
|
+
if (action === 'cancel') { cancelCurrent(); return }
|
|
1430
|
+
appendDraft(text)
|
|
1431
|
+
if (voice.lastNote) voice.lastNote.text = text
|
|
1432
|
+
if (mode === 'message') {
|
|
1433
|
+
voice.set({ phase: 'pending', pending: { text, totalMs: voice.settings.autoSendMs, leftMs: voice.settings.autoSendMs, audioUrl: noteUrl } })
|
|
1434
|
+
} else {
|
|
1435
|
+
voice.set({ phase: 'idle' })
|
|
1436
|
+
}
|
|
1437
|
+
} catch (e) {
|
|
1438
|
+
if (rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
|
|
1439
|
+
voice.set({ phase: 'error', error: String(e && e.message ? e.message : e) })
|
|
1427
1440
|
}
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
|
|
1441
|
+
}
|
|
1442
|
+
if (mode === 'dictation') {
|
|
1443
|
+
dictationQueue = dictationQueue.then(processTail)
|
|
1444
|
+
try { await dictationQueue } catch (_) {}
|
|
1445
|
+
} else {
|
|
1446
|
+
await processTail()
|
|
1431
1447
|
}
|
|
1432
1448
|
})
|
|
1433
1449
|
}
|
|
1434
|
-
|
|
1450
|
+
function clearDraft() {
|
|
1451
|
+
const actions = voice.inputActions
|
|
1452
|
+
if (!actions || typeof actions.setDraft !== 'function') {
|
|
1453
|
+
voice.set({ phase: 'error', error: t('composerUnavailable') })
|
|
1454
|
+
return false
|
|
1455
|
+
}
|
|
1456
|
+
actions.setDraft('')
|
|
1457
|
+
return true
|
|
1458
|
+
}
|
|
1459
|
+
function executeVoiceAction(action, text) {
|
|
1460
|
+
if (action === 'send') {
|
|
1461
|
+
if (text) appendDraft(text)
|
|
1462
|
+
submitPending()
|
|
1463
|
+
return true
|
|
1464
|
+
}
|
|
1465
|
+
if (action === 'clear') {
|
|
1466
|
+
const ok = clearDraft()
|
|
1467
|
+
if (ok && voice.phase !== 'recording') voice.set({ phase: 'idle' })
|
|
1468
|
+
return ok
|
|
1469
|
+
}
|
|
1470
|
+
if (action === 'newline') {
|
|
1471
|
+
const payload = text ? (text.endsWith('\n') ? text : text + '\n') : '\n'
|
|
1472
|
+
appendDraft(payload)
|
|
1473
|
+
if (voice.phase !== 'recording') voice.set({ phase: 'idle' })
|
|
1474
|
+
return true
|
|
1475
|
+
}
|
|
1476
|
+
if (action === 'cancel') { cancelCurrent(); return true }
|
|
1477
|
+
return false
|
|
1478
|
+
}
|
|
1479
|
+
voice.executeVoiceAction = executeVoiceAction
|
|
1435
1480
|
function submitPending() {
|
|
1436
1481
|
voice.pending = null
|
|
1437
|
-
voice.set({ phase: 'idle' })
|
|
1438
1482
|
const actions = voice.inputActions
|
|
1439
|
-
if (!actions || typeof actions.submit !== 'function')
|
|
1440
|
-
|
|
1483
|
+
if (!actions || typeof actions.submit !== 'function') {
|
|
1484
|
+
voice.set({ phase: 'error', error: t('composerUnavailable') })
|
|
1485
|
+
return
|
|
1486
|
+
}
|
|
1487
|
+
voice.set({ phase: 'idle' })
|
|
1441
1488
|
if (voice.settings.polishSend) {
|
|
1442
1489
|
const run = async () => {
|
|
1443
1490
|
try {
|
|
@@ -1467,36 +1514,23 @@ window.__ModuleLoader__.load({
|
|
|
1467
1514
|
}
|
|
1468
1515
|
setTimeout(() => { try { actions.submit() } catch (e) { /* composer busy */ } }, 0)
|
|
1469
1516
|
}
|
|
1470
|
-
|
|
1471
|
-
// Session voice commands (#48): clean "send/cancel/stop/continue".
|
|
1472
1517
|
function runSessionCommand(cmd) {
|
|
1473
1518
|
const actions = voice.inputActions
|
|
1474
1519
|
voice.set({ phase: 'idle', pending: null })
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
case 'continue':
|
|
1478
|
-
if (actions && typeof actions.submit === 'function') actions.submit()
|
|
1479
|
-
break
|
|
1480
|
-
case 'cancel':
|
|
1481
|
-
case 'stop':
|
|
1482
|
-
// Clear wait/recording; leave the draft text intentionally untouched.
|
|
1483
|
-
break
|
|
1484
|
-
default:
|
|
1485
|
-
break
|
|
1520
|
+
if ((cmd === 'send' || cmd === 'continue') && actions && typeof actions.submit === 'function') {
|
|
1521
|
+
actions.submit()
|
|
1486
1522
|
}
|
|
1487
1523
|
}
|
|
1488
|
-
|
|
1489
1524
|
function keepPending() {
|
|
1490
1525
|
voice.pending = null
|
|
1491
1526
|
voice.set({ phase: 'idle' })
|
|
1492
1527
|
}
|
|
1493
|
-
|
|
1494
1528
|
// ----------------------------------------------------------- components
|
|
1495
1529
|
function VoiceButtons(props) {
|
|
1496
1530
|
const v = useVoice()
|
|
1497
1531
|
voice.inputActions = props.inputActions
|
|
1498
1532
|
voice.input = props.input
|
|
1499
|
-
if (v.phase !== 'idle' && v.phase !== 'error') return null
|
|
1533
|
+
if (v.phase !== 'idle' && v.phase !== 'error' && !hold.armed) return null
|
|
1500
1534
|
const err = v.phase === 'error'
|
|
1501
1535
|
const hk = voice.hotkey ? ` (${voice.hotkey})` : ''
|
|
1502
1536
|
return React.createElement(React.Fragment, null,
|
|
@@ -1507,13 +1541,17 @@ window.__ModuleLoader__.load({
|
|
|
1507
1541
|
onClick: startDictation,
|
|
1508
1542
|
}, micIcon()),
|
|
1509
1543
|
React.createElement('button', {
|
|
1510
|
-
type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
|
|
1544
|
+
type: 'button', className: 'dvo-btn' + (hold.armed ? ' dvo-btn-active' : ''), 'data-err': err ? '1' : '0',
|
|
1511
1545
|
title: err ? v.error : (t('messageBtn') + hk),
|
|
1512
1546
|
'aria-label': err ? v.error : (t('messageBtn') + hk),
|
|
1513
|
-
|
|
1514
|
-
onPointerDown: (e) => { e.preventDefault(); beginHold('message') },
|
|
1547
|
+
onPointerDown: (e) => { e.preventDefault(); beginHold('message', e) },
|
|
1515
1548
|
onPointerUp: () => endHold(false),
|
|
1516
|
-
|
|
1549
|
+
onPointerCancel: () => endHold(true),
|
|
1550
|
+
onPointerLeave: (e) => {
|
|
1551
|
+
if (hold.armed && (!e.target || typeof e.target.hasPointerCapture !== 'function' || !e.target.hasPointerCapture(e.pointerId))) {
|
|
1552
|
+
endHold(true)
|
|
1553
|
+
}
|
|
1554
|
+
},
|
|
1517
1555
|
}, waveIcon()),
|
|
1518
1556
|
voice.lastNote && voice.lastNote.url
|
|
1519
1557
|
? React.createElement('button', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.11",
|
|
4
4
|
"description": "Voice input for DeepSeek Harness: dictation chunked by pauses and voice messages, each with its own provider fallback chain (Deepgram, Groq, HuggingFace, local whisper.cpp, plus any OpenAI-compatible API of your own).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|