@goodandready/dsh-voice 0.9.16 → 0.9.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +547 -394
- package/lib/host-routes.js +186 -0
- package/lib/index.js +75 -457
- package/lib/local-daemon.js +272 -0
- package/lib/schema.js +1 -1
- package/lib/sensevoice-installer.js +4 -2
- package/lib/updater.js +17 -3
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,54 +1,46 @@
|
|
|
1
|
-
// dsh-voice
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
1
|
+
// Cordis plugin entrypoint for @goodandready/dsh-voice
|
|
2
|
+
// Provides:
|
|
3
|
+
// - Fallback STT chain across cloud and local engines
|
|
4
|
+
// - Volatile config schema for dynamic UI updates
|
|
5
|
+
// - HTTP routes: /dsh-voice/status, /dsh-voice/polish, /dsh-voice/transcribe, /dsh-voice/config
|
|
6
|
+
// - Agent tool: transcribe_audio
|
|
7
|
+
// - Sensevoice installer and self-updater routes
|
|
8
|
+
|
|
9
|
+
import z from '@deepseek-ai/schemastery'
|
|
10
|
+
import { toWav16k } from './wav.js'
|
|
11
|
+
import { makeProviders, KNOWN_KEYS, DEFAULT_MODELS } from './providers.js'
|
|
12
|
+
import { runChain } from './chain.js'
|
|
13
|
+
import { registerSensevoiceInstaller } from './sensevoice-installer.js'
|
|
14
|
+
import { registerPluginUpdater } from './updater.js'
|
|
15
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
16
|
+
import { registerConfigRoutes } from './config-routes.js'
|
|
17
|
+
import { registerHostRoutes } from './host-routes.js'
|
|
18
|
+
import { createConfigReader } from './config-compat.js'
|
|
19
|
+
import { createStatsTracker, mergeContextVocabulary } from './stats.js'
|
|
20
|
+
import { registerTranscribeAudioTool } from './tool.js'
|
|
21
|
+
import { createPolishText } from './polish.js'
|
|
22
|
+
import { buildProviderOrder, sessionCommand } from './transcribe-core.js'
|
|
23
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
24
|
+
import { applyJargonDictionary, extractVoiceActions } from './normalize.js'
|
|
25
|
+
import { ChainEntry, CustomProvider, BaseConfig } from './schema.js'
|
|
26
|
+
import { createLocalDaemons } from './local-daemon.js'
|
|
14
27
|
|
|
15
|
-
import z from "@deepseek-ai/schemastery"
|
|
16
|
-
import { credentialRef } from "@deepseek-ai/dsh-credentials"
|
|
17
|
-
import path from "node:path"
|
|
18
|
-
import { runChain } from "./chain.js"
|
|
19
|
-
import { makeProviders, KNOWN_KEYS, DEFAULT_MODELS } from "./providers.js"
|
|
20
|
-
import { toWav16k } from "./wav.js"
|
|
21
|
-
import { applyJargonDictionary, extractVoiceActions } from "./normalize.js"
|
|
22
|
-
import { createStatsTracker, mergeContextVocabulary } from "./stats.js"
|
|
23
|
-
import { writeJson, readBody, isTrustedCaller } from "./http-util.js"
|
|
24
|
-
import { createPolishText } from "./polish.js"
|
|
25
|
-
import { createUserMessage } from "@deepseek-ai/dsh-llm"
|
|
26
|
-
import { buildProviderOrder, sessionCommand } from "./transcribe-core.js"
|
|
27
|
-
import { registerPluginUpdater } from "./updater.js"
|
|
28
|
-
import { registerSensevoiceInstaller, buildSensevoiceArgs } from "./sensevoice-installer.js"
|
|
29
|
-
import { createConfigReader } from "./config-compat.js"
|
|
30
|
-
import { registerConfigRoutes } from "./config-routes.js"
|
|
31
|
-
import { isAutoLang } from "./provider-http.js"
|
|
32
|
-
import { ChainEntry, CustomProvider, BaseConfig } from "./schema.js"
|
|
33
|
-
import { registerTranscribeAudioTool } from "./tool.js"
|
|
34
28
|
export { ChainEntry, CustomProvider, BaseConfig }
|
|
35
|
-
|
|
36
29
|
export const name = '@goodandready/dsh-voice'
|
|
37
30
|
export const NS = 'dsh-voice'
|
|
38
31
|
export const inject = ['tools', 'credentials', 'webServer', 'shell', 'settings', 'llm']
|
|
39
32
|
|
|
40
33
|
function makeVolatile(schema) {
|
|
41
|
-
if (!schema || typeof schema
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return res
|
|
34
|
+
if (!schema || typeof schema !== 'object' || !schema.dict) return schema
|
|
35
|
+
const dict = {}
|
|
36
|
+
for (const [k, v] of Object.entries(schema.dict)) {
|
|
37
|
+
dict[k] = (v && typeof v.volatile === 'function') ? v.volatile() : v
|
|
38
|
+
}
|
|
39
|
+
const res = z.object(dict)
|
|
40
|
+
if (typeof schema.description === 'function' && schema.meta?.description) {
|
|
41
|
+
return res.description(schema.meta.description)
|
|
50
42
|
}
|
|
51
|
-
return
|
|
43
|
+
return res
|
|
52
44
|
}
|
|
53
45
|
|
|
54
46
|
export const Config = typeof z.number().volatile === 'function'
|
|
@@ -56,214 +48,34 @@ export const Config = typeof z.number().volatile === 'function'
|
|
|
56
48
|
: BaseConfig
|
|
57
49
|
|
|
58
50
|
export function apply(ctx, baseConfig) {
|
|
59
|
-
let
|
|
51
|
+
let settingsService = null
|
|
52
|
+
let scope = null
|
|
53
|
+
let getConfig = () => baseConfig
|
|
60
54
|
|
|
61
|
-
// The settings card edits a namespace named after the plugin.
|
|
62
|
-
// Using createConfigReader decodes volatile nodes on modern DSH cores
|
|
63
|
-
// while caching unwrapped values across requests.
|
|
64
55
|
const readEntryConfig = createConfigReader(baseConfig, ctx)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
let getConfig = readEntryConfig
|
|
56
|
+
getConfig = readEntryConfig
|
|
57
|
+
|
|
68
58
|
const live = () => BaseConfig(structuredClone(getConfig() ?? {})) ?? readEntryConfig()
|
|
69
59
|
|
|
70
|
-
async
|
|
60
|
+
const resolveKey = async (ref) => {
|
|
61
|
+
if (!ref || !ref.type) return ''
|
|
71
62
|
try {
|
|
72
63
|
const resolved = await ctx.credentials.resolve(credentialRef(ref))
|
|
73
|
-
|
|
74
|
-
} catch { /* fall through to environment */ }
|
|
75
|
-
return process.env[ref] || ''
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async function whisperAlive() {
|
|
79
|
-
const rawUrl = String(live().whisperUrl || '').trim()
|
|
80
|
-
const base = rawUrl.includes('/inference') ? rawUrl.split('/inference')[0] : rawUrl.replace(/\/+$/, '')
|
|
81
|
-
if (!base) return false
|
|
82
|
-
try {
|
|
83
|
-
const res = await fetch(base + '/', { signal: AbortSignal.timeout(2000) })
|
|
84
|
-
return res.ok
|
|
85
|
-
} catch {
|
|
86
|
-
return false
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
let whisperError = null
|
|
91
|
-
let startingWhisper = false
|
|
92
|
-
async function startWhisper() {
|
|
93
|
-
if (startingWhisper) return false
|
|
94
|
-
startingWhisper = true
|
|
95
|
-
try {
|
|
96
|
-
const cfg = live()
|
|
97
|
-
if (!cfg.autoStart) return false
|
|
98
|
-
// Without a model path there is nothing to start: the package cannot know
|
|
99
|
-
// where a user's model lives and must not launch a random binary.
|
|
100
|
-
if (!cfg.whisperModel) return false
|
|
101
|
-
if (await whisperAlive()) {
|
|
102
|
-
whisperError = null
|
|
103
|
-
return true
|
|
104
|
-
}
|
|
105
|
-
if (child && typeof child.kill === 'function') {
|
|
106
|
-
try { child.kill() } catch { /* ignore */ }
|
|
107
|
-
child = null
|
|
108
|
-
}
|
|
109
|
-
const spec = typeof ctx.shell?.resolve === 'function'
|
|
110
|
-
? ctx.shell.resolve({
|
|
111
|
-
command: `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
|
|
112
|
-
+ ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${isAutoLang(cfg.dictation.language) ? 'auto' : cfg.dictation.language}`,
|
|
113
|
-
timeoutMs: 0,
|
|
114
|
-
stdoutMaxBytes: 4 * 1024 * 1024,
|
|
115
|
-
})
|
|
116
|
-
: {
|
|
117
|
-
command: `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
|
|
118
|
-
+ ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${isAutoLang(cfg.dictation.language) ? 'auto' : cfg.dictation.language}`,
|
|
119
|
-
timeoutMs: 0,
|
|
120
|
-
stdoutMaxBytes: 4 * 1024 * 1024,
|
|
121
|
-
}
|
|
122
|
-
child = typeof ctx.shell?.execute === 'function'
|
|
123
|
-
? ctx.shell.execute(spec)
|
|
124
|
-
: typeof ctx.shell?.start === 'function'
|
|
125
|
-
? ctx.shell.start(spec)
|
|
126
|
-
: null
|
|
127
|
-
if (child && typeof child.on === 'function') {
|
|
128
|
-
child.on('error', (err) => {
|
|
129
|
-
whisperError = err?.message || String(err)
|
|
130
|
-
})
|
|
131
|
-
child.on('exit', (code) => {
|
|
132
|
-
if (code !== 0 && code !== null) {
|
|
133
|
-
whisperError = `whisper server process exited with code ${code}`
|
|
134
|
-
}
|
|
135
|
-
})
|
|
136
|
-
}
|
|
137
|
-
if (child && child.done && typeof child.done.then === 'function') {
|
|
138
|
-
child.done.then((res) => {
|
|
139
|
-
if (res && res.code !== 0 && res.code !== null && res.code !== undefined) {
|
|
140
|
-
whisperError = `whisper server process exited with code ${res.code}`
|
|
141
|
-
}
|
|
142
|
-
}).catch((err) => {
|
|
143
|
-
whisperError = err?.message || String(err)
|
|
144
|
-
})
|
|
145
|
-
}
|
|
146
|
-
// Wait for it to become healthy (up to 30s for cold model loading on Metal/CPU).
|
|
147
|
-
for (let i = 0; i < 60; i++) {
|
|
148
|
-
await new Promise((r) => setTimeout(r, 500))
|
|
149
|
-
if (await whisperAlive()) {
|
|
150
|
-
whisperError = null
|
|
151
|
-
return true
|
|
152
|
-
}
|
|
153
|
-
if (child && child.exitCode !== null && child.exitCode !== undefined) {
|
|
154
|
-
whisperError = `whisper server process exited prematurely with code ${child.exitCode}`
|
|
155
|
-
return false
|
|
156
|
-
}
|
|
157
|
-
if (child && (child.status === 'exited' || child.status === 'failed')) {
|
|
158
|
-
whisperError = `whisper server process exited prematurely with status ${child.status}`
|
|
159
|
-
return false
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
whisperError = 'whisper server process started but failed health check on port 8001'
|
|
163
|
-
return false
|
|
164
|
-
} catch (err) {
|
|
165
|
-
whisperError = String(err && err.message ? err.message : err)
|
|
166
|
-
return false
|
|
167
|
-
}
|
|
168
|
-
finally { startingWhisper = false }
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// SenseVoice-ONNX / Sherpa-ONNX: autostart and health check.
|
|
172
|
-
let sensevoiceChild = null
|
|
173
|
-
let sensevoiceError = null
|
|
174
|
-
|
|
175
|
-
async function sensevoiceAlive() {
|
|
176
|
-
const cfg = live()
|
|
177
|
-
const base = String(cfg.sensevoiceUrl || '').replace(/\/api\/.*$/, '').replace(/\/v1\/.*$/, '').replace(/\/+$/, '')
|
|
178
|
-
if (!base) return false
|
|
179
|
-
try {
|
|
180
|
-
const res = await fetch(base + '/', { signal: AbortSignal.timeout(2000) })
|
|
181
|
-
return res.ok || res.status === 404 // sherpa-onnx answers 404 on / but is alive
|
|
64
|
+
return resolved || ''
|
|
182
65
|
} catch {
|
|
183
|
-
return
|
|
66
|
+
return ''
|
|
184
67
|
}
|
|
185
68
|
}
|
|
186
69
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (startingSensevoice) return false
|
|
190
|
-
startingSensevoice = true
|
|
191
|
-
try {
|
|
192
|
-
const cfg = live()
|
|
193
|
-
if (!cfg.sensevoiceAutostart) return false
|
|
194
|
-
if (!cfg.sensevoiceModel) return false
|
|
195
|
-
if (await sensevoiceAlive()) {
|
|
196
|
-
sensevoiceError = null
|
|
197
|
-
return true
|
|
198
|
-
}
|
|
199
|
-
if (sensevoiceChild && typeof sensevoiceChild.kill === 'function') {
|
|
200
|
-
try { sensevoiceChild.kill() } catch { /* ignore */ }
|
|
201
|
-
sensevoiceChild = null
|
|
202
|
-
}
|
|
203
|
-
let port = '6006'
|
|
204
|
-
try { port = new URL(cfg.sensevoiceUrl).port || '6006' } catch { /* invalid URL */ }
|
|
205
|
-
const cmdArgs = buildSensevoiceArgs(cfg, port)
|
|
206
|
-
const commandStr = `${JSON.stringify(cfg.sensevoiceBin)}${cmdArgs}`
|
|
207
|
-
const specOpts = { command: commandStr, timeoutMs: 0, stdoutMaxBytes: 4 * 1024 * 1024 }
|
|
208
|
-
const spec = typeof ctx.shell?.resolve === 'function' ? ctx.shell.resolve(specOpts) : specOpts
|
|
209
|
-
sensevoiceChild = typeof ctx.shell?.execute === 'function'
|
|
210
|
-
? ctx.shell.execute(spec)
|
|
211
|
-
: typeof ctx.shell?.start === 'function'
|
|
212
|
-
? ctx.shell.start(spec)
|
|
213
|
-
: null
|
|
214
|
-
if (sensevoiceChild && typeof sensevoiceChild.on === 'function') {
|
|
215
|
-
sensevoiceChild.on('error', (err) => {
|
|
216
|
-
sensevoiceError = err?.message || String(err)
|
|
217
|
-
})
|
|
218
|
-
sensevoiceChild.on('exit', (code) => {
|
|
219
|
-
if (code !== 0 && code !== null) {
|
|
220
|
-
sensevoiceError = `sensevoice process exited with code ${code}`
|
|
221
|
-
}
|
|
222
|
-
})
|
|
223
|
-
}
|
|
224
|
-
if (sensevoiceChild && sensevoiceChild.done && typeof sensevoiceChild.done.then === 'function') {
|
|
225
|
-
sensevoiceChild.done.then((res) => {
|
|
226
|
-
if (res && res.code !== 0 && res.code !== null && res.code !== undefined) {
|
|
227
|
-
sensevoiceError = `sensevoice process exited with code ${res.code}`
|
|
228
|
-
}
|
|
229
|
-
}).catch((err) => {
|
|
230
|
-
sensevoiceError = err?.message || String(err)
|
|
231
|
-
})
|
|
232
|
-
}
|
|
233
|
-
// Wait for it to become healthy (up to 30s).
|
|
234
|
-
for (let i = 0; i < 60; i++) {
|
|
235
|
-
await new Promise((r) => setTimeout(r, 500))
|
|
236
|
-
if (await sensevoiceAlive()) {
|
|
237
|
-
sensevoiceError = null
|
|
238
|
-
return true
|
|
239
|
-
}
|
|
240
|
-
if (sensevoiceChild && sensevoiceChild.exitCode !== null && sensevoiceChild.exitCode !== undefined) {
|
|
241
|
-
sensevoiceError = `sensevoice process exited prematurely with code ${sensevoiceChild.exitCode}`
|
|
242
|
-
return false
|
|
243
|
-
}
|
|
244
|
-
if (sensevoiceChild && (sensevoiceChild.status === 'exited' || sensevoiceChild.status === 'failed')) {
|
|
245
|
-
sensevoiceError = `sensevoice process exited prematurely with status ${sensevoiceChild.status}`
|
|
246
|
-
return false
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
sensevoiceError = 'sensevoice process started but failed health check'
|
|
250
|
-
return false
|
|
251
|
-
} catch (err) {
|
|
252
|
-
sensevoiceError = String(err && err.message ? err.message : err)
|
|
253
|
-
return false
|
|
254
|
-
}
|
|
255
|
-
finally { startingSensevoice = false }
|
|
256
|
-
}
|
|
70
|
+
const daemons = createLocalDaemons({ live, ctx })
|
|
71
|
+
const { whisperAlive, sensevoiceAlive, startWhisper, startSensevoice } = daemons
|
|
257
72
|
|
|
258
73
|
function triggerAutostart() {
|
|
259
|
-
|
|
260
|
-
ctx.logger?.debug?.('whisper autostart failed: %s', err?.message || err)
|
|
261
|
-
})
|
|
262
|
-
startSensevoice().catch((err) => {
|
|
263
|
-
ctx.logger?.debug?.('sensevoice autostart failed: %s', err?.message || err)
|
|
264
|
-
})
|
|
74
|
+
daemons.triggerAutostart()
|
|
265
75
|
}
|
|
266
76
|
|
|
77
|
+
ctx.effect(() => () => daemons.dispose(), 'dsh-voice: daemon cleanup')
|
|
78
|
+
|
|
267
79
|
ctx.inject(['settings'], (sctx) => {
|
|
268
80
|
settingsService = sctx.settings
|
|
269
81
|
try {
|
|
@@ -281,11 +93,11 @@ export function apply(ctx, baseConfig) {
|
|
|
281
93
|
triggerAutostart()
|
|
282
94
|
})
|
|
283
95
|
|
|
284
|
-
if (typeof ctx.on ===
|
|
285
|
-
if (typeof ctx.effect ===
|
|
286
|
-
ctx.effect(() => ctx.on('loader/volatile-update', () => triggerAutostart()),
|
|
287
|
-
ctx.effect(() => ctx.on('settings/document-updated', () => triggerAutostart()),
|
|
288
|
-
ctx.effect(() => ctx.on('config', () => triggerAutostart()),
|
|
96
|
+
if (typeof ctx.on === 'function') {
|
|
97
|
+
if (typeof ctx.effect === 'function') {
|
|
98
|
+
ctx.effect(() => ctx.on('loader/volatile-update', () => triggerAutostart()), 'dsh-voice: volatile autostart')
|
|
99
|
+
ctx.effect(() => ctx.on('settings/document-updated', () => triggerAutostart()), 'dsh-voice: settings autostart')
|
|
100
|
+
ctx.effect(() => ctx.on('config', () => triggerAutostart()), 'dsh-voice: config autostart')
|
|
289
101
|
} else {
|
|
290
102
|
ctx.on('loader/volatile-update', () => triggerAutostart())
|
|
291
103
|
ctx.on('settings/document-updated', () => triggerAutostart())
|
|
@@ -295,10 +107,8 @@ export function apply(ctx, baseConfig) {
|
|
|
295
107
|
|
|
296
108
|
triggerAutostart()
|
|
297
109
|
|
|
298
|
-
// Provider health and latency stats (Latency & Health Dashboard).
|
|
299
110
|
const statsTracker = createStatsTracker()
|
|
300
111
|
|
|
301
|
-
// Shared recognition path: build providers from the mode chain and run it.
|
|
302
112
|
async function transcribe(modeCfg, bytes, mime, signal, contextWords) {
|
|
303
113
|
const cfg = live()
|
|
304
114
|
const customKeys = (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
|
|
@@ -322,191 +132,28 @@ export function apply(ctx, baseConfig) {
|
|
|
322
132
|
return runChain(order, providers, statsTracker.record)
|
|
323
133
|
}
|
|
324
134
|
|
|
325
|
-
// Transcript polish (#35) with local LLM support (#47).
|
|
326
|
-
// Errors/timeouts do not block: return the raw text.
|
|
327
135
|
const polishText = createPolishText({
|
|
328
136
|
resolveKey,
|
|
329
137
|
fetchImpl: (...args) => fetch(...args),
|
|
330
138
|
getConfig: live,
|
|
331
139
|
llm: ctx.llm,
|
|
332
140
|
createUserMessage,
|
|
333
|
-
getAgentDefaultModel: () => (ctx.get && ctx.get('agentDefaultModel')
|
|
141
|
+
getAgentDefaultModel: () => (ctx.get && ctx.get('agentDefaultModel')?.currentSelection)
|
|
334
142
|
? ctx.get('agentDefaultModel').currentSelection()
|
|
335
143
|
: null,
|
|
336
144
|
})
|
|
337
145
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
ok: true,
|
|
350
|
-
whisperRunning: whisperOk,
|
|
351
|
-
whisperError: whisperOk ? null : whisperError,
|
|
352
|
-
sensevoiceRunning: sensevoiceOk,
|
|
353
|
-
sensevoiceError: sensevoiceOk ? null : sensevoiceError,
|
|
354
|
-
// The hotkey is needed by the browser half: it installs the hold handler.
|
|
355
|
-
hotkey: cfg.hotkey,
|
|
356
|
-
providers: KNOWN_KEYS.concat(
|
|
357
|
-
(Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
|
|
358
|
-
.map((c) => String(c && c.key || '').trim()).filter(Boolean),
|
|
359
|
-
),
|
|
360
|
-
modes: {
|
|
361
|
-
dictation: {
|
|
362
|
-
chain: cfg.dictation.chain, language: cfg.dictation.language,
|
|
363
|
-
vadSilenceMs: cfg.dictation.vadSilenceMs,
|
|
364
|
-
sendDelayMs: cfg.dictation.sendDelayMs, polish: cfg.dictation.polish,
|
|
365
|
-
stream: cfg.dictation.stream, streamChunkMs: cfg.dictation.streamChunkMs,
|
|
366
|
-
vadAdapt: cfg.dictation.vadAdapt,
|
|
367
|
-
},
|
|
368
|
-
message: {
|
|
369
|
-
chain: cfg.message.chain, language: cfg.message.language,
|
|
370
|
-
autoSendMs: cfg.message.autoSendMs, polish: cfg.message.polish,
|
|
371
|
-
sessionCommands: cfg.message.sessionCommands, polishSend: cfg.message.polishSend,
|
|
372
|
-
},
|
|
373
|
-
},
|
|
374
|
-
beep: cfg.beep,
|
|
375
|
-
localOnly: cfg.localOnly,
|
|
376
|
-
micDeviceId: cfg.micDeviceId,
|
|
377
|
-
noiseGateDb: cfg.noiseGateDb !== undefined ? cfg.noiseGateDb : -45,
|
|
378
|
-
historyLimit: cfg.historyLimit,
|
|
379
|
-
voiceCommands: cfg.voiceCommands,
|
|
380
|
-
wakeWord: String(cfg.wakeWord || ''),
|
|
381
|
-
bargeIn: !!cfg.bargeIn,
|
|
382
|
-
polishBaseUrl: String(cfg.polishBaseUrl || ''),
|
|
383
|
-
noiseSuppression: cfg.noiseSuppression !== false,
|
|
384
|
-
contextGlossary: cfg.contextGlossary !== false,
|
|
385
|
-
sensevoiceUrl: cfg.sensevoiceUrl,
|
|
386
|
-
sensevoiceAutostart: !!cfg.sensevoiceAutostart,
|
|
387
|
-
sensevoiceProvider: cfg.sensevoiceProvider || 'cpu',
|
|
388
|
-
sensevoiceThreads: cfg.sensevoiceThreads || 4,
|
|
389
|
-
webgpuModel: cfg.webgpuModel || 'onnx-community/whisper-tiny',
|
|
390
|
-
deepgramBaseUrl: cfg.deepgramBaseUrl || 'https://api.deepgram.com',
|
|
391
|
-
visualizerStyle: cfg.visualizerStyle || 'liquid-wave',
|
|
392
|
-
gatedTurnTaking: cfg.gatedTurnTaking !== false,
|
|
393
|
-
autoSendVisualRing: cfg.autoSendVisualRing !== false,
|
|
394
|
-
voiceActions: cfg.voiceActions !== false,
|
|
395
|
-
techJargonCorrection: cfg.techJargonCorrection !== false,
|
|
396
|
-
jargonDictionary: Array.isArray(cfg.jargonDictionary) ? cfg.jargonDictionary : [],
|
|
397
|
-
liveInterimPreview: cfg.liveInterimPreview !== false,
|
|
398
|
-
structuredPromptVoice: cfg.structuredPromptVoice !== false,
|
|
399
|
-
providerStats: statsTracker.get(),
|
|
400
|
-
})
|
|
401
|
-
},
|
|
402
|
-
}), 'dsh-voice: /status route')
|
|
403
|
-
|
|
404
|
-
ctx.effect(() => ctx.webServer.register({
|
|
405
|
-
kind: 'exact',
|
|
406
|
-
path: '/dsh-voice/polish',
|
|
407
|
-
handler: async (req, res) => {
|
|
408
|
-
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
409
|
-
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
410
|
-
const cfg = live()
|
|
411
|
-
let raw
|
|
412
|
-
try { raw = await readBody(req, 1024 * 1024) } catch (e) {
|
|
413
|
-
if (e && (e.statusCode === 413 || e.message === 'body too large')) {
|
|
414
|
-
writeJson(res, 413, { ok: false, error: { code: 'too-large', message: 'body exceeds limit of 1MB' } })
|
|
415
|
-
return
|
|
416
|
-
}
|
|
417
|
-
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
418
|
-
return
|
|
419
|
-
}
|
|
420
|
-
let payload = {}
|
|
421
|
-
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
|
|
422
|
-
const text = typeof payload.text === 'string' ? payload.text.trim() : ''
|
|
423
|
-
if (!text) { writeJson(res, 400, { ok: false, error: { code: 'empty', message: 'text required' } }); return }
|
|
424
|
-
const controller = new AbortController()
|
|
425
|
-
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
426
|
-
try {
|
|
427
|
-
const out = await polishText(text, { polish: true }, controller.signal)
|
|
428
|
-
writeJson(res, 200, { ok: true, text: out })
|
|
429
|
-
} catch (e) {
|
|
430
|
-
writeJson(res, 502, { ok: false, error: { code: 'polish', message: String(e && e.message || e) } })
|
|
431
|
-
} finally { clearTimeout(timer) }
|
|
432
|
-
},
|
|
433
|
-
}), 'dsh-voice: /polish route')
|
|
434
|
-
|
|
435
|
-
ctx.effect(() => ctx.webServer.register({
|
|
436
|
-
kind: 'exact',
|
|
437
|
-
path: '/dsh-voice/transcribe',
|
|
438
|
-
handler: async (req, res) => {
|
|
439
|
-
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
440
|
-
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
441
|
-
const cfg = live()
|
|
442
|
-
const maxBodyBytes = Math.ceil((cfg.maxFileBytes * 4) / 3) + 64 * 1024
|
|
443
|
-
let raw
|
|
444
|
-
try {
|
|
445
|
-
raw = await readBody(req, maxBodyBytes)
|
|
446
|
-
} catch (e) {
|
|
447
|
-
if (e && e.message === 'body too large') {
|
|
448
|
-
writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio body exceeds limit for max ${cfg.maxFileBytes} bytes` } })
|
|
449
|
-
return
|
|
450
|
-
}
|
|
451
|
-
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
|
|
452
|
-
}
|
|
453
|
-
let payload
|
|
454
|
-
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
|
|
455
|
-
|
|
456
|
-
const dataBase64 = typeof payload.dataBase64 === 'string' ? payload.dataBase64 : ''
|
|
457
|
-
if (!dataBase64) { writeJson(res, 400, { ok: false, error: { code: 'no-audio', message: 'no audio data' } }); return }
|
|
458
|
-
const mime = typeof payload.mimeType === 'string' && payload.mimeType ? payload.mimeType : 'audio/webm'
|
|
459
|
-
const modeCfg = payload.mode === 'message' ? cfg.message : cfg.dictation
|
|
460
|
-
|
|
461
|
-
let bytes
|
|
462
|
-
try { bytes = Buffer.from(dataBase64, 'base64') } catch { bytes = null }
|
|
463
|
-
if (!bytes || bytes.length === 0) {
|
|
464
|
-
writeJson(res, 400, { ok: false, error: { code: 'decode', message: 'failed to decode audio' } }); return
|
|
465
|
-
}
|
|
466
|
-
if (bytes.length > cfg.maxFileBytes) {
|
|
467
|
-
writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio is ${bytes.length} bytes, max ${cfg.maxFileBytes}` } }); return
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// Local whisper in the chain — start the server up front, otherwise the
|
|
471
|
-
// first chunk fails while the server is still booting.
|
|
472
|
-
if ((modeCfg.chain || []).some((e) => e.provider === 'local-whisper') && !(await whisperAlive())) {
|
|
473
|
-
await startWhisper()
|
|
474
|
-
}
|
|
475
|
-
// Same for SenseVoice-ONNX / Sherpa-ONNX.
|
|
476
|
-
if ((modeCfg.chain || []).some((e) => e.provider === 'sensevoice') && !(await sensevoiceAlive())) {
|
|
477
|
-
await startSensevoice()
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const controller = new AbortController()
|
|
481
|
-
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
482
|
-
try {
|
|
483
|
-
const out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
|
|
484
|
-
// Session voice commands (#48): clean "send/cancel/stop/continue"
|
|
485
|
-
// become a command for the browser instead of composer text.
|
|
486
|
-
if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
|
|
487
|
-
const cmd = sessionCommand(out.text)
|
|
488
|
-
if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
|
|
489
|
-
}
|
|
490
|
-
let text = await polishText(out.text, modeCfg, controller.signal)
|
|
491
|
-
if (cfg.techJargonCorrection !== false) {
|
|
492
|
-
text = applyJargonDictionary(text, cfg.jargonDictionary)
|
|
493
|
-
}
|
|
494
|
-
let action = null
|
|
495
|
-
if (cfg.voiceActions !== false) {
|
|
496
|
-
const actionRes = extractVoiceActions(text)
|
|
497
|
-
if (actionRes.action) {
|
|
498
|
-
text = actionRes.text
|
|
499
|
-
action = actionRes.action
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
writeJson(res, 200, { ok: true, text, action, provider: out.provider, tookMs: out.tookMs })
|
|
503
|
-
} catch (e) {
|
|
504
|
-
writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
|
|
505
|
-
} finally {
|
|
506
|
-
clearTimeout(timer)
|
|
507
|
-
}
|
|
508
|
-
},
|
|
509
|
-
}), 'dsh-voice: /transcribe route')
|
|
146
|
+
registerHostRoutes(ctx, {
|
|
147
|
+
live,
|
|
148
|
+
daemons,
|
|
149
|
+
statsTracker,
|
|
150
|
+
transcribe,
|
|
151
|
+
polishText,
|
|
152
|
+
sessionCommand,
|
|
153
|
+
extractVoiceActions,
|
|
154
|
+
applyJargonDictionary,
|
|
155
|
+
KNOWN_KEYS,
|
|
156
|
+
})
|
|
510
157
|
|
|
511
158
|
// Registers agent tool name: 'transcribe_audio'
|
|
512
159
|
ctx.effect(() => registerTranscribeAudioTool(ctx, {
|
|
@@ -516,11 +163,6 @@ export function apply(ctx, baseConfig) {
|
|
|
516
163
|
polishText,
|
|
517
164
|
}), 'dsh-voice: transcribe_audio tool')
|
|
518
165
|
|
|
519
|
-
ctx.effect(() => () => {
|
|
520
|
-
if (child) { try { child.kill && child.kill() } catch { /* already dead */ } }
|
|
521
|
-
if (sensevoiceChild) { try { sensevoiceChild.kill && sensevoiceChild.kill() } catch { /* already dead */ } }
|
|
522
|
-
}, 'dsh-voice: stop child processes')
|
|
523
|
-
|
|
524
166
|
ctx.effect(() => registerPluginUpdater(ctx, {
|
|
525
167
|
endpoint: '/api/dsh-voice/update',
|
|
526
168
|
packageName: '@goodandready/dsh-voice',
|
|
@@ -531,42 +173,19 @@ export function apply(ctx, baseConfig) {
|
|
|
531
173
|
liveConfig: live,
|
|
532
174
|
isAlive: sensevoiceAlive,
|
|
533
175
|
updateConfig: async (patch) => {
|
|
534
|
-
|
|
535
|
-
const current = live()
|
|
536
|
-
const merged = { ...current, ...patch }
|
|
176
|
+
const merged = { ...live(), ...patch }
|
|
537
177
|
if (settingsService) {
|
|
538
178
|
let rev
|
|
539
|
-
try {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
if (typeof
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
await settingsService.update(NS, merged, rev)
|
|
548
|
-
saved = true
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
if (scope) {
|
|
553
|
-
if (typeof scope.replace === 'function') {
|
|
554
|
-
await scope.replace(merged)
|
|
555
|
-
saved = true
|
|
556
|
-
} else if (typeof scope.patch === 'function') {
|
|
557
|
-
await scope.patch(merged)
|
|
558
|
-
saved = true
|
|
559
|
-
} else if (typeof scope.set === 'function') {
|
|
560
|
-
for (const [k, v] of Object.entries(merged)) {
|
|
561
|
-
await scope.set(k, v).catch(() => {})
|
|
562
|
-
}
|
|
563
|
-
saved = true
|
|
179
|
+
try { rev = settingsService.describe?.()?.find?.((r) => r.ns === NS)?.revision } catch {}
|
|
180
|
+
if (typeof settingsService.replace === 'function') await settingsService.replace(NS, merged, rev)
|
|
181
|
+
else if (typeof settingsService.update === 'function') await settingsService.update(NS, merged, rev)
|
|
182
|
+
} else if (scope) {
|
|
183
|
+
if (typeof scope.replace === 'function') await scope.replace(merged)
|
|
184
|
+
else if (typeof scope.patch === 'function') await scope.patch(merged)
|
|
185
|
+
else if (typeof scope.set === 'function') {
|
|
186
|
+
for (const [k, v] of Object.entries(merged)) await scope.set(k, v).catch(() => {})
|
|
564
187
|
}
|
|
565
188
|
}
|
|
566
|
-
|
|
567
|
-
if (!saved) {
|
|
568
|
-
throw new Error('failed to persist settings: neither settingsService nor scope available')
|
|
569
|
-
}
|
|
570
189
|
},
|
|
571
190
|
startSensevoice,
|
|
572
191
|
}), 'dsh-voice: sensevoice installer route')
|
|
@@ -580,4 +199,3 @@ export function apply(ctx, baseConfig) {
|
|
|
580
199
|
triggerAutostart,
|
|
581
200
|
}), 'dsh-voice: config route')
|
|
582
201
|
}
|
|
583
|
-
|