@goodandready/dsh-voice 0.9.3 → 0.9.5
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 +6 -3
- package/lib/http-util.js +40 -6
- package/lib/index.js +24 -207
- package/lib/normalize.js +19 -6
- package/lib/schema.js +153 -0
- package/lib/tool.js +69 -0
- 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
|
@@ -918,7 +918,7 @@ window.__ModuleLoader__.load({
|
|
|
918
918
|
const row = modes[mode] || {}
|
|
919
919
|
return {
|
|
920
920
|
chain: Array.isArray(row.chain) ? row.chain.map((e) => e && e.provider) : [],
|
|
921
|
-
language: row.language
|
|
921
|
+
language: row.language !== undefined ? row.language : '',
|
|
922
922
|
}
|
|
923
923
|
})
|
|
924
924
|
}
|
|
@@ -2403,7 +2403,10 @@ window.__ModuleLoader__.load({
|
|
|
2403
2403
|
setInstallingSensevoice(true)
|
|
2404
2404
|
fetch('/dsh-voice/sensevoice-installer', {
|
|
2405
2405
|
method: 'POST',
|
|
2406
|
-
headers: {
|
|
2406
|
+
headers: {
|
|
2407
|
+
'x-dsh-plugin-update': '1',
|
|
2408
|
+
'Content-Type': 'application/json',
|
|
2409
|
+
},
|
|
2407
2410
|
})
|
|
2408
2411
|
.then((r) => r.json())
|
|
2409
2412
|
.then(() => {
|
|
@@ -3227,7 +3230,7 @@ window.__ModuleLoader__.load({
|
|
|
3227
3230
|
React.createElement('div', { className: 'cb-section-desc' }, t('versionHint')),
|
|
3228
3231
|
React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px' } },
|
|
3229
3232
|
React.createElement('span', { className: 'cb-badge cb-badge-ok' },
|
|
3230
|
-
'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.9.
|
|
3233
|
+
'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.9.4')
|
|
3231
3234
|
),
|
|
3232
3235
|
updaterStatus && updaterStatus.updateAvailable
|
|
3233
3236
|
? 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
|
@@ -32,39 +32,14 @@ import { createConfigReader } from './config-compat.js'
|
|
|
32
32
|
import { registerConfigRoutes } from './config-routes.js'
|
|
33
33
|
import { getAllowedAudioRoots, validateAudioPath, validateAudioContent } from './audio-guard.js'
|
|
34
34
|
import { isAutoLang } from './provider-http.js'
|
|
35
|
+
import { ChainEntry, CustomProvider, BaseConfig } from './schema.js'
|
|
36
|
+
import { registerTranscribeAudioTool, MIME_BY_EXT } from './tool.js'
|
|
37
|
+
export { ChainEntry, CustomProvider, BaseConfig }
|
|
35
38
|
|
|
36
39
|
export const name = '@goodandready/dsh-voice'
|
|
37
40
|
export const NS = 'dsh-voice'
|
|
38
41
|
export const inject = ['tools', 'credentials', 'webServer', 'shell', 'settings', 'llm']
|
|
39
42
|
|
|
40
|
-
const ChainEntry = z.object({
|
|
41
|
-
provider: z.string().default('local-whisper')
|
|
42
|
-
.description(`Provider key. Built in: ${PROVIDER_KEYS.join(', ')}. `
|
|
43
|
-
+ `Ready-made, just add the key: ${PRESET_KEYS.join(', ')}. `
|
|
44
|
-
+ 'Or the name of an entry from customProviders. '
|
|
45
|
-
+ '"browser" recognises speech in the page itself — no key, no upload to this host, '
|
|
46
|
-
+ 'text appears while you speak; put a normal provider after it as a fallback.'),
|
|
47
|
-
model: z.string().default('')
|
|
48
|
-
.description('Model override. Empty means the provider default.'),
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
// Custom provider: everything needed to call any OpenAI-compatible API.
|
|
52
|
-
const CustomProvider = z.object({
|
|
53
|
-
key: z.string().default('')
|
|
54
|
-
.description('Name used in the chains above. Must differ from the built-in keys.'),
|
|
55
|
-
template: z.string().default('openai-transcriptions')
|
|
56
|
-
.description(`API shape: ${CUSTOM_TEMPLATES.join(' or ')}. `
|
|
57
|
-
+ 'OpenRouter has no /audio/transcriptions, use openai-chat-audio there.'),
|
|
58
|
-
baseURL: z.string().default('')
|
|
59
|
-
.description('API root without a trailing slash, e.g. https://openrouter.ai/api/v1'),
|
|
60
|
-
model: z.string().default(''),
|
|
61
|
-
keyEnv: z.string().default('')
|
|
62
|
-
.description('Credential name holding the API key. Empty means no authorization header.'),
|
|
63
|
-
prompt: z.string().default('')
|
|
64
|
-
.description('openai-chat-audio only: instruction sent along with the audio. '
|
|
65
|
-
+ 'Empty means the built-in one.'),
|
|
66
|
-
})
|
|
67
|
-
|
|
68
43
|
function makeVolatile(schema) {
|
|
69
44
|
if (!schema || typeof schema.volatile !== 'function') return schema
|
|
70
45
|
if (schema.type === 'object' && schema.dict) {
|
|
@@ -79,133 +54,10 @@ function makeVolatile(schema) {
|
|
|
79
54
|
return schema.volatile()
|
|
80
55
|
}
|
|
81
56
|
|
|
82
|
-
const BaseConfig = z.object({
|
|
83
|
-
dictation: z.object({
|
|
84
|
-
chain: z.array(ChainEntry)
|
|
85
|
-
.default([{ provider: 'deepgram', model: '' }, { provider: 'groq', model: '' }, { provider: 'local-whisper', model: '' }])
|
|
86
|
-
.description('Fallback chain for dictation. Speed matters more than accuracy here.'),
|
|
87
|
-
language: z.string().default('ru'),
|
|
88
|
-
vadSilenceMs: z.number().default(700)
|
|
89
|
-
.description('Silence longer than this ends a phrase and sends the chunk.'),
|
|
90
|
-
sendDelayMs: z.number().default(0)
|
|
91
|
-
.description('Dictation: wait this many ms after a phrase before appending it, with a cancel window. 0 disables the delay.'),
|
|
92
|
-
polish: z.boolean().default(false)
|
|
93
|
-
.description('Polish the transcript through the harness model before inserting: punctuation, paragraphs, filler-word removal.'),
|
|
94
|
-
stream: z.boolean().default(false)
|
|
95
|
-
.description('Continuous dictation: cut phrases by a timer instead of waiting for a long silence, so text flows while you speak.'),
|
|
96
|
-
streamChunkMs: z.number().default(1200)
|
|
97
|
-
.description('Continuous dictation: phrase length in ms of uninterrupted speech before the chunk is sent.'),
|
|
98
|
-
vadAdapt: z.number().min(0).max(1).default(0)
|
|
99
|
-
.description('Adaptive silence threshold: 0 = fixed (current behaviour); >0 shrinks the threshold during dense speech and grows it during pauses.'),
|
|
100
|
-
}).default({}),
|
|
101
|
-
message: z.object({
|
|
102
|
-
chain: z.array(ChainEntry)
|
|
103
|
-
.default([{ provider: 'groq', model: '' }, { provider: 'hf', model: '' }, { provider: 'local-whisper', model: '' }])
|
|
104
|
-
.description('Fallback chain for voice messages. Accuracy matters more than speed.'),
|
|
105
|
-
language: z.string().default('ru'),
|
|
106
|
-
autoSendMs: z.number().default(4000)
|
|
107
|
-
.description('Cancel window before the recognized text is sent to the agent.'),
|
|
108
|
-
sessionCommands: z.boolean().default(false)
|
|
109
|
-
.description('Voice session commands: a clean "send", "cancel", "stop", "continue" does not become text — it drives the composer/session.'),
|
|
110
|
-
polishSend: z.boolean().default(false)
|
|
111
|
-
.description('Polish the whole composed draft through the model right before sending, not just single phrases.'),
|
|
112
|
-
}).default({}),
|
|
113
|
-
hotkey: z.string().default('Control')
|
|
114
|
-
.description('Hold this key anywhere in the page to record a voice message; release to send, '
|
|
115
|
-
+ 'Escape to discard. Modifier names (Control, Alt, Shift) or a KeyboardEvent code. '
|
|
116
|
-
+ 'Empty disables the hotkey.'),
|
|
117
|
-
customProviders: z.array(CustomProvider).default([])
|
|
118
|
-
.description('Own recognition providers, usable in both chains next to the built-in ones.'),
|
|
119
|
-
deepgramKeyEnv: z.string().default('DEEPGRAM_API_KEY'),
|
|
120
|
-
deepgramBaseUrl: z.string().default('https://api.deepgram.com')
|
|
121
|
-
.description('Deepgram-compatible host. Point this at a private/self-hosted Deepgram deployment '
|
|
122
|
-
+ 'instead of the public api.deepgram.com endpoint.'),
|
|
123
|
-
groqKeyEnv: z.string().default('GROQ_API_KEY'),
|
|
124
|
-
hfTokenEnv: z.string().default('HF_TOKEN'),
|
|
125
|
-
whisperUrl: z.string().default('http://127.0.0.1:8001/inference'),
|
|
126
|
-
whisperBin: z.string().default('whisper-server')
|
|
127
|
-
.description('whisper.cpp server binary, looked up in PATH unless an absolute path is given.'),
|
|
128
|
-
whisperModel: z.string().default('')
|
|
129
|
-
.description('Absolute path to the ggml model. Autostart stays off while this is empty; '
|
|
130
|
-
+ 'point it at your own model file to let the plugin launch whisper.cpp itself.'),
|
|
131
|
-
autoStart: z.boolean().default(true)
|
|
132
|
-
.description('Launch the local whisper.cpp server on activation if the port is free. '
|
|
133
|
-
+ 'Requires whisperModel to be set.'),
|
|
134
|
-
ffmpegBin: z.string().default('ffmpeg')
|
|
135
|
-
.description('ffmpeg used to convert browser webm/opus into the WAV that whisper.cpp requires.'),
|
|
136
|
-
timeoutMs: z.number().default(120000),
|
|
137
|
-
allowedAudioDirs: z.array(z.string()).default([]),
|
|
138
|
-
maxFileBytes: z.number().default(25 * 1024 * 1024),
|
|
139
|
-
normalizeTranscript: z.boolean().default(false)
|
|
140
|
-
.description('transcribe_audio: convert spoken numbers to digits and tidy punctuation.'),
|
|
141
|
-
beep: z.boolean().default(false)
|
|
142
|
-
.description('Play a short beep when recording starts and stops.'),
|
|
143
|
-
localOnly: z.boolean().default(false)
|
|
144
|
-
.description('Restrict both chains to local-whisper only: fully offline, no cloud providers.'),
|
|
145
|
-
micDeviceId: z.string().default('')
|
|
146
|
-
.description('Microphone device id for recording. Empty means the system default.'),
|
|
147
|
-
noiseGateDb: z.number().default(-45)
|
|
148
|
-
.description('Audio noise gate threshold in dB for client-side recording (-50 to -25 dB, or <= -90 to disable). Silence below this threshold cuts ambient background noise and prevents false VAD triggers.'),
|
|
149
|
-
historyLimit: z.number().default(20)
|
|
150
|
-
.description('How many recent dictation inserts to keep for undo in the browser. 0 disables history.'),
|
|
151
|
-
vocabulary: z.array(z.string()).default([])
|
|
152
|
-
.description('Custom words (names, terms) hinted to providers so they recognize them correctly.'),
|
|
153
|
-
contextGlossary: z.boolean().default(true)
|
|
154
|
-
.description('Automatically extract code words and terms from context and hint them to STT providers.'),
|
|
155
|
-
noiseSuppression: z.boolean().default(true)
|
|
156
|
-
.description('Enable hardware noise suppression, echo cancellation, and auto gain control.'),
|
|
157
|
-
voiceCommands: z.boolean().default(false)
|
|
158
|
-
.description('During dictation, spoken edit commands ("new line", "paragraph") become real line breaks instead of words.'),
|
|
159
|
-
wakeWord: z.string().default('')
|
|
160
|
-
.description('Heads-free dictation: a phrase that, when recognized by the browser leg, starts a recording. Empty disables.'),
|
|
161
|
-
bargeIn: z.boolean().default(false)
|
|
162
|
-
.description('Ongoing playback or a long turn is interrupted by detected speech (browser leg).'),
|
|
163
|
-
polishBaseUrl: z.string().default('')
|
|
164
|
-
.description('Offline polish: OpenAI-compatible /chat/completions endpoint (e.g. local Ollama). Empty uses the harness model.'),
|
|
165
|
-
polishModel: z.string().default('')
|
|
166
|
-
.description('Offline polish: model id on polishBaseUrl.'),
|
|
167
|
-
polishKeyEnv: z.string().default('')
|
|
168
|
-
.description('Offline polish: credential name for the api key. Empty means no Authorization header.'),
|
|
169
|
-
polishProvider: z.string().default('')
|
|
170
|
-
.description('Harness polish: provider id. Empty uses the agent default model selection.'),
|
|
171
|
-
polishModelId: z.string().default('')
|
|
172
|
-
.description('Harness polish: model id. Empty uses the agent default model selection.'),
|
|
173
|
-
sensevoiceUrl: z.string().default('http://127.0.0.1:6006/api/v1/asr')
|
|
174
|
-
.description('SenseVoice-ONNX / Sherpa-ONNX endpoint: POST /api/v1/asr or OpenAI-compatible /v1/audio/transcriptions.'),
|
|
175
|
-
sensevoiceBin: z.string().default('sherpa-onnx-offline-http-server')
|
|
176
|
-
.description('SenseVoice / sherpa-onnx executable binary used when autostart is enabled.'),
|
|
177
|
-
sensevoiceModel: z.string().default('')
|
|
178
|
-
.description('SenseVoice / sherpa-onnx model path or model identifier.'),
|
|
179
|
-
sensevoiceAutostart: z.boolean().default(false)
|
|
180
|
-
.description('Start the local SenseVoice / sherpa-onnx server process automatically on startup.'),
|
|
181
|
-
visualizerStyle: z.union(['liquid-wave', 'dynamic-orb', 'bars', 'off']).default('liquid-wave')
|
|
182
|
-
.description('Audio visualizer animation style in the recording pill: "liquid-wave", "dynamic-orb", "bars", or "off".'),
|
|
183
|
-
gatedTurnTaking: z.boolean().default(true)
|
|
184
|
-
.description('Gated mode for speakers: pause recording or ignore input while assistant TTS is speaking to prevent acoustic feedback.'),
|
|
185
|
-
autoSendVisualRing: z.boolean().default(true)
|
|
186
|
-
.description('Show animated circular countdown ring on recording pill before auto-sending.'),
|
|
187
|
-
voiceActions: z.boolean().default(true)
|
|
188
|
-
.description('Enable spoken action triggers ("send", "cancel", "clear", "new line" in English/Russian).'),
|
|
189
|
-
techJargonCorrection: z.boolean().default(true)
|
|
190
|
-
.description('Apply developer lexicon and IT jargon normalization (GitHub, Docker, pnpm, Kubernetes, etc.).'),
|
|
191
|
-
jargonDictionary: z.array(z.object({ from: z.string(), to: z.string() })).default([])
|
|
192
|
-
.description('Custom jargon replacement dictionary.'),
|
|
193
|
-
liveInterimPreview: z.boolean().default(true)
|
|
194
|
-
.description('Display real-time interim speech preview (ghost text) in the recording pill.'),
|
|
195
|
-
structuredPromptVoice: z.boolean().default(true)
|
|
196
|
-
.description('Target voice dictation and numeric choices to active DSH structured prompt modals.'),
|
|
197
|
-
})
|
|
198
|
-
|
|
199
57
|
export const Config = typeof z.number().volatile === 'function'
|
|
200
58
|
? makeVolatile(BaseConfig)
|
|
201
59
|
: BaseConfig
|
|
202
60
|
|
|
203
|
-
const MIME_BY_EXT = {
|
|
204
|
-
'.wav': 'audio/wav', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.mp4': 'audio/mp4',
|
|
205
|
-
'.ogg': 'audio/ogg', '.oga': 'audio/ogg', '.flac': 'audio/flac', '.webm': 'audio/webm', '.aac': 'audio/aac',
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
|
|
209
61
|
export function apply(ctx, baseConfig) {
|
|
210
62
|
let child = null
|
|
211
63
|
|
|
@@ -253,6 +105,10 @@ export function apply(ctx, baseConfig) {
|
|
|
253
105
|
return true
|
|
254
106
|
}
|
|
255
107
|
try {
|
|
108
|
+
if (child && typeof child.kill === 'function') {
|
|
109
|
+
try { child.kill() } catch { /* ignore */ }
|
|
110
|
+
child = null
|
|
111
|
+
}
|
|
256
112
|
const spec = ctx.shell.resolve({
|
|
257
113
|
command: `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
|
|
258
114
|
+ ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${isAutoLang(cfg.dictation.language) ? 'auto' : cfg.dictation.language}`,
|
|
@@ -319,6 +175,10 @@ export function apply(ctx, baseConfig) {
|
|
|
319
175
|
return true
|
|
320
176
|
}
|
|
321
177
|
try {
|
|
178
|
+
if (sensevoiceChild && typeof sensevoiceChild.kill === 'function') {
|
|
179
|
+
try { sensevoiceChild.kill() } catch { /* ignore */ }
|
|
180
|
+
sensevoiceChild = null
|
|
181
|
+
}
|
|
322
182
|
let port = '6006'
|
|
323
183
|
try { port = new URL(cfg.sensevoiceUrl).port || '6006' } catch { /* invalid URL */ }
|
|
324
184
|
const tokensPath = findTokensFile(path.dirname(cfg.sensevoiceModel))
|
|
@@ -531,10 +391,15 @@ export function apply(ctx, baseConfig) {
|
|
|
531
391
|
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
532
392
|
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
533
393
|
const cfg = live()
|
|
394
|
+
const maxBodyBytes = Math.ceil((cfg.maxFileBytes * 4) / 3) + 64 * 1024
|
|
534
395
|
let raw
|
|
535
396
|
try {
|
|
536
|
-
raw = await readBody(req,
|
|
397
|
+
raw = await readBody(req, maxBodyBytes)
|
|
537
398
|
} catch (e) {
|
|
399
|
+
if (e && e.message === 'body too large') {
|
|
400
|
+
writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio body exceeds limit for max ${cfg.maxFileBytes} bytes` } })
|
|
401
|
+
return
|
|
402
|
+
}
|
|
538
403
|
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
|
|
539
404
|
}
|
|
540
405
|
let payload
|
|
@@ -595,61 +460,13 @@ export function apply(ctx, baseConfig) {
|
|
|
595
460
|
},
|
|
596
461
|
}), 'dsh-voice: /transcribe route')
|
|
597
462
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
+ 'Use for voice messages, recordings, interviews.',
|
|
606
|
-
parameters: {
|
|
607
|
-
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) || 'ru'}.` },
|
|
609
|
-
},
|
|
610
|
-
output: {
|
|
611
|
-
schema: {
|
|
612
|
-
type: 'object',
|
|
613
|
-
additionalProperties: false,
|
|
614
|
-
properties: { provider: { type: 'string' }, text: { type: 'string' }, tookMs: { type: 'integer' } },
|
|
615
|
-
},
|
|
616
|
-
render(args, value) {
|
|
617
|
-
const body = value.text.length > 4000
|
|
618
|
-
? `${value.text.slice(0, 4000)}\n…[truncated ${value.text.length} chars]`
|
|
619
|
-
: value.text
|
|
620
|
-
return [{ type: 'text', text: `transcribe_audio (${value.provider}, ${value.tookMs}ms):\n${body}` }]
|
|
621
|
-
},
|
|
622
|
-
},
|
|
623
|
-
isConcurrencySafe: () => false,
|
|
624
|
-
timeoutMs: 365000,
|
|
625
|
-
async execute(args, exec) {
|
|
626
|
-
const cfg = live()
|
|
627
|
-
const filePath = String(args.file_path || '').trim()
|
|
628
|
-
if (!filePath) throw new Error('transcribe_audio: file_path is required')
|
|
629
|
-
const allowedRoots = getAllowedAudioRoots(cfg)
|
|
630
|
-
const { real } = await validateAudioPath(filePath, allowedRoots)
|
|
631
|
-
const info = await stat(real).catch(() => null)
|
|
632
|
-
if (!info) throw new Error(`transcribe_audio: file not found: ${filePath}`)
|
|
633
|
-
if (info.size > cfg.maxFileBytes) {
|
|
634
|
-
throw new Error(`transcribe_audio: file too large (${info.size} bytes, max ${cfg.maxFileBytes})`)
|
|
635
|
-
}
|
|
636
|
-
if (info.size < 44) throw new Error('transcribe_audio: file is empty or too small')
|
|
637
|
-
const bytes = await readFile(real)
|
|
638
|
-
const sniffedMime = validateAudioContent(bytes)
|
|
639
|
-
const mime = sniffedMime || MIME_BY_EXT[path.extname(real).toLowerCase()] || 'audio/wav'
|
|
640
|
-
const modeCfg = { ...cfg.message, language: String(args.language || cfg.message.language) }
|
|
641
|
-
let raw = await transcribe(modeCfg, bytes, mime, exec.signal)
|
|
642
|
-
if (raw && raw.text) raw = { ...raw, text: await polishText(raw.text, modeCfg, exec.signal) }
|
|
643
|
-
const out = raw
|
|
644
|
-
if (cfg.normalizeTranscript && out && out.text) {
|
|
645
|
-
out.text = normalizePhrase(out.text, {
|
|
646
|
-
digits: true, capSentences: true, commaSpacing: true, trailingPeriod: true,
|
|
647
|
-
})
|
|
648
|
-
}
|
|
649
|
-
return out
|
|
650
|
-
},
|
|
651
|
-
}),
|
|
652
|
-
), 'dsh-voice: transcribe_audio tool')
|
|
463
|
+
// Registers agent tool name: 'transcribe_audio'
|
|
464
|
+
ctx.effect(() => registerTranscribeAudioTool(ctx, {
|
|
465
|
+
live,
|
|
466
|
+
baseConfig,
|
|
467
|
+
transcribe,
|
|
468
|
+
polishText,
|
|
469
|
+
}), 'dsh-voice: transcribe_audio tool')
|
|
653
470
|
|
|
654
471
|
ctx.effect(() => () => {
|
|
655
472
|
if (child) { try { child.kill && child.kill() } catch { /* already dead */ } }
|
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/schema.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// lib/schema.js — Schemastery configuration schemas for @goodandready/dsh-voice
|
|
2
|
+
import z from '@deepseek-ai/schemastery'
|
|
3
|
+
import { PROVIDER_KEYS, PRESET_KEYS, CUSTOM_TEMPLATES } from './providers.js'
|
|
4
|
+
|
|
5
|
+
export const ChainEntry = z.object({
|
|
6
|
+
provider: z.string().default('local-whisper')
|
|
7
|
+
.description(`Provider key. Built in: ${PROVIDER_KEYS.join(', ')}. `
|
|
8
|
+
+ `Ready-made, just add the key: ${PRESET_KEYS.join(', ')}. `
|
|
9
|
+
+ 'Or the name of an entry from customProviders. '
|
|
10
|
+
+ '"browser" recognises speech in the page itself — no key, no upload to this host, '
|
|
11
|
+
+ 'text appears while you speak; put a normal provider after it as a fallback.'),
|
|
12
|
+
model: z.string().default('')
|
|
13
|
+
.description('Model override. Empty means the provider default.'),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
// Custom provider: everything needed to call any OpenAI-compatible API.
|
|
17
|
+
export const CustomProvider = z.object({
|
|
18
|
+
key: z.string().default('')
|
|
19
|
+
.description('Name used in the chains above. Must differ from the built-in keys.'),
|
|
20
|
+
template: z.string().default('openai-transcriptions')
|
|
21
|
+
.description(`API shape: ${CUSTOM_TEMPLATES.join(' or ')}. `
|
|
22
|
+
+ 'OpenRouter has no /audio/transcriptions, use openai-chat-audio there.'),
|
|
23
|
+
baseURL: z.string().default('')
|
|
24
|
+
.description('API root without a trailing slash, e.g. https://openrouter.ai/api/v1'),
|
|
25
|
+
model: z.string().default(''),
|
|
26
|
+
keyEnv: z.string().default('')
|
|
27
|
+
.description('Credential name holding the API key. Empty means no authorization header.'),
|
|
28
|
+
prompt: z.string().default('')
|
|
29
|
+
.description('openai-chat-audio only: instruction sent along with the audio. '
|
|
30
|
+
+ 'Empty means the built-in one.'),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
export const BaseConfig = z.object({
|
|
35
|
+
dictation: z.object({
|
|
36
|
+
chain: z.array(ChainEntry)
|
|
37
|
+
.default([{ provider: 'deepgram', model: '' }, { provider: 'groq', model: '' }, { provider: 'local-whisper', model: '' }])
|
|
38
|
+
.description('Fallback chain for dictation. Speed matters more than accuracy here.'),
|
|
39
|
+
language: z.string().default('')
|
|
40
|
+
.description('Recognition language code for dictation (e.g. "en", "ru", "zh", or empty string for auto-detection).'),
|
|
41
|
+
vadSilenceMs: z.number().default(700)
|
|
42
|
+
.description('Silence longer than this ends a phrase and sends the chunk.'),
|
|
43
|
+
sendDelayMs: z.number().default(0)
|
|
44
|
+
.description('Dictation: wait this many ms after a phrase before appending it, with a cancel window. 0 disables the delay.'),
|
|
45
|
+
polish: z.boolean().default(false)
|
|
46
|
+
.description('Polish the transcript through the harness model before inserting: punctuation, paragraphs, filler-word removal.'),
|
|
47
|
+
stream: z.boolean().default(false)
|
|
48
|
+
.description('Continuous dictation: cut phrases by a timer instead of waiting for a long silence, so text flows while you speak.'),
|
|
49
|
+
streamChunkMs: z.number().default(1200)
|
|
50
|
+
.description('Continuous dictation: phrase length in ms of uninterrupted speech before the chunk is sent.'),
|
|
51
|
+
vadAdapt: z.number().min(0).max(1).default(0)
|
|
52
|
+
.description('Adaptive silence threshold: 0 = fixed (current behaviour); >0 shrinks the threshold during dense speech and grows it during pauses.'),
|
|
53
|
+
}).default({}),
|
|
54
|
+
message: z.object({
|
|
55
|
+
chain: z.array(ChainEntry)
|
|
56
|
+
.default([{ provider: 'groq', model: '' }, { provider: 'hf', model: '' }, { provider: 'local-whisper', model: '' }])
|
|
57
|
+
.description('Fallback chain for voice messages. Accuracy matters more than speed.'),
|
|
58
|
+
language: z.string().default('')
|
|
59
|
+
.description('Recognition language code for messages (e.g. "en", "ru", "zh", or empty string for auto-detection).'),
|
|
60
|
+
autoSendMs: z.number().default(4000)
|
|
61
|
+
.description('Cancel window before the recognized text is sent to the agent.'),
|
|
62
|
+
sessionCommands: z.boolean().default(false)
|
|
63
|
+
.description('Voice session commands: a clean "send", "cancel", "stop", "continue" does not become text — it drives the composer/session.'),
|
|
64
|
+
polishSend: z.boolean().default(false)
|
|
65
|
+
.description('Polish the whole composed draft through the model right before sending, not just single phrases.'),
|
|
66
|
+
}).default({}),
|
|
67
|
+
hotkey: z.string().default('Control')
|
|
68
|
+
.description('Hold this key anywhere in the page to record a voice message; release to send, '
|
|
69
|
+
+ 'Escape to discard. Modifier names (Control, Alt, Shift) or a KeyboardEvent code. '
|
|
70
|
+
+ 'Empty disables the hotkey.'),
|
|
71
|
+
customProviders: z.array(CustomProvider).default([])
|
|
72
|
+
.description('Own recognition providers, usable in both chains next to the built-in ones.'),
|
|
73
|
+
deepgramKeyEnv: z.string().default('DEEPGRAM_API_KEY'),
|
|
74
|
+
deepgramBaseUrl: z.string().default('https://api.deepgram.com')
|
|
75
|
+
.description('Deepgram-compatible host. Point this at a private/self-hosted Deepgram deployment '
|
|
76
|
+
+ 'instead of the public api.deepgram.com endpoint.'),
|
|
77
|
+
groqKeyEnv: z.string().default('GROQ_API_KEY'),
|
|
78
|
+
hfTokenEnv: z.string().default('HF_TOKEN'),
|
|
79
|
+
whisperUrl: z.string().default('http://127.0.0.1:8001/inference'),
|
|
80
|
+
whisperBin: z.string().default('whisper-server')
|
|
81
|
+
.description('whisper.cpp server binary, looked up in PATH unless an absolute path is given.'),
|
|
82
|
+
whisperModel: z.string().default('')
|
|
83
|
+
.description('Absolute path to the ggml model. Autostart stays off while this is empty; '
|
|
84
|
+
+ 'point it at your own model file to let the plugin launch whisper.cpp itself.'),
|
|
85
|
+
autoStart: z.boolean().default(true)
|
|
86
|
+
.description('Launch the local whisper.cpp server on activation if the port is free. '
|
|
87
|
+
+ 'Requires whisperModel to be set.'),
|
|
88
|
+
ffmpegBin: z.string().default('ffmpeg')
|
|
89
|
+
.description('ffmpeg used to convert browser webm/opus into the WAV that whisper.cpp requires.'),
|
|
90
|
+
timeoutMs: z.number().default(120000),
|
|
91
|
+
allowedAudioDirs: z.array(z.string()).default([]),
|
|
92
|
+
maxFileBytes: z.number().default(25 * 1024 * 1024),
|
|
93
|
+
normalizeTranscript: z.boolean().default(false)
|
|
94
|
+
.description('transcribe_audio: convert spoken numbers to digits and tidy punctuation.'),
|
|
95
|
+
beep: z.boolean().default(false)
|
|
96
|
+
.description('Play a short beep when recording starts and stops.'),
|
|
97
|
+
localOnly: z.boolean().default(false)
|
|
98
|
+
.description('Restrict both chains to local-whisper only: fully offline, no cloud providers.'),
|
|
99
|
+
micDeviceId: z.string().default('')
|
|
100
|
+
.description('Microphone device id for recording. Empty means the system default.'),
|
|
101
|
+
noiseGateDb: z.number().default(-45)
|
|
102
|
+
.description('Audio noise gate threshold in dB for client-side recording (-50 to -25 dB, or <= -90 to disable). Silence below this threshold cuts ambient background noise and prevents false VAD triggers.'),
|
|
103
|
+
historyLimit: z.number().default(20)
|
|
104
|
+
.description('How many recent dictation inserts to keep for undo in the browser. 0 disables history.'),
|
|
105
|
+
vocabulary: z.array(z.string()).default([])
|
|
106
|
+
.description('Custom words (names, terms) hinted to providers so they recognize them correctly.'),
|
|
107
|
+
contextGlossary: z.boolean().default(true)
|
|
108
|
+
.description('Automatically extract code words and terms from context and hint them to STT providers.'),
|
|
109
|
+
noiseSuppression: z.boolean().default(true)
|
|
110
|
+
.description('Enable hardware noise suppression, echo cancellation, and auto gain control.'),
|
|
111
|
+
voiceCommands: z.boolean().default(false)
|
|
112
|
+
.description('During dictation, spoken edit commands ("new line", "paragraph") become real line breaks instead of words.'),
|
|
113
|
+
wakeWord: z.string().default('')
|
|
114
|
+
.description('Heads-free dictation: a phrase that, when recognized by the browser leg, starts a recording. Empty disables.'),
|
|
115
|
+
bargeIn: z.boolean().default(false)
|
|
116
|
+
.description('Ongoing playback or a long turn is interrupted by detected speech (browser leg).'),
|
|
117
|
+
polishBaseUrl: z.string().default('')
|
|
118
|
+
.description('Offline polish: OpenAI-compatible /chat/completions endpoint (e.g. local Ollama). Empty uses the harness model.'),
|
|
119
|
+
polishModel: z.string().default('')
|
|
120
|
+
.description('Offline polish: model id on polishBaseUrl.'),
|
|
121
|
+
polishKeyEnv: z.string().default('')
|
|
122
|
+
.description('Offline polish: credential name for the api key. Empty means no Authorization header.'),
|
|
123
|
+
polishProvider: z.string().default('')
|
|
124
|
+
.description('Harness polish: provider id. Empty uses the agent default model selection.'),
|
|
125
|
+
polishModelId: z.string().default('')
|
|
126
|
+
.description('Harness polish: model id. Empty uses the agent default model selection.'),
|
|
127
|
+
sensevoiceUrl: z.string().default('http://127.0.0.1:6006/api/v1/asr')
|
|
128
|
+
.description('SenseVoice-ONNX / Sherpa-ONNX endpoint: POST /api/v1/asr or OpenAI-compatible /v1/audio/transcriptions.'),
|
|
129
|
+
sensevoiceBin: z.string().default('sherpa-onnx-offline-http-server')
|
|
130
|
+
.description('SenseVoice / sherpa-onnx executable binary used when autostart is enabled.'),
|
|
131
|
+
sensevoiceModel: z.string().default('')
|
|
132
|
+
.description('SenseVoice / sherpa-onnx model path or model identifier.'),
|
|
133
|
+
sensevoiceAutostart: z.boolean().default(false)
|
|
134
|
+
.description('Start the local SenseVoice / sherpa-onnx server process automatically on startup.'),
|
|
135
|
+
visualizerStyle: z.union(['liquid-wave', 'dynamic-orb', 'bars', 'off']).default('liquid-wave')
|
|
136
|
+
.description('Audio visualizer animation style in the recording pill: "liquid-wave", "dynamic-orb", "bars", or "off".'),
|
|
137
|
+
gatedTurnTaking: z.boolean().default(true)
|
|
138
|
+
.description('Gated mode for speakers: pause recording or ignore input while assistant TTS is speaking to prevent acoustic feedback.'),
|
|
139
|
+
autoSendVisualRing: z.boolean().default(true)
|
|
140
|
+
.description('Show animated circular countdown ring on recording pill before auto-sending.'),
|
|
141
|
+
voiceActions: z.boolean().default(true)
|
|
142
|
+
.description('Enable spoken action triggers ("send", "cancel", "clear", "new line" in English/Russian).'),
|
|
143
|
+
techJargonCorrection: z.boolean().default(true)
|
|
144
|
+
.description('Apply developer lexicon and IT jargon normalization (GitHub, Docker, pnpm, Kubernetes, etc.).'),
|
|
145
|
+
jargonDictionary: z.array(z.object({ from: z.string(), to: z.string() })).default([])
|
|
146
|
+
.description('Custom jargon replacement dictionary.'),
|
|
147
|
+
liveInterimPreview: z.boolean().default(true)
|
|
148
|
+
.description('Display real-time interim speech preview (ghost text) in the recording pill.'),
|
|
149
|
+
structuredPromptVoice: z.boolean().default(true)
|
|
150
|
+
.description('Target voice dictation and numeric choices to active DSH structured prompt modals.'),
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
|
package/lib/tool.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { getAllowedAudioRoots, validateAudioPath, validateAudioContent } from './audio-guard.js'
|
|
5
|
+
import { normalizePhrase } from './normalize.js'
|
|
6
|
+
|
|
7
|
+
export const MIME_BY_EXT = {
|
|
8
|
+
'.wav': 'audio/wav', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.mp4': 'audio/mp4',
|
|
9
|
+
'.ogg': 'audio/ogg', '.oga': 'audio/ogg', '.flac': 'audio/flac', '.webm': 'audio/webm', '.aac': 'audio/aac',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function registerTranscribeAudioTool(ctx, { live, baseConfig, transcribe, polishText }) {
|
|
13
|
+
return ctx.tools.register(
|
|
14
|
+
defineTool({
|
|
15
|
+
name: 'transcribe_audio',
|
|
16
|
+
description:
|
|
17
|
+
'Recognize speech in an audio file and return the transcript as text. '
|
|
18
|
+
+ 'Uses the voice-message fallback chain from the dsh-voice settings, so a single '
|
|
19
|
+
+ 'provider outage or rate limit does not fail the request. '
|
|
20
|
+
+ 'Use for voice messages, recordings, interviews.',
|
|
21
|
+
parameters: {
|
|
22
|
+
file_path: { type: 'string', required: true, description: 'Absolute path to the audio file (wav, mp3, m4a, ogg, flac, webm).' },
|
|
23
|
+
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'}.` },
|
|
24
|
+
},
|
|
25
|
+
output: {
|
|
26
|
+
schema: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
additionalProperties: false,
|
|
29
|
+
properties: { provider: { type: 'string' }, text: { type: 'string' }, tookMs: { type: 'integer' } },
|
|
30
|
+
},
|
|
31
|
+
render(args, value) {
|
|
32
|
+
const body = value.text.length > 4000
|
|
33
|
+
? `${value.text.slice(0, 4000)}\n…[truncated ${value.text.length} chars]`
|
|
34
|
+
: value.text
|
|
35
|
+
return [{ type: 'text', text: `transcribe_audio (${value.provider}, ${value.tookMs}ms):\n${body}` }]
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
isConcurrencySafe: () => false,
|
|
39
|
+
timeoutMs: 365000,
|
|
40
|
+
async execute(args, exec) {
|
|
41
|
+
const cfg = live()
|
|
42
|
+
const filePath = String(args.file_path || '').trim()
|
|
43
|
+
if (!filePath) throw new Error('transcribe_audio: file_path is required')
|
|
44
|
+
const allowedRoots = getAllowedAudioRoots(cfg)
|
|
45
|
+
const { real } = await validateAudioPath(filePath, allowedRoots)
|
|
46
|
+
const info = await stat(real).catch(() => null)
|
|
47
|
+
if (!info) throw new Error(`transcribe_audio: file not found: ${filePath}`)
|
|
48
|
+
if (info.size > cfg.maxFileBytes) {
|
|
49
|
+
throw new Error(`transcribe_audio: file too large (${info.size} bytes, max ${cfg.maxFileBytes})`)
|
|
50
|
+
}
|
|
51
|
+
if (info.size < 44) throw new Error('transcribe_audio: file is empty or too small')
|
|
52
|
+
const bytes = await readFile(real)
|
|
53
|
+
const sniffedMime = validateAudioContent(bytes)
|
|
54
|
+
const mime = sniffedMime || MIME_BY_EXT[path.extname(real).toLowerCase()] || 'audio/wav'
|
|
55
|
+
const modeCfg = { ...cfg.message, language: String(args.language !== undefined ? args.language : cfg.message.language) }
|
|
56
|
+
let raw = await transcribe(modeCfg, bytes, mime, exec.signal)
|
|
57
|
+
if (raw && raw.text) raw = { ...raw, text: await polishText(raw.text, modeCfg, exec.signal) }
|
|
58
|
+
const out = raw
|
|
59
|
+
if (cfg.normalizeTranscript && out && out.text) {
|
|
60
|
+
out.text = normalizePhrase(out.text, {
|
|
61
|
+
lang: modeCfg.language,
|
|
62
|
+
digits: true, capSentences: true, commaSpacing: true, trailingPeriod: true,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
return out
|
|
66
|
+
},
|
|
67
|
+
}),
|
|
68
|
+
)
|
|
69
|
+
}
|
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.5",
|
|
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",
|