@goodandready/dsh-voice 0.8.9 → 0.8.11
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/LICENSE +1 -1
- package/README.md +137 -181
- package/README.ru.md +172 -0
- package/README.zh.md +116 -0
- package/lib/chain.js +18 -3
- package/lib/client.js +290 -8
- package/lib/index.js +304 -5
- package/lib/providers.js +62 -2
- package/lib/stats.js +72 -0
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { runChain } from './chain.js'
|
|
|
21
21
|
import { makeProviders, PROVIDER_KEYS, PRESET_KEYS, KNOWN_KEYS, DEFAULT_MODELS, CUSTOM_TEMPLATES } from './providers.js'
|
|
22
22
|
import { toWav16k } from './wav.js'
|
|
23
23
|
import { normalizePhrase } from './normalize.js'
|
|
24
|
+
import { createStatsTracker, mergeContextVocabulary } from './stats.js'
|
|
24
25
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
25
26
|
|
|
26
27
|
function isAutoLang(lang) {
|
|
@@ -123,6 +124,10 @@ export const Config = z.object({
|
|
|
123
124
|
.description('How many recent dictation inserts to keep for undo in the browser. 0 disables history.'),
|
|
124
125
|
vocabulary: z.array(z.string()).default([])
|
|
125
126
|
.description('Custom words (names, terms) hinted to providers so they recognize them correctly.'),
|
|
127
|
+
contextGlossary: z.boolean().default(true)
|
|
128
|
+
.description('Automatically extract code words and terms from context and hint them to STT providers.'),
|
|
129
|
+
noiseSuppression: z.boolean().default(true)
|
|
130
|
+
.description('Enable hardware noise suppression, echo cancellation, and auto gain control.'),
|
|
126
131
|
voiceCommands: z.boolean().default(false)
|
|
127
132
|
.description('During dictation, spoken edit commands ("new line", "paragraph") become real line breaks instead of words.'),
|
|
128
133
|
wakeWord: z.string().default('')
|
|
@@ -139,6 +144,20 @@ export const Config = z.object({
|
|
|
139
144
|
.description('Harness polish: provider id. Empty uses the agent default model selection.'),
|
|
140
145
|
polishModelId: z.string().default('')
|
|
141
146
|
.description('Harness polish: model id. Empty uses the agent default model selection.'),
|
|
147
|
+
sensevoiceUrl: z.string().default('http://127.0.0.1:6006/api/v1/asr')
|
|
148
|
+
.description('SenseVoice-ONNX / Sherpa-ONNX endpoint: POST /api/v1/asr or OpenAI-compatible /v1/audio/transcriptions.'),
|
|
149
|
+
sensevoiceBin: z.string().default('sherpa-onnx-offline-http-server')
|
|
150
|
+
.description('SenseVoice / sherpa-onnx executable binary used when autostart is enabled.'),
|
|
151
|
+
sensevoiceModel: z.string().default('')
|
|
152
|
+
.description('SenseVoice / sherpa-onnx model path or model identifier.'),
|
|
153
|
+
sensevoiceAutostart: z.boolean().default(false)
|
|
154
|
+
.description('Start the local SenseVoice / sherpa-onnx server process automatically on startup.'),
|
|
155
|
+
realtimeStreaming: z.boolean().default(false)
|
|
156
|
+
.description('Enable low-latency streaming recognition (OpenAI Realtime API or Sherpa-ONNX).'),
|
|
157
|
+
realtimeProvider: z.string().default('openai')
|
|
158
|
+
.description('Realtime streaming provider: "openai" (OpenAI Realtime API) or "sherpa-onnx" (local streaming).'),
|
|
159
|
+
realtimeModel: z.string().default('gpt-4o-realtime-preview')
|
|
160
|
+
.description('Realtime model identifier.'),
|
|
142
161
|
})
|
|
143
162
|
|
|
144
163
|
const MIME_BY_EXT = {
|
|
@@ -227,8 +246,52 @@ export function apply(ctx, baseConfig) {
|
|
|
227
246
|
|
|
228
247
|
startWhisper().catch(() => {})
|
|
229
248
|
|
|
249
|
+
// SenseVoice-ONNX / Sherpa-ONNX: автозапуск и проверка здоровья.
|
|
250
|
+
let sensevoiceChild = null
|
|
251
|
+
|
|
252
|
+
async function sensevoiceAlive() {
|
|
253
|
+
try {
|
|
254
|
+
const cfg = live()
|
|
255
|
+
const base = String(cfg.sensevoiceUrl || '').replace(/\/api\/.*$/, '').replace(/\/v1\/.*$/, '').replace(/\/+$/, '')
|
|
256
|
+
if (!base) return false
|
|
257
|
+
const controller = new AbortController()
|
|
258
|
+
const t = setTimeout(() => controller.abort(), 2000)
|
|
259
|
+
const res = await fetch(base + '/', { signal: controller.signal })
|
|
260
|
+
clearTimeout(t)
|
|
261
|
+
return res.ok || res.status === 404 // sherpa-onnx отвечает 404 на /, но живой
|
|
262
|
+
} catch { return false }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function startSensevoice() {
|
|
266
|
+
const cfg = live()
|
|
267
|
+
if (!cfg.sensevoiceAutostart) return false
|
|
268
|
+
if (!cfg.sensevoiceModel) return false
|
|
269
|
+
if (await sensevoiceAlive()) return true
|
|
270
|
+
try {
|
|
271
|
+
const port = new URL(cfg.sensevoiceUrl).port || '6006'
|
|
272
|
+
const spec = ctx.shell.resolve({
|
|
273
|
+
command: `${JSON.stringify(cfg.sensevoiceBin)}`
|
|
274
|
+
+ ` --sense-voice-model=${JSON.stringify(cfg.sensevoiceModel)}`
|
|
275
|
+
+ ` --port=${port} --num-threads=4`,
|
|
276
|
+
timeoutMs: 0,
|
|
277
|
+
stdoutMaxBytes: 4 * 1024 * 1024,
|
|
278
|
+
})
|
|
279
|
+
sensevoiceChild = ctx.shell.start(spec)
|
|
280
|
+
for (let i = 0; i < 20; i++) {
|
|
281
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
282
|
+
if (await sensevoiceAlive()) return true
|
|
283
|
+
}
|
|
284
|
+
return false
|
|
285
|
+
} catch { return false }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
startSensevoice().catch(() => {})
|
|
289
|
+
|
|
290
|
+
// Статистика здоровья и задержек провайдеров (Latency & Health Dashboard).
|
|
291
|
+
const statsTracker = createStatsTracker()
|
|
292
|
+
|
|
230
293
|
// Общий путь распознавания: собрать провайдеров по цепочке режима и пройти её.
|
|
231
|
-
async function transcribe(modeCfg, bytes, mime, signal) {
|
|
294
|
+
async function transcribe(modeCfg, bytes, mime, signal, contextWords) {
|
|
232
295
|
const cfg = live()
|
|
233
296
|
const customKeys = (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
|
|
234
297
|
.map((c) => String(c && c.key || '').trim()).filter(Boolean)
|
|
@@ -248,11 +311,16 @@ export function apply(ctx, baseConfig) {
|
|
|
248
311
|
if (cfg.localOnly && order.length === 0) {
|
|
249
312
|
throw new Error('localOnly mode is on, but local-whisper is not in the chain')
|
|
250
313
|
}
|
|
314
|
+
|
|
315
|
+
const vocab = cfg.contextGlossary
|
|
316
|
+
? mergeContextVocabulary(cfg.vocabulary, contextWords)
|
|
317
|
+
: (Array.isArray(cfg.vocabulary) ? cfg.vocabulary : [])
|
|
318
|
+
|
|
251
319
|
const providers = makeProviders(
|
|
252
320
|
{ resolveKey, fetchImpl: fetch, cfg, toWav: (b) => toWav16k(b, cfg.ffmpegBin) },
|
|
253
|
-
{ bytes, mime, lang: modeCfg.language, signal, models, vocabulary:
|
|
321
|
+
{ bytes, mime, lang: modeCfg.language, signal, models, vocabulary: vocab },
|
|
254
322
|
)
|
|
255
|
-
return runChain(order, providers)
|
|
323
|
+
return runChain(order, providers, statsTracker.record)
|
|
256
324
|
}
|
|
257
325
|
|
|
258
326
|
// Полировка транскрипта (#35) с поддержкой локального LLM (#47).
|
|
@@ -364,6 +432,14 @@ export function apply(ctx, baseConfig) {
|
|
|
364
432
|
wakeWord: String(cfg.wakeWord || ''),
|
|
365
433
|
bargeIn: !!cfg.bargeIn,
|
|
366
434
|
polishBaseUrl: String(cfg.polishBaseUrl || ''),
|
|
435
|
+
noiseSuppression: cfg.noiseSuppression !== false,
|
|
436
|
+
contextGlossary: cfg.contextGlossary !== false,
|
|
437
|
+
sensevoiceUrl: cfg.sensevoiceUrl,
|
|
438
|
+
sensevoiceAutostart: !!cfg.sensevoiceAutostart,
|
|
439
|
+
realtimeStreaming: !!cfg.realtimeStreaming,
|
|
440
|
+
realtimeProvider: cfg.realtimeProvider || 'openai',
|
|
441
|
+
realtimeModel: cfg.realtimeModel || 'gpt-4o-realtime-preview',
|
|
442
|
+
providerStats: getProviderStats(),
|
|
367
443
|
})
|
|
368
444
|
},
|
|
369
445
|
}), 'dsh-voice: /status route')
|
|
@@ -425,11 +501,15 @@ export function apply(ctx, baseConfig) {
|
|
|
425
501
|
if ((modeCfg.chain || []).some((e) => e.provider === 'local-whisper') && !(await whisperAlive())) {
|
|
426
502
|
await startWhisper()
|
|
427
503
|
}
|
|
504
|
+
// Аналогично для SenseVoice-ONNX / Sherpa-ONNX.
|
|
505
|
+
if ((modeCfg.chain || []).some((e) => e.provider === 'sensevoice') && !(await sensevoiceAlive())) {
|
|
506
|
+
await startSensevoice()
|
|
507
|
+
}
|
|
428
508
|
|
|
429
509
|
const controller = new AbortController()
|
|
430
510
|
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
431
511
|
try {
|
|
432
|
-
const out = await transcribe(modeCfg, bytes, mime, controller.signal)
|
|
512
|
+
const out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
|
|
433
513
|
// Голосовые команды сессии (#48): чистые «отправь/отмени/стоп/продолжи»
|
|
434
514
|
// не становятся текстом, а возвращаются командой для браузера.
|
|
435
515
|
if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
|
|
@@ -499,7 +579,226 @@ export function apply(ctx, baseConfig) {
|
|
|
499
579
|
}),
|
|
500
580
|
)
|
|
501
581
|
|
|
582
|
+
// ─── Realtime WebSocket Bridge ───────────────────────────────────────
|
|
583
|
+
// Клиент открывает WS на /dsh-voice/realtime, шлёт бинарные аудио-чанки.
|
|
584
|
+
// Хост открывает WS к провайдеру (OpenAI Realtime API или совместимому),
|
|
585
|
+
// пересылает аудио и получает текстовые дельты обратно клиенту.
|
|
586
|
+
// API-ключи никогда не покидают хост.
|
|
587
|
+
const realtimeSessions = new Set()
|
|
588
|
+
|
|
589
|
+
ctx.effect(() => ctx.webServer.register({
|
|
590
|
+
kind: 'exact',
|
|
591
|
+
path: '/dsh-voice/realtime',
|
|
592
|
+
handler: async (req, res) => {
|
|
593
|
+
const cfg = live()
|
|
594
|
+
if (!cfg.realtimeStreaming) {
|
|
595
|
+
writeJson(res, 403, { ok: false, error: { code: 'disabled', message: 'realtime streaming is disabled' } })
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
// Только WebSocket upgrade
|
|
599
|
+
if (!req.headers.upgrade || req.headers.upgrade.toLowerCase() !== 'websocket') {
|
|
600
|
+
writeJson(res, 426, { ok: false, error: { code: 'upgrade', message: 'WebSocket upgrade required' } })
|
|
601
|
+
return
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Получаем API-ключ для upstream-провайдера
|
|
605
|
+
const provider = cfg.realtimeProvider || 'openai'
|
|
606
|
+
let apiKey = ''
|
|
607
|
+
if (provider === 'openai') {
|
|
608
|
+
apiKey = await resolveKey('OPENAI_API_KEY')
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Определяем upstream WebSocket URL
|
|
612
|
+
let upstreamUrl
|
|
613
|
+
const model = cfg.realtimeModel || 'gpt-4o-realtime-preview'
|
|
614
|
+
if (provider === 'openai') {
|
|
615
|
+
upstreamUrl = `wss://api.openai.com/v1/realtime?model=${encodeURIComponent(model)}`
|
|
616
|
+
} else if (provider === 'sherpa-onnx') {
|
|
617
|
+
// Локальный sherpa-onnx WebSocket endpoint
|
|
618
|
+
const base = String(cfg.sensevoiceUrl || '').replace(/^http/, 'ws').replace(/\/api\/.*$/, '').replace(/\/v1\/.*$/, '')
|
|
619
|
+
upstreamUrl = base + '/ws/transcribe'
|
|
620
|
+
} else {
|
|
621
|
+
writeJson(res, 400, { ok: false, error: { code: 'provider', message: `unknown realtime provider: ${provider}` } })
|
|
622
|
+
return
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Upgrade клиентский запрос в WebSocket через ручной handshake
|
|
626
|
+
// Node.js http.Server не имеет встроенного WS — используем raw socket
|
|
627
|
+
const crypto = await import('node:crypto')
|
|
628
|
+
const wsKey = req.headers['sec-websocket-key']
|
|
629
|
+
if (!wsKey) {
|
|
630
|
+
res.writeHead(400); res.end('Missing Sec-WebSocket-Key'); return
|
|
631
|
+
}
|
|
632
|
+
const accept = crypto.createHash('sha1')
|
|
633
|
+
.update(wsKey + '258EAFA5-E914-47DA-95CA-5AB5ADF35F20')
|
|
634
|
+
.digest('base64')
|
|
635
|
+
|
|
636
|
+
res.writeHead(101, {
|
|
637
|
+
Upgrade: 'websocket',
|
|
638
|
+
Connection: 'Upgrade',
|
|
639
|
+
'Sec-WebSocket-Accept': accept,
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
const clientSocket = req.socket
|
|
643
|
+
|
|
644
|
+
// Простой WS frame parser/writer для бинарных и текстовых сообщений
|
|
645
|
+
function writeWsFrame(socket, data, opcode = 0x01) {
|
|
646
|
+
const payload = typeof data === 'string' ? Buffer.from(data, 'utf8') : data
|
|
647
|
+
const len = payload.length
|
|
648
|
+
let header
|
|
649
|
+
if (len < 126) {
|
|
650
|
+
header = Buffer.alloc(2)
|
|
651
|
+
header[0] = 0x80 | opcode
|
|
652
|
+
header[1] = len
|
|
653
|
+
} else if (len < 65536) {
|
|
654
|
+
header = Buffer.alloc(4)
|
|
655
|
+
header[0] = 0x80 | opcode
|
|
656
|
+
header[1] = 126
|
|
657
|
+
header.writeUInt16BE(len, 2)
|
|
658
|
+
} else {
|
|
659
|
+
header = Buffer.alloc(10)
|
|
660
|
+
header[0] = 0x80 | opcode
|
|
661
|
+
header[1] = 127
|
|
662
|
+
header.writeBigUInt64BE(BigInt(len), 2)
|
|
663
|
+
}
|
|
664
|
+
socket.write(Buffer.concat([header, payload]))
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Открываем upstream WebSocket через глобальный WebSocket (Node 22+)
|
|
668
|
+
let upstream = null
|
|
669
|
+
try {
|
|
670
|
+
const headers = {}
|
|
671
|
+
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
|
672
|
+
if (provider === 'openai') headers['OpenAI-Beta'] = 'realtime=v1'
|
|
673
|
+
upstream = new WebSocket(upstreamUrl, { headers })
|
|
674
|
+
} catch (e) {
|
|
675
|
+
writeWsFrame(clientSocket, JSON.stringify({ type: 'error', message: String(e && e.message || e) }))
|
|
676
|
+
clientSocket.end()
|
|
677
|
+
return
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const sessionId = crypto.randomUUID()
|
|
681
|
+
realtimeSessions.add(sessionId)
|
|
682
|
+
|
|
683
|
+
upstream.addEventListener('open', () => {
|
|
684
|
+
writeWsFrame(clientSocket, JSON.stringify({ type: 'session.created', sessionId }))
|
|
685
|
+
// Для OpenAI Realtime — отправить session.update с конфигурацией
|
|
686
|
+
if (provider === 'openai') {
|
|
687
|
+
upstream.send(JSON.stringify({
|
|
688
|
+
type: 'session.update',
|
|
689
|
+
session: {
|
|
690
|
+
modalities: ['text'],
|
|
691
|
+
input_audio_format: 'pcm16',
|
|
692
|
+
input_audio_transcription: { model: 'whisper-1' },
|
|
693
|
+
turn_detection: { type: 'server_vad' },
|
|
694
|
+
},
|
|
695
|
+
}))
|
|
696
|
+
}
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
upstream.addEventListener('message', (event) => {
|
|
700
|
+
// Пересылаем текстовые сообщения от upstream к клиенту
|
|
701
|
+
const msg = typeof event.data === 'string' ? event.data : event.data.toString()
|
|
702
|
+
try {
|
|
703
|
+
const parsed = JSON.parse(msg)
|
|
704
|
+
// Фильтруем и пересылаем только полезные события
|
|
705
|
+
if (parsed.type === 'conversation.item.input_audio_transcription.completed'
|
|
706
|
+
|| parsed.type === 'response.audio_transcript.delta'
|
|
707
|
+
|| parsed.type === 'response.audio_transcript.done'
|
|
708
|
+
|| parsed.type === 'input_audio_buffer.speech_started'
|
|
709
|
+
|| parsed.type === 'input_audio_buffer.speech_stopped'
|
|
710
|
+
|| parsed.type === 'error') {
|
|
711
|
+
writeWsFrame(clientSocket, msg)
|
|
712
|
+
}
|
|
713
|
+
} catch {
|
|
714
|
+
// Не JSON — пересылаем как есть
|
|
715
|
+
writeWsFrame(clientSocket, msg)
|
|
716
|
+
}
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
upstream.addEventListener('error', () => {
|
|
720
|
+
writeWsFrame(clientSocket, JSON.stringify({ type: 'error', message: 'upstream connection error' }))
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
upstream.addEventListener('close', () => {
|
|
724
|
+
writeWsFrame(clientSocket, JSON.stringify({ type: 'session.closed' }))
|
|
725
|
+
try { clientSocket.end() } catch { /* socket уже закрыт */ }
|
|
726
|
+
realtimeSessions.delete(sessionId)
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
// Парсим входящие WS фреймы от клиента
|
|
730
|
+
let frameBuf = Buffer.alloc(0)
|
|
731
|
+
clientSocket.on('data', (chunk) => {
|
|
732
|
+
frameBuf = Buffer.concat([frameBuf, chunk])
|
|
733
|
+
while (frameBuf.length >= 2) {
|
|
734
|
+
const opcode = frameBuf[0] & 0x0f
|
|
735
|
+
const masked = !!(frameBuf[1] & 0x80)
|
|
736
|
+
let payloadLen = frameBuf[1] & 0x7f
|
|
737
|
+
let offset = 2
|
|
738
|
+
if (payloadLen === 126) {
|
|
739
|
+
if (frameBuf.length < 4) return
|
|
740
|
+
payloadLen = frameBuf.readUInt16BE(2)
|
|
741
|
+
offset = 4
|
|
742
|
+
} else if (payloadLen === 127) {
|
|
743
|
+
if (frameBuf.length < 10) return
|
|
744
|
+
payloadLen = Number(frameBuf.readBigUInt64BE(2))
|
|
745
|
+
offset = 10
|
|
746
|
+
}
|
|
747
|
+
if (masked) offset += 4
|
|
748
|
+
if (frameBuf.length < offset + payloadLen) return
|
|
749
|
+
|
|
750
|
+
let payload = frameBuf.subarray(offset, offset + payloadLen)
|
|
751
|
+
if (masked) {
|
|
752
|
+
const mask = frameBuf.subarray(offset - 4, offset)
|
|
753
|
+
payload = Buffer.from(payload)
|
|
754
|
+
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3]
|
|
755
|
+
}
|
|
756
|
+
frameBuf = frameBuf.subarray(offset + payloadLen)
|
|
757
|
+
|
|
758
|
+
if (opcode === 0x08) {
|
|
759
|
+
// Close frame
|
|
760
|
+
upstream.close()
|
|
761
|
+
try { clientSocket.end() } catch { /* ok */ }
|
|
762
|
+
realtimeSessions.delete(sessionId)
|
|
763
|
+
return
|
|
764
|
+
}
|
|
765
|
+
if (opcode === 0x09) {
|
|
766
|
+
// Ping → Pong
|
|
767
|
+
writeWsFrame(clientSocket, payload, 0x0a)
|
|
768
|
+
continue
|
|
769
|
+
}
|
|
770
|
+
if (opcode === 0x01) {
|
|
771
|
+
// Текст: JSON команда от клиента → upstream
|
|
772
|
+
try { upstream.send(payload.toString('utf8')) } catch { /* upstream закрыт */ }
|
|
773
|
+
} else if (opcode === 0x02) {
|
|
774
|
+
// Бинарные данные: аудио-чанк → upstream как input_audio_buffer.append
|
|
775
|
+
if (provider === 'openai') {
|
|
776
|
+
upstream.send(JSON.stringify({
|
|
777
|
+
type: 'input_audio_buffer.append',
|
|
778
|
+
audio: payload.toString('base64'),
|
|
779
|
+
}))
|
|
780
|
+
} else {
|
|
781
|
+
// Для sherpa-onnx — отправляем бинарные данные напрямую
|
|
782
|
+
try { upstream.send(payload) } catch { /* upstream закрыт */ }
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
clientSocket.on('close', () => {
|
|
789
|
+
try { upstream.close() } catch { /* ok */ }
|
|
790
|
+
realtimeSessions.delete(sessionId)
|
|
791
|
+
})
|
|
792
|
+
clientSocket.on('error', () => {
|
|
793
|
+
try { upstream.close() } catch { /* ok */ }
|
|
794
|
+
realtimeSessions.delete(sessionId)
|
|
795
|
+
})
|
|
796
|
+
},
|
|
797
|
+
}), 'dsh-voice: /realtime route')
|
|
798
|
+
|
|
502
799
|
ctx.effect(() => () => {
|
|
503
800
|
if (child) { try { child.kill && child.kill() } catch { /* уже мёртв */ } }
|
|
504
|
-
|
|
801
|
+
if (sensevoiceChild) { try { sensevoiceChild.kill && sensevoiceChild.kill() } catch { /* уже мёртв */ } }
|
|
802
|
+
for (const sid of realtimeSessions) realtimeSessions.delete(sid)
|
|
803
|
+
}, 'dsh-voice: stop child processes')
|
|
505
804
|
}
|
package/lib/providers.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// браузер, до хоста звук не доходит. Ключ нужен, чтобы такую цепочку принимал
|
|
7
7
|
// и валидатор настроек, и перебор ниже — иначе цепочка ['browser', 'groq'] на
|
|
8
8
|
// хосте оборвалась бы на первом же шаге вместо перехода к groq.
|
|
9
|
-
export const PROVIDER_KEYS = ['browser', 'deepgram', 'groq', 'hf', 'local-whisper']
|
|
9
|
+
export const PROVIDER_KEYS = ['browser', 'deepgram', 'groq', 'hf', 'local-whisper', 'sensevoice']
|
|
10
10
|
|
|
11
11
|
// Свой провайдер описывается одним из двух шаблонов, потому что
|
|
12
12
|
// OpenAI-совместимые API разошлись: у OpenRouter, например, нет
|
|
@@ -84,6 +84,7 @@ export const DEFAULT_MODELS = {
|
|
|
84
84
|
groq: 'whisper-large-v3-turbo',
|
|
85
85
|
hf: 'openai/whisper-large-v3',
|
|
86
86
|
'local-whisper': '',
|
|
87
|
+
sensevoice: 'SenseVoiceSmall',
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
function pickModel(models, key) {
|
|
@@ -199,6 +200,65 @@ export function makeProviders(deps, req) {
|
|
|
199
200
|
return { ok: text.length > 0, provider: 'local-whisper', text, reason: text ? '' : 'empty transcript' }
|
|
200
201
|
}
|
|
201
202
|
|
|
203
|
+
// SenseVoice-ONNX / Sherpa-ONNX: сверхбыстрое локальное распознавание (~50-100ms).
|
|
204
|
+
// Поддерживает эндпоинты sherpa-onnx (/api/v1/asr) и OpenAI-совместимые (/audio/transcriptions).
|
|
205
|
+
async function sensevoice() {
|
|
206
|
+
const url = String(cfg.sensevoiceUrl || 'http://127.0.0.1:6006/api/v1/asr').trim()
|
|
207
|
+
let sendBytes = bytes
|
|
208
|
+
let sendMime = mime
|
|
209
|
+
if (!mime.includes('wav')) {
|
|
210
|
+
if (typeof deps.toWav !== 'function') {
|
|
211
|
+
return { ok: false, provider: 'sensevoice', reason: 'sensevoice needs WAV, no converter configured' }
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
sendBytes = await deps.toWav(bytes)
|
|
215
|
+
sendMime = 'audio/wav'
|
|
216
|
+
} catch (e) {
|
|
217
|
+
return { ok: false, provider: 'sensevoice', reason: `sensevoice: ${String(e && e.message || e)}` }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const form = new FormData()
|
|
221
|
+
form.append('file', new Blob([sendBytes], { type: sendMime }), fileName(sendMime))
|
|
222
|
+
const isOpenAI = url.includes('/transcriptions')
|
|
223
|
+
if (isOpenAI) {
|
|
224
|
+
const model = pickModel(models, 'sensevoice') || 'SenseVoiceSmall'
|
|
225
|
+
form.append('model', model)
|
|
226
|
+
form.append('response_format', 'json')
|
|
227
|
+
}
|
|
228
|
+
if (!isAutoLang(lang)) form.append('language', lang)
|
|
229
|
+
if (vocab) form.append('prompt', vocab)
|
|
230
|
+
|
|
231
|
+
let res
|
|
232
|
+
try {
|
|
233
|
+
res = await fetchImpl(url, { method: 'POST', body: form, signal })
|
|
234
|
+
} catch (e) {
|
|
235
|
+
return { ok: false, provider: 'sensevoice', reason: `sensevoice: ${String(e && e.message || e)}` }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!res.ok) {
|
|
239
|
+
let detail = `HTTP ${res.status}`
|
|
240
|
+
try { const e = await res.json(); if (e?.error) detail = e.error } catch { /* тело не json */ }
|
|
241
|
+
return { ok: false, provider: 'sensevoice', reason: `sensevoice: ${detail}` }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let data
|
|
245
|
+
try {
|
|
246
|
+
data = await res.json()
|
|
247
|
+
} catch {
|
|
248
|
+
return { ok: false, provider: 'sensevoice', reason: 'sensevoice: invalid json response' }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let text = String(data?.text || data?.transcript || '').trim()
|
|
252
|
+
// Очищаем служебные теги эмоций и звуковых событий SenseVoice (<|NEUTRAL|>, <|HAPPY|>, <|Speech|> и др.)
|
|
253
|
+
text = text.replace(/<\|[^|>]+\|>/g, '').trim()
|
|
254
|
+
return {
|
|
255
|
+
ok: text.length > 0,
|
|
256
|
+
provider: 'sensevoice',
|
|
257
|
+
text,
|
|
258
|
+
reason: text ? '' : 'empty transcript',
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
202
262
|
// Свой провайдер. Ключ в цепочке — его имя, поэтому в остальном коде он
|
|
203
263
|
// ничем не отличается от встроенного.
|
|
204
264
|
function customProvider(spec) {
|
|
@@ -289,7 +349,7 @@ export function makeProviders(deps, req) {
|
|
|
289
349
|
}
|
|
290
350
|
}
|
|
291
351
|
|
|
292
|
-
const out = { browser, deepgram, groq, hf, 'local-whisper': localWhisper }
|
|
352
|
+
const out = { browser, deepgram, groq, hf, 'local-whisper': localWhisper, sensevoice }
|
|
293
353
|
|
|
294
354
|
// Заготовки: те же свои провайдеры, только анкета заполнена заранее.
|
|
295
355
|
for (const key of PRESET_KEYS) {
|
package/lib/stats.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// dsh-voice — чистые утилиты метрик провайдеров и объединения контекстного словаря.
|
|
2
|
+
// Без cordis и без сети — тестируются юнит-тестами.
|
|
3
|
+
|
|
4
|
+
export function createStatsTracker() {
|
|
5
|
+
const stats = new Map()
|
|
6
|
+
|
|
7
|
+
function record(key, res = {}) {
|
|
8
|
+
if (!key) return
|
|
9
|
+
const cur = stats.get(key) || {
|
|
10
|
+
attempts: 0,
|
|
11
|
+
successes: 0,
|
|
12
|
+
failures: 0,
|
|
13
|
+
totalTookMs: 0,
|
|
14
|
+
avgTookMs: 0,
|
|
15
|
+
lastTookMs: 0,
|
|
16
|
+
lastError: '',
|
|
17
|
+
lastSuccessAt: 0,
|
|
18
|
+
lastErrorAt: 0,
|
|
19
|
+
}
|
|
20
|
+
cur.attempts += 1
|
|
21
|
+
cur.lastTookMs = res.tookMs || 0
|
|
22
|
+
if (res.ok) {
|
|
23
|
+
cur.successes += 1
|
|
24
|
+
cur.totalTookMs += res.tookMs || 0
|
|
25
|
+
cur.avgTookMs = Math.round(cur.totalTookMs / cur.successes)
|
|
26
|
+
cur.lastSuccessAt = Date.now()
|
|
27
|
+
} else {
|
|
28
|
+
cur.failures += 1
|
|
29
|
+
cur.lastError = String(res.reason || 'unknown').slice(0, 150)
|
|
30
|
+
cur.lastErrorAt = Date.now()
|
|
31
|
+
}
|
|
32
|
+
stats.set(key, cur)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function get() {
|
|
36
|
+
const out = {}
|
|
37
|
+
for (const [k, v] of stats.entries()) {
|
|
38
|
+
out[k] = {
|
|
39
|
+
attempts: v.attempts,
|
|
40
|
+
successes: v.successes,
|
|
41
|
+
failures: v.failures,
|
|
42
|
+
avgTookMs: v.avgTookMs || v.lastTookMs || 0,
|
|
43
|
+
lastTookMs: v.lastTookMs || 0,
|
|
44
|
+
lastError: v.lastError,
|
|
45
|
+
lastSuccessAt: v.lastSuccessAt,
|
|
46
|
+
lastErrorAt: v.lastErrorAt,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function clear() {
|
|
53
|
+
stats.clear()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { record, get, clear }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function mergeContextVocabulary(baseVocab = [], contextWords = [], maxWords = 50) {
|
|
60
|
+
const vocab = Array.isArray(baseVocab) ? [...baseVocab] : []
|
|
61
|
+
if (!Array.isArray(contextWords) || contextWords.length === 0) return vocab
|
|
62
|
+
const seen = new Set(vocab.map((w) => String(w).toLowerCase()))
|
|
63
|
+
for (const word of contextWords) {
|
|
64
|
+
const clean = String(word || '').trim()
|
|
65
|
+
if (clean && clean.length >= 2 && !seen.has(clean.toLowerCase())) {
|
|
66
|
+
seen.add(clean.toLowerCase())
|
|
67
|
+
vocab.push(clean)
|
|
68
|
+
if (vocab.length >= maxWords) break
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return vocab
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.11",
|
|
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",
|
|
@@ -61,4 +61,4 @@
|
|
|
61
61
|
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
|
62
62
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
63
63
|
}
|
|
64
|
-
}
|
|
64
|
+
}
|