@goodandready/dsh-voice 0.9.38 → 0.9.40

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.
@@ -4,6 +4,7 @@ import { readBody, isLoopback, isTrustedCaller, getVoiceSessionToken } from './h
4
4
  import { resolveSensevoiceProvider } from './sensevoice-installer.js'
5
5
  import { applyVoiceMacros } from './macros.js'
6
6
  import { createSyntheticWav } from './wav.js'
7
+ import { handleWsUpgrade } from './ws-framer.js'
7
8
 
8
9
  function writeJson(res, status, obj) {
9
10
  res.writeHead(status, { 'content-type': 'application/json' })
@@ -361,4 +362,96 @@ export function registerHostRoutes(ctx, deps) {
361
362
  }
362
363
  },
363
364
  }), 'dsh-voice: /transcribe route')
365
+
366
+ ctx.effect(() => ctx.webServer.register({
367
+ kind: 'exact',
368
+ path: '/dsh-voice/vad/status',
369
+ handler: async (req, res) => {
370
+ if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: 'forbidden' }); return }
371
+ writeJson(res, 200, { ok: true, sileroAvailable: true, sampleRate: 16000, frameSamples: 512 })
372
+ },
373
+ }), 'dsh-voice: /dsh-voice/vad/status route')
374
+
375
+ if (typeof ctx.webServer.registerUpgrade === 'function') {
376
+ ctx.effect(() => {
377
+ const unreg = ctx.webServer.registerUpgrade({
378
+ path: '/dsh-voice/ws/stream',
379
+ handler: (req, socket, head) => {
380
+ if (!isTrustedCaller(req)) {
381
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
382
+ socket.destroy()
383
+ return
384
+ }
385
+ const url = new URL(req.url || '/', 'http://127.0.0.1')
386
+ const token = req.headers['x-dsh-voice-token'] || url.searchParams.get('token')
387
+ const expected = getVoiceSessionToken()
388
+ if (expected && token !== expected) {
389
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
390
+ socket.destroy()
391
+ return
392
+ }
393
+
394
+ handleWsUpgrade(req, socket, head, (ws) => {
395
+ let streamChunks = []
396
+ let streamMime = 'audio/webm'
397
+ let streamMode = 'dictation'
398
+ ws.on('message', async (data, isBinary) => {
399
+ if (isBinary && Buffer.isBuffer(data)) {
400
+ streamChunks.push(data)
401
+ } else if (typeof data === 'string') {
402
+ try {
403
+ const msg = JSON.parse(data)
404
+ if (msg.type === 'init') {
405
+ streamMime = msg.mimeType || 'audio/webm'
406
+ streamMode = msg.mode || 'dictation'
407
+ ws.send(JSON.stringify({ type: 'ready' }))
408
+ } else if (msg.type === 'chunk' && msg.dataBase64) {
409
+ streamChunks.push(Buffer.from(msg.dataBase64, 'base64'))
410
+ } else if (msg.type === 'interim') {
411
+ if (streamChunks.length > 0) {
412
+ const buf = Buffer.concat(streamChunks)
413
+ if (buf.length > 3000) {
414
+ try {
415
+ const interimOut = await transcribe(buf, streamMime, streamMode)
416
+ if (interimOut && interimOut.text) {
417
+ ws.send(JSON.stringify({ type: 'interim', text: interimOut.text }))
418
+ }
419
+ } catch (_) {}
420
+ }
421
+ }
422
+ } else if (msg.type === 'finish') {
423
+ const buf = Buffer.concat(streamChunks)
424
+ streamChunks = []
425
+ if (buf.length < 600) {
426
+ ws.send(JSON.stringify({ type: 'final', text: '', ok: true }))
427
+ return
428
+ }
429
+ try {
430
+ const finalOut = await transcribe(buf, streamMime, streamMode)
431
+ let text = finalOut && finalOut.text ? finalOut.text : ''
432
+ let action = null
433
+ if (extractVoiceActions) {
434
+ const act = extractVoiceActions(text)
435
+ if (act.action) { action = act.action; text = act.text }
436
+ }
437
+ ws.send(JSON.stringify({
438
+ type: 'final', text, action,
439
+ provider: finalOut.provider || 'unknown', ok: true,
440
+ }))
441
+ } catch (err) {
442
+ ws.send(JSON.stringify({
443
+ type: 'error',
444
+ error: String(err && err.message ? err.message : err), ok: false,
445
+ }))
446
+ }
447
+ }
448
+ } catch (_) {}
449
+ }
450
+ })
451
+ })
452
+ },
453
+ })
454
+ return () => { if (typeof unreg === 'function') unreg() }
455
+ }, 'dsh-voice: /dsh-voice/ws/stream upgrade route')
456
+ }
364
457
  }
