@goodandready/dsh-voice 0.9.29 → 0.9.31

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.
@@ -1,188 +1,188 @@
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
- import { resolveSensevoiceProvider } from './sensevoice-installer.js'
4
-
5
- export function registerHostRoutes(ctx, {
6
- live,
7
- daemons,
8
- statsTracker,
9
- transcribe,
10
- polishText,
11
- sessionCommand,
12
- extractVoiceActions,
13
- applyJargonDictionary,
14
- KNOWN_KEYS,
15
- }) {
16
- const { whisperAlive, sensevoiceAlive, getWhisperError, getSensevoiceError, startWhisper, startSensevoice } = daemons
17
-
18
- ctx.effect(() => ctx.webServer.register({
19
- kind: 'exact',
20
- path: '/dsh-voice/status',
21
- handler: async (req, res) => {
22
- if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
23
- const cfg = live()
24
- const whisperOk = await whisperAlive()
25
- const sensevoiceOk = await sensevoiceAlive()
26
- const whisperError = whisperOk ? null : getWhisperError()
27
- const sensevoiceError = sensevoiceOk ? null : getSensevoiceError()
28
- writeJson(res, 200, {
29
- ok: true,
30
- whisperRunning: whisperOk,
31
- whisperError: whisperOk ? null : whisperError,
32
- sensevoiceRunning: sensevoiceOk,
33
- sensevoiceError: sensevoiceOk ? null : sensevoiceError,
34
- hotkey: cfg.hotkey,
35
- providers: KNOWN_KEYS.concat(
36
- (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
37
- .map((c) => String(c && c.key || '').trim()).filter(Boolean),
38
- ),
39
- modes: {
40
- dictation: {
41
- chain: cfg.dictation.chain, language: cfg.dictation.language,
42
- vadSilenceMs: cfg.dictation.vadSilenceMs,
43
- sendDelayMs: cfg.dictation.sendDelayMs, polish: cfg.dictation.polish,
44
- stream: cfg.dictation.stream, streamChunkMs: cfg.dictation.streamChunkMs,
45
- vadAdapt: cfg.dictation.vadAdapt,
46
- },
47
- message: {
48
- chain: cfg.message.chain, language: cfg.message.language,
49
- autoSendMs: cfg.message.autoSendMs, polish: cfg.message.polish,
50
- sessionCommands: cfg.message.sessionCommands, polishSend: cfg.message.polishSend,
51
- },
52
- },
53
- beep: cfg.beep,
54
- localOnly: cfg.localOnly,
55
- micDeviceId: cfg.micDeviceId,
56
- noiseGateDb: cfg.noiseGateDb !== undefined ? cfg.noiseGateDb : -45,
57
- historyLimit: cfg.historyLimit,
58
- voiceCommands: cfg.voiceCommands,
59
- wakeWord: String(cfg.wakeWord || ''),
60
- bargeIn: !!cfg.bargeIn,
61
- polishBaseUrl: String(cfg.polishBaseUrl || ''),
62
- noiseSuppression: cfg.noiseSuppression !== false,
63
- contextGlossary: cfg.contextGlossary !== false,
64
- sensevoiceUrl: cfg.sensevoiceUrl,
65
- sensevoiceAutostart: !!cfg.sensevoiceAutostart,
66
- sensevoiceProvider: cfg.sensevoiceProvider || 'cpu',
67
- effectiveSensevoiceProvider: resolveSensevoiceProvider(cfg),
68
- sensevoiceThreads: cfg.sensevoiceThreads || 4,
69
- webgpuModel: cfg.webgpuModel || 'onnx-community/whisper-tiny',
70
- deepgramBaseUrl: cfg.deepgramBaseUrl || 'https://api.deepgram.com',
71
- visualizerStyle: cfg.visualizerStyle || 'liquid-wave',
72
- gatedTurnTaking: cfg.gatedTurnTaking !== false,
73
- autoSendVisualRing: cfg.autoSendVisualRing !== false,
74
- voiceActions: cfg.voiceActions !== false,
75
- techJargonCorrection: cfg.techJargonCorrection !== false,
76
- jargonDictionary: Array.isArray(cfg.jargonDictionary) ? cfg.jargonDictionary : [],
77
- liveInterimPreview: cfg.liveInterimPreview !== false,
78
- structuredPromptVoice: cfg.structuredPromptVoice !== false,
79
- providerStats: statsTracker.get(),
80
- })
81
- },
82
- }), 'dsh-voice: /status route')
83
-
84
- ctx.effect(() => ctx.webServer.register({
85
- kind: 'exact',
86
- path: '/dsh-voice/polish',
87
- handler: async (req, res) => {
88
- if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
89
- if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
90
- const cfg = live()
91
- let raw
92
- try { raw = await readBody(req, 1024 * 1024) } catch (e) {
93
- if (e && (e.statusCode === 413 || e.message === 'body too large')) {
94
- writeJson(res, 413, { ok: false, error: { code: 'too-large', message: 'body exceeds limit of 1MB' } })
95
- return
96
- }
97
- writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
98
- return
99
- }
100
- let payload = {}
101
- try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
102
- const text = typeof payload.text === 'string' ? payload.text.trim() : ''
103
- if (!text) { writeJson(res, 400, { ok: false, error: { code: 'empty', message: 'text required' } }); return }
104
- const controller = new AbortController()
105
- const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
106
- try {
107
- const out = await polishText(text, { polish: true }, controller.signal)
108
- writeJson(res, 200, { ok: true, text: out })
109
- } catch (e) {
110
- writeJson(res, 502, { ok: false, error: { code: 'polish', message: String(e && e.message || e) } })
111
- } finally { clearTimeout(timer) }
112
- },
113
- }), 'dsh-voice: /polish route')
114
-
115
- ctx.effect(() => ctx.webServer.register({
116
- kind: 'exact',
117
- path: '/dsh-voice/transcribe',
118
- handler: async (req, res) => {
119
- if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
120
- if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
121
- const cfg = live()
122
- const maxBodyBytes = Math.ceil((cfg.maxFileBytes * 4) / 3) + 64 * 1024
123
- let raw
124
- try {
125
- raw = await readBody(req, maxBodyBytes)
126
- } catch (e) {
127
- if (e && e.message === 'body too large') {
128
- writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio body exceeds limit for max ${cfg.maxFileBytes} bytes` } })
129
- return
130
- }
131
- writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
132
- }
133
- let payload
134
- try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
135
-
136
- const dataBase64 = typeof payload.dataBase64 === 'string' ? payload.dataBase64 : ''
137
- if (!dataBase64) { writeJson(res, 400, { ok: false, error: { code: 'no-audio', message: 'no audio data' } }); return }
138
- const mime = typeof payload.mimeType === 'string' && payload.mimeType ? payload.mimeType : 'audio/webm'
139
- const baseModeCfg = payload.mode === 'message' ? cfg.message : cfg.dictation
140
- const modeCfg = Array.isArray(payload.chain)
141
- ? { ...baseModeCfg, chain: payload.chain }
142
- : baseModeCfg
143
-
144
- let bytes
145
- try { bytes = Buffer.from(dataBase64, 'base64') } catch { bytes = null }
146
- if (!bytes || bytes.length === 0) {
147
- writeJson(res, 400, { ok: false, error: { code: 'decode', message: 'failed to decode audio' } }); return
148
- }
149
- if (bytes.length > cfg.maxFileBytes) {
150
- writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio is ${bytes.length} bytes, max ${cfg.maxFileBytes}` } }); return
151
- }
152
-
153
- if ((modeCfg.chain || []).some((e) => e.provider === 'local-whisper') && !(await whisperAlive())) {
154
- await startWhisper()
155
- }
156
- if ((modeCfg.chain || []).some((e) => e.provider === 'sensevoice') && !(await sensevoiceAlive())) {
157
- await startSensevoice()
158
- }
159
-
160
- const controller = new AbortController()
161
- const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
162
- try {
163
- const out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
164
- if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
165
- const cmd = sessionCommand(out.text)
166
- if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
167
- }
168
- let text = await polishText(out.text, modeCfg, controller.signal)
169
- if (cfg.techJargonCorrection !== false) {
170
- text = applyJargonDictionary(text, cfg.jargonDictionary)
171
- }
172
- let action = null
173
- if (cfg.voiceActions !== false) {
174
- const actionRes = extractVoiceActions(text)
175
- if (actionRes.action) {
176
- text = actionRes.text
177
- action = actionRes.action
178
- }
179
- }
180
- writeJson(res, 200, { ok: true, text, action, provider: out.provider, tookMs: out.tookMs })
181
- } catch (e) {
182
- writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
183
- } finally {
184
- clearTimeout(timer)
185
- }
186
- },
187
- }), 'dsh-voice: /transcribe route')
188
- }
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
+ import { resolveSensevoiceProvider } from './sensevoice-installer.js'
4
+
5
+ export function registerHostRoutes(ctx, {
6
+ live,
7
+ daemons,
8
+ statsTracker,
9
+ transcribe,
10
+ polishText,
11
+ sessionCommand,
12
+ extractVoiceActions,
13
+ applyJargonDictionary,
14
+ KNOWN_KEYS,
15
+ }) {
16
+ const { whisperAlive, sensevoiceAlive, getWhisperError, getSensevoiceError, startWhisper, startSensevoice } = daemons
17
+
18
+ ctx.effect(() => ctx.webServer.register({
19
+ kind: 'exact',
20
+ path: '/dsh-voice/status',
21
+ handler: async (req, res) => {
22
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
23
+ const cfg = live()
24
+ const whisperOk = await whisperAlive()
25
+ const sensevoiceOk = await sensevoiceAlive()
26
+ const whisperError = whisperOk ? null : getWhisperError()
27
+ const sensevoiceError = sensevoiceOk ? null : getSensevoiceError()
28
+ writeJson(res, 200, {
29
+ ok: true,
30
+ whisperRunning: whisperOk,
31
+ whisperError: whisperOk ? null : whisperError,
32
+ sensevoiceRunning: sensevoiceOk,
33
+ sensevoiceError: sensevoiceOk ? null : sensevoiceError,
34
+ hotkey: cfg.hotkey,
35
+ providers: KNOWN_KEYS.concat(
36
+ (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
37
+ .map((c) => String(c && c.key || '').trim()).filter(Boolean),
38
+ ),
39
+ modes: {
40
+ dictation: {
41
+ chain: cfg.dictation.chain, language: cfg.dictation.language,
42
+ vadSilenceMs: cfg.dictation.vadSilenceMs,
43
+ sendDelayMs: cfg.dictation.sendDelayMs, polish: cfg.dictation.polish,
44
+ stream: cfg.dictation.stream, streamChunkMs: cfg.dictation.streamChunkMs,
45
+ vadAdapt: cfg.dictation.vadAdapt,
46
+ },
47
+ message: {
48
+ chain: cfg.message.chain, language: cfg.message.language,
49
+ autoSendMs: cfg.message.autoSendMs, polish: cfg.message.polish,
50
+ sessionCommands: cfg.message.sessionCommands, polishSend: cfg.message.polishSend, editablePreview: cfg.message.editablePreview !== false,
51
+ },
52
+ },
53
+ beep: cfg.beep,
54
+ localOnly: cfg.localOnly,
55
+ micDeviceId: cfg.micDeviceId,
56
+ noiseGateDb: cfg.noiseGateDb !== undefined ? cfg.noiseGateDb : -45,
57
+ historyLimit: cfg.historyLimit,
58
+ voiceCommands: cfg.voiceCommands,
59
+ wakeWord: String(cfg.wakeWord || ''),
60
+ bargeIn: !!cfg.bargeIn,
61
+ polishBaseUrl: String(cfg.polishBaseUrl || ''),
62
+ noiseSuppression: cfg.noiseSuppression !== false,
63
+ contextGlossary: cfg.contextGlossary !== false,
64
+ sensevoiceUrl: cfg.sensevoiceUrl,
65
+ sensevoiceAutostart: !!cfg.sensevoiceAutostart,
66
+ sensevoiceProvider: cfg.sensevoiceProvider || 'cpu',
67
+ effectiveSensevoiceProvider: resolveSensevoiceProvider(cfg),
68
+ sensevoiceThreads: cfg.sensevoiceThreads || 4,
69
+ webgpuModel: cfg.webgpuModel || 'onnx-community/whisper-tiny',
70
+ deepgramBaseUrl: cfg.deepgramBaseUrl || 'https://api.deepgram.com',
71
+ visualizerStyle: cfg.visualizerStyle || 'liquid-wave',
72
+ gatedTurnTaking: cfg.gatedTurnTaking !== false,
73
+ autoSendVisualRing: cfg.autoSendVisualRing !== false,
74
+ voiceActions: cfg.voiceActions !== false,
75
+ techJargonCorrection: cfg.techJargonCorrection !== false,
76
+ jargonDictionary: Array.isArray(cfg.jargonDictionary) ? cfg.jargonDictionary : [],
77
+ liveInterimPreview: cfg.liveInterimPreview !== false,
78
+ structuredPromptVoice: cfg.structuredPromptVoice !== false,
79
+ providerStats: statsTracker.get(),
80
+ })
81
+ },
82
+ }), 'dsh-voice: /status route')
83
+
84
+ ctx.effect(() => ctx.webServer.register({
85
+ kind: 'exact',
86
+ path: '/dsh-voice/polish',
87
+ handler: async (req, res) => {
88
+ if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
89
+ if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
90
+ const cfg = live()
91
+ let raw
92
+ try { raw = await readBody(req, 1024 * 1024) } catch (e) {
93
+ if (e && (e.statusCode === 413 || e.message === 'body too large')) {
94
+ writeJson(res, 413, { ok: false, error: { code: 'too-large', message: 'body exceeds limit of 1MB' } })
95
+ return
96
+ }
97
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
98
+ return
99
+ }
100
+ let payload = {}
101
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
102
+ const text = typeof payload.text === 'string' ? payload.text.trim() : ''
103
+ if (!text) { writeJson(res, 400, { ok: false, error: { code: 'empty', message: 'text required' } }); return }
104
+ const controller = new AbortController()
105
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
106
+ try {
107
+ const out = await polishText(text, { polish: true }, controller.signal)
108
+ writeJson(res, 200, { ok: true, text: out })
109
+ } catch (e) {
110
+ writeJson(res, 502, { ok: false, error: { code: 'polish', message: String(e && e.message || e) } })
111
+ } finally { clearTimeout(timer) }
112
+ },
113
+ }), 'dsh-voice: /polish route')
114
+
115
+ ctx.effect(() => ctx.webServer.register({
116
+ kind: 'exact',
117
+ path: '/dsh-voice/transcribe',
118
+ handler: async (req, res) => {
119
+ if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
120
+ if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
121
+ const cfg = live()
122
+ const maxBodyBytes = Math.ceil((cfg.maxFileBytes * 4) / 3) + 64 * 1024
123
+ let raw
124
+ try {
125
+ raw = await readBody(req, maxBodyBytes)
126
+ } catch (e) {
127
+ if (e && e.message === 'body too large') {
128
+ writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio body exceeds limit for max ${cfg.maxFileBytes} bytes` } })
129
+ return
130
+ }
131
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
132
+ }
133
+ let payload
134
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
135
+
136
+ const dataBase64 = typeof payload.dataBase64 === 'string' ? payload.dataBase64 : ''
137
+ if (!dataBase64) { writeJson(res, 400, { ok: false, error: { code: 'no-audio', message: 'no audio data' } }); return }
138
+ const mime = typeof payload.mimeType === 'string' && payload.mimeType ? payload.mimeType : 'audio/webm'
139
+ const baseModeCfg = payload.mode === 'message' ? cfg.message : cfg.dictation
140
+ const modeCfg = Array.isArray(payload.chain)
141
+ ? { ...baseModeCfg, chain: payload.chain }
142
+ : baseModeCfg
143
+
144
+ let bytes
145
+ try { bytes = Buffer.from(dataBase64, 'base64') } catch { bytes = null }
146
+ if (!bytes || bytes.length === 0) {
147
+ writeJson(res, 400, { ok: false, error: { code: 'decode', message: 'failed to decode audio' } }); return
148
+ }
149
+ if (bytes.length > cfg.maxFileBytes) {
150
+ writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio is ${bytes.length} bytes, max ${cfg.maxFileBytes}` } }); return
151
+ }
152
+
153
+ if ((modeCfg.chain || []).some((e) => e.provider === 'local-whisper') && !(await whisperAlive())) {
154
+ await startWhisper()
155
+ }
156
+ if ((modeCfg.chain || []).some((e) => e.provider === 'sensevoice') && !(await sensevoiceAlive())) {
157
+ await startSensevoice()
158
+ }
159
+
160
+ const controller = new AbortController()
161
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
162
+ try {
163
+ const out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
164
+ if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
165
+ const cmd = sessionCommand(out.text)
166
+ if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
167
+ }
168
+ let text = await polishText(out.text, modeCfg, controller.signal)
169
+ if (cfg.techJargonCorrection !== false) {
170
+ text = applyJargonDictionary(text, cfg.jargonDictionary)
171
+ }
172
+ let action = null
173
+ if (cfg.voiceActions !== false) {
174
+ const actionRes = extractVoiceActions(text)
175
+ if (actionRes.action) {
176
+ text = actionRes.text
177
+ action = actionRes.action
178
+ }
179
+ }
180
+ writeJson(res, 200, { ok: true, text, action, provider: out.provider, tookMs: out.tookMs })
181
+ } catch (e) {
182
+ writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
183
+ } finally {
184
+ clearTimeout(timer)
185
+ }
186
+ },
187
+ }), 'dsh-voice: /transcribe route')
188
+ }
@@ -37,7 +37,7 @@ export function createLocalDaemons({ live, ctx }) {
37
37
  }
