@goodandready/dsh-voice 0.9.16 → 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
@@ -568,6 +568,7 @@ window.__ModuleLoader__.load({
568
568
  window.addEventListener('beforeunload', onPageUnload)
569
569
 
570
570
  return () => {
571
+ voice.disposed = true
571
572
  window.removeEventListener('dsh:tts:start', onTtsStart)
572
573
  window.removeEventListener('dsh:tts:stop', onTtsStop)
573
574
  window.removeEventListener('dsh-voice:settings-saved', onSettingsSaved)
@@ -734,51 +735,93 @@ window.__ModuleLoader__.load({
734
735
 
735
736
  async function sendAudio(blob, mime, mode) {
736
737
  const activeChain = (voice._activeChain || [])
737
- if (activeChain.includes('browser-webgpu')) {
738
- if (typeof isWebGpuSupported === 'function' && isWebGpuSupported()) {
738
+ const modelMap = voice._activeModelMap || {}
739
+ const chosenModel = modelMap['browser-webgpu'] || (voice.settings && voice.settings.webgpuModel)
740
+
741
+ const tryHost = async (chainOverride) => {
742
+ const dataBase64 = await blobToBase64(blob)
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
+ }
747
+ const contextWords = extractContextKeywords()
748
+ if (contextWords && contextWords.length > 0) payload.contextWords = contextWords
749
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
750
+ const timer = controller ? setTimeout(() => controller.abort(), 60000) : null
751
+ let res = null
752
+ try {
753
+ res = await fetch('/dsh-voice/transcribe', {
754
+ method: 'POST',
755
+ headers: { 'content-type': 'application/json' },
756
+ body: JSON.stringify(payload),
757
+ signal: controller ? controller.signal : undefined,
758
+ })
759
+ } catch (e) {
760
+ if (e && e.name === 'AbortError') {
761
+ throw new Error('Transcription request timed out (60s)')
762
+ }
763
+ throw e
764
+ } finally {
765
+ if (timer) clearTimeout(timer)
766
+ }
767
+ let parsed = null
768
+ try { parsed = await res.json() } catch (e) { /* not json */ }
769
+ if (!res.ok || !parsed || !parsed.ok) {
770
+ throw new Error((parsed && parsed.error && parsed.error.message) || `HTTP ${res.status}`)
771
+ }
772
+ if (parsed.command) return { command: parsed.command, text: '', action: null }
773
+ return { text: String(parsed.text || '').trim(), command: null, action: parsed.action || null }
774
+ }
775
+
776
+ const tryWebGpu = async () => {
777
+ const canGpu = voice.webGpu?.isSupported ? voice.webGpu.isSupported() : (typeof isWebGpuSupported === 'function' && isWebGpuSupported())
778
+ if (canGpu) {
739
779
  try {
740
- const out = await transcribeWithWebGpu(blob, mime, {
780
+ const fn = voice.webGpu?.transcribe || transcribeWithWebGpu
781
+ const out = await fn(blob, mime, {
741
782
  language: voice._activeLang || (voice.settings && voice.settings.dictation && voice.settings.dictation.language) || '',
742
- model: voice.settings && voice.settings.webgpuModel,
783
+ model: chosenModel,
743
784
  })
744
- if (out && typeof out.text === 'string') {
745
- return { text: tidyPhrase(out.text), command: null, action: null, provider: 'browser-webgpu' }
746
- }
785
+ const text = (out && typeof out.text === 'string') ? tidyPhrase(out.text) : ''
786
+ return { text, command: null, action: null, provider: 'browser-webgpu' }
747
787
  } catch (webGpuErr) {
748
- // Graceful fallback to server chain
749
- console.warn('[dsh-voice] WebGPU Whisper failed, falling back to server chain:', webGpuErr)
788
+ console.warn('[dsh-voice] WebGPU Whisper failed:', webGpuErr)
750
789
  }
751
790
  }
791
+ return null
752
792
  }
753
- const dataBase64 = await blobToBase64(blob)
754
- const payload = { dataBase64, mimeType: mime, mode }
755
- const contextWords = extractContextKeywords()
756
- if (contextWords && contextWords.length > 0) payload.contextWords = contextWords
757
- const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
758
- const timer = controller ? setTimeout(() => controller.abort(), 60000) : null
759
- let res = null
760
- try {
761
- res = await fetch('/dsh-voice/transcribe', {
762
- method: 'POST',
763
- headers: { 'content-type': 'application/json' },
764
- body: JSON.stringify(payload),
765
- signal: controller ? controller.signal : undefined,
766
- })
767
- } catch (e) {
768
- if (e && e.name === 'AbortError') {
769
- throw new Error('Transcription request timed out (60s)')
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
770
808
  }
771
- throw e
772
- } finally {
773
- if (timer) clearTimeout(timer)
774
809
  }
775
- let parsed = null
776
- try { parsed = await res.json() } catch (e) { /* not json */ }
777
- if (!res.ok || !parsed || !parsed.ok) {
778
- throw new Error((parsed && parsed.error && parsed.error.message) || `HTTP ${res.status}`)
810
+
811
+ const gpuOut = await tryWebGpu()
812
+ if (gpuOut && gpuOut.text) return gpuOut
813
+
814
+ if (tail.length > 0) {
815
+ try {
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
820
+ }
779
821
  }
780
- if (parsed.command) return { command: parsed.command, text: '', action: null }
781
- return { text: String(parsed.text || '').trim(), command: null, action: parsed.action || null }
822
+
823
+ if (gpuOut) return gpuOut
824
+ return { text: '', command: null, action: null }
782
825
  }
783
826
 
784
827
  function tidyPhrase(text) {
@@ -836,8 +879,20 @@ window.__ModuleLoader__.load({
836
879
  return t('undone')
837
880
  }
838
881
 
839
- function insertDraftText(text) {
840
- const actions = voice.inputActions
882
+ function insertDraftText(text, target) {
883
+ if (voice.disposed) return false
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
841
896
  if (!actions || (typeof actions.setDraft !== 'function' && typeof actions.insertText !== 'function')) {
842
897
  voice.set({ phase: 'error', error: t('composerUnavailable') })
843
898
  return false
@@ -845,7 +900,7 @@ window.__ModuleLoader__.load({
845
900
  if (typeof actions.insertText === 'function') {
846
901
  try { actions.insertText(text); return true } catch (_) {}
847
902
  }
848
- const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
903
+ const draft = input && typeof input.draft === 'string' ? input.draft : ''
849
904
  const before = draft
850
905
  const sep = (!draft || draft.endsWith('\n') || text.startsWith('\n')) ? '' : ' '
851
906
  const after = draft + sep + text
@@ -858,100 +913,13 @@ window.__ModuleLoader__.load({
858
913
  return true
859
914
  }
860
915
 
861
- function appendDraft(text) {
862
- if (!text) return
916
+ function appendDraft(text, target) {
917
+ if (voice.disposed || !text) return
863
918
  let clean = voice.settings.voiceCommands ? applyVoiceCommands(text) : tidyPhrase(text)
864
919
  if (!clean) return
865
- insertDraftText(clean)
866
- }
867
-
868
- // Human-readable key name.
869
- const KEY_LABELS = {
870
- Control: 'Ctrl', Alt: 'Alt', Shift: 'Shift', Meta: 'Win',
871
- Escape: 'Esc',
872
- }
873
-
874
- function keyLabel(name) {
875
- if (!name) return t('keyUnset')
876
- if (typeof name === 'string' && name.includes('+')) {
877
- return name.split('+').map((p) => keyLabel(p)).join(' + ')
878
- }
879
- if (name === 'Space') return t('keySpace')
880
- if (KEY_LABELS[name]) return KEY_LABELS[name]
881
- // Drop the Key/Digit prefix from codes like KeyR and Digit5.
882
- return String(name).replace(/^Key/, '').replace(/^Digit/, '')
883
- }
884
-
885
- // What to store on key press. Pure modifiers are remembered by name:
886
- // left and right have different codes but the user means "either".
887
- // Supports combos like Control+Space or Alt+KeyV.
888
- function keyFromEvent(event) {
889
- if (!event) return ''
890
- const modifiers = []
891
- if (event.ctrlKey && event.key !== 'Control') modifiers.push('Control')
892
- if (event.altKey && event.key !== 'Alt') modifiers.push('Alt')
893
- if (event.shiftKey && event.key !== 'Shift') modifiers.push('Shift')
894
- if (event.metaKey && event.key !== 'Meta') modifiers.push('Meta')
895
-
896
- const main = ['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)
897
- ? event.key
898
- : (event.code || event.key || '')
899
-
900
- if (modifiers.length > 0 && !['Control', 'Alt', 'Shift', 'Meta'].includes(main)) {
901
- return modifiers.concat(main).join('+')
902
- }
903
- return main
904
- }
905
-
906
- function hotkeyMatches(event, name) {
907
- if (!event || !name) return false
908
- if (typeof name === 'string' && name.includes('+')) {
909
- const parts = name.split('+')
910
- const main = parts[parts.length - 1]
911
- const needCtrl = parts.includes('Control')
912
- const needAlt = parts.includes('Alt')
913
- const needShift = parts.includes('Shift')
914
- const needMeta = parts.includes('Meta')
915
- if (needCtrl && !event.ctrlKey) return false
916
- if (needAlt && !event.altKey) return false
917
- if (needShift && !event.shiftKey) return false
918
- if (needMeta && !event.metaKey) return false
919
- return event.code === main || event.key === main
920
- }
921
- if (name === 'Control') return event.key === 'Control'
922
- if (name === 'Alt') return event.key === 'Alt'
923
- if (name === 'Shift') return event.key === 'Shift'
924
- if (name === 'Meta') return event.key === 'Meta'
925
- return event.code === name || event.key === name
920
+ insertDraftText(clean, target)
926
921
  }
927
922
 
928
- function isAllowedInEditable(name) {
929
- if (!name) return false
930
- if (['Control', 'Alt', 'Shift', 'Meta'].includes(name)) return true
931
- if (/^F\d{1,2}$/.test(name)) return true
932
- if (typeof name === 'string' && name.includes('+')) return true
933
- if (['Tab', 'Pause', 'ScrollLock', 'Insert'].includes(name)) return true
934
- return false
935
- }
936
-
937
- function isHotkeyRelease(event, name) {
938
- if (!event || !name) return false
939
- if (typeof name === 'string' && name.includes('+')) {
940
- const parts = name.split('+')
941
- if (event.code === parts[parts.length - 1] || event.key === parts[parts.length - 1]) return true
942
- for (let i = 0; i < parts.length - 1; i++) {
943
- if (event.key === parts[i] || event.code === parts[i]) return true
944
- }
945
- return false
946
- }
947
- return event.code === name || event.key === name
948
- }
949
-
950
- // Announce that the user started speaking.
951
- //
952
- // Playback should mute immediately: listening and talking at once is
953
- // impossible. There is no plugin-to-plugin API — this broadcasts a window
954
- // event that anyone may hear, so either side works alone.
955
923
  function announceVoice(phase) {
956
924
  try {
957
925
  window.dispatchEvent(new CustomEvent('dsh-voice:speaking', { detail: { phase } }))
@@ -1087,35 +1055,32 @@ function isWebGpuSupported() {
1087
1055
  return true
1088
1056
  }
1089
1057
 
1090
- let _webGpuPipeline = null
1091
- let _webGpuLoading = false
1058
+ const _webGpuPipelines = new Map()
1092
1059
 
1093
- async function loadWebGpuWhisper(modelName) {
1094
- if (_webGpuPipeline) return _webGpuPipeline
1095
- if (_webGpuLoading) {
1096
- while (_webGpuLoading) {
1097
- await new Promise((r) => setTimeout(r, 80))
1098
- }
1099
- if (_webGpuPipeline) return _webGpuPipeline
1060
+ function loadWebGpuWhisper(modelName) {
1061
+ const targetModel = modelName || (voice.settings && voice.settings.webgpuModel) || 'onnx-community/whisper-tiny'
1062
+ if (_webGpuPipelines.has(targetModel)) {
1063
+ return _webGpuPipelines.get(targetModel)
1100
1064
  }
1101
- _webGpuLoading = true
1102
- try {
1065
+ const promise = (async () => {
1103
1066
  if (!isWebGpuSupported()) {
1104
1067
  throw new Error('WebGPU is not supported in this browser environment')
1105
1068
  }
1106
- // Dynamic import of transformers.js from ESM CDN
1107
- const mod = 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'))
1108
1072
  const pipeline = mod.pipeline || (mod.default && mod.default.pipeline)
1109
1073
  if (!pipeline) throw new Error('Transformers pipeline not found in loaded bundle')
1110
- const targetModel = modelName || (voice.settings && voice.settings.webgpuModel) || 'onnx-community/whisper-tiny'
1111
- _webGpuPipeline = await pipeline('automatic-speech-recognition', targetModel, {
1074
+ return pipeline('automatic-speech-recognition', targetModel, {
1112
1075
  device: 'webgpu',
1113
1076
  dtype: 'fp32',
1114
1077
  })
1115
- return _webGpuPipeline
1116
- } finally {
1117
- _webGpuLoading = false
1118
- }
1078
+ })().catch((err) => {
1079
+ _webGpuPipelines.delete(targetModel)
1080
+ throw err
1081
+ })
1082
+ _webGpuPipelines.set(targetModel, promise)
1083
+ return promise
1119
1084
  }
1120
1085
 
1121
1086
  async function audioBlobToFloat32Array(blob) {
@@ -1150,13 +1115,23 @@ async function transcribeWithWebGpu(blob, mime, options) {
1150
1115
  voice.webGpu = {
1151
1116
  isSupported: isWebGpuSupported,
1152
1117
  transcribe: transcribeWithWebGpu,
1118
+ load: loadWebGpuWhisper,
1119
+ clearCache: () => { _webGpuPipelines.clear() },
1153
1120
  }
1154
- voice._chainsPromise = null
1121
+ voice._chainsPromise = null
1155
1122
  function modeChain(mode) {
1156
1123
  if (!voice._chainsPromise) {
1157
1124
  voice._chainsPromise = fetch('/dsh-voice/status', { cache: 'no-store' })
1158
1125
  .then((res) => res.json())
1159
- .then((data) => (data && data.modes) || {})
1126
+ .then((data) => {
1127
+ if (data && typeof data.localOnly === 'boolean') {
1128
+ voice.settings.localOnly = data.localOnly
1129
+ }
1130
+ if (data && data.webgpuModel) {
1131
+ voice.settings.webgpuModel = data.webgpuModel
1132
+ }
1133
+ return (data && data.modes) || {}
1134
+ })
1160
1135
  .catch(() => {
1161
1136
  voice._chainsPromise = null
1162
1137
  return {}
@@ -1164,9 +1139,21 @@ voice.webGpu = {
1164
1139
  }
1165
1140
  return voice._chainsPromise.then((modes) => {
1166
1141
  const row = modes[mode] || {}
1142
+ let chain = Array.isArray(row.chain) ? row.chain : []
1143
+ let providers = chain.map((e) => (typeof e === 'string' ? e : e && e.provider)).filter(Boolean)
1144
+ if (voice.settings && voice.settings.localOnly) {
1145
+ providers = providers.filter((p) => p !== 'browser')
1146
+ }
1147
+ const modelMap = {}
1148
+ for (const entry of chain) {
1149
+ if (entry && entry.provider && entry.model) {
1150
+ modelMap[entry.provider] = entry.model
1151
+ }
1152
+ }
1167
1153
  return {
1168
- chain: Array.isArray(row.chain) ? row.chain.map((e) => e && e.provider) : [],
1154
+ chain: providers,
1169
1155
  language: row.language !== undefined ? row.language : '',
1156
+ modelMap,
1170
1157
  }
1171
1158
  })
1172
1159
  }
@@ -1184,7 +1171,7 @@ voice.webGpu = {
1184
1171
  if (!recorder || recorder.state === 'inactive') return Promise.resolve()
1185
1172
  return new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true }))
1186
1173
  }
1187
- async function openMic(mode) {
1174
+ async function openMic(mode, opId, target) {
1188
1175
  if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
1189
1176
  throw new Error(t('micUnavailable'))
1190
1177
  }
@@ -1197,42 +1184,54 @@ voice.webGpu = {
1197
1184
  }
1198
1185
  if (voice.settings.micDeviceId) audio.deviceId = { exact: voice.settings.micDeviceId }
1199
1186
  const stream = await navigator.mediaDevices.getUserMedia({ audio })
1200
- let mimeType = 'audio/webm;codecs=opus'
1201
- if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = ''
1202
- const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream)
1203
- const rec = {
1204
- recorder, stream, mode,
1205
- chunks: [],
1206
- mime: mimeType || recorder.mimeType || 'audio/webm',
1207
- audioCtx: null, analyser: null,
1208
- cutting: false, closing: false,
1209
- silenceMs: 0, hadSpeech: false,
1210
- streamMs: 0,
1211
- }
1212
- recorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) rec.chunks.push(e.data) }
1213
- const AC = typeof AudioContext !== 'undefined' ? AudioContext
1214
- : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
1215
- if (AC) {
1216
- rec.audioCtx = new AC()
1217
- if (rec.audioCtx.state === 'suspended') { rec.audioCtx.resume().catch(() => {}) }
1218
- const src = rec.audioCtx.createMediaStreamSource(stream)
1219
- rec.srcNode = src
1220
- let lastNode = src
1221
- try {
1222
- const filter = rec.audioCtx.createBiquadFilter()
1223
- filter.type = 'highpass'
1224
- filter.frequency.value = 80
1225
- filter.Q.value = 0.707
1226
- src.connect(filter)
1227
- lastNode = filter
1228
- rec.filterNode = filter
1229
- } catch (e) { /* audio filter fallback */ }
1230
- rec.analyser = rec.audioCtx.createAnalyser()
1231
- rec.analyser.fftSize = 128
1232
- lastNode.connect(rec.analyser)
1233
- }
1234
- recorder.start()
1235
- 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
+ }
1236
1235
  }
1237
1236
  function currentLevel(rec) {
1238
1237
  if (!rec || !rec.analyser) return 0
@@ -1252,6 +1251,8 @@ voice.webGpu = {
1252
1251
  function cutPhrase() {
1253
1252
  const rec = voice.rec
1254
1253
  if (!rec || rec.cutting || rec.closing) return
1254
+ const opId = rec.opId || voice.activeOpId
1255
+ const target = rec.target || voice._activeTarget
1255
1256
  rec.cutting = true
1256
1257
  const stopped = waitStop(rec.recorder)
1257
1258
  try {
@@ -1263,7 +1264,7 @@ voice.webGpu = {
1263
1264
  return
1264
1265
  }
1265
1266
  stopped.then(async () => {
1266
- if (rec.cancelled || voice.activeOpId !== opId) return
1267
+ if (rec.cancelled || voice.activeOpId !== opId || voice.disposed) return
1267
1268
  const blob = new Blob(rec.chunks, { type: rec.mime })
1268
1269
  rec.chunks = []
1269
1270
  rec.silenceMs = 0
@@ -1277,42 +1278,42 @@ voice.webGpu = {
1277
1278
  rec.cutting = false
1278
1279
  if (rec.cancelled || blob.size < 600) return // cancelled or too short — not speech
1279
1280
  dictationQueue = dictationQueue.then(async () => {
1280
- if (rec.cancelled) return
1281
- try {
1282
- const out = await sendAudio(blob, rec.mime, 'dictation')
1283
- if (rec.cancelled) return
1284
- const text = out && out.text ? out.text : ''
1285
- const action = out && out.action ? out.action : null
1286
- if (voice.settings.structuredPromptVoice !== false && text) {
1287
- const promptInfo = findActiveStructuredPrompt()
1288
- if (promptInfo && submitToStructuredPrompt(text, promptInfo)) {
1281
+ if (rec.cancelled || voice.activeOpId !== opId || voice.disposed) return
1282
+ try {
1283
+ const out = await sendAudio(blob, rec.mime, 'dictation')
1284
+ if (rec.cancelled || voice.activeOpId !== opId || voice.disposed) return
1285
+ const text = out && out.text ? out.text : ''
1286
+ const action = out && out.action ? out.action : null
1287
+ if (voice.settings.structuredPromptVoice !== false && text) {
1288
+ const promptInfo = findActiveStructuredPrompt()
1289
+ if (promptInfo && submitToStructuredPrompt(text, promptInfo)) {
1290
+ return
1291
+ }
1292
+ }
1293
+ if (action === 'send' || action === 'clear' || action === 'newline') {
1294
+ executeVoiceAction(action, text)
1289
1295
  return
1290
1296
  }
1297
+ if (action === 'cancel') {
1298
+ cancelCurrent()
1299
+ return
1300
+ }
1301
+ const delay = Number(voice.settings.sendDelayMs) || 0
1302
+ if (text && delay > 0 && !voice.holding) {
1303
+ appendDraft(text, target)
1304
+ voice.set({ phase: 'pending', pending: { text, undoOnly: true, leftMs: delay } })
1305
+ return
1306
+ }
1307
+ if (text) appendDraft(text, target)
1308
+ } catch (e) {
1309
+ if (!rec.cancelled && voice.activeOpId === opId && !voice.disposed) {
1310
+ voice.set({ error: String(e && e.message ? e.message : e) })
1311
+ }
1291
1312
  }
1292
- if (action === 'send' || action === 'clear' || action === 'newline') {
1293
- executeVoiceAction(action, text)
1294
- return
1295
- }
1296
- if (action === 'cancel') {
1297
- cancelCurrent()
1298
- return
1299
- }
1300
- const delay = Number(voice.settings.sendDelayMs) || 0
1301
- if (text && delay > 0 && !voice.holding) {
1302
- appendDraft(text)
1303
- voice.set({ phase: 'pending', pending: { text, undoOnly: true, leftMs: delay } })
1304
- return
1305
- }
1306
- if (text) appendDraft(text)
1307
- } catch (e) {
1308
- if (!rec.cancelled) {
1309
- voice.set({ error: String(e && e.message ? e.message : e) })
1310
- }
1311
- }
1312
1313
  })
1313
1314
  })
1314
1315
  }
1315
- function startBrowserLeg(mode, language) {
1316
+ function startBrowserLeg(mode, language, opId, target) {
1316
1317
  if (voice.settings && voice.settings.localOnly) return false
1317
1318
  if (!browserRecognitionAvailable()) return false
1318
1319
  const finals = []
@@ -1321,6 +1322,7 @@ voice.webGpu = {
1321
1322
  lang: language,
1322
1323
  continuous: true,
1323
1324
  onInterim: (text) => {
1325
+ if (voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) return
1324
1326
  voice.caption = text
1325
1327
  if (voice.settings.liveInterimPreview !== false) {
1326
1328
  voice.interimTranscript = text
@@ -1336,27 +1338,34 @@ voice.webGpu = {
1336
1338
  voice.browser = null
1337
1339
  voice.caption = ''
1338
1340
  voice.interimTranscript = ''
1339
- openMic(mode)
1341
+ openMic(mode, opId, target)
1340
1342
  .then((rec) => {
1341
- if (voice.phase !== 'recording') {
1343
+ if (voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) {
1342
1344
  teardown(rec)
1343
1345
  return
1344
1346
  }
1347
+ rec.opId = opId
1348
+ rec.target = target
1345
1349
  voice.rec = rec
1346
1350
  voice.notify()
1347
1351
  })
1348
- .catch((err) => voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null }))
1352
+ .catch((err) => {
1353
+ if (voice.activeOpId !== opId) return
1354
+ voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null })
1355
+ })
1349
1356
  }
1350
1357
  }
1351
1358
  },
1352
1359
  onFinal: (text) => {
1360
+ if (voice.activeOpId !== opId || (voice.phase !== 'recording' && voice.phase !== 'finishing') || voice.disposed) return
1353
1361
  finals.push(text)
1354
1362
  voice.caption = ''
1355
1363
  voice.interimTranscript = ''
1356
- if (mode === 'dictation') appendDraft(text)
1364
+ if (mode === 'dictation') appendDraft(text, target)
1357
1365
  else voice.notify()
1358
1366
  },
1359
1367
  onError: (reason) => {
1368
+ if (voice.activeOpId !== opId || voice.disposed) return
1360
1369
  voice.browser = null
1361
1370
  voice.interimTranscript = ''
1362
1371
  voice.set({ phase: 'error', error: t('browserFailed') + reason })
@@ -1369,6 +1378,16 @@ voice.webGpu = {
1369
1378
  function startRecording(mode) {
1370
1379
  if (voice.phase !== 'idle' && voice.phase !== 'error') return
1371
1380
  voice.activeOpId = (voice.activeOpId || 0) + 1
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
+
1372
1391
  if (voice.isTtsSpeaking) {
1373
1392
  if (voice.settings.bargeIn) {
1374
1393
  triggerBargeIn()
@@ -1381,121 +1400,31 @@ voice.webGpu = {
1381
1400
  dictationQueue = Promise.resolve()
1382
1401
  voice.set({ phase: 'recording', mode, error: '', levels: [], caption: '', interimTranscript: '' })
1383
1402
  modeChain(mode).then((info) => {
1403
+ if (voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) return
1384
1404
  voice._activeChain = info.chain || []
1385
1405
  voice._activeLang = info.language || ''
1386
- if (info.chain[0] === 'browser' && startBrowserLeg(mode, info.language)) return
1387
- openMic(mode)
1406
+ voice._activeModelMap = info.modelMap || {}
1407
+ if (info.chain[0] === 'browser' && !voice.settings.localOnly && startBrowserLeg(mode, info.language, opId, target)) return
1408
+ openMic(mode, opId, target)
1388
1409
  .then((rec) => {
1389
- if (voice.phase !== 'recording') {
1410
+ if (voice.activeOpId !== opId || voice.phase !== 'recording' || voice.disposed) {
1390
1411
  teardown(rec)
1391
1412
  return
1392
1413
  }
1414
+ rec.opId = opId
1415
+ rec.target = target
1393
1416
  voice.rec = rec
1394
1417
  voice.notify()
1395
1418
  })
1396
- .catch((err) => voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null }))
1419
+ .catch((err) => {
1420
+ if (voice.activeOpId !== opId) return
1421
+ voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null })
1422
+ })
1397
1423
  })
1398
1424
  }
1399
1425
  function startDictation() { startRecording('dictation') }
1400
1426
  function startMessage() { startRecording('message') }
1401
- // --------------------------------------------------------- hold gesture
1402
- const HOLD_THRESHOLD_MS = 350
1403
- const hold = { active: false, mode: null, startedAt: 0, armed: false }
1404
- let activePointerCleanup = null
1405
- function beginHold(mode, pointerEvent) {
1406
- if (hold.armed || (voice.phase !== 'idle' && voice.phase !== 'error')) return
1407
- hold.armed = true
1408
- hold.mode = mode
1409
- hold.startedAt = Date.now()
1410
- hold.active = false
1411
- if (pointerEvent?.target?.setPointerCapture && pointerEvent.pointerId !== undefined) {
1412
- try { pointerEvent.target.setPointerCapture(pointerEvent.pointerId) } catch (e) {}
1413
- }
1414
- if (typeof window !== 'undefined') {
1415
- if (activePointerCleanup) { try { activePointerCleanup() } catch (e) {} }
1416
- const onUp = () => endHold(false)
1417
- const onCancel = () => endHold(true)
1418
- window.addEventListener('pointerup', onUp, true)
1419
- window.addEventListener('pointercancel', onCancel, true)
1420
- window.addEventListener('blur', onCancel, true)
1421
- activePointerCleanup = () => {
1422
- window.removeEventListener('pointerup', onUp, true)
1423
- window.removeEventListener('pointercancel', onCancel, true)
1424
- window.removeEventListener('blur', onCancel, true)
1425
- activePointerCleanup = null
1426
- }
1427
- }
1428
- startRecording(mode)
1429
- voice.holding = true
1430
- voice.notify()
1431
- }
1432
- function endHold(cancelled) {
1433
- if (!hold.armed) return
1434
- if (activePointerCleanup) { try { activePointerCleanup() } catch (e) {} }
1435
- const heldMs = Date.now() - hold.startedAt
1436
- hold.armed = false
1437
- hold.active = false
1438
- voice.holding = false
1439
- if (!cancelled && heldMs < HOLD_THRESHOLD_MS) { voice.notify(); return }
1440
- if (cancelled) cancelCurrent()
1441
- else stopCurrent()
1442
- }
1443
1427
 
1444
- let activeHotkeyCleanup = null
1445
- function clearGlobalHotkey() {
1446
- if (typeof activeHotkeyCleanup === 'function') {
1447
- try { activeHotkeyCleanup() } catch (_) { /* ignore */ }
1448
- activeHotkeyCleanup = null
1449
- }
1450
- if (typeof window !== 'undefined' && typeof window.__dsh_voice_hotkey_cleanup === 'function') {
1451
- try { window.__dsh_voice_hotkey_cleanup() } catch (_) { /* ignore */ }
1452
- window.__dsh_voice_hotkey_cleanup = null
1453
- }
1454
- }
1455
- voice.clearGlobalHotkey = clearGlobalHotkey
1456
- function installHotkey(ctx, keyName, mode) {
1457
- clearGlobalHotkey()
1458
- if (typeof document === 'undefined' || !keyName) return () => {}
1459
- const down = (event) => {
1460
- if (event.repeat) return
1461
- const target = event.target
1462
- const isEditable = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || (typeof target.getAttribute === 'function' && target.getAttribute('contenteditable') === 'true'))
1463
- if (isEditable && !isAllowedInEditable(keyName)) return
1464
- if (hotkeyMatches(event, keyName)) {
1465
- event.preventDefault()
1466
- event.stopPropagation()
1467
- beginHold(mode)
1468
- }
1469
- }
1470
-
1471
- const up = (event) => {
1472
- if (hold.armed && isHotkeyRelease(event, keyName)) {
1473
- event.preventDefault()
1474
- endHold(false)
1475
- } else if (event.key === 'Escape' && hold.armed) {
1476
- event.preventDefault()
1477
- endHold(true)
1478
- }
1479
- }
1480
- const blur = () => { if (hold.armed) endHold(true) }
1481
- document.addEventListener('keydown', down, true)
1482
- document.addEventListener('keyup', up, true)
1483
- window.addEventListener('blur', blur)
1484
- const cleanup = () => {
1485
- document.removeEventListener('keydown', down, true)
1486
- document.removeEventListener('keyup', up, true)
1487
- window.removeEventListener('blur', blur)
1488
- if (activeHotkeyCleanup === cleanup) activeHotkeyCleanup = null
1489
- if (typeof window !== 'undefined' && window.__dsh_voice_hotkey_cleanup === cleanup) {
1490
- window.__dsh_voice_hotkey_cleanup = null
1491
- }
1492
- }
1493
- activeHotkeyCleanup = cleanup
1494
- if (typeof window !== 'undefined') {
1495
- window.__dsh_voice_hotkey_cleanup = cleanup
1496
- }
1497
- return cleanup
1498
- }
1499
1428
  function cancelCurrent() {
1500
1429
  voice.activeOpId = (voice.activeOpId || 0) + 1
1501
1430
  announceVoice('end')
@@ -1522,16 +1451,20 @@ voice.webGpu = {
1522
1451
  announceVoice('end')
1523
1452
  focusComposer()
1524
1453
  if (voice.browser) {
1454
+ const opId = voice.activeOpId
1525
1455
  const mode = voice.mode
1526
1456
  const b = voice.browser
1457
+ const target = voice._activeTarget
1527
1458
  voice.browser = null
1528
1459
  voice.caption = ''
1460
+ voice.set({ phase: 'finishing' })
1529
1461
  Promise.resolve(b.stop()).then(() => {
1462
+ if (voice.disposed || voice.activeOpId !== opId) return
1530
1463
  const said = (voice.browserFinals || []).join(' ').trim()
1531
1464
  voice.browserFinals = null
1532
1465
  if (!said) { voice.set({ phase: 'idle' }); return }
1533
1466
  if (mode === 'message') {
1534
- appendDraft(said)
1467
+ appendDraft(said, target)
1535
1468
  voice.set({ phase: 'pending', pending: { text: said, leftMs: voice.settings.autoSendMs } })
1536
1469
  } else {
1537
1470
  voice.set({ phase: 'idle' })
@@ -1542,17 +1475,23 @@ voice.webGpu = {
1542
1475
  const rec = voice.rec
1543
1476
  if (!rec || rec.closing) return
1544
1477
  rec.closing = true
1545
- voice.activeOpId = (voice.activeOpId || 0) + 1
1546
- const opId = voice.activeOpId
1478
+ const opId = rec.opId || voice.activeOpId
1479
+ const target = rec.target || voice._activeTarget
1547
1480
  const mode = rec.mode
1548
1481
  const stopped = waitStop(rec.recorder)
1549
1482
  try {
1550
1483
  if (rec.recorder && rec.recorder.state !== 'inactive') rec.recorder.stop()
1551
1484
  } catch (e) { /* already stopped */ }
1552
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
+ }
1553
1491
  const blob = new Blob(rec.chunks, { type: rec.mime })
1554
1492
  teardown(rec)
1555
- voice.rec = null
1493
+ if (voice.rec === rec) voice.rec = null
1494
+ if (voice.disposed || rec.cancelled || voice.activeOpId !== opId) return
1556
1495
  if (blob.size < (mode === 'dictation' ? 600 : 1200)) {
1557
1496
  if (mode === 'dictation') { try { await dictationQueue } catch (_) {} }
1558
1497
  voice.set({ phase: 'idle' })
@@ -1566,10 +1505,10 @@ voice.webGpu = {
1566
1505
  voice.lastNote = { blob, url: noteUrl, mime: rec.mime, text: '' }
1567
1506
  voice.set({ phase: 'processing' })
1568
1507
  const processTail = async () => {
1569
- if (rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1508
+ if (voice.disposed || rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1570
1509
  try {
1571
1510
  const out = await sendAudio(blob, rec.mime, mode)
1572
- if (rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1511
+ if (voice.disposed || rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1573
1512
  if (out.command) { runSessionCommand(out.command); return }
1574
1513
  const text = out.text || ''
1575
1514
  const action = out.action || null
@@ -1590,7 +1529,7 @@ voice.webGpu = {
1590
1529
  return
1591
1530
  }
1592
1531
  if (action === 'cancel') { cancelCurrent(); return }
1593
- appendDraft(text)
1532
+ appendDraft(text, target)
1594
1533
  if (voice.lastNote) voice.lastNote.text = text
1595
1534
  if (mode === 'message') {
1596
1535
  voice.set({ phase: 'pending', pending: { text, totalMs: voice.settings.autoSendMs, leftMs: voice.settings.autoSendMs, audioUrl: noteUrl } })
@@ -1598,7 +1537,7 @@ voice.webGpu = {
1598
1537
  voice.set({ phase: 'idle' })
1599
1538
  }
1600
1539
  } catch (e) {
1601
- if (rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1540
+ if (voice.disposed || rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1602
1541
  voice.set({ phase: 'error', error: String(e && e.message ? e.message : e) })
1603
1542
  }
1604
1543
  }
@@ -1612,78 +1551,69 @@ voice.webGpu = {
1612
1551
  }
1613
1552
  function clearDraft() {
1614
1553
  const actions = voice.inputActions
1615
- if (!actions || typeof actions.setDraft !== 'function') {
1616
- voice.set({ phase: 'error', error: t('composerUnavailable') })
1617
- return false
1618
- }
1619
- actions.setDraft('')
1620
- return true
1554
+ if (actions && typeof actions.setDraft === 'function') actions.setDraft('')
1621
1555
  }
1622
- function executeVoiceAction(action, text) {
1556
+
1557
+ // Voice actions (#29-7): spoken triggers perform composer actions.
1558
+ function executeVoiceAction(action, rawText) {
1623
1559
  if (action === 'send') {
1560
+ const text = rawText.replace(/(^|[\s,.!?])(отправь|пошли|send)([\s,.!?]|$)/gi, '').trim()
1624
1561
  if (text) appendDraft(text)
1625
- submitPending()
1626
- return true
1627
- }
1628
- if (action === 'clear') {
1629
- const ok = clearDraft()
1630
- if (ok && voice.phase !== 'recording') voice.set({ phase: 'idle' })
1631
- return ok
1632
- }
1633
- if (action === 'newline') {
1634
- 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())
1635
1571
  appendDraft(payload)
1636
- if (voice.phase !== 'recording') voice.set({ phase: 'idle' })
1637
- return true
1572
+ voice.set({ phase: 'idle' })
1638
1573
  }
1639
- if (action === 'cancel') { cancelCurrent(); return true }
1640
- return false
1641
1574
  }
1642
- voice.executeVoiceAction = executeVoiceAction
1575
+
1643
1576
  function submitPending() {
1644
- voice.pending = null
1577
+ const pending = voice.pending
1578
+ if (!pending) return
1579
+ const text = pending.text
1645
1580
  const actions = voice.inputActions
1646
- if (!actions || typeof actions.submit !== 'function') {
1647
- voice.set({ phase: 'error', error: t('composerUnavailable') })
1648
- return
1649
- }
1581
+ voice.pending = null
1650
1582
  voice.set({ phase: 'idle' })
1583
+
1651
1584
  if (voice.settings.polishSend) {
1652
- const run = async () => {
1653
- try {
1654
- const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
1655
- if (draft.trim()) {
1656
- const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
1657
- const timer = controller ? setTimeout(() => controller.abort(), 10000) : null
1658
- try {
1659
- const res = await fetch('/dsh-voice/polish', {
1660
- method: 'POST',
1661
- headers: { 'content-type': 'application/json' },
1662
- body: JSON.stringify({ text: draft }),
1663
- signal: controller ? controller.signal : undefined,
1664
- })
1665
- const parsed = await res.json().catch(() => null)
1666
- if (parsed && parsed.ok && typeof parsed.text === 'string' && parsed.text.trim()) {
1667
- actions.setDraft(parsed.text.trim())
1668
- }
1669
- } finally {
1670
- if (timer) clearTimeout(timer)
1671
- }
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)
1672
1595
  }
1673
- } catch (e) { /* polish is best-effort */ }
1674
- }
1675
- run().finally(() => setTimeout(() => { try { actions.submit() } catch (e) { /* busy */ } }, 0))
1676
- 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()
1677
1607
  }
1678
- setTimeout(() => { try { actions.submit() } catch (e) { /* composer busy */ } }, 0)
1679
1608
  }
1609
+
1680
1610
  function runSessionCommand(cmd) {
1681
- const actions = voice.inputActions
1682
- voice.set({ phase: 'idle', pending: null })
1683
- if ((cmd === 'send' || cmd === 'continue') && actions && typeof actions.submit === 'function') {
1684
- actions.submit()
1685
- }
1611
+ if (cmd === 'send') submitPending()
1612
+ else if (cmd === 'cancel') cancelCurrent()
1613
+ else if (cmd === 'stop') cancelCurrent()
1614
+ else if (cmd === 'continue') { /* keep recording */ }
1686
1615
  }
1616
+
1687
1617
  function keepPending() {
1688
1618
  voice.pending = null
1689
1619
  voice.set({ phase: 'idle' })
@@ -1727,6 +1657,194 @@ voice.webGpu = {
1727
1657
  )
1728
1658
  }
1729
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
+ }
1730
1848
  // ----------------------------------------------------------- visualizers
1731
1849
  let _accentColorCache = { color: '', expires: 0 }
1732
1850
  let _softColorCache = { color: '', expires: 0 }
@@ -2180,6 +2298,7 @@ voice.webGpu = {
2180
2298
  Object.assign(voice.settings, {
2181
2299
  beep: !!(data && data.beep),
2182
2300
  localOnly: !!(data && data.localOnly),
2301
+ webgpuModel: String((data && data.webgpuModel) || (voice.settings && voice.settings.webgpuModel) || 'onnx-community/whisper-tiny'),
2183
2302
  micDeviceId: String((data && data.micDeviceId) || ''),
2184
2303
  historyLimit: Number(data && data.historyLimit),
2185
2304
  voiceCommands: !!(data && data.voiceCommands),
@@ -2709,7 +2828,7 @@ voice.webGpu = {
2709
2828
  style: { flex: 1 },
2710
2829
  },
2711
2830
  React.createElement('option', { value: 'cpu' }, 'CPU (Standard)'),
2712
- React.createElement('option', { value: 'rknpu' }, 'Rockchip RK3588 NPU (rknpu)'),
2831
+ React.createElement('option', { value: 'rknn' }, 'Rockchip RK3588 NPU (RKNN)'),
2713
2832
  React.createElement('option', { value: 'openvino' }, 'Intel OpenVINO'),
2714
2833
  React.createElement('option', { value: 'cuda' }, 'NVIDIA CUDA'),
2715
2834
  ),
@@ -3124,7 +3243,7 @@ voice.webGpu = {
3124
3243
  // ------------------------------------------------------- settings voice section
3125
3244
  function VoiceSection(props) {
3126
3245
  const t = (props && props.t) || moduleT
3127
- const ctx = props.ctx
3246
+ const ctx = (props && props.ctx) || {}
3128
3247
  // On a non-localhost page the kernel disables settings entirely: the
3129
3248
  // shared document is not readable, every section gets "unavailable" and
3130
3249
  // writes are dropped. The server does not share that restriction, and
@@ -3227,6 +3346,27 @@ voice.webGpu = {
3227
3346
  try { return JSON.parse(JSON.stringify(obj)) } catch (e) { return Object.assign({}, obj) }
3228
3347
  }
3229
3348
 
3349
+ const setTop = (key, val) => {
3350
+ setDraft((d) => Object.assign({}, d || {}, { [key]: val }))
3351
+ }
3352
+ const setIn = (mode, key, val) => {
3353
+ setDraft((d) => {
3354
+ const next = Object.assign({}, d || {})
3355
+ next[mode] = Object.assign({}, next[mode] || {}, { [key]: val })
3356
+ return next
3357
+ })
3358
+ }
3359
+ const textField = (key, label, hint) => React.createElement('div', { className: 'cb-field' },
3360
+ React.createElement('label', null, label),
3361
+ React.createElement('input', {
3362
+ type: 'text',
3363
+ value: (draft && draft[key]) ?? '',
3364
+ disabled: !writable,
3365
+ onChange: (e) => setTop(key, e.target.value),
3366
+ }),
3367
+ hint ? React.createElement('span', { className: 'dvs-sub' }, hint) : null,
3368
+ )
3369
+
3230
3370
  React.useEffect(() => { if (ready && draft === null) setDraft(deepClone(value)) }, [ready, draft, value])
3231
3371
 
3232
3372
  React.useEffect(() => {
@@ -3259,13 +3399,10 @@ voice.webGpu = {
3259
3399
  techJargonCorrection: val.techJargonCorrection !== false,
3260
3400
  liveInterimPreview: val.liveInterimPreview !== false,
3261
3401
  structuredPromptVoice: val.structuredPromptVoice !== false,
3402
+ webgpuModel: String(val.webgpuModel || (voice.settings && voice.settings.webgpuModel) || 'onnx-community/whisper-tiny'),
3262
3403
  })
3263
3404
  }, [ready, value])
3264
3405
 
3265
- const { sensevoiceState, installingSensevoice, triggerInstallSensevoice } = useSensevoiceInstaller(fetchStatus)
3266
-
3267
- const { testingMic, testLevel, toggleTestMic, gateDbVal, gateThresholdPercent } = useMicTester(draft, value, setErr)
3268
-
3269
3406
  const [statusData, setStatusData] = React.useState(null)
3270
3407
  const [statusLatency, setStatusLatency] = React.useState(null)
3271
3408
  const fetchStatus = React.useCallback(() => {
@@ -3279,6 +3416,9 @@ voice.webGpu = {
3279
3416
  setStatusLatency(Math.round(performance.now() - start))
3280
3417
  if (data && data.ok) {
3281
3418
  setStatusData(data)
3419
+ if (data.webgpuModel && voice.settings) {
3420
+ voice.settings.webgpuModel = data.webgpuModel
3421
+ }
3282
3422
  }
3283
3423
  })
3284
3424
  .catch(() => {
@@ -3291,6 +3431,10 @@ voice.webGpu = {
3291
3431
  fetchStatus()
3292
3432
  }, [fetchStatus])
3293
3433
 
3434
+ const { sensevoiceState, installingSensevoice, triggerInstallSensevoice } = useSensevoiceInstaller(fetchStatus)
3435
+
3436
+ const { testingMic, testLevel, toggleTestMic, gateDbVal, gateThresholdPercent } = useMicTester(draft, value, setErr)
3437
+
3294
3438
  const { updaterStatus, updating, updaterMsg, updaterLoading, checkUpdater, onUpdateNow } = usePluginUpdater()
3295
3439
 
3296
3440
  const save = async () => {
@@ -3783,6 +3927,15 @@ voice.webGpu = {
3783
3927
  isHotkeyRelease,
3784
3928
  extractContextKeywords,
3785
3929
  resolveBrowserRecognitionLang,
3930
+ VoiceSection,
3931
+ cutPhrase,
3932
+ sendAudio,
3933
+ modeChain,
3934
+ openMic,
3935
+ startRecording,
3936
+ cancelCurrent,
3937
+ stopCurrent,
3938
+ loadWebGpuWhisper,
3786
3939
  }
3787
3940
  return module.exports
3788
3941
  },