package/lib/index.js CHANGED
@@ -93,11 +93,16 @@ export function apply(ctx, baseConfig) {
93
93
  }
94
94
  }
95
95
 
96
+ /**
97
+ * Resolve one environment reference to its secret.
98
+ * `ref` is a plain string (e.g. `DEEPGRAM_API_KEY`); `credentials.resolve()`
99
+ * answers `{ value, source }`, so the value is unwrapped here.
100
+ */
96
101
  const resolveKey = async (ref) => {
97
- if (!ref || !ref.type) return ''
102
+ if (!ref || typeof ref !== 'string') return ''
98
103
  try {
99
104
  const resolved = await ctx.credentials.resolve(credentialRef(ref))
100
- return resolved || ''
105
+ return (typeof resolved === 'string' ? resolved : resolved?.value) || ''
101
106
  } catch {
102
107
  return ''
103
108
  }
package/lib/schema.js CHANGED
@@ -106,6 +106,10 @@ export const BaseConfig = z.object({
106
106
  .description('Microphone device id for recording. Empty means the system default.'),
107
107
  noiseGateDb: z.number().default(-45)
108
108
  .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.'),
109
+ vadEngine: z.union(['silero', 'amplitude']).default('silero')
110
+ .description('Voice activity detection engine: neural Silero VAD (WASM/ONNX) or classic amplitude threshold.'),
111
+ streamingWs: z.boolean().default(true)
112
+ .description('Enable ultra-low latency WebSocket streaming to local SenseVoice/Whisper servers with seamless HTTP fallback.'),
109
113
  historyLimit: z.number().default(20)
110
114
  .description('How many recent dictation inserts to keep for undo in the browser. 0 disables history.'),
111
115
  vocabulary: z.array(z.string()).default([])
@@ -0,0 +1,160 @@
1
+ // Pure Node.js RFC 6455 WebSocket framer & upgrade handler with zero dependencies.
2
+ import crypto from 'node:crypto'
3
+ import { EventEmitter } from 'node:events'
4
+
5
+ const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
6
+
7
+ export function computeAcceptKey(key) {
8
+ return crypto.createHash('sha1').update(String(key || '') + WS_GUID).digest('base64')
9
+ }
10
+
11
+ export function createWsFrame(data, opcode = 0x01) {
12
+ const isBinary = Buffer.isBuffer(data)
13
+ const payload = isBinary ? data : Buffer.from(String(data || ''), 'utf8')
14
+ const op = isBinary ? 0x02 : opcode
15
+ const len = payload.length
16
+
17
+ let header
18
+ if (len <= 125) {
19
+ header = Buffer.alloc(2)
20
+ header[0] = 0x80 | (op & 0x0f)
21
+ header[1] = len
22
+ } else if (len <= 65535) {
23
+ header = Buffer.alloc(4)
24
+ header[0] = 0x80 | (op & 0x0f)
25
+ header[1] = 126
26
+ header.writeUInt16BE(len, 2)
27
+ } else {
28
+ header = Buffer.alloc(10)
29
+ header[0] = 0x80 | (op & 0x0f)
30
+ header[1] = 127
31
+ header.writeBigUInt64BE(BigInt(len), 2)
32
+ }
33
+ return Buffer.concat([header, payload])
34
+ }
35
+
36
+ export class WsConnection extends EventEmitter {
37
+ constructor(socket) {
38
+ super()
39
+ this.socket = socket
40
+ this.readyState = 1 // OPEN
41
+ this._buffer = Buffer.alloc(0)
42
+
43
+ socket.on('data', (chunk) => this._onData(chunk))
44
+ socket.on('close', () => {
45
+ this.readyState = 3
46
+ this.emit('close')
47
+ })
48
+ socket.on('error', (err) => {
49
+ this.emit('error', err)
50
+ })
51
+ }
52
+
53
+ send(data) {
54
+ if (this.readyState !== 1 || !this.socket.writable) return false
55
+ try {
56
+ this.socket.write(createWsFrame(data))
57
+ return true
58
+ } catch (e) {
59
+ this.emit('error', e)
60
+ return false
61
+ }
62
+ }
63
+
64
+ close(code = 1000, reason = '') {
65
+ if (this.readyState !== 1) return
66
+ this.readyState = 2 // CLOSING
67
+ try {
68
+ const reasonBuf = Buffer.from(reason, 'utf8')
69
+ const payload = Buffer.alloc(2 + reasonBuf.length)
70
+ payload.writeUInt16BE(code, 0)
71
+ reasonBuf.copy(payload, 2)
72
+ this.socket.write(createWsFrame(payload, 0x08))
73
+ this.socket.end()
74
+ } catch (_) {
75
+ try { this.socket.destroy() } catch (_) {}
76
+ }
77
+ this.readyState = 3
78
+ }
79
+
80
+ _onData(chunk) {
81
+ this._buffer = Buffer.concat([this._buffer, chunk])
82
+ while (this._buffer.length >= 2) {
83
+ const b0 = this._buffer[0]
84
+ const b1 = this._buffer[1]
85
+ const opcode = b0 & 0x0f
86
+ const isMasked = (b1 & 0x80) !== 0
87
+ let payloadLen = b1 & 0x7f
88
+ let offset = 2
89
+
90
+ if (payloadLen === 126) {
91
+ if (this._buffer.length < offset + 2) break
92
+ payloadLen = this._buffer.readUInt16BE(offset)
93
+ offset += 2
94
+ } else if (payloadLen === 127) {
95
+ if (this._buffer.length < offset + 8) break
96
+ payloadLen = Number(this._buffer.readBigUInt64BE(offset))
97
+ offset += 8
98
+ }
99
+
100
+ let maskKey = null
101
+ if (isMasked) {
102
+ if (this._buffer.length < offset + 4) break
103
+ maskKey = this._buffer.slice(offset, offset + 4)
104
+ offset += 4
105
+ }
106
+
107
+ if (this._buffer.length < offset + payloadLen) break
108
+ const rawPayload = this._buffer.slice(offset, offset + payloadLen)
109
+ this._buffer = this._buffer.slice(offset + payloadLen)
110
+
111
+ let payload = rawPayload
112
+ if (maskKey) {
113
+ payload = Buffer.alloc(rawPayload.length)
114
+ for (let i = 0; i < rawPayload.length; i++) {
115
+ payload[i] = rawPayload[i] ^ maskKey[i % 4]
116
+ }
117
+ }
118
+
119
+ if (opcode === 0x08) { // CLOSE
120
+ this.close()
121
+ break
122
+ } else if (opcode === 0x09) { // PING -> PONG
123
+ if (this.readyState === 1 && this.socket.writable) {
124
+ try { this.socket.write(createWsFrame(payload, 0x0a)) } catch (_) {}
125
+ }
126
+ } else if (opcode === 0x01) { // TEXT
127
+ this.emit('message', payload.toString('utf8'), false)
128
+ } else if (opcode === 0x02) { // BINARY
129
+ this.emit('message', payload, true)
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ export function handleWsUpgrade(req, socket, head, callback) {
136
+ const key = req.headers['sec-websocket-key']
137
+ if (!key) {
138
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n')
139
+ socket.destroy()
140
+ return null
141
+ }
142
+ const accept = computeAcceptKey(key)
143
+ const responseHeaders = [
144
+ 'HTTP/1.1 101 Switching Protocols',
145
+ 'Upgrade: websocket',
146
+ 'Connection: Upgrade',
147
+ `Sec-WebSocket-Accept: ${accept}`,
148
+ '\r\n',
149
+ ].join('\r\n')
150
+
151
+ socket.write(responseHeaders)
152
+ const ws = new WsConnection(socket)
153
+ if (head && head.length > 0) {
154
+ ws._onData(head)
155
+ }
156
+ if (typeof callback === 'function') {
157
+ callback(ws)
158
+ }
159
+ return ws
160
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.9.38",
3
+ "version": "0.9.40",
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",