38
38
  const command = `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
39
39
  + ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${isAutoLang(cfg.dictation.language) ? 'auto' : cfg.dictation.language}`
40
- const specOpts = { command, onExpiry: 'none', timeoutMs: 0, stdoutMaxBytes: 4 * 1024 * 1024 }
40
+ const specOpts = { command, onExpiry: 'none', timeoutMs: 60000, stdoutMaxBytes: 4 * 1024 * 1024 }
41
41
  const spec = typeof ctx.shell?.resolve === 'function' ? ctx.shell.resolve(specOpts) : specOpts
42
42
 
43
43
  if (typeof ctx.shell?.execute === 'function') {
@@ -216,7 +216,7 @@ export function createLocalDaemons({ live, ctx }) {
216
216
  try { port = new URL(cfg.sensevoiceUrl).port || '6006' } catch { /* invalid URL */ }
217
217
  const cmdArgs = buildSensevoiceArgs(cfg, port)
218
218
  const commandStr = `${JSON.stringify(cfg.sensevoiceBin)}${cmdArgs}`
219
- const specOpts = { command: commandStr, onExpiry: 'none', timeoutMs: 0, stdoutMaxBytes: 4 * 1024 * 1024 }
219
+ const specOpts = { command: commandStr, onExpiry: 'none', timeoutMs: 60000, stdoutMaxBytes: 4 * 1024 * 1024 }
220
220
  const spec = typeof ctx.shell?.resolve === 'function' ? ctx.shell.resolve(specOpts) : specOpts
221
221
 
222
222
  if (typeof ctx.shell?.execute === 'function') {