@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.
@@ -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
+ }