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