@goodandready/dsh-voice 0.9.17 → 0.9.18

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,9 +879,20 @@ 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 { span.insert(text); return true } catch (_) {}
888
+ } else if (typeof span.insertText === 'function') {
889
+ try { span.insertText(text); return true } catch (_) {}
890
+ } else if (typeof span === 'function') {
891
+ try { span(text); return true } catch (_) {}
892
+ }
893
+ }
894
+ const actions = (target && target.actions) || voice.inputActions
895
+ const input = (target && target.input) || voice.input
872
896
  if (!actions || (typeof actions.setDraft !== 'function' && typeof actions.insertText !== 'function')) {
873
897
  voice.set({ phase: 'error', error: t('composerUnavailable') })
874
898
  return false
@@ -876,7 +900,7 @@ window.__ModuleLoader__.load({
876
900
  if (typeof actions.insertText === 'function') {
877
901
  try { actions.insertText(text); return true } catch (_) {}
878
902
  }
879
- const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
903
+ const draft = input && typeof input.draft === 'string' ? input.draft : ''
880
904
  const before = draft
881
905
  const sep = (!draft || draft.endsWith('\n') || text.startsWith('\n')) ? '' : ' '
882
906
  const after = draft + sep + text
@@ -889,100 +913,13 @@ window.__ModuleLoader__.load({
889
913
  return true
890
914
  }
891
915
 
892
- function appendDraft(text) {
916
+ function appendDraft(text, target) {
893
917
  if (voice.disposed || !text) return
894
918
  let clean = voice.settings.voiceCommands ? applyVoiceCommands(text) : tidyPhrase(text)
895
919
  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
957
- }
958
-
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
920
+ insertDraftText(clean, target)
966
921
  }
967
922
 
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
923
  function announceVoice(phase) {
987
924
  try {
988
925
  window.dispatchEvent(new CustomEvent('dsh-voice:speaking', { detail: { phase } }))
@@ -1119,41 +1056,31 @@ function isWebGpuSupported() {
1119
1056
  }
1120
1057
 
1121
1058
  const _webGpuPipelines = new Map()
1122
- let _webGpuLoadingModel = null
1123
1059
 
1124
- async function loadWebGpuWhisper(modelName) {
1060
+ function loadWebGpuWhisper(modelName) {
1125
1061
  const targetModel = modelName || (voice.settings && voice.settings.webgpuModel) || 'onnx-community/whisper-tiny'
1126
1062
  if (_webGpuPipelines.has(targetModel)) {
1127
1063
  return _webGpuPipelines.get(targetModel)
1128
1064
  }
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 {
1065
+ const promise = (async () => {
1139
1066
  if (!isWebGpuSupported()) {
1140
1067
  throw new Error('WebGPU is not supported in this browser environment')
1141
1068
  }
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'))
1069
+ const mod = typeof globalThis !== 'undefined' && globalThis.__mockTransformers
1070
+ ? globalThis.__mockTransformers
1071
+ : (await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.3.3'))
1144
1072
  const pipeline = mod.pipeline || (mod.default && mod.default.pipeline)
1145
1073
  if (!pipeline) throw new Error('Transformers pipeline not found in loaded bundle')
1146
- const instance = await pipeline('automatic-speech-recognition', targetModel, {
1074
+ return pipeline('automatic-speech-recognition', targetModel, {
1147
1075
  device: 'webgpu',
1148
1076
  dtype: 'fp32',
1149
1077
  })
1150
- _webGpuPipelines.set(targetModel, instance)
1151
- return instance
1152
- } finally {
1153
- if (_webGpuLoadingModel === targetModel) {
1154
- _webGpuLoadingModel = null
1155
- }
1156
- }
1078
+ })().catch((err) => {
1079
+ _webGpuPipelines.delete(targetModel)
1080
+ throw err
1081
+ })
1082
+ _webGpuPipelines.set(targetModel, promise)
1083
+ return promise
1157
1084
  }
1158
1085
 
1159
1086
  async function audioBlobToFloat32Array(blob) {
@@ -1191,7 +1118,7 @@ voice.webGpu = {
1191
1118
  load: loadWebGpuWhisper,
1192
1119
  clearCache: () => { _webGpuPipelines.clear() },
1193
1120
  }
1194
- voice._chainsPromise = null
1121
+ voice._chainsPromise = null
1195
1122
  function modeChain(mode) {
1196
1123
  if (!voice._chainsPromise) {
1197
1124
  voice._chainsPromise = fetch('/dsh-voice/status', { cache: 'no-store' })
@@ -1244,7 +1171,7 @@ voice.webGpu = {
1244
1171
  if (!recorder || recorder.state === 'inactive') return Promise.resolve()
1245
1172
  return new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true }))
1246
1173
  }
1247
- async function openMic(mode, opId) {
1174
+ async function openMic(mode, opId, target) {
1248
1175
  if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
1249
1176
  throw new Error(t('micUnavailable'))
1250
1177
  }
@@ -1257,43 +1184,54 @@ voice.webGpu = {
1257
1184
  }
1258
1185
  if (voice.settings.micDeviceId) audio.deviceId = { exact: voice.settings.micDeviceId }
1259
1186
  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
1187
+ let rec = null
1188
+ try {
1189
+ let mimeType = 'audio/webm;codecs=opus'
1190
+ if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = ''
1191
+ const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream)
1192
+ rec = {
1193
+ recorder, stream, mode,
1194
+ target: target || voice._activeTarget,
1195
+ opId: opId || (voice.activeOpId || 0),
1196
+ chunks: [],
1197
+ mime: mimeType || recorder.mimeType || 'audio/webm',
1198
+ audioCtx: null, analyser: null,
1199
+ cutting: false, closing: false,
1200
+ silenceMs: 0, hadSpeech: false,
1201
+ streamMs: 0,
1202
+ }
1203
+ recorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) rec.chunks.push(e.data) }
1204
+ const AC = typeof AudioContext !== 'undefined' ? AudioContext
1205
+ : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
1206
+ if (AC) {
1207
+ rec.audioCtx = new AC()
1208
+ if (rec.audioCtx.state === 'suspended') { rec.audioCtx.resume().catch(() => {}) }
1209
+ const src = rec.audioCtx.createMediaStreamSource(stream)
1210
+ rec.srcNode = src
1211
+ let lastNode = src
1212
+ try {
1213
+ const filter = rec.audioCtx.createBiquadFilter()
1214
+ filter.type = 'highpass'
1215
+ filter.frequency.value = 80
1216
+ filter.Q.value = 0.707
1217
+ src.connect(filter)
1218
+ lastNode = filter
1219
+ rec.filterNode = filter
1220
+ } catch (e) { /* audio filter fallback */ }
1221
+ rec.analyser = rec.audioCtx.createAnalyser()
1222
+ rec.analyser.fftSize = 128
1223
+ lastNode.connect(rec.analyser)
1224
+ }
1225
+ recorder.start()
1226
+ return rec
1227
+ } catch (err) {
1228
+ if (rec) {
1229
+ teardown(rec)
1230
+ } else {
1231
+ try { stream.getTracks().forEach((t) => t.stop()) } catch (_) {}
1232
+ }
1233
+ throw err
1234
+ }
1297
1235
  }
1298
1236
  function currentLevel(rec) {
1299
1237
  if (!rec || !rec.analyser) return 0
@@ -1314,6 +1252,7 @@ voice.webGpu = {
1314
1252
  const rec = voice.rec
1315
1253
  if (!rec || rec.cutting || rec.closing) return
1316
1254
  const opId = rec.opId || voice.activeOpId
1255
+ const target = rec.target || voice._activeTarget
1317
1256
  rec.cutting = true
1318
1257
  const stopped = waitStop(rec.recorder)
1319
1258
  try {
@@ -1325,7 +1264,7 @@ voice.webGpu = {
1325
1264
  return
1326
1265
  }
1327
1266
  stopped.then(async () => {
1328
- if (rec.cancelled || voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) return
1267
+ if (rec.cancelled || voice.activeOpId !== opId || voice.disposed) return
1329
1268
  const blob = new Blob(rec.chunks, { type: rec.mime })
1330
1269
  rec.chunks = []
1331
1270
  rec.silenceMs = 0
@@ -1339,10 +1278,10 @@ voice.webGpu = {
1339
1278
  rec.cutting = false
1340
1279
  if (rec.cancelled || blob.size < 600) return // cancelled or too short — not speech
1341
1280
  dictationQueue = dictationQueue.then(async () => {
1342
- if (rec.cancelled || voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) return
1281
+ if (rec.cancelled || voice.activeOpId !== opId || voice.disposed) return
1343
1282
  try {
1344
1283
  const out = await sendAudio(blob, rec.mime, 'dictation')
1345
- if (rec.cancelled || voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) return
1284
+ if (rec.cancelled || voice.activeOpId !== opId || voice.disposed) return
1346
1285
  const text = out && out.text ? out.text : ''
1347
1286
  const action = out && out.action ? out.action : null
1348
1287
  if (voice.settings.structuredPromptVoice !== false && text) {
@@ -1361,20 +1300,20 @@ voice.webGpu = {
1361
1300
  }
1362
1301
  const delay = Number(voice.settings.sendDelayMs) || 0
1363
1302
  if (text && delay > 0 && !voice.holding) {
1364
- appendDraft(text)
1303
+ appendDraft(text, target)
1365
1304
  voice.set({ phase: 'pending', pending: { text, undoOnly: true, leftMs: delay } })
1366
1305
  return
1367
1306
  }
1368
- if (text) appendDraft(text)
1307
+ if (text) appendDraft(text, target)
1369
1308
  } catch (e) {
1370
- if (!rec.cancelled && voice.activeOpId === opId) {
1309
+ if (!rec.cancelled && voice.activeOpId === opId && !voice.disposed) {
1371
1310
  voice.set({ error: String(e && e.message ? e.message : e) })
1372
1311
  }
1373
1312
  }
1374
1313
  })
1375
1314
  })
1376
1315
  }
1377
- function startBrowserLeg(mode, language, opId) {
1316
+ function startBrowserLeg(mode, language, opId, target) {
1378
1317
  if (voice.settings && voice.settings.localOnly) return false
1379
1318
  if (!browserRecognitionAvailable()) return false
1380
1319
  const finals = []
@@ -1399,13 +1338,14 @@ voice.webGpu = {
1399
1338
  voice.browser = null
1400
1339
  voice.caption = ''
1401
1340
  voice.interimTranscript = ''
1402
- openMic(mode, opId)
1341
+ openMic(mode, opId, target)
1403
1342
  .then((rec) => {
1404
1343
  if (voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) {
1405
1344
  teardown(rec)
1406
1345
  return
1407
1346
  }
1408
1347
  rec.opId = opId
1348
+ rec.target = target
1409
1349
  voice.rec = rec
1410
1350
  voice.notify()
1411
1351
  })
@@ -1417,11 +1357,11 @@ voice.webGpu = {
1417
1357
  }
1418
1358
  },
1419
1359
  onFinal: (text) => {
1420
- if (voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) return
1360
+ if (voice.activeOpId !== opId || (voice.phase !== 'recording' && voice.phase !== 'finishing') || voice.disposed) return
1421
1361
  finals.push(text)
1422
1362
  voice.caption = ''
1423
1363
  voice.interimTranscript = ''
1424
- if (mode === 'dictation') appendDraft(text)
1364
+ if (mode === 'dictation') appendDraft(text, target)
1425
1365
  else voice.notify()
1426
1366
  },
1427
1367
  onError: (reason) => {
@@ -1439,6 +1379,15 @@ voice.webGpu = {
1439
1379
  if (voice.phase !== 'idle' && voice.phase !== 'error') return
1440
1380
  voice.activeOpId = (voice.activeOpId || 0) + 1
1441
1381
  const opId = voice.activeOpId
1382
+ const targetActions = voice.inputActions
1383
+ const targetInput = voice.input
1384
+ let span = null
1385
+ if (targetActions && typeof targetActions.captureInsertion === 'function') {
1386
+ try { span = targetActions.captureInsertion() } catch (_) {}
1387
+ }
1388
+ const target = { actions: targetActions, input: targetInput, span }
1389
+ voice._activeTarget = target
1390
+
1442
1391
  if (voice.isTtsSpeaking) {
1443
1392
  if (voice.settings.bargeIn) {
1444
1393
  triggerBargeIn()
@@ -1455,14 +1404,15 @@ voice.webGpu = {
1455
1404
  voice._activeChain = info.chain || []
1456
1405
  voice._activeLang = info.language || ''
1457
1406
  voice._activeModelMap = info.modelMap || {}
1458
- if (info.chain[0] === 'browser' && !voice.settings.localOnly && startBrowserLeg(mode, info.language, opId)) return
1459
- openMic(mode, opId)
1407
+ if (info.chain[0] === 'browser' && !voice.settings.localOnly && startBrowserLeg(mode, info.language, opId, target)) return
1408
+ openMic(mode, opId, target)
1460
1409
  .then((rec) => {
1461
1410
  if (voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) {
1462
1411
  teardown(rec)
1463
1412
  return
1464
1413
  }
1465
1414
  rec.opId = opId
1415
+ rec.target = target
1466
1416
  voice.rec = rec
1467
1417
  voice.notify()
1468
1418
  })
@@ -1474,104 +1424,7 @@ voice.webGpu = {
1474
1424
  }
1475
1425
  function startDictation() { startRecording('dictation') }
1476
1426
  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
-
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
1427
 
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
1428
  function cancelCurrent() {
1576
1429
  voice.activeOpId = (voice.activeOpId || 0) + 1
1577
1430
  announceVoice('end')
@@ -1598,19 +1451,20 @@ voice.webGpu = {
1598
1451
  announceVoice('end')
1599
1452
  focusComposer()
1600
1453
  if (voice.browser) {
1601
- voice.activeOpId = (voice.activeOpId || 0) + 1
1602
1454
  const opId = voice.activeOpId
1603
1455
  const mode = voice.mode
1604
1456
  const b = voice.browser
1457
+ const target = voice._activeTarget
1605
1458
  voice.browser = null
1606
1459
  voice.caption = ''
1460
+ voice.set({ phase: 'finishing' })
1607
1461
  Promise.resolve(b.stop()).then(() => {
1608
- if (voice.activeOpId !== opId || voice.phase === 'idle' || voice.disposed) return
1462
+ if (voice.disposed || voice.activeOpId !== opId) return
1609
1463
  const said = (voice.browserFinals || []).join(' ').trim()
1610
1464
  voice.browserFinals = null
1611
1465
  if (!said) { voice.set({ phase: 'idle' }); return }
1612
1466
  if (mode === 'message') {
1613
- appendDraft(said)
1467
+ appendDraft(said, target)
1614
1468
  voice.set({ phase: 'pending', pending: { text: said, leftMs: voice.settings.autoSendMs } })
1615
1469
  } else {
1616
1470
  voice.set({ phase: 'idle' })
@@ -1621,17 +1475,23 @@ voice.webGpu = {
1621
1475
  const rec = voice.rec
1622
1476
  if (!rec || rec.closing) return
1623
1477
  rec.closing = true
1624
- voice.activeOpId = (voice.activeOpId || 0) + 1
1625
- const opId = voice.activeOpId
1478
+ const opId = rec.opId || voice.activeOpId
1479
+ const target = rec.target || voice._activeTarget
1626
1480
  const mode = rec.mode
1627
1481
  const stopped = waitStop(rec.recorder)
1628
1482
  try {
1629
1483
  if (rec.recorder && rec.recorder.state !== 'inactive') rec.recorder.stop()
1630
1484
  } catch (e) { /* already stopped */ }
1631
1485
  stopped.then(async () => {
1486
+ if (voice.disposed || rec.cancelled || voice.activeOpId !== opId) {
1487
+ teardown(rec)
1488
+ if (voice.rec === rec) voice.rec = null
1489
+ return
1490
+ }
1632
1491
  const blob = new Blob(rec.chunks, { type: rec.mime })
1633
1492
  teardown(rec)
1634
- voice.rec = null
1493
+ if (voice.rec === rec) voice.rec = null
1494
+ if (voice.disposed || rec.cancelled || voice.activeOpId !== opId) return
1635
1495
  if (blob.size < (mode === 'dictation' ? 600 : 1200)) {
1636
1496
  if (mode === 'dictation') { try { await dictationQueue } catch (_) {} }
1637
1497
  voice.set({ phase: 'idle' })
@@ -1645,10 +1505,10 @@ voice.webGpu = {
1645
1505
  voice.lastNote = { blob, url: noteUrl, mime: rec.mime, text: '' }
1646
1506
  voice.set({ phase: 'processing' })
1647
1507
  const processTail = async () => {
1648
- if (rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1508
+ if (voice.disposed || rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1649
1509
  try {
1650
1510
  const out = await sendAudio(blob, rec.mime, mode)
1651
- if (rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1511
+ if (voice.disposed || rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1652
1512
  if (out.command) { runSessionCommand(out.command); return }
1653
1513
  const text = out.text || ''
1654
1514
  const action = out.action || null
@@ -1669,7 +1529,7 @@ voice.webGpu = {
1669
1529
  return
1670
1530
  }
1671
1531
  if (action === 'cancel') { cancelCurrent(); return }
1672
- appendDraft(text)
1532
+ appendDraft(text, target)
1673
1533
  if (voice.lastNote) voice.lastNote.text = text
1674
1534
  if (mode === 'message') {
1675
1535
  voice.set({ phase: 'pending', pending: { text, totalMs: voice.settings.autoSendMs, leftMs: voice.settings.autoSendMs, audioUrl: noteUrl } })
@@ -1677,7 +1537,7 @@ voice.webGpu = {
1677
1537
  voice.set({ phase: 'idle' })
1678
1538
  }
1679
1539
  } catch (e) {
1680
- if (rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1540
+ if (voice.disposed || rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1681
1541
  voice.set({ phase: 'error', error: String(e && e.message ? e.message : e) })
1682
1542
  }
1683
1543
  }
@@ -1691,78 +1551,69 @@ voice.webGpu = {
1691
1551
  }
1692
1552
  function clearDraft() {
1693
1553
  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
1554
+ if (actions && typeof actions.setDraft === 'function') actions.setDraft('')
1700
1555
  }
1701
- function executeVoiceAction(action, text) {
1556
+
1557
+ // Voice actions (#29-7): spoken triggers perform composer actions.
1558
+ function executeVoiceAction(action, rawText) {
1702
1559
  if (action === 'send') {
1560
+ const text = rawText.replace(/(^|[\s,.!?])(отправь|пошли|send)([\s,.!?]|$)/gi, '').trim()
1703
1561
  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'
1562
+ if (typeof voice.inputActions?.submit === 'function') {
1563
+ voice.inputActions.submit()
1564
+ }
1565
+ voice.set({ phase: 'idle' })
1566
+ } else if (action === 'clear') {
1567
+ clearDraft()
1568
+ voice.set({ phase: 'idle' })
1569
+ } else if (action === 'newline') {
1570
+ const payload = (rawText.replace(/(^|[\s,.!?])(с новой строки|новая строка|абзац|new line|paragraph)([\s,.!?]|$)/gi, '').trim())
1714
1571
  appendDraft(payload)
1715
- if (voice.phase !== 'recording') voice.set({ phase: 'idle' })
1716
- return true
1572
+ voice.set({ phase: 'idle' })
1717
1573
  }
1718
- if (action === 'cancel') { cancelCurrent(); return true }
1719
- return false
1720
1574
  }
1721
- voice.executeVoiceAction = executeVoiceAction
1575
+
1722
1576
  function submitPending() {
1723
- voice.pending = null
1577
+ const pending = voice.pending
1578
+ if (!pending) return
1579
+ const text = pending.text
1724
1580
  const actions = voice.inputActions
1725
- if (!actions || typeof actions.submit !== 'function') {
1726
- voice.set({ phase: 'error', error: t('composerUnavailable') })
1727
- return
1728
- }
1581
+ voice.pending = null
1729
1582
  voice.set({ phase: 'idle' })
1583
+
1730
1584
  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
- }
1585
+ fetch('/dsh-voice/polish', {
1586
+ method: 'POST',
1587
+ headers: { 'content-type': 'application/json' },
1588
+ body: JSON.stringify({ text }),
1589
+ })
1590
+ .then((res) => res.json())
1591
+ .then((data) => {
1592
+ const polished = (data && data.text) || text
1593
+ if (actions && typeof actions.setDraft === 'function') {
1594
+ actions.setDraft(polished)
1751
1595
  }
1752
- } catch (e) { /* polish is best-effort */ }
1753
- }
1754
- run().finally(() => setTimeout(() => { try { actions.submit() } catch (e) { /* busy */ } }, 0))
1755
- return
1596
+ if (actions && typeof actions.submit === 'function') {
1597
+ actions.submit()
1598
+ }
1599
+ })
1600
+ .catch(() => {
1601
+ if (actions && typeof actions.submit === 'function') {
1602
+ actions.submit()
1603
+ }
1604
+ })
1605
+ } else if (actions && typeof actions.submit === 'function') {
1606
+ actions.submit()
1756
1607
  }
1757
- setTimeout(() => { try { actions.submit() } catch (e) { /* composer busy */ } }, 0)
1758
1608
  }
1609
+
1759
1610
  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
- }
1611
+ if (cmd === 'send') submitPending()
1612
+ else if (cmd === 'cancel') cancelCurrent()
1613
+ else if (cmd === 'stop') cancelCurrent()
1614
+ else if (cmd === 'continue') { /* keep recording */ }
1765
1615
  }
1616
+
1766
1617
  function keepPending() {
1767
1618
  voice.pending = null
1768
1619
  voice.set({ phase: 'idle' })
@@ -1806,6 +1657,194 @@ voice.webGpu = {
1806
1657
  )
1807
1658
  }
1808
1659
 
1660
+ // Human-readable key name.
1661
+ const KEY_LABELS = {
1662
+ Control: 'Ctrl', Alt: 'Alt', Shift: 'Shift', Meta: 'Win',
1663
+ Escape: 'Esc',
1664
+ }
1665
+
1666
+ function keyLabel(name) {
1667
+ if (!name) return t('keyUnset')
1668
+ if (typeof name === 'string' && name.includes('+')) {
1669
+ return name.split('+').map((p) => keyLabel(p)).join(' + ')
1670
+ }
1671
+ if (name === 'Space') return t('keySpace')
1672
+ if (KEY_LABELS[name]) return KEY_LABELS[name]
1673
+ // Drop the Key/Digit prefix from codes like KeyR and Digit5.
1674
+ return String(name).replace(/^Key/, '').replace(/^Digit/, '')
1675
+ }
1676
+
1677
+ // What to store on key press. Pure modifiers are remembered by name:
1678
+ // left and right have different codes but the user means "either".
1679
+ // Supports combos like Control+Space or Alt+KeyV.
1680
+ function keyFromEvent(event) {
1681
+ if (!event) return ''
1682
+ const modifiers = []
1683
+ if (event.ctrlKey && event.key !== 'Control') modifiers.push('Control')
1684
+ if (event.altKey && event.key !== 'Alt') modifiers.push('Alt')
1685
+ if (event.shiftKey && event.key !== 'Shift') modifiers.push('Shift')
1686
+ if (event.metaKey && event.key !== 'Meta') modifiers.push('Meta')
1687
+
1688
+ const main = ['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)
1689
+ ? event.key
1690
+ : (event.code || event.key || '')
1691
+
1692
+ if (modifiers.length > 0 && !['Control', 'Alt', 'Shift', 'Meta'].includes(main)) {
1693
+ return modifiers.concat(main).join('+')
1694
+ }
1695
+ return main
1696
+ }
1697
+
1698
+ function hotkeyMatches(event, name) {
1699
+ if (!event || !name) return false
1700
+ if (typeof name === 'string' && name.includes('+')) {
1701
+ const parts = name.split('+')
1702
+ const main = parts[parts.length - 1]
1703
+ const needCtrl = parts.includes('Control')
1704
+ const needAlt = parts.includes('Alt')
1705
+ const needShift = parts.includes('Shift')
1706
+ const needMeta = parts.includes('Meta')
1707
+ if (needCtrl && !event.ctrlKey) return false
1708
+ if (needAlt && !event.altKey) return false
1709
+ if (needShift && !event.shiftKey) return false
1710
+ if (needMeta && !event.metaKey) return false
1711
+ return event.code === main || event.key === main
1712
+ }
1713
+ if (name === 'Control') return event.key === 'Control'
1714
+ if (name === 'Alt') return event.key === 'Alt'
1715
+ if (name === 'Shift') return event.key === 'Shift'
1716
+ if (name === 'Meta') return event.key === 'Meta'
1717
+ return event.code === name || event.key === name
1718
+ }
1719
+
1720
+ function isAllowedInEditable(name) {
1721
+ if (!name) return false
1722
+ if (['Control', 'Alt', 'Shift', 'Meta'].includes(name)) return true
1723
+ if (/^F\d{1,2}$/.test(name)) return true
1724
+ if (typeof name === 'string' && name.includes('+')) return true
1725
+ if (['Tab', 'Pause', 'ScrollLock', 'Insert'].includes(name)) return true
1726
+ return false
1727
+ }
1728
+
1729
+ function isHotkeyRelease(event, name) {
1730
+ if (!event || !name) return false
1731
+ if (typeof name === 'string' && name.includes('+')) {
1732
+ const parts = name.split('+')
1733
+ if (event.code === parts[parts.length - 1] || event.key === parts[parts.length - 1]) return true
1734
+ for (let i = 0; i < parts.length - 1; i++) {
1735
+ if (event.key === parts[i] || event.code === parts[i]) return true
1736
+ }
1737
+ return false
1738
+ }
1739
+ return event.code === name || event.key === name
1740
+ }
1741
+
1742
+ // Announce that the user started speaking.
1743
+ //
1744
+ // Playback should mute immediately: listening and talking at once is
1745
+ // impossible. There is no plugin-to-plugin API — this broadcasts a window
1746
+ // event that anyone may hear, so either side works alone.
1747
+
1748
+ // --------------------------------------------------------- hold gesture
1749
+ const HOLD_THRESHOLD_MS = 350
1750
+ const hold = { active: false, mode: null, startedAt: 0, armed: false }
1751
+ let activePointerCleanup = null
1752
+ function beginHold(mode, pointerEvent) {
1753
+ if (hold.armed || (voice.phase !== 'idle' && voice.phase !== 'error')) return
1754
+ hold.armed = true
1755
+ hold.mode = mode
1756
+ hold.startedAt = Date.now()
1757
+ hold.active = false
1758
+ if (pointerEvent?.target?.setPointerCapture && pointerEvent.pointerId !== undefined) {
1759
+ try { pointerEvent.target.setPointerCapture(pointerEvent.pointerId) } catch (e) {}
1760
+ }
1761
+ if (typeof window !== 'undefined') {
1762
+ if (activePointerCleanup) { try { activePointerCleanup() } catch (e) {} }
1763
+ const onUp = () => endHold(false)
1764
+ const onCancel = () => endHold(true)
1765
+ window.addEventListener('pointerup', onUp, { once: true })
1766
+ window.addEventListener('pointercancel', onCancel, { once: true })
1767
+ activePointerCleanup = () => {
1768
+ window.removeEventListener('pointerup', onUp)
1769
+ window.removeEventListener('pointercancel', onCancel)
1770
+ activePointerCleanup = null
1771
+ }
1772
+ }
1773
+ voice.holding = false
1774
+ startRecording(mode)
1775
+ setTimeout(() => {
1776
+ if (hold.armed) {
1777
+ hold.active = true
1778
+ voice.holding = true
1779
+ voice.notify()
1780
+ }
1781
+ }, HOLD_THRESHOLD_MS)
1782
+ }
1783
+ function endHold(cancelled) {
1784
+ if (activePointerCleanup) { try { activePointerCleanup() } catch (e) {} }
1785
+ if (!hold.armed) return
1786
+ hold.armed = false
1787
+ voice.holding = false
1788
+ voice.notify()
1789
+ if (cancelled) cancelCurrent()
1790
+ else stopCurrent()
1791
+ }
1792
+
1793
+ let activeHotkeyCleanup = null
1794
+ function clearGlobalHotkey() {
1795
+ if (typeof activeHotkeyCleanup === 'function') {
1796
+ try { activeHotkeyCleanup() } catch (_) { /* ignore */ }
1797
+ activeHotkeyCleanup = null
1798
+ }
1799
+ if (typeof window !== 'undefined' && typeof window.__dsh_voice_hotkey_cleanup === 'function') {
1800
+ try { window.__dsh_voice_hotkey_cleanup() } catch (_) { /* ignore */ }
1801
+ window.__dsh_voice_hotkey_cleanup = null
1802
+ }
1803
+ }
1804
+ voice.clearGlobalHotkey = clearGlobalHotkey
1805
+ function installHotkey(ctx, keyName, mode) {
1806
+ clearGlobalHotkey()
1807
+ if (typeof document === 'undefined' || !keyName) return () => {}
1808
+ const down = (event) => {
1809
+ if (event.repeat) return
1810
+ const target = event.target
1811
+ const isEditable = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || (typeof target.getAttribute === 'function' && target.getAttribute('contenteditable') === 'true'))
1812
+ if (isEditable && !isAllowedInEditable(keyName)) return
1813
+ if (hotkeyMatches(event, keyName)) {
1814
+ event.preventDefault()
1815
+ event.stopPropagation()
1816
+ beginHold(mode)
1817
+ }
1818
+ }
1819
+
1820
+ const up = (event) => {
1821
+ if (hold.armed && isHotkeyRelease(event, keyName)) {
1822
+ event.preventDefault()
1823
+ endHold(false)
1824
+ } else if (event.key === 'Escape' && hold.armed) {
1825
+ event.preventDefault()
1826
+ endHold(true)
1827
+ }
1828
+ }
1829
+ const blur = () => { if (hold.armed) endHold(true) }
1830
+ document.addEventListener('keydown', down, true)
1831
+ document.addEventListener('keyup', up, true)
1832
+ window.addEventListener('blur', blur)
1833
+ const cleanup = () => {
1834
+ document.removeEventListener('keydown', down, true)
1835
+ document.removeEventListener('keyup', up, true)
1836
+ window.removeEventListener('blur', blur)
1837
+ if (activeHotkeyCleanup === cleanup) activeHotkeyCleanup = null
1838
+ if (typeof window !== 'undefined' && window.__dsh_voice_hotkey_cleanup === cleanup) {
1839
+ window.__dsh_voice_hotkey_cleanup = null
1840
+ }
1841
+ }
1842
+ activeHotkeyCleanup = cleanup
1843
+ if (typeof window !== 'undefined') {
1844
+ window.__dsh_voice_hotkey_cleanup = cleanup
1845
+ }
1846
+ return cleanup
1847
+ }
1809
1848
  // ----------------------------------------------------------- visualizers
1810
1849
  let _accentColorCache = { color: '', expires: 0 }
1811
1850
  let _softColorCache = { color: '', expires: 0 }
@@ -3204,7 +3243,7 @@ voice.webGpu = {
3204
3243
  // ------------------------------------------------------- settings voice section
3205
3244
  function VoiceSection(props) {
3206
3245
  const t = (props && props.t) || moduleT
3207
- const ctx = props.ctx
3246
+ const ctx = (props && props.ctx) || {}
3208
3247
  // On a non-localhost page the kernel disables settings entirely: the
3209
3248
  // shared document is not readable, every section gets "unavailable" and
3210
3249
  // writes are dropped. The server does not share that restriction, and
@@ -3892,6 +3931,7 @@ voice.webGpu = {
3892
3931
  cutPhrase,
3893
3932
  sendAudio,
3894
3933
  modeChain,
3934
+ openMic,
3895
3935
  startRecording,
3896
3936
  cancelCurrent,
3897
3937
  stopCurrent,