@goodandready/dsh-voice 0.9.17 → 0.9.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +406 -366
- package/lib/host-routes.js +186 -0
- package/lib/index.js +73 -281
- package/lib/local-daemon.js +78 -24
- package/lib/updater.js +15 -7
- package/package.json +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// lib/host-routes.js — Host HTTP routes for dsh-voice status, polish, and transcribe
|
|
2
|
+
import { writeJson, readBody, isTrustedCaller } from './http-util.js'
|
|
3
|
+
|
|
4
|
+
export function registerHostRoutes(ctx, {
|
|
5
|
+
live,
|
|
6
|
+
daemons,
|
|
7
|
+
statsTracker,
|
|
8
|
+
transcribe,
|
|
9
|
+
polishText,
|
|
10
|
+
sessionCommand,
|
|
11
|
+
extractVoiceActions,
|
|
12
|
+
applyJargonDictionary,
|
|
13
|
+
KNOWN_KEYS,
|
|
14
|
+
}) {
|
|
15
|
+
const { whisperAlive, sensevoiceAlive, getWhisperError, getSensevoiceError, startWhisper, startSensevoice } = daemons
|
|
16
|
+
|
|
17
|
+
ctx.effect(() => ctx.webServer.register({
|
|
18
|
+
kind: 'exact',
|
|
19
|
+
path: '/dsh-voice/status',
|
|
20
|
+
handler: async (req, res) => {
|
|
21
|
+
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
22
|
+
const cfg = live()
|
|
23
|
+
const whisperOk = await whisperAlive()
|
|
24
|
+
const sensevoiceOk = await sensevoiceAlive()
|
|
25
|
+
const whisperError = whisperOk ? null : getWhisperError()
|
|
26
|
+
const sensevoiceError = sensevoiceOk ? null : getSensevoiceError()
|
|
27
|
+
writeJson(res, 200, {
|
|
28
|
+
ok: true,
|
|
29
|
+
whisperRunning: whisperOk,
|
|
30
|
+
whisperError: whisperOk ? null : whisperError,
|
|
31
|
+
sensevoiceRunning: sensevoiceOk,
|
|
32
|
+
sensevoiceError: sensevoiceOk ? null : sensevoiceError,
|
|
33
|
+
hotkey: cfg.hotkey,
|
|
34
|
+
providers: KNOWN_KEYS.concat(
|
|
35
|
+
(Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
|
|
36
|
+
.map((c) => String(c && c.key || '').trim()).filter(Boolean),
|
|
37
|
+
),
|
|
38
|
+
modes: {
|
|
39
|
+
dictation: {
|
|
40
|
+
chain: cfg.dictation.chain, language: cfg.dictation.language,
|
|
41
|
+
vadSilenceMs: cfg.dictation.vadSilenceMs,
|
|
42
|
+
sendDelayMs: cfg.dictation.sendDelayMs, polish: cfg.dictation.polish,
|
|
43
|
+
stream: cfg.dictation.stream, streamChunkMs: cfg.dictation.streamChunkMs,
|
|
44
|
+
vadAdapt: cfg.dictation.vadAdapt,
|
|
45
|
+
},
|
|
46
|
+
message: {
|
|
47
|
+
chain: cfg.message.chain, language: cfg.message.language,
|
|
48
|
+
autoSendMs: cfg.message.autoSendMs, polish: cfg.message.polish,
|
|
49
|
+
sessionCommands: cfg.message.sessionCommands, polishSend: cfg.message.polishSend,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
beep: cfg.beep,
|
|
53
|
+
localOnly: cfg.localOnly,
|
|
54
|
+
micDeviceId: cfg.micDeviceId,
|
|
55
|
+
noiseGateDb: cfg.noiseGateDb !== undefined ? cfg.noiseGateDb : -45,
|
|
56
|
+
historyLimit: cfg.historyLimit,
|
|
57
|
+
voiceCommands: cfg.voiceCommands,
|
|
58
|
+
wakeWord: String(cfg.wakeWord || ''),
|
|
59
|
+
bargeIn: !!cfg.bargeIn,
|
|
60
|
+
polishBaseUrl: String(cfg.polishBaseUrl || ''),
|
|
61
|
+
noiseSuppression: cfg.noiseSuppression !== false,
|
|
62
|
+
contextGlossary: cfg.contextGlossary !== false,
|
|
63
|
+
sensevoiceUrl: cfg.sensevoiceUrl,
|
|
64
|
+
sensevoiceAutostart: !!cfg.sensevoiceAutostart,
|
|
65
|
+
sensevoiceProvider: cfg.sensevoiceProvider || 'cpu',
|
|
66
|
+
sensevoiceThreads: cfg.sensevoiceThreads || 4,
|
|
67
|
+
webgpuModel: cfg.webgpuModel || 'onnx-community/whisper-tiny',
|
|
68
|
+
deepgramBaseUrl: cfg.deepgramBaseUrl || 'https://api.deepgram.com',
|
|
69
|
+
visualizerStyle: cfg.visualizerStyle || 'liquid-wave',
|
|
70
|
+
gatedTurnTaking: cfg.gatedTurnTaking !== false,
|
|
71
|
+
autoSendVisualRing: cfg.autoSendVisualRing !== false,
|
|
72
|
+
voiceActions: cfg.voiceActions !== false,
|
|
73
|
+
techJargonCorrection: cfg.techJargonCorrection !== false,
|
|
74
|
+
jargonDictionary: Array.isArray(cfg.jargonDictionary) ? cfg.jargonDictionary : [],
|
|
75
|
+
liveInterimPreview: cfg.liveInterimPreview !== false,
|
|
76
|
+
structuredPromptVoice: cfg.structuredPromptVoice !== false,
|
|
77
|
+
providerStats: statsTracker.get(),
|
|
78
|
+
})
|
|
79
|
+
},
|
|
80
|
+
}), 'dsh-voice: /status route')
|
|
81
|
+
|
|
82
|
+
ctx.effect(() => ctx.webServer.register({
|
|
83
|
+
kind: 'exact',
|
|
84
|
+
path: '/dsh-voice/polish',
|
|
85
|
+
handler: async (req, res) => {
|
|
86
|
+
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
87
|
+
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
88
|
+
const cfg = live()
|
|
89
|
+
let raw
|
|
90
|
+
try { raw = await readBody(req, 1024 * 1024) } catch (e) {
|
|
91
|
+
if (e && (e.statusCode === 413 || e.message === 'body too large')) {
|
|
92
|
+
writeJson(res, 413, { ok: false, error: { code: 'too-large', message: 'body exceeds limit of 1MB' } })
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
let payload = {}
|
|
99
|
+
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
|
|
100
|
+
const text = typeof payload.text === 'string' ? payload.text.trim() : ''
|
|
101
|
+
if (!text) { writeJson(res, 400, { ok: false, error: { code: 'empty', message: 'text required' } }); return }
|
|
102
|
+
const controller = new AbortController()
|
|
103
|
+
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
104
|
+
try {
|
|
105
|
+
const out = await polishText(text, { polish: true }, controller.signal)
|
|
106
|
+
writeJson(res, 200, { ok: true, text: out })
|
|
107
|
+
} catch (e) {
|
|
108
|
+
writeJson(res, 502, { ok: false, error: { code: 'polish', message: String(e && e.message || e) } })
|
|
109
|
+
} finally { clearTimeout(timer) }
|
|
110
|
+
},
|
|
111
|
+
}), 'dsh-voice: /polish route')
|
|
112
|
+
|
|
113
|
+
ctx.effect(() => ctx.webServer.register({
|
|
114
|
+
kind: 'exact',
|
|
115
|
+
path: '/dsh-voice/transcribe',
|
|
116
|
+
handler: async (req, res) => {
|
|
117
|
+
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
118
|
+
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
119
|
+
const cfg = live()
|
|
120
|
+
const maxBodyBytes = Math.ceil((cfg.maxFileBytes * 4) / 3) + 64 * 1024
|
|
121
|
+
let raw
|
|
122
|
+
try {
|
|
123
|
+
raw = await readBody(req, maxBodyBytes)
|
|
124
|
+
} catch (e) {
|
|
125
|
+
if (e && e.message === 'body too large') {
|
|
126
|
+
writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio body exceeds limit for max ${cfg.maxFileBytes} bytes` } })
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
|
|
130
|
+
}
|
|
131
|
+
let payload
|
|
132
|
+
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
|
|
133
|
+
|
|
134
|
+
const dataBase64 = typeof payload.dataBase64 === 'string' ? payload.dataBase64 : ''
|
|
135
|
+
if (!dataBase64) { writeJson(res, 400, { ok: false, error: { code: 'no-audio', message: 'no audio data' } }); return }
|
|
136
|
+
const mime = typeof payload.mimeType === 'string' && payload.mimeType ? payload.mimeType : 'audio/webm'
|
|
137
|
+
const baseModeCfg = payload.mode === 'message' ? cfg.message : cfg.dictation
|
|
138
|
+
const modeCfg = Array.isArray(payload.chain)
|
|
139
|
+
? { ...baseModeCfg, chain: payload.chain }
|
|
140
|
+
: baseModeCfg
|
|
141
|
+
|
|
142
|
+
let bytes
|
|
143
|
+
try { bytes = Buffer.from(dataBase64, 'base64') } catch { bytes = null }
|
|
144
|
+
if (!bytes || bytes.length === 0) {
|
|
145
|
+
writeJson(res, 400, { ok: false, error: { code: 'decode', message: 'failed to decode audio' } }); return
|
|
146
|
+
}
|
|
147
|
+
if (bytes.length > cfg.maxFileBytes) {
|
|
148
|
+
writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio is ${bytes.length} bytes, max ${cfg.maxFileBytes}` } }); return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if ((modeCfg.chain || []).some((e) => e.provider === 'local-whisper') && !(await whisperAlive())) {
|
|
152
|
+
await startWhisper()
|
|
153
|
+
}
|
|
154
|
+
if ((modeCfg.chain || []).some((e) => e.provider === 'sensevoice') && !(await sensevoiceAlive())) {
|
|
155
|
+
await startSensevoice()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const controller = new AbortController()
|
|
159
|
+
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
160
|
+
try {
|
|
161
|
+
const out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
|
|
162
|
+
if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
|
|
163
|
+
const cmd = sessionCommand(out.text)
|
|
164
|
+
if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
|
|
165
|
+
}
|
|
166
|
+
let text = await polishText(out.text, modeCfg, controller.signal)
|
|
167
|
+
if (cfg.techJargonCorrection !== false) {
|
|
168
|
+
text = applyJargonDictionary(text, cfg.jargonDictionary)
|
|
169
|
+
}
|
|
170
|
+
let action = null
|
|
171
|
+
if (cfg.voiceActions !== false) {
|
|
172
|
+
const actionRes = extractVoiceActions(text)
|
|
173
|
+
if (actionRes.action) {
|
|
174
|
+
text = actionRes.text
|
|
175
|
+
action = actionRes.action
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
writeJson(res, 200, { ok: true, text, action, provider: out.provider, tookMs: out.tookMs })
|
|
179
|
+
} catch (e) {
|
|
180
|
+
writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
|
|
181
|
+
} finally {
|
|
182
|
+
clearTimeout(timer)
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
}), 'dsh-voice: /transcribe route')
|
|
186
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -1,55 +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
|
-
import { createLocalDaemons } from "./local-daemon.js"
|
|
35
28
|
export { ChainEntry, CustomProvider, BaseConfig }
|
|
36
|
-
|
|
37
29
|
export const name = '@goodandready/dsh-voice'
|
|
38
30
|
export const NS = 'dsh-voice'
|
|
39
31
|
export const inject = ['tools', 'credentials', 'webServer', 'shell', 'settings', 'llm']
|
|
40
32
|
|
|
41
33
|
function makeVolatile(schema) {
|
|
42
|
-
if (!schema || typeof schema
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
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)
|
|
51
42
|
}
|
|
52
|
-
return
|
|
43
|
+
return res
|
|
53
44
|
}
|
|
54
45
|
|
|
55
46
|
export const Config = typeof z.number().volatile === 'function'
|
|
@@ -57,30 +48,27 @@ export const Config = typeof z.number().volatile === 'function'
|
|
|
57
48
|
: BaseConfig
|
|
58
49
|
|
|
59
50
|
export function apply(ctx, baseConfig) {
|
|
60
|
-
let
|
|
51
|
+
let settingsService = null
|
|
52
|
+
let scope = null
|
|
53
|
+
let getConfig = () => baseConfig
|
|
61
54
|
|
|
62
|
-
// The settings card edits a namespace named after the plugin.
|
|
63
|
-
// Using createConfigReader decodes volatile nodes on modern DSH cores
|
|
64
|
-
// while caching unwrapped values across requests.
|
|
65
55
|
const readEntryConfig = createConfigReader(baseConfig, ctx)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
let getConfig = readEntryConfig
|
|
56
|
+
getConfig = readEntryConfig
|
|
57
|
+
|
|
69
58
|
const live = () => BaseConfig(structuredClone(getConfig() ?? {})) ?? readEntryConfig()
|
|
70
59
|
|
|
71
|
-
async
|
|
60
|
+
const resolveKey = async (ref) => {
|
|
61
|
+
if (!ref || !ref.type) return ''
|
|
72
62
|
try {
|
|
73
63
|
const resolved = await ctx.credentials.resolve(credentialRef(ref))
|
|
74
|
-
|
|
75
|
-
} catch {
|
|
76
|
-
|
|
64
|
+
return resolved || ''
|
|
65
|
+
} catch {
|
|
66
|
+
return ''
|
|
67
|
+
}
|
|
77
68
|
}
|
|
78
69
|
|
|
79
70
|
const daemons = createLocalDaemons({ live, ctx })
|
|
80
|
-
const {
|
|
81
|
-
whisperAlive, startWhisper, getWhisperError,
|
|
82
|
-
sensevoiceAlive, startSensevoice, getSensevoiceError,
|
|
83
|
-
} = daemons
|
|
71
|
+
const { whisperAlive, sensevoiceAlive, startWhisper, startSensevoice } = daemons
|
|
84
72
|
|
|
85
73
|
function triggerAutostart() {
|
|
86
74
|
daemons.triggerAutostart()
|
|
@@ -105,11 +93,11 @@ export function apply(ctx, baseConfig) {
|
|
|
105
93
|
triggerAutostart()
|
|
106
94
|
})
|
|
107
95
|
|
|
108
|
-
if (typeof ctx.on ===
|
|
109
|
-
if (typeof ctx.effect ===
|
|
110
|
-
ctx.effect(() => ctx.on('loader/volatile-update', () => triggerAutostart()),
|
|
111
|
-
ctx.effect(() => ctx.on('settings/document-updated', () => triggerAutostart()),
|
|
112
|
-
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')
|
|
113
101
|
} else {
|
|
114
102
|
ctx.on('loader/volatile-update', () => triggerAutostart())
|
|
115
103
|
ctx.on('settings/document-updated', () => triggerAutostart())
|
|
@@ -119,10 +107,8 @@ export function apply(ctx, baseConfig) {
|
|
|
119
107
|
|
|
120
108
|
triggerAutostart()
|
|
121
109
|
|
|
122
|
-
// Provider health and latency stats (Latency & Health Dashboard).
|
|
123
110
|
const statsTracker = createStatsTracker()
|
|
124
111
|
|
|
125
|
-
// Shared recognition path: build providers from the mode chain and run it.
|
|
126
112
|
async function transcribe(modeCfg, bytes, mime, signal, contextWords) {
|
|
127
113
|
const cfg = live()
|
|
128
114
|
const customKeys = (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
|
|
@@ -146,193 +132,28 @@ export function apply(ctx, baseConfig) {
|
|
|
146
132
|
return runChain(order, providers, statsTracker.record)
|
|
147
133
|
}
|
|
148
134
|
|
|
149
|
-
// Transcript polish (#35) with local LLM support (#47).
|
|
150
|
-
// Errors/timeouts do not block: return the raw text.
|
|
151
135
|
const polishText = createPolishText({
|
|
152
136
|
resolveKey,
|
|
153
137
|
fetchImpl: (...args) => fetch(...args),
|
|
154
138
|
getConfig: live,
|
|
155
139
|
llm: ctx.llm,
|
|
156
140
|
createUserMessage,
|
|
157
|
-
getAgentDefaultModel: () => (ctx.get && ctx.get('agentDefaultModel')
|
|
141
|
+
getAgentDefaultModel: () => (ctx.get && ctx.get('agentDefaultModel')?.currentSelection)
|
|
158
142
|
? ctx.get('agentDefaultModel').currentSelection()
|
|
159
143
|
: null,
|
|
160
144
|
})
|
|
161
145
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const sensevoiceError = sensevoiceOk ? null : getSensevoiceError()
|
|
174
|
-
writeJson(res, 200, {
|
|
175
|
-
ok: true,
|
|
176
|
-
whisperRunning: whisperOk,
|
|
177
|
-
whisperError: whisperOk ? null : whisperError,
|
|
178
|
-
sensevoiceRunning: sensevoiceOk,
|
|
179
|
-
sensevoiceError: sensevoiceOk ? null : sensevoiceError,
|
|
180
|
-
// The hotkey is needed by the browser half: it installs the hold handler.
|
|
181
|
-
hotkey: cfg.hotkey,
|
|
182
|
-
providers: KNOWN_KEYS.concat(
|
|
183
|
-
(Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
|
|
184
|
-
.map((c) => String(c && c.key || '').trim()).filter(Boolean),
|
|
185
|
-
),
|
|
186
|
-
modes: {
|
|
187
|
-
dictation: {
|
|
188
|
-
chain: cfg.dictation.chain, language: cfg.dictation.language,
|
|
189
|
-
vadSilenceMs: cfg.dictation.vadSilenceMs,
|
|
190
|
-
sendDelayMs: cfg.dictation.sendDelayMs, polish: cfg.dictation.polish,
|
|
191
|
-
stream: cfg.dictation.stream, streamChunkMs: cfg.dictation.streamChunkMs,
|
|
192
|
-
vadAdapt: cfg.dictation.vadAdapt,
|
|
193
|
-
},
|
|
194
|
-
message: {
|
|
195
|
-
chain: cfg.message.chain, language: cfg.message.language,
|
|
196
|
-
autoSendMs: cfg.message.autoSendMs, polish: cfg.message.polish,
|
|
197
|
-
sessionCommands: cfg.message.sessionCommands, polishSend: cfg.message.polishSend,
|
|
198
|
-
},
|
|
199
|
-
},
|
|
200
|
-
beep: cfg.beep,
|
|
201
|
-
localOnly: cfg.localOnly,
|
|
202
|
-
micDeviceId: cfg.micDeviceId,
|
|
203
|
-
noiseGateDb: cfg.noiseGateDb !== undefined ? cfg.noiseGateDb : -45,
|
|
204
|
-
historyLimit: cfg.historyLimit,
|
|
205
|
-
voiceCommands: cfg.voiceCommands,
|
|
206
|
-
wakeWord: String(cfg.wakeWord || ''),
|
|
207
|
-
bargeIn: !!cfg.bargeIn,
|
|
208
|
-
polishBaseUrl: String(cfg.polishBaseUrl || ''),
|
|
209
|
-
noiseSuppression: cfg.noiseSuppression !== false,
|
|
210
|
-
contextGlossary: cfg.contextGlossary !== false,
|
|
211
|
-
sensevoiceUrl: cfg.sensevoiceUrl,
|
|
212
|
-
sensevoiceAutostart: !!cfg.sensevoiceAutostart,
|
|
213
|
-
sensevoiceProvider: cfg.sensevoiceProvider || 'cpu',
|
|
214
|
-
sensevoiceThreads: cfg.sensevoiceThreads || 4,
|
|
215
|
-
webgpuModel: cfg.webgpuModel || 'onnx-community/whisper-tiny',
|
|
216
|
-
deepgramBaseUrl: cfg.deepgramBaseUrl || 'https://api.deepgram.com',
|
|
217
|
-
visualizerStyle: cfg.visualizerStyle || 'liquid-wave',
|
|
218
|
-
gatedTurnTaking: cfg.gatedTurnTaking !== false,
|
|
219
|
-
autoSendVisualRing: cfg.autoSendVisualRing !== false,
|
|
220
|
-
voiceActions: cfg.voiceActions !== false,
|
|
221
|
-
techJargonCorrection: cfg.techJargonCorrection !== false,
|
|
222
|
-
jargonDictionary: Array.isArray(cfg.jargonDictionary) ? cfg.jargonDictionary : [],
|
|
223
|
-
liveInterimPreview: cfg.liveInterimPreview !== false,
|
|
224
|
-
structuredPromptVoice: cfg.structuredPromptVoice !== false,
|
|
225
|
-
providerStats: statsTracker.get(),
|
|
226
|
-
})
|
|
227
|
-
},
|
|
228
|
-
}), 'dsh-voice: /status route')
|
|
229
|
-
|
|
230
|
-
ctx.effect(() => ctx.webServer.register({
|
|
231
|
-
kind: 'exact',
|
|
232
|
-
path: '/dsh-voice/polish',
|
|
233
|
-
handler: async (req, res) => {
|
|
234
|
-
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
235
|
-
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
236
|
-
const cfg = live()
|
|
237
|
-
let raw
|
|
238
|
-
try { raw = await readBody(req, 1024 * 1024) } catch (e) {
|
|
239
|
-
if (e && (e.statusCode === 413 || e.message === 'body too large')) {
|
|
240
|
-
writeJson(res, 413, { ok: false, error: { code: 'too-large', message: 'body exceeds limit of 1MB' } })
|
|
241
|
-
return
|
|
242
|
-
}
|
|
243
|
-
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
244
|
-
return
|
|
245
|
-
}
|
|
246
|
-
let payload = {}
|
|
247
|
-
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
|
|
248
|
-
const text = typeof payload.text === 'string' ? payload.text.trim() : ''
|
|
249
|
-
if (!text) { writeJson(res, 400, { ok: false, error: { code: 'empty', message: 'text required' } }); return }
|
|
250
|
-
const controller = new AbortController()
|
|
251
|
-
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
252
|
-
try {
|
|
253
|
-
const out = await polishText(text, { polish: true }, controller.signal)
|
|
254
|
-
writeJson(res, 200, { ok: true, text: out })
|
|
255
|
-
} catch (e) {
|
|
256
|
-
writeJson(res, 502, { ok: false, error: { code: 'polish', message: String(e && e.message || e) } })
|
|
257
|
-
} finally { clearTimeout(timer) }
|
|
258
|
-
},
|
|
259
|
-
}), 'dsh-voice: /polish route')
|
|
260
|
-
|
|
261
|
-
ctx.effect(() => ctx.webServer.register({
|
|
262
|
-
kind: 'exact',
|
|
263
|
-
path: '/dsh-voice/transcribe',
|
|
264
|
-
handler: async (req, res) => {
|
|
265
|
-
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
266
|
-
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
267
|
-
const cfg = live()
|
|
268
|
-
const maxBodyBytes = Math.ceil((cfg.maxFileBytes * 4) / 3) + 64 * 1024
|
|
269
|
-
let raw
|
|
270
|
-
try {
|
|
271
|
-
raw = await readBody(req, maxBodyBytes)
|
|
272
|
-
} catch (e) {
|
|
273
|
-
if (e && e.message === 'body too large') {
|
|
274
|
-
writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio body exceeds limit for max ${cfg.maxFileBytes} bytes` } })
|
|
275
|
-
return
|
|
276
|
-
}
|
|
277
|
-
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
|
|
278
|
-
}
|
|
279
|
-
let payload
|
|
280
|
-
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
|
|
281
|
-
|
|
282
|
-
const dataBase64 = typeof payload.dataBase64 === 'string' ? payload.dataBase64 : ''
|
|
283
|
-
if (!dataBase64) { writeJson(res, 400, { ok: false, error: { code: 'no-audio', message: 'no audio data' } }); return }
|
|
284
|
-
const mime = typeof payload.mimeType === 'string' && payload.mimeType ? payload.mimeType : 'audio/webm'
|
|
285
|
-
const modeCfg = payload.mode === 'message' ? cfg.message : cfg.dictation
|
|
286
|
-
|
|
287
|
-
let bytes
|
|
288
|
-
try { bytes = Buffer.from(dataBase64, 'base64') } catch { bytes = null }
|
|
289
|
-
if (!bytes || bytes.length === 0) {
|
|
290
|
-
writeJson(res, 400, { ok: false, error: { code: 'decode', message: 'failed to decode audio' } }); return
|
|
291
|
-
}
|
|
292
|
-
if (bytes.length > cfg.maxFileBytes) {
|
|
293
|
-
writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio is ${bytes.length} bytes, max ${cfg.maxFileBytes}` } }); return
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// Local whisper in the chain — start the server up front, otherwise the
|
|
297
|
-
// first chunk fails while the server is still booting.
|
|
298
|
-
if ((modeCfg.chain || []).some((e) => e.provider === 'local-whisper') && !(await whisperAlive())) {
|
|
299
|
-
await startWhisper()
|
|
300
|
-
}
|
|
301
|
-
// Same for SenseVoice-ONNX / Sherpa-ONNX.
|
|
302
|
-
if ((modeCfg.chain || []).some((e) => e.provider === 'sensevoice') && !(await sensevoiceAlive())) {
|
|
303
|
-
await startSensevoice()
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
const controller = new AbortController()
|
|
307
|
-
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
308
|
-
try {
|
|
309
|
-
const out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
|
|
310
|
-
// Session voice commands (#48): clean "send/cancel/stop/continue"
|
|
311
|
-
// become a command for the browser instead of composer text.
|
|
312
|
-
if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
|
|
313
|
-
const cmd = sessionCommand(out.text)
|
|
314
|
-
if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
|
|
315
|
-
}
|
|
316
|
-
let text = await polishText(out.text, modeCfg, controller.signal)
|
|
317
|
-
if (cfg.techJargonCorrection !== false) {
|
|
318
|
-
text = applyJargonDictionary(text, cfg.jargonDictionary)
|
|
319
|
-
}
|
|
320
|
-
let action = null
|
|
321
|
-
if (cfg.voiceActions !== false) {
|
|
322
|
-
const actionRes = extractVoiceActions(text)
|
|
323
|
-
if (actionRes.action) {
|
|
324
|
-
text = actionRes.text
|
|
325
|
-
action = actionRes.action
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
writeJson(res, 200, { ok: true, text, action, provider: out.provider, tookMs: out.tookMs })
|
|
329
|
-
} catch (e) {
|
|
330
|
-
writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
|
|
331
|
-
} finally {
|
|
332
|
-
clearTimeout(timer)
|
|
333
|
-
}
|
|
334
|
-
},
|
|
335
|
-
}), '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
|
+
})
|
|
336
157
|
|
|
337
158
|
// Registers agent tool name: 'transcribe_audio'
|
|
338
159
|
ctx.effect(() => registerTranscribeAudioTool(ctx, {
|
|
@@ -342,11 +163,6 @@ export function apply(ctx, baseConfig) {
|
|
|
342
163
|
polishText,
|
|
343
164
|
}), 'dsh-voice: transcribe_audio tool')
|
|
344
165
|
|
|
345
|
-
ctx.effect(() => () => {
|
|
346
|
-
if (child) { try { child.kill && child.kill() } catch { /* already dead */ } }
|
|
347
|
-
if (sensevoiceChild) { try { sensevoiceChild.kill && sensevoiceChild.kill() } catch { /* already dead */ } }
|
|
348
|
-
}, 'dsh-voice: stop child processes')
|
|
349
|
-
|
|
350
166
|
ctx.effect(() => registerPluginUpdater(ctx, {
|
|
351
167
|
endpoint: '/api/dsh-voice/update',
|
|
352
168
|
packageName: '@goodandready/dsh-voice',
|
|
@@ -357,42 +173,19 @@ export function apply(ctx, baseConfig) {
|
|
|
357
173
|
liveConfig: live,
|
|
358
174
|
isAlive: sensevoiceAlive,
|
|
359
175
|
updateConfig: async (patch) => {
|
|
360
|
-
|
|
361
|
-
const current = live()
|
|
362
|
-
const merged = { ...current, ...patch }
|
|
176
|
+
const merged = { ...live(), ...patch }
|
|
363
177
|
if (settingsService) {
|
|
364
178
|
let rev
|
|
365
|
-
try {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
if (typeof
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
await settingsService.update(NS, merged, rev)
|
|
374
|
-
saved = true
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
if (scope) {
|
|
379
|
-
if (typeof scope.replace === 'function') {
|
|
380
|
-
await scope.replace(merged)
|
|
381
|
-
saved = true
|
|
382
|
-
} else if (typeof scope.patch === 'function') {
|
|
383
|
-
await scope.patch(merged)
|
|
384
|
-
saved = true
|
|
385
|
-
} else if (typeof scope.set === 'function') {
|
|
386
|
-
for (const [k, v] of Object.entries(merged)) {
|
|
387
|
-
await scope.set(k, v).catch(() => {})
|
|
388
|
-
}
|
|
389
|
-
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(() => {})
|
|
390
187
|
}
|
|
391
188
|
}
|
|
392
|
-
|
|
393
|
-
if (!saved) {
|
|
394
|
-
throw new Error('failed to persist settings: neither settingsService nor scope available')
|
|
395
|
-
}
|
|
396
189
|
},
|
|
397
190
|
startSensevoice,
|
|
398
191
|
}), 'dsh-voice: sensevoice installer route')
|
|
@@ -406,4 +199,3 @@ export function apply(ctx, baseConfig) {
|
|
|
406
199
|
triggerAutostart,
|
|
407
200
|
}), 'dsh-voice: config route')
|
|
408
201
|
}
|
|
409
|
-
|