@goodandready/dsh-voice 0.9.37 → 0.9.39

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' })
@@ -334,6 +335,8 @@ export function registerHostRoutes(ctx, deps) {
334
335
  polishMs: polishTookMs,
335
336
  vadMs: Number(payload.vadMs) || 0,
336
337
  wordCount: text ? text.split(/\s+/).filter(Boolean).length : 0,
338
+ clipping: Boolean(payload.clipping),
339
+ lowVolume: Boolean(payload.lowVolume),
337
340
  })
338
341
  }
339
342
  writeJson(res, 200, { ok: true, text, rawText, subMode, action, provider: out.provider, tookMs: out.tookMs, polishTookMs, totalTookMs })
@@ -349,6 +352,8 @@ export function registerHostRoutes(ctx, deps) {
349
352
  totalTookMs: Date.now() - reqStart,
350
353
  vadMs: Number(payload.vadMs) || 0,
351
354
  error: String(e && e.message || e),
355
+ clipping: Boolean(payload.clipping),
356
+ lowVolume: Boolean(payload.lowVolume),
352
357
  })
353
358
  }
354
359
  writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
@@ -357,4 +362,96 @@ export function registerHostRoutes(ctx, deps) {
357
362
  }
358
363
  },
359
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
+ }
360
457
  }
package/lib/normalize.js CHANGED
@@ -149,6 +149,9 @@ export function extractVoiceActions(text) {
149
149
  if (/^(новая строка|с новой строки|new line|newline|换行|新行)$/i.test(lower)) {
150
150
  return { text: '', action: 'newline' }
151
151
  }
152
+ if (/^(отмени последнее|сотри последнее|удали последнее|откати последнее|undo|undo last|undo chunk|撤销|撤销上一句)$/i.test(lower)) {
153
+ return { text: '', action: 'undo_chunk' }
154
+ }
152
155
 
153
156
  const trailingSend = /[\s,]+(отправь|отправить|send|send it|发送|发出)[.!?…]*$/i
154
157
  if (trailingSend.test(raw)) {
@@ -162,6 +165,10 @@ export function extractVoiceActions(text) {
162
165
  if (trailingNewline.test(raw)) {
163
166
  return { text: raw.replace(trailingNewline, '').trim() + '\n', action: 'newline' }
164
167
  }
168
+ const trailingUndo = /[\s,]+(отмени последнее|сотри последнее|удали последнее|откати последнее|undo last|undo chunk|撤销上一句)[.!?…]*$/i
169
+ if (trailingUndo.test(raw)) {
170
+ return { text: raw.replace(trailingUndo, '').trim(), action: 'undo_chunk' }
171
+ }
165
172
 
166
173
  return { text: raw, action: null }
167
174
  }
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([])
package/lib/stats.js CHANGED
@@ -30,6 +30,8 @@ export function createStatsTracker() {
30
30
  })) : [],
31
31
  error: entry.error ? String(entry.error).slice(0, 200) : undefined,
32
32
  wordCount: typeof entry.wordCount === 'number' ? entry.wordCount : undefined,
33
+ clipping: entry.clipping !== undefined ? !!entry.clipping : undefined,
34
+ lowVolume: entry.lowVolume !== undefined ? !!entry.lowVolume : undefined,
33
35
  })
34
36
  if (diagnostics.length > MAX_DIAGNOSTICS) {
35
37
  diagnostics.shift()
@@ -35,11 +35,12 @@ const SESSION_COMMANDS = [
35
35
  { re: /^(отмени|отмена|cancel|отменить|取消|算了)\s*[.!?]*$/i, cmd: 'cancel' },
36
36
  { re: /^(стоп|stop|хватит|停止|暂停)\s*[.!?]*$/i, cmd: 'stop' },
37
37
  { re: /^(продолжи|continue|продолжай|继续)\s*[.!?]*$/i, cmd: 'continue' },
38
+ { re: /^(отмени последнее|сотри последнее|удали последнее|откати последнее|undo|undo last|undo chunk|撤销|撤销上一句)\s*[.!?]*$/i, cmd: 'undo_chunk' },
38
39
  ]
39
40
 
40
41
  /**
41
42
  * @param {string} text
42
- * @returns {'send'|'cancel'|'stop'|'continue'|null}
43
+ * @returns {'send'|'cancel'|'stop'|'continue'|'undo_chunk'|null}
43
44
  */
44
45
  export function sessionCommand(text) {
45
46
  const t = String(text || '').trim().toLowerCase()
@@ -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.37",
3
+ "version": "0.9.39",
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",