@goodandready/dsh-voice 0.9.2 → 0.9.4
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/README.md +4 -2
- package/README.ru.md +4 -2
- package/lib/client.js +103 -13
- package/lib/http-util.js +40 -6
- package/lib/index.js +13 -5
- package/lib/normalize.js +19 -6
- package/lib/updater.js +67 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -189,14 +189,14 @@ Open **Settings → Plugins → Plugin settings → Voice** in the Web UI:
|
|
|
189
189
|
- id: dsh-voice
|
|
190
190
|
config:
|
|
191
191
|
dictation:
|
|
192
|
-
language: ru
|
|
192
|
+
language: "" # auto-detect by default; or specify "en", "ru", "zh", etc.
|
|
193
193
|
vadSilenceMs: 700
|
|
194
194
|
chain:
|
|
195
195
|
- provider: deepgram
|
|
196
196
|
- provider: groq
|
|
197
197
|
- provider: local-whisper
|
|
198
198
|
message:
|
|
199
|
-
language: ru
|
|
199
|
+
language: "" # auto-detect by default; or specify "en", "ru", "zh", etc.
|
|
200
200
|
autoSendMs: 4000
|
|
201
201
|
chain:
|
|
202
202
|
- provider: openai
|
|
@@ -206,6 +206,8 @@ Open **Settings → Plugins → Plugin settings → Voice** in the Web UI:
|
|
|
206
206
|
whisperModel: /models/ggml-medium-q8_0.bin
|
|
207
207
|
```
|
|
208
208
|
|
|
209
|
+
> **Note on language handling**: Setting `language: ""` (default) allows providers to auto-detect the spoken language. Spoken numeral normalization (`wordsToDigits`) and standard Russian tech slang correction (`DEFAULT_JARGON_DICTIONARY`) activate conditionally only when Russian is selected or auto-detected in the transcript, ensuring non-Russian speech is never distorted.
|
|
210
|
+
|
|
209
211
|
---
|
|
210
212
|
|
|
211
213
|
## 🤖 Agent Tool & HTTP API
|
package/README.ru.md
CHANGED
|
@@ -189,14 +189,14 @@ dsh plugin --profile web add @goodandready/dsh-voice
|
|
|
189
189
|
- id: dsh-voice
|
|
190
190
|
config:
|
|
191
191
|
dictation:
|
|
192
|
-
language: ru
|
|
192
|
+
language: "" # автоопределение по умолчанию; или укажите "ru", "en", "zh" и др.
|
|
193
193
|
vadSilenceMs: 700
|
|
194
194
|
chain:
|
|
195
195
|
- provider: deepgram
|
|
196
196
|
- provider: groq
|
|
197
197
|
- provider: local-whisper
|
|
198
198
|
message:
|
|
199
|
-
language: ru
|
|
199
|
+
language: "" # автоопределение по умолчанию; или укажите "ru", "en", "zh" и др.
|
|
200
200
|
autoSendMs: 4000
|
|
201
201
|
chain:
|
|
202
202
|
- provider: openai
|
|
@@ -206,6 +206,8 @@ dsh plugin --profile web add @goodandready/dsh-voice
|
|
|
206
206
|
whisperModel: /models/ggml-medium-q8_0.bin
|
|
207
207
|
```
|
|
208
208
|
|
|
209
|
+
> **Примечание по языку**: Значение `language: ""` (по умолчанию) включает автоопределение языка провайдером. Нормализация числительных словами в цифры и стандартный словарь технического сленга применяются адаптивно только при распознавании русской речи, исключая искажение англоязычного текста.
|
|
210
|
+
|
|
209
211
|
---
|
|
210
212
|
|
|
211
213
|
## 🤖 Инструмент агента и HTTP API
|
package/lib/client.js
CHANGED
|
@@ -773,6 +773,9 @@ window.__ModuleLoader__.load({
|
|
|
773
773
|
|
|
774
774
|
function keyLabel(name) {
|
|
775
775
|
if (!name) return t('keyUnset')
|
|
776
|
+
if (typeof name === 'string' && name.includes('+')) {
|
|
777
|
+
return name.split('+').map((p) => keyLabel(p)).join(' + ')
|
|
778
|
+
}
|
|
776
779
|
if (name === 'Space') return t('keySpace')
|
|
777
780
|
if (KEY_LABELS[name]) return KEY_LABELS[name]
|
|
778
781
|
// Drop the Key/Digit prefix from codes like KeyR and Digit5.
|
|
@@ -781,10 +784,23 @@ window.__ModuleLoader__.load({
|
|
|
781
784
|
|
|
782
785
|
// What to store on key press. Pure modifiers are remembered by name:
|
|
783
786
|
// left and right have different codes but the user means "either".
|
|
787
|
+
// Supports combos like Control+Space or Alt+KeyV.
|
|
784
788
|
function keyFromEvent(event) {
|
|
785
|
-
if (
|
|
786
|
-
|
|
787
|
-
|
|
789
|
+
if (!event) return ''
|
|
790
|
+
const modifiers = []
|
|
791
|
+
if (event.ctrlKey && event.key !== 'Control') modifiers.push('Control')
|
|
792
|
+
if (event.altKey && event.key !== 'Alt') modifiers.push('Alt')
|
|
793
|
+
if (event.shiftKey && event.key !== 'Shift') modifiers.push('Shift')
|
|
794
|
+
if (event.metaKey && event.key !== 'Meta') modifiers.push('Meta')
|
|
795
|
+
|
|
796
|
+
const main = ['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)
|
|
797
|
+
? event.key
|
|
798
|
+
: (event.code || event.key || '')
|
|
799
|
+
|
|
800
|
+
if (modifiers.length > 0 && !['Control', 'Alt', 'Shift', 'Meta'].includes(main)) {
|
|
801
|
+
return modifiers.concat(main).join('+')
|
|
802
|
+
}
|
|
803
|
+
return main
|
|
788
804
|
}
|
|
789
805
|
|
|
790
806
|
// Announce that the user started speaking.
|
|
@@ -1215,18 +1231,56 @@ window.__ModuleLoader__.load({
|
|
|
1215
1231
|
// Hotkey: holding a key is easier than aiming the mouse. While the key is
|
|
1216
1232
|
// down we record; Escape cancels.
|
|
1217
1233
|
function hotkeyMatches(event, name) {
|
|
1234
|
+
if (!event || !name) return false
|
|
1235
|
+
if (typeof name === 'string' && name.includes('+')) {
|
|
1236
|
+
const parts = name.split('+')
|
|
1237
|
+
const main = parts[parts.length - 1]
|
|
1238
|
+
const needCtrl = parts.includes('Control')
|
|
1239
|
+
const needAlt = parts.includes('Alt')
|
|
1240
|
+
const needShift = parts.includes('Shift')
|
|
1241
|
+
const needMeta = parts.includes('Meta')
|
|
1242
|
+
if (needCtrl && !event.ctrlKey) return false
|
|
1243
|
+
if (needAlt && !event.altKey) return false
|
|
1244
|
+
if (needShift && !event.shiftKey) return false
|
|
1245
|
+
if (needMeta && !event.metaKey) return false
|
|
1246
|
+
return event.code === main || event.key === main
|
|
1247
|
+
}
|
|
1218
1248
|
if (name === 'Control') return event.key === 'Control'
|
|
1219
1249
|
if (name === 'Alt') return event.key === 'Alt'
|
|
1220
1250
|
if (name === 'Shift') return event.key === 'Shift'
|
|
1251
|
+
if (name === 'Meta') return event.key === 'Meta'
|
|
1221
1252
|
return event.code === name || event.key === name
|
|
1222
1253
|
}
|
|
1223
1254
|
|
|
1255
|
+
function isAllowedInEditable(name) {
|
|
1256
|
+
if (!name) return false
|
|
1257
|
+
if (['Control', 'Alt', 'Shift', 'Meta'].includes(name)) return true
|
|
1258
|
+
if (/^F\d{1,2}$/.test(name)) return true
|
|
1259
|
+
if (typeof name === 'string' && name.includes('+')) return true
|
|
1260
|
+
if (['Tab', 'Pause', 'ScrollLock', 'Insert'].includes(name)) return true
|
|
1261
|
+
return false
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
let activeHotkeyCleanup = null
|
|
1265
|
+
|
|
1266
|
+
function clearGlobalHotkey() {
|
|
1267
|
+
if (typeof activeHotkeyCleanup === 'function') {
|
|
1268
|
+
try { activeHotkeyCleanup() } catch (_) { /* ignore */ }
|
|
1269
|
+
activeHotkeyCleanup = null
|
|
1270
|
+
}
|
|
1271
|
+
if (typeof window !== 'undefined' && typeof window.__dsh_voice_hotkey_cleanup === 'function') {
|
|
1272
|
+
try { window.__dsh_voice_hotkey_cleanup() } catch (_) { /* ignore */ }
|
|
1273
|
+
window.__dsh_voice_hotkey_cleanup = null
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1224
1277
|
function installHotkey(ctx, keyName, mode) {
|
|
1278
|
+
clearGlobalHotkey()
|
|
1225
1279
|
if (typeof document === 'undefined' || !keyName) return () => {}
|
|
1226
1280
|
const down = (event) => {
|
|
1227
1281
|
if (event.repeat) return
|
|
1228
|
-
// A modifier
|
|
1229
|
-
// type. A plain letter key must not be hijacked while typing.
|
|
1282
|
+
// A modifier, function key or combo does not fight the text field:
|
|
1283
|
+
// modifiers/combos do not type plain text. A plain letter key must not be hijacked while typing.
|
|
1230
1284
|
const target = event.target
|
|
1231
1285
|
const isEditable = target && (
|
|
1232
1286
|
target.tagName === 'INPUT' ||
|
|
@@ -1234,23 +1288,41 @@ window.__ModuleLoader__.load({
|
|
|
1234
1288
|
target.isContentEditable ||
|
|
1235
1289
|
(typeof target.getAttribute === 'function' && target.getAttribute('contenteditable') === 'true')
|
|
1236
1290
|
)
|
|
1237
|
-
if (isEditable && !
|
|
1291
|
+
if (isEditable && !isAllowedInEditable(keyName)) return
|
|
1238
1292
|
|
|
1239
|
-
if (hotkeyMatches(event, keyName))
|
|
1293
|
+
if (hotkeyMatches(event, keyName)) {
|
|
1294
|
+
event.preventDefault()
|
|
1295
|
+
event.stopPropagation()
|
|
1296
|
+
beginHold(mode)
|
|
1297
|
+
}
|
|
1240
1298
|
}
|
|
1241
1299
|
const up = (event) => {
|
|
1242
|
-
if (hotkeyMatches(event, keyName))
|
|
1243
|
-
|
|
1300
|
+
if (hotkeyMatches(event, keyName)) {
|
|
1301
|
+
event.preventDefault()
|
|
1302
|
+
endHold(false)
|
|
1303
|
+
} else if (event.key === 'Escape' && hold.armed) {
|
|
1304
|
+
event.preventDefault()
|
|
1305
|
+
endHold(true)
|
|
1306
|
+
}
|
|
1244
1307
|
}
|
|
1245
1308
|
const blur = () => { if (hold.armed) endHold(true) }
|
|
1246
1309
|
document.addEventListener('keydown', down, true)
|
|
1247
1310
|
document.addEventListener('keyup', up, true)
|
|
1248
1311
|
window.addEventListener('blur', blur)
|
|
1249
|
-
|
|
1312
|
+
const cleanup = () => {
|
|
1250
1313
|
document.removeEventListener('keydown', down, true)
|
|
1251
1314
|
document.removeEventListener('keyup', up, true)
|
|
1252
1315
|
window.removeEventListener('blur', blur)
|
|
1316
|
+
if (activeHotkeyCleanup === cleanup) activeHotkeyCleanup = null
|
|
1317
|
+
if (typeof window !== 'undefined' && window.__dsh_voice_hotkey_cleanup === cleanup) {
|
|
1318
|
+
window.__dsh_voice_hotkey_cleanup = null
|
|
1319
|
+
}
|
|
1253
1320
|
}
|
|
1321
|
+
activeHotkeyCleanup = cleanup
|
|
1322
|
+
if (typeof window !== 'undefined') {
|
|
1323
|
+
window.__dsh_voice_hotkey_cleanup = cleanup
|
|
1324
|
+
}
|
|
1325
|
+
return cleanup
|
|
1254
1326
|
}
|
|
1255
1327
|
|
|
1256
1328
|
function cancelCurrent() {
|
|
@@ -1941,6 +2013,7 @@ window.__ModuleLoader__.load({
|
|
|
1941
2013
|
alive = false
|
|
1942
2014
|
window.removeEventListener('dsh-voice:settings-saved', reload)
|
|
1943
2015
|
dispose()
|
|
2016
|
+
clearGlobalHotkey()
|
|
1944
2017
|
}
|
|
1945
2018
|
}, 'dsh-voice: hold hotkey')
|
|
1946
2019
|
|
|
@@ -2219,11 +2292,17 @@ window.__ModuleLoader__.load({
|
|
|
2219
2292
|
if (event.key === 'Escape') { setCatching(false); return }
|
|
2220
2293
|
const chosen = keyFromEvent(event)
|
|
2221
2294
|
setDraft((d) => Object.assign({}, d || {}, { hotkey: chosen }))
|
|
2295
|
+
voice.hotkey = chosen || ''
|
|
2296
|
+
if (chosen) {
|
|
2297
|
+
installHotkey(ctx, chosen, 'message')
|
|
2298
|
+
} else {
|
|
2299
|
+
clearGlobalHotkey()
|
|
2300
|
+
}
|
|
2222
2301
|
setCatching(false)
|
|
2223
2302
|
}
|
|
2224
2303
|
document.addEventListener('keydown', onKey, true)
|
|
2225
2304
|
return () => document.removeEventListener('keydown', onKey, true)
|
|
2226
|
-
}, [catching])
|
|
2305
|
+
}, [catching, ctx])
|
|
2227
2306
|
|
|
2228
2307
|
React.useEffect(() => {
|
|
2229
2308
|
let alive = true
|
|
@@ -2562,7 +2641,14 @@ window.__ModuleLoader__.load({
|
|
|
2562
2641
|
}
|
|
2563
2642
|
|
|
2564
2643
|
const d = draft || {}
|
|
2644
|
+
voice.hotkey = d.hotkey || ''
|
|
2645
|
+
if (voice.hotkey) {
|
|
2646
|
+
installHotkey(ctx, voice.hotkey, 'message')
|
|
2647
|
+
} else {
|
|
2648
|
+
clearGlobalHotkey()
|
|
2649
|
+
}
|
|
2565
2650
|
Object.assign(voice.settings, {
|
|
2651
|
+
hotkey: d.hotkey || '',
|
|
2566
2652
|
vadSilenceMs: Number(d.dictation && d.dictation.vadSilenceMs) || 700,
|
|
2567
2653
|
autoSendMs: Number(d.message && d.message.autoSendMs) || 4000,
|
|
2568
2654
|
beep: !!d.beep,
|
|
@@ -2611,7 +2697,11 @@ window.__ModuleLoader__.load({
|
|
|
2611
2697
|
}, catching ? t('pressKey') : keyLabel(draft && draft.hotkey)),
|
|
2612
2698
|
React.createElement('button', {
|
|
2613
2699
|
type: 'button', className: 'cb-btn cb-btn-mini', title: t('clearKey'),
|
|
2614
|
-
disabled: !writable, onClick: () =>
|
|
2700
|
+
disabled: !writable, onClick: () => {
|
|
2701
|
+
setDraft((d) => Object.assign({}, d || {}, { hotkey: '' }))
|
|
2702
|
+
voice.hotkey = ''
|
|
2703
|
+
clearGlobalHotkey()
|
|
2704
|
+
},
|
|
2615
2705
|
}, '×'),
|
|
2616
2706
|
),
|
|
2617
2707
|
React.createElement('span', { className: 'dvs-sub' }, t('hotkeyHint1') + t('hotkeyHint2')),
|
|
@@ -3137,7 +3227,7 @@ window.__ModuleLoader__.load({
|
|
|
3137
3227
|
React.createElement('div', { className: 'cb-section-desc' }, t('versionHint')),
|
|
3138
3228
|
React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px' } },
|
|
3139
3229
|
React.createElement('span', { className: 'cb-badge cb-badge-ok' },
|
|
3140
|
-
'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.9.
|
|
3230
|
+
'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.9.4')
|
|
3141
3231
|
),
|
|
3142
3232
|
updaterStatus && updaterStatus.updateAvailable
|
|
3143
3233
|
? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
|
package/lib/http-util.js
CHANGED
|
@@ -46,14 +46,52 @@ export function isLoopback(addr) {
|
|
|
46
46
|
*/
|
|
47
47
|
export function isTrustedCaller(req) {
|
|
48
48
|
if (!req) return false
|
|
49
|
-
const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || ''
|
|
50
|
-
if (isLoopback(remote)) return true
|
|
51
49
|
|
|
50
|
+
// 1. Browser security headers: immediately reject cross-site and same-site requests (#117, #173)
|
|
52
51
|
const secSite = String(req.headers?.['sec-fetch-site'] || '').toLowerCase()
|
|
53
52
|
if (secSite === 'cross-site' || secSite === 'same-site') return false
|
|
54
53
|
|
|
54
|
+
// 2. If Origin header is present, it must strictly match the Host header
|
|
55
55
|
const host = String(req.headers?.host || '').toLowerCase()
|
|
56
56
|
const origin = String(req.headers?.origin || '').trim().toLowerCase()
|
|
57
|
+
if (origin) {
|
|
58
|
+
try {
|
|
59
|
+
const u = new URL(origin)
|
|
60
|
+
if (u.host !== host) return false
|
|
61
|
+
} catch {
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 3. If Referer header is present, it must strictly match the Host header
|
|
67
|
+
const referer = String(req.headers?.referer || '').trim().toLowerCase()
|
|
68
|
+
if (referer) {
|
|
69
|
+
try {
|
|
70
|
+
const u = new URL(referer)
|
|
71
|
+
if (u.host !== host) return false
|
|
72
|
+
} catch {
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 4. For state-changing or data-carrying requests (POST/PUT/PATCH), require application/json or octet-stream
|
|
78
|
+
// Cross-origin simple form submissions cannot set application/json without a CORS preflight.
|
|
79
|
+
const method = String(req.method || 'GET').toUpperCase()
|
|
80
|
+
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
|
81
|
+
const contentType = String(req.headers?.['content-type'] || '').toLowerCase()
|
|
82
|
+
const isJson = contentType.startsWith('application/json')
|
|
83
|
+
const isOctet = contentType.startsWith('application/octet-stream')
|
|
84
|
+
if (!isJson && !isOctet) {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 5. Loopback remote address is trusted when not rejected by browser headers above
|
|
90
|
+
const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || ''
|
|
91
|
+
if (isLoopback(remote)) return true
|
|
92
|
+
|
|
93
|
+
// 6. Non-loopback requests must be verified same-origin
|
|
94
|
+
if (secSite === 'same-origin') return true
|
|
57
95
|
if (origin) {
|
|
58
96
|
try {
|
|
59
97
|
const u = new URL(origin)
|
|
@@ -61,10 +99,6 @@ export function isTrustedCaller(req) {
|
|
|
61
99
|
} catch { /* invalid url */ }
|
|
62
100
|
return false
|
|
63
101
|
}
|
|
64
|
-
|
|
65
|
-
if (secSite === 'same-origin') return true
|
|
66
|
-
|
|
67
|
-
const referer = String(req.headers?.referer || '').trim().toLowerCase()
|
|
68
102
|
if (referer) {
|
|
69
103
|
try {
|
|
70
104
|
const u = new URL(referer)
|
package/lib/index.js
CHANGED
|
@@ -84,7 +84,8 @@ const BaseConfig = z.object({
|
|
|
84
84
|
chain: z.array(ChainEntry)
|
|
85
85
|
.default([{ provider: 'deepgram', model: '' }, { provider: 'groq', model: '' }, { provider: 'local-whisper', model: '' }])
|
|
86
86
|
.description('Fallback chain for dictation. Speed matters more than accuracy here.'),
|
|
87
|
-
language: z.string().default('
|
|
87
|
+
language: z.string().default('')
|
|
88
|
+
.description('Recognition language code for dictation (e.g. "en", "ru", "zh", or empty string for auto-detection).'),
|
|
88
89
|
vadSilenceMs: z.number().default(700)
|
|
89
90
|
.description('Silence longer than this ends a phrase and sends the chunk.'),
|
|
90
91
|
sendDelayMs: z.number().default(0)
|
|
@@ -102,7 +103,8 @@ const BaseConfig = z.object({
|
|
|
102
103
|
chain: z.array(ChainEntry)
|
|
103
104
|
.default([{ provider: 'groq', model: '' }, { provider: 'hf', model: '' }, { provider: 'local-whisper', model: '' }])
|
|
104
105
|
.description('Fallback chain for voice messages. Accuracy matters more than speed.'),
|
|
105
|
-
language: z.string().default('
|
|
106
|
+
language: z.string().default('')
|
|
107
|
+
.description('Recognition language code for messages (e.g. "en", "ru", "zh", or empty string for auto-detection).'),
|
|
106
108
|
autoSendMs: z.number().default(4000)
|
|
107
109
|
.description('Cancel window before the recognized text is sent to the agent.'),
|
|
108
110
|
sessionCommands: z.boolean().default(false)
|
|
@@ -531,10 +533,15 @@ export function apply(ctx, baseConfig) {
|
|
|
531
533
|
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
532
534
|
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
533
535
|
const cfg = live()
|
|
536
|
+
const maxBodyBytes = Math.ceil((cfg.maxFileBytes * 4) / 3) + 64 * 1024
|
|
534
537
|
let raw
|
|
535
538
|
try {
|
|
536
|
-
raw = await readBody(req,
|
|
539
|
+
raw = await readBody(req, maxBodyBytes)
|
|
537
540
|
} catch (e) {
|
|
541
|
+
if (e && e.message === 'body too large') {
|
|
542
|
+
writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio body exceeds limit for max ${cfg.maxFileBytes} bytes` } })
|
|
543
|
+
return
|
|
544
|
+
}
|
|
538
545
|
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
|
|
539
546
|
}
|
|
540
547
|
let payload
|
|
@@ -605,7 +612,7 @@ export function apply(ctx, baseConfig) {
|
|
|
605
612
|
+ 'Use for voice messages, recordings, interviews.',
|
|
606
613
|
parameters: {
|
|
607
614
|
file_path: { type: 'string', required: true, description: 'Absolute path to the audio file (wav, mp3, m4a, ogg, flac, webm).' },
|
|
608
|
-
language: { type: 'string', description: `Recognition language code. Default: ${(baseConfig && baseConfig.message && baseConfig.message.language) || '
|
|
615
|
+
language: { type: 'string', description: `Recognition language code (e.g. "en", "ru", "zh", or empty string for auto-detection). Default: ${(baseConfig && baseConfig.message && baseConfig.message.language) || 'auto'}.` },
|
|
609
616
|
},
|
|
610
617
|
output: {
|
|
611
618
|
schema: {
|
|
@@ -637,12 +644,13 @@ export function apply(ctx, baseConfig) {
|
|
|
637
644
|
const bytes = await readFile(real)
|
|
638
645
|
const sniffedMime = validateAudioContent(bytes)
|
|
639
646
|
const mime = sniffedMime || MIME_BY_EXT[path.extname(real).toLowerCase()] || 'audio/wav'
|
|
640
|
-
const modeCfg = { ...cfg.message, language: String(args.language
|
|
647
|
+
const modeCfg = { ...cfg.message, language: String(args.language !== undefined ? args.language : cfg.message.language) }
|
|
641
648
|
let raw = await transcribe(modeCfg, bytes, mime, exec.signal)
|
|
642
649
|
if (raw && raw.text) raw = { ...raw, text: await polishText(raw.text, modeCfg, exec.signal) }
|
|
643
650
|
const out = raw
|
|
644
651
|
if (cfg.normalizeTranscript && out && out.text) {
|
|
645
652
|
out.text = normalizePhrase(out.text, {
|
|
653
|
+
lang: modeCfg.language,
|
|
646
654
|
digits: true, capSentences: true, commaSpacing: true, trailingPeriod: true,
|
|
647
655
|
})
|
|
648
656
|
}
|
package/lib/normalize.js
CHANGED
|
@@ -107,7 +107,14 @@ function cachedJargonRegex(from) {
|
|
|
107
107
|
return re
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
export function
|
|
110
|
+
export function isRussianLang(lang, text = '') {
|
|
111
|
+
const l = String(lang || '').trim().toLowerCase()
|
|
112
|
+
if (l === 'ru' || l.startsWith('ru-') || l.startsWith('ru_')) return true
|
|
113
|
+
if (l === '' || l === 'auto') return /[а-яё]/i.test(String(text || ''))
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function applyJargonDictionary(text, customReplacements = [], options = {}) {
|
|
111
118
|
if (!text || typeof text !== 'string') return text || ''
|
|
112
119
|
let result = text
|
|
113
120
|
for (const item of customReplacements || []) {
|
|
@@ -116,9 +123,11 @@ export function applyJargonDictionary(text, customReplacements = []) {
|
|
|
116
123
|
re.lastIndex = 0
|
|
117
124
|
result = result.replace(re, item.to)
|
|
118
125
|
}
|
|
119
|
-
|
|
120
|
-
item
|
|
121
|
-
|
|
126
|
+
if (options.defaultJargon !== false) {
|
|
127
|
+
for (const item of DEFAULT_JARGON_REGEX) {
|
|
128
|
+
item.re.lastIndex = 0
|
|
129
|
+
result = result.replace(item.re, item.to)
|
|
130
|
+
}
|
|
122
131
|
}
|
|
123
132
|
return result
|
|
124
133
|
}
|
|
@@ -160,9 +169,13 @@ export function extractVoiceActions(text) {
|
|
|
160
169
|
export function normalizePhrase(text, opts = {}) {
|
|
161
170
|
let out = String(text || '').trim()
|
|
162
171
|
if (!out) return out
|
|
163
|
-
|
|
172
|
+
const lang = opts.lang ?? opts.language
|
|
173
|
+
const isRu = opts.isRussian ?? (lang !== undefined ? isRussianLang(lang, out) : true)
|
|
174
|
+
if (opts.digits && isRu) out = wordsToDigits(out)
|
|
164
175
|
if (opts.commaSpacing) out = fixCommaSpacing(out)
|
|
165
|
-
if (opts.jargon)
|
|
176
|
+
if (opts.jargon) {
|
|
177
|
+
out = applyJargonDictionary(out, opts.customJargon, { defaultJargon: isRu })
|
|
178
|
+
}
|
|
166
179
|
if (opts.capSentences) out = capitalizeSentences(out)
|
|
167
180
|
if (opts.trailingPeriod) out = ensureTrailingPeriod(out)
|
|
168
181
|
return out
|
package/lib/updater.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Implements canonical DSH plugin-updater specification.
|
|
3
3
|
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
6
6
|
import { readFile } from 'node:fs/promises';
|
|
7
7
|
import { homedir } from 'node:os';
|
|
8
8
|
import { basename, dirname, isAbsolute, resolve } from 'node:path';
|
|
@@ -169,12 +169,57 @@ export async function getUpdateStatus(options, target = runtime()) {
|
|
|
169
169
|
};
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
export function readLockPid(lockPath) {
|
|
173
|
+
if (!existsSync(lockPath)) return null;
|
|
174
|
+
try {
|
|
175
|
+
const raw = readFileSync(lockPath, 'utf8').trim();
|
|
176
|
+
if (/^\d+$/.test(raw)) {
|
|
177
|
+
return parseInt(raw, 10);
|
|
178
|
+
}
|
|
179
|
+
const parsed = JSON.parse(raw);
|
|
180
|
+
if (parsed && typeof parsed.pid === 'number') {
|
|
181
|
+
return parsed.pid;
|
|
182
|
+
}
|
|
183
|
+
} catch { /* ignore unparseable lock file */ }
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function isProcessAlive(pid) {
|
|
188
|
+
if (!pid || typeof pid !== 'number' || pid <= 0) return false;
|
|
189
|
+
try {
|
|
190
|
+
process.kill(pid, 0);
|
|
191
|
+
return true;
|
|
192
|
+
} catch (e) {
|
|
193
|
+
return e && e.code === 'EPERM';
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function checkProfileLock(profileDir) {
|
|
198
|
+
const lockPath = resolve(profileDir, 'package.json.lock');
|
|
199
|
+
if (!existsSync(lockPath)) return { locked: false };
|
|
200
|
+
const pid = readLockPid(lockPath);
|
|
201
|
+
if (pid && isProcessAlive(pid)) {
|
|
202
|
+
return { locked: true, pid };
|
|
203
|
+
}
|
|
204
|
+
// Stale lock: PID is dead or unreadable; clean it up to prevent permanent blockage
|
|
205
|
+
try {
|
|
206
|
+
unlinkSync(lockPath);
|
|
207
|
+
return { locked: false, cleanedStale: true };
|
|
208
|
+
} catch {
|
|
209
|
+
return { locked: false };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
172
213
|
async function installExact(target, packageSpec, options) {
|
|
173
214
|
if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.');
|
|
215
|
+
const lockCheck = checkProfileLock(target.profileDir);
|
|
216
|
+
if (lockCheck.locked) {
|
|
217
|
+
throw new Error(`Another package installation is currently in progress (PID ${lockCheck.pid}).`);
|
|
218
|
+
}
|
|
174
219
|
await new Promise((resolvePromise, reject) => {
|
|
175
220
|
const child = spawn(process.execPath, [
|
|
176
221
|
target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
|
|
177
|
-
|
|
222
|
+
packageSpec,
|
|
178
223
|
`--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
|
|
179
224
|
], {
|
|
180
225
|
cwd: target.profileDir,
|
|
@@ -186,12 +231,23 @@ async function installExact(target, packageSpec, options) {
|
|
|
186
231
|
child.stdout?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000); });
|
|
187
232
|
child.stderr?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000); });
|
|
188
233
|
const timer = setTimeout(() => {
|
|
189
|
-
child.kill();
|
|
234
|
+
child.kill('SIGTERM');
|
|
235
|
+
setTimeout(() => {
|
|
236
|
+
if (!child.killed) {
|
|
237
|
+
try { child.kill('SIGKILL'); } catch { /* ignore */ }
|
|
238
|
+
}
|
|
239
|
+
checkProfileLock(target.profileDir);
|
|
240
|
+
}, 1000).unref?.();
|
|
190
241
|
reject(new Error('Update timed out; use the normal DSH update flow.'));
|
|
191
242
|
}, UPDATE_TIMEOUT_MS);
|
|
192
|
-
child.once('error', error => {
|
|
243
|
+
child.once('error', error => {
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
checkProfileLock(target.profileDir);
|
|
246
|
+
reject(error);
|
|
247
|
+
});
|
|
193
248
|
child.once('exit', code => {
|
|
194
249
|
clearTimeout(timer);
|
|
250
|
+
checkProfileLock(target.profileDir);
|
|
195
251
|
if (code === 0) resolvePromise();
|
|
196
252
|
else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`));
|
|
197
253
|
});
|
|
@@ -233,8 +289,13 @@ export function registerPluginUpdater(ctx, options) {
|
|
|
233
289
|
json(response, 403, { error: 'Rejected non-local or cross-origin update request.' });
|
|
234
290
|
return;
|
|
235
291
|
}
|
|
236
|
-
|
|
237
|
-
|
|
292
|
+
const lockStatus = checkProfileLock(target.profileDir);
|
|
293
|
+
if (installing || lockStatus.locked) {
|
|
294
|
+
json(response, 409, {
|
|
295
|
+
error: lockStatus.locked
|
|
296
|
+
? `Another package installation is currently in progress (PID ${lockStatus.pid}).`
|
|
297
|
+
: 'This plugin is already updating.',
|
|
298
|
+
});
|
|
238
299
|
return;
|
|
239
300
|
}
|
|
240
301
|
installing = true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
4
|
"description": "Voice input for DeepSeek Harness: dictation chunked by pauses and voice messages, each with its own provider fallback chain (Deepgram, Groq, HuggingFace, local whisper.cpp, plus any OpenAI-compatible API of your own).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|