@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.
Files changed (2) hide show
  1. package/lib/client.js +198 -160
  2. 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
- let s = String(text || '').trim()
706
- if (!s) return s
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, lead, ch) => lead + ch.toUpperCase())
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
- return s.replace(/[ \t]*\n[ \t]*/g, '\n').replace(/[ \t]+/g, ' ').trim()
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 appendDraft(text) {
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
- actions.setDraft(draft ? draft + ' ' + clean : clean)
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: draft ? ' ' + clean : clean, after: draft ? draft + ' ' + clean : clean })
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) return
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() { stopped = true; try { recognition.stop() } catch (already) { /* already stopped */ } },
895
- abort() { stopped = true; try { recognition.abort() } catch (already) { /* already stopped */ } },
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
- if (action === 'send') {
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
- // Start recording immediately: waiting for the threshold loses the first word.
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 (hotkeyMatches(event, keyName)) {
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 said = (voice.browserFinals || []).join(' ').trim()
1352
- voice.browser.stop()
1363
+ const b = voice.browser
1353
1364
  voice.browser = null
1354
- voice.browserFinals = null
1355
1365
  voice.caption = ''
1356
- if (!said) { voice.set({ phase: 'idle' }); return }
1357
- if (mode === 'message') {
1358
- appendDraft(said)
1359
- voice.set({ phase: 'pending', pending: { text: said, leftMs: voice.settings.autoSendMs } })
1360
- } else {
1361
- // Dictation already appended while speaking — nothing extra to add.
1362
- voice.set({ phase: 'idle' })
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 < 1200) { voice.set({ phase: 'idle' }); return }
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) { /* ignore */ }
1399
+ try { URL.revokeObjectURL(voice.lastNote.url) } catch (e) {}
1383
1400
  }
1384
1401
  let noteUrl = ''
1385
- try { noteUrl = URL.createObjectURL(blob) } catch (e) { /* ignore */ }
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
- try {
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
- if (out.command) { runSessionCommand(out.command); return }
1392
- const text = out.text || ''
1393
- const action = out.action || null
1394
- if (!text && !action) { voice.set({ phase: 'error', error: t('nothingHeard') }); return }
1395
-
1396
- if (voice.settings.structuredPromptVoice !== false && text) {
1397
- const promptInfo = findActiveStructuredPrompt()
1398
- if (promptInfo && submitToStructuredPrompt(text, promptInfo)) {
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
- if (action === 'send') {
1405
- if (text) appendDraft(text)
1406
- submitPending()
1407
- return
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
- voice.set({ phase: 'idle' })
1418
- return
1419
- }
1420
-
1421
- appendDraft(text)
1422
- if (voice.lastNote) voice.lastNote.text = text
1423
- if (mode === 'message') {
1424
- voice.set({ phase: 'pending', pending: { text: text, totalMs: voice.settings.autoSendMs, leftMs: voice.settings.autoSendMs, audioUrl: noteUrl } })
1425
- } else {
1426
- voice.set({ phase: 'idle' })
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
- } catch (e) {
1429
- if (rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1430
- voice.set({ phase: 'error', error: String(e && e.message ? e.message : e) })
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') return
1440
- // Polish the whole draft before submit (#46). Errors do not block.
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
- switch (cmd) {
1476
- case 'send':
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
- // Hold: record while pressed; leaving the control cancels.
1514
- onPointerDown: (e) => { e.preventDefault(); beginHold('message') },
1547
+ onPointerDown: (e) => { e.preventDefault(); beginHold('message', e) },
1515
1548
  onPointerUp: () => endHold(false),
1516
- onPointerLeave: () => { if (hold.armed) endHold(true) },
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.10",
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",