@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/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: cfg.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
- }, 'dsh-voice: stop whisper child')
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.9",
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
+ }