@goodandready/dsh-voice 0.9.17 → 0.9.19

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 CHANGED
@@ -735,31 +735,15 @@ window.__ModuleLoader__.load({
735
735
 
736
736
  async function sendAudio(blob, mime, mode) {
737
737
  const activeChain = (voice._activeChain || [])
738
- const gpuIdx = activeChain.indexOf('browser-webgpu')
739
738
  const modelMap = voice._activeModelMap || {}
740
739
  const chosenModel = modelMap['browser-webgpu'] || (voice.settings && voice.settings.webgpuModel)
741
740
 
742
- const tryWebGpu = async () => {
743
- const canGpu = voice.webGpu?.isSupported ? voice.webGpu.isSupported() : (typeof isWebGpuSupported === 'function' && isWebGpuSupported())
744
- if (canGpu) {
745
- try {
746
- const fn = voice.webGpu?.transcribe || transcribeWithWebGpu
747
- const out = await fn(blob, mime, {
748
- language: voice._activeLang || (voice.settings && voice.settings.dictation && voice.settings.dictation.language) || '',
749
- model: chosenModel,
750
- })
751
- const text = (out && typeof out.text === 'string') ? tidyPhrase(out.text) : ''
752
- if (text) return { text, command: null, action: null, provider: 'browser-webgpu' }
753
- } catch (webGpuErr) {
754
- console.warn('[dsh-voice] WebGPU Whisper failed:', webGpuErr)
755
- }
756
- }
757
- return null
758
- }
759
-
760
- const tryHost = async () => {
741
+ const tryHost = async (chainOverride) => {
761
742
  const dataBase64 = await blobToBase64(blob)
762
743
  const payload = { dataBase64, mimeType: mime, mode }
744
+ if (Array.isArray(chainOverride)) {
745
+ payload.chain = chainOverride.map((p) => (typeof p === 'string' ? { provider: p, model: modelMap[p] } : p))
746
+ }
763
747
  const contextWords = extractContextKeywords()
764
748
  if (contextWords && contextWords.length > 0) payload.contextWords = contextWords
765
749
  const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
@@ -789,26 +773,55 @@ window.__ModuleLoader__.load({
789
773
  return { text: String(parsed.text || '').trim(), command: null, action: parsed.action || null }
790
774
  }
791
775
 
792
- if (gpuIdx === 0) {
793
- const gpuOut = await tryWebGpu()
794
- if (gpuOut && gpuOut.text) return gpuOut
795
- if (activeChain.length > 1) return await tryHost()
796
- return gpuOut || { text: '', command: null, action: null, provider: 'browser-webgpu' }
797
- } else if (gpuIdx > 0) {
776
+ const tryWebGpu = async () => {
777
+ const canGpu = voice.webGpu?.isSupported ? voice.webGpu.isSupported() : (typeof isWebGpuSupported === 'function' && isWebGpuSupported())
778
+ if (canGpu) {
779
+ try {
780
+ const fn = voice.webGpu?.transcribe || transcribeWithWebGpu
781
+ const out = await fn(blob, mime, {
782
+ language: voice._activeLang || (voice.settings && voice.settings.dictation && voice.settings.dictation.language) || '',
783
+ model: chosenModel,
784
+ })
785
+ const text = (out && typeof out.text === 'string') ? tidyPhrase(out.text) : ''
786
+ return { text, command: null, action: null, provider: 'browser-webgpu' }
787
+ } catch (webGpuErr) {
788
+ console.warn('[dsh-voice] WebGPU Whisper failed:', webGpuErr)
789
+ }
790
+ }
791
+ return null
792
+ }
793
+
794
+ const gpuIdx = activeChain.indexOf('browser-webgpu')
795
+ if (gpuIdx === -1) {
796
+ return await tryHost()
797
+ }
798
+
799
+ const head = activeChain.slice(0, gpuIdx)
800
+ const tail = activeChain.slice(gpuIdx + 1)
801
+
802
+ if (head.length > 0) {
803
+ try {
804
+ const headOut = await tryHost(head)
805
+ if (headOut && (headOut.text || headOut.command || headOut.action)) return headOut
806
+ } catch (headErr) {
807
+ // Continue to WebGPU
808
+ }
809
+ }
810
+
811
+ const gpuOut = await tryWebGpu()
812
+ if (gpuOut && gpuOut.text) return gpuOut
813
+
814
+ if (tail.length > 0) {
798
815
  try {
799
- const hostOut = await tryHost()
800
- if (hostOut && (hostOut.text || hostOut.command || hostOut.action)) return hostOut
801
- } catch (hostErr) {
802
- const gpuOut = await tryWebGpu()
803
- if (gpuOut && gpuOut.text) return gpuOut
804
- throw hostErr
816
+ const tailOut = await tryHost(tail)
817
+ if (tailOut && (tailOut.text || tailOut.command || tailOut.action)) return tailOut
818
+ } catch (tailErr) {
819
+ if (head.length === 0 && !gpuOut) throw tailErr
805
820
  }
806
- const gpuOut = await tryWebGpu()
807
- if (gpuOut && gpuOut.text) return gpuOut
808
- return { text: '', command: null, action: null }
809
821
  }
810
822
 
811
- return await tryHost()
823
+ if (gpuOut) return gpuOut
824
+ return { text: '', command: null, action: null }
812
825
  }
813
826
 
814
827
  function tidyPhrase(text) {
@@ -866,21 +879,54 @@ window.__ModuleLoader__.load({
866
879
  return t('undone')
867
880
  }
868
881
 
869
- function insertDraftText(text) {
882
+ function insertDraftText(text, target) {
870
883
  if (voice.disposed) return false
871
- const actions = voice.inputActions
884
+ const span = target && target.span
885
+ if (span) {
886
+ if (typeof span.insert === 'function') {
887
+ try {
888
+ const r = span.insert(text)
889
+ if (r !== false) return true
890
+ return false
891
+ } catch (_) {}
892
+ } else if (typeof span.insertText === 'function') {
893
+ try {
894
+ const r = span.insertText(text)
895
+ if (r !== false) return true
896
+ return false
897
+ } catch (_) {}
898
+ } else if (typeof span === 'function') {
899
+ try {
900
+ const r = span(text)
901
+ if (r !== false) return true
902
+ return false
903
+ } catch (_) {}
904
+ }
905
+ }
906
+ const actions = (target && target.actions) || voice.inputActions
907
+ const input = (target && target.input) || voice.input
872
908
  if (!actions || (typeof actions.setDraft !== 'function' && typeof actions.insertText !== 'function')) {
873
909
  voice.set({ phase: 'error', error: t('composerUnavailable') })
874
910
  return false
875
911
  }
876
912
  if (typeof actions.insertText === 'function') {
877
- try { actions.insertText(text); return true } catch (_) {}
913
+ try {
914
+ const res = actions.insertText(text, span)
915
+ if (res !== false) return true
916
+ return false
917
+ } catch (_) {
918
+ return false
919
+ }
878
920
  }
879
- const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
921
+ const draft = input && typeof input.draft === 'string' ? input.draft : ''
880
922
  const before = draft
881
923
  const sep = (!draft || draft.endsWith('\n') || text.startsWith('\n')) ? '' : ' '
882
924
  const after = draft + sep + text
883
- actions.setDraft(after)
925
+ try {
926
+ actions.setDraft(after)
927
+ } catch (_) {
928
+ return false
929
+ }
884
930
  const limit = Number(voice.settings.historyLimit)
885
931
  if (limit > 0) {
886
932
  insertHistory.push({ before, added: sep + text, after })
@@ -889,100 +935,13 @@ window.__ModuleLoader__.load({
889
935
  return true
890
936
  }
891
937
 
892
- function appendDraft(text) {
938
+ function appendDraft(text, target) {
893
939
  if (voice.disposed || !text) return
894
940
  let clean = voice.settings.voiceCommands ? applyVoiceCommands(text) : tidyPhrase(text)
895
941
  if (!clean) return
896
- insertDraftText(clean)
897
- }
898
-
899
- // Human-readable key name.
900
- const KEY_LABELS = {
901
- Control: 'Ctrl', Alt: 'Alt', Shift: 'Shift', Meta: 'Win',
902
- Escape: 'Esc',
903
- }
904
-
905
- function keyLabel(name) {
906
- if (!name) return t('keyUnset')
907
- if (typeof name === 'string' && name.includes('+')) {
908
- return name.split('+').map((p) => keyLabel(p)).join(' + ')
909
- }
910
- if (name === 'Space') return t('keySpace')
911
- if (KEY_LABELS[name]) return KEY_LABELS[name]
912
- // Drop the Key/Digit prefix from codes like KeyR and Digit5.
913
- return String(name).replace(/^Key/, '').replace(/^Digit/, '')
914
- }
915
-
916
- // What to store on key press. Pure modifiers are remembered by name:
917
- // left and right have different codes but the user means "either".
918
- // Supports combos like Control+Space or Alt+KeyV.
919
- function keyFromEvent(event) {
920
- if (!event) return ''
921
- const modifiers = []
922
- if (event.ctrlKey && event.key !== 'Control') modifiers.push('Control')
923
- if (event.altKey && event.key !== 'Alt') modifiers.push('Alt')
924
- if (event.shiftKey && event.key !== 'Shift') modifiers.push('Shift')
925
- if (event.metaKey && event.key !== 'Meta') modifiers.push('Meta')
926
-
927
- const main = ['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)
928
- ? event.key
929
- : (event.code || event.key || '')
930
-
931
- if (modifiers.length > 0 && !['Control', 'Alt', 'Shift', 'Meta'].includes(main)) {
932
- return modifiers.concat(main).join('+')
933
- }
934
- return main
935
- }
936
-
937
- function hotkeyMatches(event, name) {
938
- if (!event || !name) return false
939
- if (typeof name === 'string' && name.includes('+')) {
940
- const parts = name.split('+')
941
- const main = parts[parts.length - 1]
942
- const needCtrl = parts.includes('Control')
943
- const needAlt = parts.includes('Alt')
944
- const needShift = parts.includes('Shift')
945
- const needMeta = parts.includes('Meta')
946
- if (needCtrl && !event.ctrlKey) return false
947
- if (needAlt && !event.altKey) return false
948
- if (needShift && !event.shiftKey) return false
949
- if (needMeta && !event.metaKey) return false
950
- return event.code === main || event.key === main
951
- }
952
- if (name === 'Control') return event.key === 'Control'
953
- if (name === 'Alt') return event.key === 'Alt'
954
- if (name === 'Shift') return event.key === 'Shift'
955
- if (name === 'Meta') return event.key === 'Meta'
956
- return event.code === name || event.key === name
942
+ insertDraftText(clean, target)
957
943
  }
958
944
 
959
- function isAllowedInEditable(name) {
960
- if (!name) return false
961
- if (['Control', 'Alt', 'Shift', 'Meta'].includes(name)) return true
962
- if (/^F\d{1,2}$/.test(name)) return true
963
- if (typeof name === 'string' && name.includes('+')) return true
964
- if (['Tab', 'Pause', 'ScrollLock', 'Insert'].includes(name)) return true
965
- return false
966
- }
967
-
968
- function isHotkeyRelease(event, name) {
969
- if (!event || !name) return false
970
- if (typeof name === 'string' && name.includes('+')) {
971
- const parts = name.split('+')
972
- if (event.code === parts[parts.length - 1] || event.key === parts[parts.length - 1]) return true
973
- for (let i = 0; i < parts.length - 1; i++) {
974
- if (event.key === parts[i] || event.code === parts[i]) return true
975
- }
976
- return false
977
- }
978
- return event.code === name || event.key === name
979
- }
980
-
981
- // Announce that the user started speaking.
982
- //
983
- // Playback should mute immediately: listening and talking at once is
984
- // impossible. There is no plugin-to-plugin API — this broadcasts a window
985
- // event that anyone may hear, so either side works alone.
986
945
  function announceVoice(phase) {
987
946
  try {
988
947
  window.dispatchEvent(new CustomEvent('dsh-voice:speaking', { detail: { phase } }))
@@ -1119,41 +1078,31 @@ function isWebGpuSupported() {
1119
1078
  }
1120
1079
 
1121
1080
  const _webGpuPipelines = new Map()
1122
- let _webGpuLoadingModel = null
1123
1081
 
1124
- async function loadWebGpuWhisper(modelName) {
1082
+ function loadWebGpuWhisper(modelName) {
1125
1083
  const targetModel = modelName || (voice.settings && voice.settings.webgpuModel) || 'onnx-community/whisper-tiny'
1126
1084
  if (_webGpuPipelines.has(targetModel)) {
1127
1085
  return _webGpuPipelines.get(targetModel)
1128
1086
  }
1129
- while (_webGpuLoadingModel) {
1130
- if (_webGpuLoadingModel === targetModel) {
1131
- await new Promise((r) => setTimeout(r, 60))
1132
- if (_webGpuPipelines.has(targetModel)) return _webGpuPipelines.get(targetModel)
1133
- } else {
1134
- break
1135
- }
1136
- }
1137
- _webGpuLoadingModel = targetModel
1138
- try {
1087
+ const promise = (async () => {
1139
1088
  if (!isWebGpuSupported()) {
1140
1089
  throw new Error('WebGPU is not supported in this browser environment')
1141
1090
  }
1142
- // Dynamic import of transformers.js from ESM CDN
1143
- const mod = typeof globalThis !== 'undefined' && globalThis.__mockTransformers ? globalThis.__mockTransformers : (await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.3.3'))
1091
+ const mod = typeof globalThis !== 'undefined' && globalThis.__mockTransformers
1092
+ ? globalThis.__mockTransformers
1093
+ : (await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.3.3'))
1144
1094
  const pipeline = mod.pipeline || (mod.default && mod.default.pipeline)
1145
1095
  if (!pipeline) throw new Error('Transformers pipeline not found in loaded bundle')
1146
- const instance = await pipeline('automatic-speech-recognition', targetModel, {
1096
+ return pipeline('automatic-speech-recognition', targetModel, {
1147
1097
  device: 'webgpu',
1148
1098
  dtype: 'fp32',
1149
1099
  })
1150
- _webGpuPipelines.set(targetModel, instance)
1151
- return instance
1152
- } finally {
1153
- if (_webGpuLoadingModel === targetModel) {
1154
- _webGpuLoadingModel = null
1155
- }
1156
- }
1100
+ })().catch((err) => {
1101
+ _webGpuPipelines.delete(targetModel)
1102
+ throw err
1103
+ })
1104
+ _webGpuPipelines.set(targetModel, promise)
1105
+ return promise
1157
1106
  }
1158
1107
 
1159
1108
  async function audioBlobToFloat32Array(blob) {
@@ -1191,7 +1140,7 @@ voice.webGpu = {
1191
1140
  load: loadWebGpuWhisper,
1192
1141
  clearCache: () => { _webGpuPipelines.clear() },
1193
1142
  }
1194
- voice._chainsPromise = null
1143
+ voice._chainsPromise = null
1195
1144
  function modeChain(mode) {
1196
1145
  if (!voice._chainsPromise) {
1197
1146
  voice._chainsPromise = fetch('/dsh-voice/status', { cache: 'no-store' })
@@ -1244,7 +1193,7 @@ voice.webGpu = {
1244
1193
  if (!recorder || recorder.state === 'inactive') return Promise.resolve()
1245
1194
  return new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true }))
1246
1195
  }
1247
- async function openMic(mode, opId) {
1196
+ async function openMic(mode, opId, target) {
1248
1197
  if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
1249
1198
  throw new Error(t('micUnavailable'))
1250
1199
  }
@@ -1257,43 +1206,54 @@ voice.webGpu = {
1257
1206
  }
1258
1207
  if (voice.settings.micDeviceId) audio.deviceId = { exact: voice.settings.micDeviceId }
1259
1208
  const stream = await navigator.mediaDevices.getUserMedia({ audio })
1260
- let mimeType = 'audio/webm;codecs=opus'
1261
- if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = ''
1262
- const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream)
1263
- const rec = {
1264
- recorder, stream, mode,
1265
- opId: opId || (voice.activeOpId || 0),
1266
- chunks: [],
1267
- mime: mimeType || recorder.mimeType || 'audio/webm',
1268
- audioCtx: null, analyser: null,
1269
- cutting: false, closing: false,
1270
- silenceMs: 0, hadSpeech: false,
1271
- streamMs: 0,
1272
- }
1273
- recorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) rec.chunks.push(e.data) }
1274
- const AC = typeof AudioContext !== 'undefined' ? AudioContext
1275
- : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
1276
- if (AC) {
1277
- rec.audioCtx = new AC()
1278
- if (rec.audioCtx.state === 'suspended') { rec.audioCtx.resume().catch(() => {}) }
1279
- const src = rec.audioCtx.createMediaStreamSource(stream)
1280
- rec.srcNode = src
1281
- let lastNode = src
1282
- try {
1283
- const filter = rec.audioCtx.createBiquadFilter()
1284
- filter.type = 'highpass'
1285
- filter.frequency.value = 80
1286
- filter.Q.value = 0.707
1287
- src.connect(filter)
1288
- lastNode = filter
1289
- rec.filterNode = filter
1290
- } catch (e) { /* audio filter fallback */ }
1291
- rec.analyser = rec.audioCtx.createAnalyser()
1292
- rec.analyser.fftSize = 128
1293
- lastNode.connect(rec.analyser)
1294
- }
1295
- recorder.start()
1296
- return rec
1209
+ let rec = null
1210
+ try {
1211
+ let mimeType = 'audio/webm;codecs=opus'
1212
+ if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = ''
1213
+ const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream)
1214
+ rec = {
1215
+ recorder, stream, mode,
1216
+ target: target || voice._activeTarget,
1217
+ opId: opId || (voice.activeOpId || 0),
1218
+ chunks: [],
1219
+ mime: mimeType || recorder.mimeType || 'audio/webm',
1220
+ audioCtx: null, analyser: null,
1221
+ cutting: false, closing: false,
1222
+ silenceMs: 0, hadSpeech: false,
1223
+ streamMs: 0,
1224
+ }
1225
+ recorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) rec.chunks.push(e.data) }
1226
+ const AC = typeof AudioContext !== 'undefined' ? AudioContext
1227
+ : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
1228
+ if (AC) {
1229
+ rec.audioCtx = new AC()
1230
+ if (rec.audioCtx.state === 'suspended') { rec.audioCtx.resume().catch(() => {}) }
1231
+ const src = rec.audioCtx.createMediaStreamSource(stream)
1232
+ rec.srcNode = src
1233
+ let lastNode = src
1234
+ try {
1235
+ const filter = rec.audioCtx.createBiquadFilter()
1236
+ filter.type = 'highpass'
1237
+ filter.frequency.value = 80
1238
+ filter.Q.value = 0.707
1239
+ src.connect(filter)
1240
+ lastNode = filter
1241
+ rec.filterNode = filter
1242
+ } catch (e) { /* audio filter fallback */ }
1243
+ rec.analyser = rec.audioCtx.createAnalyser()
1244
+ rec.analyser.fftSize = 128
1245
+ lastNode.connect(rec.analyser)
1246
+ }
1247
+ recorder.start()
1248
+ return rec
1249
+ } catch (err) {
1250
+ if (rec) {
1251
+ teardown(rec)
1252
+ } else {
1253
+ try { stream.getTracks().forEach((t) => t.stop()) } catch (_) {}
1254
+ }
1255
+ throw err
1256
+ }
1297
1257
  }
1298
1258
  function currentLevel(rec) {
1299
1259
  if (!rec || !rec.analyser) return 0
@@ -1314,6 +1274,7 @@ voice.webGpu = {
1314
1274
  const rec = voice.rec
1315
1275
  if (!rec || rec.cutting || rec.closing) return
1316
1276
  const opId = rec.opId || voice.activeOpId
1277
+ const target = rec.target || voice._activeTarget
1317
1278
  rec.cutting = true
1318
1279
  const stopped = waitStop(rec.recorder)
1319
1280
  try {
@@ -1325,7 +1286,7 @@ voice.webGpu = {
1325
1286
  return
1326
1287
  }
1327
1288
  stopped.then(async () => {
1328
- if (rec.cancelled || voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) return
1289
+ if (rec.cancelled || voice.activeOpId !== opId || voice.disposed) return
1329
1290
  const blob = new Blob(rec.chunks, { type: rec.mime })
1330
1291
  rec.chunks = []
1331
1292
  rec.silenceMs = 0
@@ -1339,10 +1300,10 @@ voice.webGpu = {
1339
1300
  rec.cutting = false
1340
1301
  if (rec.cancelled || blob.size < 600) return // cancelled or too short — not speech
1341
1302
  dictationQueue = dictationQueue.then(async () => {
1342
- if (rec.cancelled || voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) return
1303
+ if (rec.cancelled || voice.activeOpId !== opId || voice.disposed) return
1343
1304
  try {
1344
1305
  const out = await sendAudio(blob, rec.mime, 'dictation')
1345
- if (rec.cancelled || voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) return
1306
+ if (rec.cancelled || voice.activeOpId !== opId || voice.disposed) return
1346
1307
  const text = out && out.text ? out.text : ''
1347
1308
  const action = out && out.action ? out.action : null
1348
1309
  if (voice.settings.structuredPromptVoice !== false && text) {
@@ -1352,7 +1313,7 @@ voice.webGpu = {
1352
1313
  }
1353
1314
  }
1354
1315
  if (action === 'send' || action === 'clear' || action === 'newline') {
1355
- executeVoiceAction(action, text)
1316
+ executeVoiceAction(action, text, target)
1356
1317
  return
1357
1318
  }
1358
1319
  if (action === 'cancel') {
@@ -1361,20 +1322,20 @@ voice.webGpu = {
1361
1322
  }
1362
1323
  const delay = Number(voice.settings.sendDelayMs) || 0
1363
1324
  if (text && delay > 0 && !voice.holding) {
1364
- appendDraft(text)
1365
- voice.set({ phase: 'pending', pending: { text, undoOnly: true, leftMs: delay } })
1325
+ appendDraft(text, target)
1326
+ voice.set({ phase: 'pending', pending: { text, target, undoOnly: true, leftMs: delay } })
1366
1327
  return
1367
1328
  }
1368
- if (text) appendDraft(text)
1329
+ if (text) appendDraft(text, target)
1369
1330
  } catch (e) {
1370
- if (!rec.cancelled && voice.activeOpId === opId) {
1331
+ if (!rec.cancelled && voice.activeOpId === opId && !voice.disposed) {
1371
1332
  voice.set({ error: String(e && e.message ? e.message : e) })
1372
1333
  }
1373
1334
  }
1374
1335
  })
1375
1336
  })
1376
1337
  }
1377
- function startBrowserLeg(mode, language, opId) {
1338
+ function startBrowserLeg(mode, language, opId, target) {
1378
1339
  if (voice.settings && voice.settings.localOnly) return false
1379
1340
  if (!browserRecognitionAvailable()) return false
1380
1341
  const finals = []
@@ -1399,13 +1360,14 @@ voice.webGpu = {
1399
1360
  voice.browser = null
1400
1361
  voice.caption = ''
1401
1362
  voice.interimTranscript = ''
1402
- openMic(mode, opId)
1363
+ openMic(mode, opId, target)
1403
1364
  .then((rec) => {
1404
1365
  if (voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) {
1405
1366
  teardown(rec)
1406
1367
  return
1407
1368
  }
1408
1369
  rec.opId = opId
1370
+ rec.target = target
1409
1371
  voice.rec = rec
1410
1372
  voice.notify()
1411
1373
  })
@@ -1417,11 +1379,11 @@ voice.webGpu = {
1417
1379
  }
1418
1380
  },
1419
1381
  onFinal: (text) => {
1420
- if (voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) return
1382
+ if (voice.activeOpId !== opId || (voice.phase !== 'recording' && voice.phase !== 'finishing') || voice.disposed) return
1421
1383
  finals.push(text)
1422
1384
  voice.caption = ''
1423
1385
  voice.interimTranscript = ''
1424
- if (mode === 'dictation') appendDraft(text)
1386
+ if (mode === 'dictation') appendDraft(text, target)
1425
1387
  else voice.notify()
1426
1388
  },
1427
1389
  onError: (reason) => {
@@ -1439,6 +1401,15 @@ voice.webGpu = {
1439
1401
  if (voice.phase !== 'idle' && voice.phase !== 'error') return
1440
1402
  voice.activeOpId = (voice.activeOpId || 0) + 1
1441
1403
  const opId = voice.activeOpId
1404
+ const targetActions = voice.inputActions
1405
+ const targetInput = voice.input
1406
+ let span = null
1407
+ if (targetActions && typeof targetActions.captureInsertion === 'function') {
1408
+ try { span = targetActions.captureInsertion() } catch (_) {}
1409
+ }
1410
+ const target = { actions: targetActions, input: targetInput, span }
1411
+ voice._activeTarget = target
1412
+
1442
1413
  if (voice.isTtsSpeaking) {
1443
1414
  if (voice.settings.bargeIn) {
1444
1415
  triggerBargeIn()
@@ -1455,14 +1426,15 @@ voice.webGpu = {
1455
1426
  voice._activeChain = info.chain || []
1456
1427
  voice._activeLang = info.language || ''
1457
1428
  voice._activeModelMap = info.modelMap || {}
1458
- if (info.chain[0] === 'browser' && !voice.settings.localOnly && startBrowserLeg(mode, info.language, opId)) return
1459
- openMic(mode, opId)
1429
+ if (info.chain[0] === 'browser' && !voice.settings.localOnly && startBrowserLeg(mode, info.language, opId, target)) return
1430
+ openMic(mode, opId, target)
1460
1431
  .then((rec) => {
1461
1432
  if (voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) {
1462
1433
  teardown(rec)
1463
1434
  return
1464
1435
  }
1465
1436
  rec.opId = opId
1437
+ rec.target = target
1466
1438
  voice.rec = rec
1467
1439
  voice.notify()
1468
1440
  })
@@ -1474,104 +1446,7 @@ voice.webGpu = {
1474
1446
  }
1475
1447
  function startDictation() { startRecording('dictation') }
1476
1448
  function startMessage() { startRecording('message') }
1477
- // --------------------------------------------------------- hold gesture
1478
- const HOLD_THRESHOLD_MS = 350
1479
- const hold = { active: false, mode: null, startedAt: 0, armed: false }
1480
- let activePointerCleanup = null
1481
- function beginHold(mode, pointerEvent) {
1482
- if (hold.armed || (voice.phase !== 'idle' && voice.phase !== 'error')) return
1483
- hold.armed = true
1484
- hold.mode = mode
1485
- hold.startedAt = Date.now()
1486
- hold.active = false
1487
- if (pointerEvent?.target?.setPointerCapture && pointerEvent.pointerId !== undefined) {
1488
- try { pointerEvent.target.setPointerCapture(pointerEvent.pointerId) } catch (e) {}
1489
- }
1490
- if (typeof window !== 'undefined') {
1491
- if (activePointerCleanup) { try { activePointerCleanup() } catch (e) {} }
1492
- const onUp = () => endHold(false)
1493
- const onCancel = () => endHold(true)
1494
- window.addEventListener('pointerup', onUp, true)
1495
- window.addEventListener('pointercancel', onCancel, true)
1496
- window.addEventListener('blur', onCancel, true)
1497
- activePointerCleanup = () => {
1498
- window.removeEventListener('pointerup', onUp, true)
1499
- window.removeEventListener('pointercancel', onCancel, true)
1500
- window.removeEventListener('blur', onCancel, true)
1501
- activePointerCleanup = null
1502
- }
1503
- }
1504
- startRecording(mode)
1505
- voice.holding = true
1506
- voice.notify()
1507
- }
1508
- function endHold(cancelled) {
1509
- if (!hold.armed) return
1510
- if (activePointerCleanup) { try { activePointerCleanup() } catch (e) {} }
1511
- const heldMs = Date.now() - hold.startedAt
1512
- hold.armed = false
1513
- hold.active = false
1514
- voice.holding = false
1515
- if (!cancelled && heldMs < HOLD_THRESHOLD_MS) { voice.notify(); return }
1516
- if (cancelled) cancelCurrent()
1517
- else stopCurrent()
1518
- }
1519
1449
 
1520
- let activeHotkeyCleanup = null
1521
- function clearGlobalHotkey() {
1522
- if (typeof activeHotkeyCleanup === 'function') {
1523
- try { activeHotkeyCleanup() } catch (_) { /* ignore */ }
1524
- activeHotkeyCleanup = null
1525
- }
1526
- if (typeof window !== 'undefined' && typeof window.__dsh_voice_hotkey_cleanup === 'function') {
1527
- try { window.__dsh_voice_hotkey_cleanup() } catch (_) { /* ignore */ }
1528
- window.__dsh_voice_hotkey_cleanup = null
1529
- }
1530
- }
1531
- voice.clearGlobalHotkey = clearGlobalHotkey
1532
- function installHotkey(ctx, keyName, mode) {
1533
- clearGlobalHotkey()
1534
- if (typeof document === 'undefined' || !keyName) return () => {}
1535
- const down = (event) => {
1536
- if (event.repeat) return
1537
- const target = event.target
1538
- const isEditable = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || (typeof target.getAttribute === 'function' && target.getAttribute('contenteditable') === 'true'))
1539
- if (isEditable && !isAllowedInEditable(keyName)) return
1540
- if (hotkeyMatches(event, keyName)) {
1541
- event.preventDefault()
1542
- event.stopPropagation()
1543
- beginHold(mode)
1544
- }
1545
- }
1546
-
1547
- const up = (event) => {
1548
- if (hold.armed && isHotkeyRelease(event, keyName)) {
1549
- event.preventDefault()
1550
- endHold(false)
1551
- } else if (event.key === 'Escape' && hold.armed) {
1552
- event.preventDefault()
1553
- endHold(true)
1554
- }
1555
- }
1556
- const blur = () => { if (hold.armed) endHold(true) }
1557
- document.addEventListener('keydown', down, true)
1558
- document.addEventListener('keyup', up, true)
1559
- window.addEventListener('blur', blur)
1560
- const cleanup = () => {
1561
- document.removeEventListener('keydown', down, true)
1562
- document.removeEventListener('keyup', up, true)
1563
- window.removeEventListener('blur', blur)
1564
- if (activeHotkeyCleanup === cleanup) activeHotkeyCleanup = null
1565
- if (typeof window !== 'undefined' && window.__dsh_voice_hotkey_cleanup === cleanup) {
1566
- window.__dsh_voice_hotkey_cleanup = null
1567
- }
1568
- }
1569
- activeHotkeyCleanup = cleanup
1570
- if (typeof window !== 'undefined') {
1571
- window.__dsh_voice_hotkey_cleanup = cleanup
1572
- }
1573
- return cleanup
1574
- }
1575
1450
  function cancelCurrent() {
1576
1451
  voice.activeOpId = (voice.activeOpId || 0) + 1
1577
1452
  announceVoice('end')
@@ -1598,20 +1473,21 @@ voice.webGpu = {
1598
1473
  announceVoice('end')
1599
1474
  focusComposer()
1600
1475
  if (voice.browser) {
1601
- voice.activeOpId = (voice.activeOpId || 0) + 1
1602
1476
  const opId = voice.activeOpId
1603
1477
  const mode = voice.mode
1604
1478
  const b = voice.browser
1479
+ const target = voice._activeTarget
1605
1480
  voice.browser = null
1606
1481
  voice.caption = ''
1482
+ voice.set({ phase: 'finishing' })
1607
1483
  Promise.resolve(b.stop()).then(() => {
1608
- if (voice.activeOpId !== opId || voice.phase === 'idle' || voice.disposed) return
1484
+ if (voice.disposed || voice.activeOpId !== opId) return
1609
1485
  const said = (voice.browserFinals || []).join(' ').trim()
1610
1486
  voice.browserFinals = null
1611
1487
  if (!said) { voice.set({ phase: 'idle' }); return }
1612
1488
  if (mode === 'message') {
1613
- appendDraft(said)
1614
- voice.set({ phase: 'pending', pending: { text: said, leftMs: voice.settings.autoSendMs } })
1489
+ appendDraft(said, target)
1490
+ voice.set({ phase: 'pending', pending: { text: said, target, totalMs: voice.settings.autoSendMs, leftMs: voice.settings.autoSendMs } })
1615
1491
  } else {
1616
1492
  voice.set({ phase: 'idle' })
1617
1493
  }
@@ -1621,17 +1497,23 @@ voice.webGpu = {
1621
1497
  const rec = voice.rec
1622
1498
  if (!rec || rec.closing) return
1623
1499
  rec.closing = true
1624
- voice.activeOpId = (voice.activeOpId || 0) + 1
1625
- const opId = voice.activeOpId
1500
+ const opId = rec.opId || voice.activeOpId
1501
+ const target = rec.target || voice._activeTarget
1626
1502
  const mode = rec.mode
1627
1503
  const stopped = waitStop(rec.recorder)
1628
1504
  try {
1629
1505
  if (rec.recorder && rec.recorder.state !== 'inactive') rec.recorder.stop()
1630
1506
  } catch (e) { /* already stopped */ }
1631
1507
  stopped.then(async () => {
1508
+ if (voice.disposed || rec.cancelled || voice.activeOpId !== opId) {
1509
+ teardown(rec)
1510
+ if (voice.rec === rec) voice.rec = null
1511
+ return
1512
+ }
1632
1513
  const blob = new Blob(rec.chunks, { type: rec.mime })
1633
1514
  teardown(rec)
1634
- voice.rec = null
1515
+ if (voice.rec === rec) voice.rec = null
1516
+ if (voice.disposed || rec.cancelled || voice.activeOpId !== opId) return
1635
1517
  if (blob.size < (mode === 'dictation' ? 600 : 1200)) {
1636
1518
  if (mode === 'dictation') { try { await dictationQueue } catch (_) {} }
1637
1519
  voice.set({ phase: 'idle' })
@@ -1645,10 +1527,10 @@ voice.webGpu = {
1645
1527
  voice.lastNote = { blob, url: noteUrl, mime: rec.mime, text: '' }
1646
1528
  voice.set({ phase: 'processing' })
1647
1529
  const processTail = async () => {
1648
- if (rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1530
+ if (voice.disposed || rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1649
1531
  try {
1650
1532
  const out = await sendAudio(blob, rec.mime, mode)
1651
- if (rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1533
+ if (voice.disposed || rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1652
1534
  if (out.command) { runSessionCommand(out.command); return }
1653
1535
  const text = out.text || ''
1654
1536
  const action = out.action || null
@@ -1665,19 +1547,19 @@ voice.webGpu = {
1665
1547
  }
1666
1548
  }
1667
1549
  if (action === 'send' || action === 'clear' || action === 'newline') {
1668
- executeVoiceAction(action, text)
1550
+ executeVoiceAction(action, text, target)
1669
1551
  return
1670
1552
  }
1671
1553
  if (action === 'cancel') { cancelCurrent(); return }
1672
- appendDraft(text)
1554
+ appendDraft(text, target)
1673
1555
  if (voice.lastNote) voice.lastNote.text = text
1674
1556
  if (mode === 'message') {
1675
- voice.set({ phase: 'pending', pending: { text, totalMs: voice.settings.autoSendMs, leftMs: voice.settings.autoSendMs, audioUrl: noteUrl } })
1557
+ voice.set({ phase: 'pending', pending: { text, target, totalMs: voice.settings.autoSendMs, leftMs: voice.settings.autoSendMs, audioUrl: noteUrl } })
1676
1558
  } else {
1677
1559
  voice.set({ phase: 'idle' })
1678
1560
  }
1679
1561
  } catch (e) {
1680
- if (rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1562
+ if (voice.disposed || rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1681
1563
  voice.set({ phase: 'error', error: String(e && e.message ? e.message : e) })
1682
1564
  }
1683
1565
  }
@@ -1689,80 +1571,75 @@ voice.webGpu = {
1689
1571
  }
1690
1572
  })
1691
1573
  }
1692
- function clearDraft() {
1693
- const actions = voice.inputActions
1694
- if (!actions || typeof actions.setDraft !== 'function') {
1695
- voice.set({ phase: 'error', error: t('composerUnavailable') })
1696
- return false
1697
- }
1698
- actions.setDraft('')
1699
- return true
1574
+ function clearDraft(target) {
1575
+ const activeTarget = target || voice._activeTarget
1576
+ const actions = (activeTarget && activeTarget.actions) || voice.inputActions
1577
+ if (actions && typeof actions.setDraft === 'function') actions.setDraft('')
1700
1578
  }
1701
- function executeVoiceAction(action, text) {
1579
+
1580
+ // Voice actions (#29-7): spoken triggers perform composer actions.
1581
+ function executeVoiceAction(action, rawText, target) {
1582
+ const activeTarget = target || voice._activeTarget
1583
+ const actions = (activeTarget && activeTarget.actions) || voice.inputActions
1702
1584
  if (action === 'send') {
1703
- if (text) appendDraft(text)
1704
- submitPending()
1705
- return true
1706
- }
1707
- if (action === 'clear') {
1708
- const ok = clearDraft()
1709
- if (ok && voice.phase !== 'recording') voice.set({ phase: 'idle' })
1710
- return ok
1711
- }
1712
- if (action === 'newline') {
1713
- const payload = text ? (text.endsWith('\n') ? text : text + '\n') : '\n'
1714
- appendDraft(payload)
1715
- if (voice.phase !== 'recording') voice.set({ phase: 'idle' })
1716
- return true
1585
+ const text = rawText.replace(/(^|[\s,.!?])(отправь|пошли|send)([\s,.!?]|$)/gi, '').trim()
1586
+ if (text) appendDraft(text, activeTarget)
1587
+ if (typeof actions?.submit === 'function') {
1588
+ actions.submit()
1589
+ }
1590
+ voice.set({ phase: 'idle' })
1591
+ } else if (action === 'clear') {
1592
+ clearDraft(activeTarget)
1593
+ voice.set({ phase: 'idle' })
1594
+ } else if (action === 'newline') {
1595
+ const payload = (rawText.replace(/(^|[\s,.!?])(с новой строки|новая строка|абзац|new line|paragraph)([\s,.!?]|$)/gi, '').trim())
1596
+ appendDraft(payload, activeTarget)
1597
+ voice.set({ phase: 'idle' })
1717
1598
  }
1718
- if (action === 'cancel') { cancelCurrent(); return true }
1719
- return false
1720
1599
  }
1721
- voice.executeVoiceAction = executeVoiceAction
1600
+
1722
1601
  function submitPending() {
1602
+ const pending = voice.pending
1603
+ if (!pending) return
1604
+ const text = pending.text
1605
+ const target = pending.target || voice._activeTarget
1606
+ const actions = (target && target.actions) || voice.inputActions
1723
1607
  voice.pending = null
1724
- const actions = voice.inputActions
1725
- if (!actions || typeof actions.submit !== 'function') {
1726
- voice.set({ phase: 'error', error: t('composerUnavailable') })
1727
- return
1728
- }
1729
1608
  voice.set({ phase: 'idle' })
1609
+
1730
1610
  if (voice.settings.polishSend) {
1731
- const run = async () => {
1732
- try {
1733
- const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
1734
- if (draft.trim()) {
1735
- const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
1736
- const timer = controller ? setTimeout(() => controller.abort(), 10000) : null
1737
- try {
1738
- const res = await fetch('/dsh-voice/polish', {
1739
- method: 'POST',
1740
- headers: { 'content-type': 'application/json' },
1741
- body: JSON.stringify({ text: draft }),
1742
- signal: controller ? controller.signal : undefined,
1743
- })
1744
- const parsed = await res.json().catch(() => null)
1745
- if (parsed && parsed.ok && typeof parsed.text === 'string' && parsed.text.trim()) {
1746
- actions.setDraft(parsed.text.trim())
1747
- }
1748
- } finally {
1749
- if (timer) clearTimeout(timer)
1750
- }
1611
+ fetch('/dsh-voice/polish', {
1612
+ method: 'POST',
1613
+ headers: { 'content-type': 'application/json' },
1614
+ body: JSON.stringify({ text }),
1615
+ })
1616
+ .then((res) => res.json())
1617
+ .then((data) => {
1618
+ const polished = (data && data.text) || text
1619
+ if (actions && typeof actions.setDraft === 'function') {
1620
+ actions.setDraft(polished)
1751
1621
  }
1752
- } catch (e) { /* polish is best-effort */ }
1753
- }
1754
- run().finally(() => setTimeout(() => { try { actions.submit() } catch (e) { /* busy */ } }, 0))
1755
- return
1622
+ if (actions && typeof actions.submit === 'function') {
1623
+ actions.submit()
1624
+ }
1625
+ })
1626
+ .catch(() => {
1627
+ if (actions && typeof actions.submit === 'function') {
1628
+ actions.submit()
1629
+ }
1630
+ })
1631
+ } else if (actions && typeof actions.submit === 'function') {
1632
+ actions.submit()
1756
1633
  }
1757
- setTimeout(() => { try { actions.submit() } catch (e) { /* composer busy */ } }, 0)
1758
1634
  }
1635
+
1759
1636
  function runSessionCommand(cmd) {
1760
- const actions = voice.inputActions
1761
- voice.set({ phase: 'idle', pending: null })
1762
- if ((cmd === 'send' || cmd === 'continue') && actions && typeof actions.submit === 'function') {
1763
- actions.submit()
1764
- }
1637
+ if (cmd === 'send') submitPending()
1638
+ else if (cmd === 'cancel') cancelCurrent()
1639
+ else if (cmd === 'stop') cancelCurrent()
1640
+ else if (cmd === 'continue') { /* keep recording */ }
1765
1641
  }
1642
+
1766
1643
  function keepPending() {
1767
1644
  voice.pending = null
1768
1645
  voice.set({ phase: 'idle' })
@@ -1806,6 +1683,194 @@ voice.webGpu = {
1806
1683
  )
1807
1684
  }
1808
1685
 
1686
+ // Human-readable key name.
1687
+ const KEY_LABELS = {
1688
+ Control: 'Ctrl', Alt: 'Alt', Shift: 'Shift', Meta: 'Win',
1689
+ Escape: 'Esc',
1690
+ }
1691
+
1692
+ function keyLabel(name) {
1693
+ if (!name) return t('keyUnset')
1694
+ if (typeof name === 'string' && name.includes('+')) {
1695
+ return name.split('+').map((p) => keyLabel(p)).join(' + ')
1696
+ }
1697
+ if (name === 'Space') return t('keySpace')
1698
+ if (KEY_LABELS[name]) return KEY_LABELS[name]
1699
+ // Drop the Key/Digit prefix from codes like KeyR and Digit5.
1700
+ return String(name).replace(/^Key/, '').replace(/^Digit/, '')
1701
+ }
1702
+
1703
+ // What to store on key press. Pure modifiers are remembered by name:
1704
+ // left and right have different codes but the user means "either".
1705
+ // Supports combos like Control+Space or Alt+KeyV.
1706
+ function keyFromEvent(event) {
1707
+ if (!event) return ''
1708
+ const modifiers = []
1709
+ if (event.ctrlKey && event.key !== 'Control') modifiers.push('Control')
1710
+ if (event.altKey && event.key !== 'Alt') modifiers.push('Alt')
1711
+ if (event.shiftKey && event.key !== 'Shift') modifiers.push('Shift')
1712
+ if (event.metaKey && event.key !== 'Meta') modifiers.push('Meta')
1713
+
1714
+ const main = ['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)
1715
+ ? event.key
1716
+ : (event.code || event.key || '')
1717
+
1718
+ if (modifiers.length > 0 && !['Control', 'Alt', 'Shift', 'Meta'].includes(main)) {
1719
+ return modifiers.concat(main).join('+')
1720
+ }
1721
+ return main
1722
+ }
1723
+
1724
+ function hotkeyMatches(event, name) {
1725
+ if (!event || !name) return false
1726
+ if (typeof name === 'string' && name.includes('+')) {
1727
+ const parts = name.split('+')
1728
+ const main = parts[parts.length - 1]
1729
+ const needCtrl = parts.includes('Control')
1730
+ const needAlt = parts.includes('Alt')
1731
+ const needShift = parts.includes('Shift')
1732
+ const needMeta = parts.includes('Meta')
1733
+ if (needCtrl && !event.ctrlKey) return false
1734
+ if (needAlt && !event.altKey) return false
1735
+ if (needShift && !event.shiftKey) return false
1736
+ if (needMeta && !event.metaKey) return false
1737
+ return event.code === main || event.key === main
1738
+ }
1739
+ if (name === 'Control') return event.key === 'Control'
1740
+ if (name === 'Alt') return event.key === 'Alt'
1741
+ if (name === 'Shift') return event.key === 'Shift'
1742
+ if (name === 'Meta') return event.key === 'Meta'
1743
+ return event.code === name || event.key === name
1744
+ }
1745
+
1746
+ function isAllowedInEditable(name) {
1747
+ if (!name) return false
1748
+ if (['Control', 'Alt', 'Shift', 'Meta'].includes(name)) return true
1749
+ if (/^F\d{1,2}$/.test(name)) return true
1750
+ if (typeof name === 'string' && name.includes('+')) return true
1751
+ if (['Tab', 'Pause', 'ScrollLock', 'Insert'].includes(name)) return true
1752
+ return false
1753
+ }
1754
+
1755
+ function isHotkeyRelease(event, name) {
1756
+ if (!event || !name) return false
1757
+ if (typeof name === 'string' && name.includes('+')) {
1758
+ const parts = name.split('+')
1759
+ if (event.code === parts[parts.length - 1] || event.key === parts[parts.length - 1]) return true
1760
+ for (let i = 0; i < parts.length - 1; i++) {
1761
+ if (event.key === parts[i] || event.code === parts[i]) return true
1762
+ }
1763
+ return false
1764
+ }
1765
+ return event.code === name || event.key === name
1766
+ }
1767
+
1768
+ // Announce that the user started speaking.
1769
+ //
1770
+ // Playback should mute immediately: listening and talking at once is
1771
+ // impossible. There is no plugin-to-plugin API — this broadcasts a window
1772
+ // event that anyone may hear, so either side works alone.
1773
+
1774
+ // --------------------------------------------------------- hold gesture
1775
+ const HOLD_THRESHOLD_MS = 350
1776
+ const hold = { active: false, mode: null, startedAt: 0, armed: false }
1777
+ let activePointerCleanup = null
1778
+ function beginHold(mode, pointerEvent) {
1779
+ if (hold.armed || (voice.phase !== 'idle' && voice.phase !== 'error')) return
1780
+ hold.armed = true
1781
+ hold.mode = mode
1782
+ hold.startedAt = Date.now()
1783
+ hold.active = false
1784
+ if (pointerEvent?.target?.setPointerCapture && pointerEvent.pointerId !== undefined) {
1785
+ try { pointerEvent.target.setPointerCapture(pointerEvent.pointerId) } catch (e) {}
1786
+ }
1787
+ if (typeof window !== 'undefined') {
1788
+ if (activePointerCleanup) { try { activePointerCleanup() } catch (e) {} }
1789
+ const onUp = () => endHold(false)
1790
+ const onCancel = () => endHold(true)
1791
+ window.addEventListener('pointerup', onUp, { once: true })
1792
+ window.addEventListener('pointercancel', onCancel, { once: true })
1793
+ activePointerCleanup = () => {
1794
+ window.removeEventListener('pointerup', onUp)
1795
+ window.removeEventListener('pointercancel', onCancel)
1796
+ activePointerCleanup = null
1797
+ }
1798
+ }
1799
+ voice.holding = false
1800
+ startRecording(mode)
1801
+ setTimeout(() => {
1802
+ if (hold.armed) {
1803
+ hold.active = true
1804
+ voice.holding = true
1805
+ voice.notify()
1806
+ }
1807
+ }, HOLD_THRESHOLD_MS)
1808
+ }
1809
+ function endHold(cancelled) {
1810
+ if (activePointerCleanup) { try { activePointerCleanup() } catch (e) {} }
1811
+ if (!hold.armed) return
1812
+ hold.armed = false
1813
+ voice.holding = false
1814
+ voice.notify()
1815
+ if (cancelled) cancelCurrent()
1816
+ else stopCurrent()
1817
+ }
1818
+
1819
+ let activeHotkeyCleanup = null
1820
+ function clearGlobalHotkey() {
1821
+ if (typeof activeHotkeyCleanup === 'function') {
1822
+ try { activeHotkeyCleanup() } catch (_) { /* ignore */ }
1823
+ activeHotkeyCleanup = null
1824
+ }
1825
+ if (typeof window !== 'undefined' && typeof window.__dsh_voice_hotkey_cleanup === 'function') {
1826
+ try { window.__dsh_voice_hotkey_cleanup() } catch (_) { /* ignore */ }
1827
+ window.__dsh_voice_hotkey_cleanup = null
1828
+ }
1829
+ }
1830
+ voice.clearGlobalHotkey = clearGlobalHotkey
1831
+ function installHotkey(ctx, keyName, mode) {
1832
+ clearGlobalHotkey()
1833
+ if (typeof document === 'undefined' || !keyName) return () => {}
1834
+ const down = (event) => {
1835
+ if (event.repeat) return
1836
+ const target = event.target
1837
+ const isEditable = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || (typeof target.getAttribute === 'function' && target.getAttribute('contenteditable') === 'true'))
1838
+ if (isEditable && !isAllowedInEditable(keyName)) return
1839
+ if (hotkeyMatches(event, keyName)) {
1840
+ event.preventDefault()
1841
+ event.stopPropagation()
1842
+ beginHold(mode)
1843
+ }
1844
+ }
1845
+
1846
+ const up = (event) => {
1847
+ if (hold.armed && isHotkeyRelease(event, keyName)) {
1848
+ event.preventDefault()
1849
+ endHold(false)
1850
+ } else if (event.key === 'Escape' && hold.armed) {
1851
+ event.preventDefault()
1852
+ endHold(true)
1853
+ }
1854
+ }
1855
+ const blur = () => { if (hold.armed) endHold(true) }
1856
+ document.addEventListener('keydown', down, true)
1857
+ document.addEventListener('keyup', up, true)
1858
+ window.addEventListener('blur', blur)
1859
+ const cleanup = () => {
1860
+ document.removeEventListener('keydown', down, true)
1861
+ document.removeEventListener('keyup', up, true)
1862
+ window.removeEventListener('blur', blur)
1863
+ if (activeHotkeyCleanup === cleanup) activeHotkeyCleanup = null
1864
+ if (typeof window !== 'undefined' && window.__dsh_voice_hotkey_cleanup === cleanup) {
1865
+ window.__dsh_voice_hotkey_cleanup = null
1866
+ }
1867
+ }
1868
+ activeHotkeyCleanup = cleanup
1869
+ if (typeof window !== 'undefined') {
1870
+ window.__dsh_voice_hotkey_cleanup = cleanup
1871
+ }
1872
+ return cleanup
1873
+ }
1809
1874
  // ----------------------------------------------------------- visualizers
1810
1875
  let _accentColorCache = { color: '', expires: 0 }
1811
1876
  let _softColorCache = { color: '', expires: 0 }
@@ -3204,7 +3269,7 @@ voice.webGpu = {
3204
3269
  // ------------------------------------------------------- settings voice section
3205
3270
  function VoiceSection(props) {
3206
3271
  const t = (props && props.t) || moduleT
3207
- const ctx = props.ctx
3272
+ const ctx = (props && props.ctx) || {}
3208
3273
  // On a non-localhost page the kernel disables settings entirely: the
3209
3274
  // shared document is not readable, every section gets "unavailable" and
3210
3275
  // writes are dropped. The server does not share that restriction, and
@@ -3892,9 +3957,11 @@ voice.webGpu = {
3892
3957
  cutPhrase,
3893
3958
  sendAudio,
3894
3959
  modeChain,
3960
+ openMic,
3895
3961
  startRecording,
3896
3962
  cancelCurrent,
3897
3963
  stopCurrent,
3964
+ submitPending,
3898
3965
  loadWebGpuWhisper,
3899
3966
  }
3900
3967
  return module.exports