@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 +406 -366
- package/lib/host-routes.js +186 -0
- package/lib/index.js +73 -281
- package/lib/local-daemon.js +78 -24
- package/lib/updater.js +15 -7
- package/package.json +1 -1
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
|
|
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
|
-
|
|
793
|
-
const
|
|
794
|
-
if (
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
|
800
|
-
if (
|
|
801
|
-
} catch (
|
|
802
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
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
|
-
|
|
1074
|
+
return pipeline('automatic-speech-recognition', targetModel, {
|
|
1147
1075
|
device: 'webgpu',
|
|
1148
1076
|
dtype: 'fp32',
|
|
1149
1077
|
})
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
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
|
-
|
|
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
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
recorder
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1625
|
-
const
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
}
|
|
1712
|
-
|
|
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
|
-
|
|
1716
|
-
return true
|
|
1572
|
+
voice.set({ phase: 'idle' })
|
|
1717
1573
|
}
|
|
1718
|
-
if (action === 'cancel') { cancelCurrent(); return true }
|
|
1719
|
-
return false
|
|
1720
1574
|
}
|
|
1721
|
-
|
|
1575
|
+
|
|
1722
1576
|
function submitPending() {
|
|
1723
|
-
|
|
1577
|
+
const pending = voice.pending
|
|
1578
|
+
if (!pending) return
|
|
1579
|
+
const text = pending.text
|
|
1724
1580
|
const actions = voice.inputActions
|
|
1725
|
-
|
|
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
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
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
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
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
|
-
|
|
1761
|
-
|
|
1762
|
-
if (
|
|
1763
|
-
|
|
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,
|