@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.
- package/lib/client.js +2099 -1807
- package/lib/host-routes.js +97 -0
- package/lib/normalize.js +7 -0
- package/lib/schema.js +4 -0
- package/lib/stats.js +2 -0
- package/lib/transcribe-core.js +2 -1
- package/lib/ws-framer.js +160 -0
- package/package.json +1 -1
package/lib/host-routes.js
CHANGED
|
@@ -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()
|
package/lib/transcribe-core.js
CHANGED
|
@@ -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()
|
package/lib/ws-framer.js
ADDED
|
@@ -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.
|
|
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",
|