@goodandready/dsh-voice 0.8.16 → 0.8.18

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 CHANGED
@@ -155,13 +155,6 @@ window.__ModuleLoader__.load({
155
155
  'sensevoiceBinHint': 'used when autostart is on',
156
156
  'sensevoiceModel': 'SenseVoice: model path',
157
157
  'sensevoiceAutostart': 'Autostart the SenseVoice server',
158
- 'realtimeStreaming': 'Realtime audio streaming',
159
- 'realtimeStreamingHint': 'Low-latency streaming via WebSocket (OpenAI Realtime API / sherpa-onnx)',
160
- 'realtimeProvider': 'Realtime provider',
161
- 'realtimeModel': 'Realtime model',
162
- 'realtimeActive': 'Streaming live…',
163
- 'realtimeConnect': 'Start streaming',
164
- 'realtimeDisconnect': 'Stop streaming',
165
158
  'visualizerStyle': 'Audio visualizer style',
166
159
  'visualizerStyleHint': 'Waveform animation inside the recording pill',
167
160
  'visLiquidWave': 'Liquid Wave',
@@ -302,13 +295,6 @@ window.__ModuleLoader__.load({
302
295
  'sensevoiceBinHint': 'используется при автозапуске',
303
296
  'sensevoiceModel': 'SenseVoice: путь к модели',
304
297
  'sensevoiceAutostart': 'Автозапуск сервера SenseVoice',
305
- 'realtimeStreaming': 'Потоковое аудио в реальном времени',
306
- 'realtimeStreamingHint': 'Низколатентный стриминг через WebSocket (OpenAI Realtime API / sherpa-onnx)',
307
- 'realtimeProvider': 'Провайдер реалтайма',
308
- 'realtimeModel': 'Модель реалтайма',
309
- 'realtimeActive': 'Стриминг идёт…',
310
- 'realtimeConnect': 'Начать стриминг',
311
- 'realtimeDisconnect': 'Остановить стриминг',
312
298
  'visualizerStyle': 'Стиль визуализатора звука',
313
299
  'visualizerStyleHint': 'Анимация волны внутри полоски записи',
314
300
  'visLiquidWave': 'Жидкая волна (Liquid Wave)',
@@ -529,11 +515,12 @@ window.__ModuleLoader__.load({
529
515
  // Как клавиша называется на человеческом языке.
530
516
  const KEY_LABELS = {
531
517
  Control: 'Ctrl', Alt: 'Alt', Shift: 'Shift', Meta: 'Win',
532
- Space: t('keySpace'), Escape: 'Esc',
518
+ Escape: 'Esc',
533
519
  }
534
520
 
535
521
  function keyLabel(name) {
536
522
  if (!name) return t('keyUnset')
523
+ if (name === 'Space') return t('keySpace')
537
524
  if (KEY_LABELS[name]) return KEY_LABELS[name]
538
525
  // Коды вида KeyR и Digit5 показываем без служебной приставки.
539
526
  return String(name).replace(/^Key/, '').replace(/^Digit/, '')
@@ -777,7 +764,14 @@ window.__ModuleLoader__.load({
777
764
  voice.browser = null
778
765
  voice.caption = ''
779
766
  openMic(mode)
780
- .then((rec) => { voice.rec = rec; voice.notify() })
767
+ .then((rec) => {
768
+ if (voice.phase !== 'recording') {
769
+ teardown(rec)
770
+ return
771
+ }
772
+ voice.rec = rec
773
+ voice.notify()
774
+ })
781
775
  .catch((err) => voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null }))
782
776
  }
783
777
  }
@@ -811,7 +805,14 @@ window.__ModuleLoader__.load({
811
805
  // Браузерная нога — только если её прямо поставили первой в цепочке.
812
806
  if (info.chain[0] === 'browser' && startBrowserLeg(mode, info.language)) return
813
807
  openMic(mode)
814
- .then((rec) => { voice.rec = rec; voice.notify() })
808
+ .then((rec) => {
809
+ if (voice.phase !== 'recording') {
810
+ teardown(rec)
811
+ return
812
+ }
813
+ voice.rec = rec
814
+ voice.notify()
815
+ })
815
816
  .catch((err) => voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null }))
816
817
  })
817
818
  }
@@ -1971,24 +1972,6 @@ window.__ModuleLoader__.load({
1971
1972
  onChange: (e) => setTop('sensevoiceAutostart', e.target.checked),
1972
1973
  })),
1973
1974
  ),
1974
- React.createElement('div', { className: 'dvs-block' },
1975
- React.createElement('div', { className: 'dvs-h' }, t('realtimeStreaming')),
1976
- React.createElement('div', { className: 'dvs-sub' }, t('realtimeStreamingHint')),
1977
- React.createElement('label', { className: 'dvs-field' }, t('realtimeStreaming'),
1978
- React.createElement('input', {
1979
- type: 'checkbox', checked: !!(draft && draft.realtimeStreaming), disabled: !writable,
1980
- onChange: (e) => setTop('realtimeStreaming', e.target.checked),
1981
- })),
1982
- React.createElement('label', { className: 'dvs-field' }, t('realtimeProvider'),
1983
- React.createElement('select', {
1984
- value: String((draft && draft.realtimeProvider) || 'openai'), disabled: !writable,
1985
- onChange: (e) => setTop('realtimeProvider', e.target.value),
1986
- },
1987
- React.createElement('option', { value: 'openai' }, 'OpenAI Realtime API'),
1988
- React.createElement('option', { value: 'sherpa-onnx' }, 'Sherpa-ONNX (local)'),
1989
- )),
1990
- textField('realtimeModel', t('realtimeModel'), ''),
1991
- ),
1992
1975
  React.createElement('div', { className: 'dvs-block' },
1993
1976
  React.createElement('div', { className: 'dvs-h' }, t('providerDashboard')),
1994
1977
  React.createElement('div', { className: 'dvo-dash' },
@@ -2066,37 +2049,18 @@ window.__ModuleLoader__.load({
2066
2049
  }
2067
2050
 
2068
2051
  function registerSettings(ctx) {
2069
- // Слот объявлен пакетом настроек ядра; вкладка перебирает пространства
2070
- // настроек, поэтому ключ регистрации обязан равняться NS, не id пакета:
2071
- // не равен карточка не появится вовсе, молча.
2072
- const tryPluginItem = () => {
2073
- try {
2074
- ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
2075
- {
2076
- name: 'settings.plugin.item',
2077
- key: NS,
2078
- // locale в записи слота — то, из-за чего компонент получает props.t.
2079
- locale: NS,
2080
- inject: () => ({ ctx: ctx }),
2081
- },
2082
- PluginCard,
2083
- ))
2084
- return true
2085
- } catch (e) { return false }
2086
- }
2087
- if (tryPluginItem()) return
2088
- // Запасной путь: в этой сборке слота нет — остаёмся боковым
2089
- // разделом, чтобы настройки не пропали.
2090
- ctx.slots.inject('settings.section', () => ctx.slots.register(
2052
+ // Карточка настроек во вкладке «Настройки Плагины → Настройки плагинов» (#18).
2053
+ // Ключ регистрации обязан равняться NS (пространству настроек), иначе
2054
+ // вкладка молча пропустит слот.
2055
+ ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
2091
2056
  {
2092
- name: 'settings.section',
2093
- id: '@goodandready/dsh-voice',
2094
- order: 30,
2057
+ name: 'settings.plugin.item',
2058
+ key: NS,
2059
+ // locale в записи слота — то, из-за чего компонент получает props.t.
2095
2060
  locale: NS,
2096
- label: () => t('title'),
2097
2061
  inject: () => ({ ctx: ctx }),
2098
2062
  },
2099
- VoiceSection,
2063
+ PluginCard,
2100
2064
  ))
2101
2065
  }
2102
2066
 
package/lib/index.js CHANGED
@@ -155,12 +155,6 @@ export const Config = z.object({
155
155
  .description('SenseVoice / sherpa-onnx model path or model identifier.'),
156
156
  sensevoiceAutostart: z.boolean().default(false)
157
157
  .description('Start the local SenseVoice / sherpa-onnx server process automatically on startup.'),
158
- realtimeStreaming: z.boolean().default(false)
159
- .description('Enable low-latency streaming recognition (OpenAI Realtime API or Sherpa-ONNX).'),
160
- realtimeProvider: z.string().default('openai')
161
- .description('Realtime streaming provider: "openai" (OpenAI Realtime API) or "sherpa-onnx" (local streaming).'),
162
- realtimeModel: z.string().default('gpt-4o-realtime-preview')
163
- .description('Realtime model identifier.'),
164
158
  visualizerStyle: z.union(['liquid-wave', 'dynamic-orb', 'bars', 'off']).default('liquid-wave')
165
159
  .description('Audio visualizer animation style in the recording pill: "liquid-wave", "dynamic-orb", "bars", or "off".'),
166
160
  })
@@ -442,9 +436,6 @@ export function apply(ctx, baseConfig) {
442
436
  sensevoiceUrl: cfg.sensevoiceUrl,
443
437
  sensevoiceAutostart: !!cfg.sensevoiceAutostart,
444
438
  deepgramBaseUrl: cfg.deepgramBaseUrl || 'https://api.deepgram.com',
445
- realtimeStreaming: !!cfg.realtimeStreaming,
446
- realtimeProvider: cfg.realtimeProvider || 'openai',
447
- realtimeModel: cfg.realtimeModel || 'gpt-4o-realtime-preview',
448
439
  visualizerStyle: cfg.visualizerStyle || 'liquid-wave',
449
440
  providerStats: statsTracker.get(),
450
441
  })
@@ -586,226 +577,8 @@ export function apply(ctx, baseConfig) {
586
577
  }),
587
578
  )
588
579
 
589
- // ─── Realtime WebSocket Bridge ───────────────────────────────────────
590
- // Клиент открывает WS на /dsh-voice/realtime, шлёт бинарные аудио-чанки.
591
- // Хост открывает WS к провайдеру (OpenAI Realtime API или совместимому),
592
- // пересылает аудио и получает текстовые дельты обратно клиенту.
593
- // API-ключи никогда не покидают хост.
594
- const realtimeSessions = new Set()
595
-
596
- ctx.effect(() => ctx.webServer.register({
597
- kind: 'exact',
598
- path: '/dsh-voice/realtime',
599
- handler: async (req, res) => {
600
- const cfg = live()
601
- if (!cfg.realtimeStreaming) {
602
- writeJson(res, 403, { ok: false, error: { code: 'disabled', message: 'realtime streaming is disabled' } })
603
- return
604
- }
605
- // Только WebSocket upgrade
606
- if (!req.headers.upgrade || req.headers.upgrade.toLowerCase() !== 'websocket') {
607
- writeJson(res, 426, { ok: false, error: { code: 'upgrade', message: 'WebSocket upgrade required' } })
608
- return
609
- }
610
-
611
- // Получаем API-ключ для upstream-провайдера
612
- const provider = cfg.realtimeProvider || 'openai'
613
- let apiKey = ''
614
- if (provider === 'openai') {
615
- apiKey = await resolveKey('OPENAI_API_KEY')
616
- }
617
-
618
- // Определяем upstream WebSocket URL
619
- let upstreamUrl
620
- const model = cfg.realtimeModel || 'gpt-4o-realtime-preview'
621
- if (provider === 'openai') {
622
- upstreamUrl = `wss://api.openai.com/v1/realtime?model=${encodeURIComponent(model)}`
623
- } else if (provider === 'sherpa-onnx') {
624
- // Локальный sherpa-onnx WebSocket endpoint
625
- const base = String(cfg.sensevoiceUrl || '').replace(/^http/, 'ws').replace(/\/api\/.*$/, '').replace(/\/v1\/.*$/, '')
626
- upstreamUrl = base + '/ws/transcribe'
627
- } else {
628
- writeJson(res, 400, { ok: false, error: { code: 'provider', message: `unknown realtime provider: ${provider}` } })
629
- return
630
- }
631
-
632
- // Upgrade клиентский запрос в WebSocket через ручной handshake
633
- // Node.js http.Server не имеет встроенного WS — используем raw socket
634
- const crypto = await import('node:crypto')
635
- const wsKey = req.headers['sec-websocket-key']
636
- if (!wsKey) {
637
- res.writeHead(400); res.end('Missing Sec-WebSocket-Key'); return
638
- }
639
- const accept = crypto.createHash('sha1')
640
- .update(wsKey + '258EAFA5-E914-47DA-95CA-5AB5ADF35F20')
641
- .digest('base64')
642
-
643
- res.writeHead(101, {
644
- Upgrade: 'websocket',
645
- Connection: 'Upgrade',
646
- 'Sec-WebSocket-Accept': accept,
647
- })
648
-
649
- const clientSocket = req.socket
650
-
651
- // Простой WS frame parser/writer для бинарных и текстовых сообщений
652
- function writeWsFrame(socket, data, opcode = 0x01) {
653
- const payload = typeof data === 'string' ? Buffer.from(data, 'utf8') : data
654
- const len = payload.length
655
- let header
656
- if (len < 126) {
657
- header = Buffer.alloc(2)
658
- header[0] = 0x80 | opcode
659
- header[1] = len
660
- } else if (len < 65536) {
661
- header = Buffer.alloc(4)
662
- header[0] = 0x80 | opcode
663
- header[1] = 126
664
- header.writeUInt16BE(len, 2)
665
- } else {
666
- header = Buffer.alloc(10)
667
- header[0] = 0x80 | opcode
668
- header[1] = 127
669
- header.writeBigUInt64BE(BigInt(len), 2)
670
- }
671
- socket.write(Buffer.concat([header, payload]))
672
- }
673
-
674
- // Открываем upstream WebSocket через глобальный WebSocket (Node 22+)
675
- let upstream = null
676
- try {
677
- const headers = {}
678
- if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
679
- if (provider === 'openai') headers['OpenAI-Beta'] = 'realtime=v1'
680
- upstream = new WebSocket(upstreamUrl, { headers })
681
- } catch (e) {
682
- writeWsFrame(clientSocket, JSON.stringify({ type: 'error', message: String(e && e.message || e) }))
683
- clientSocket.end()
684
- return
685
- }
686
-
687
- const sessionId = crypto.randomUUID()
688
- realtimeSessions.add(sessionId)
689
-
690
- upstream.addEventListener('open', () => {
691
- writeWsFrame(clientSocket, JSON.stringify({ type: 'session.created', sessionId }))
692
- // Для OpenAI Realtime — отправить session.update с конфигурацией
693
- if (provider === 'openai') {
694
- upstream.send(JSON.stringify({
695
- type: 'session.update',
696
- session: {
697
- modalities: ['text'],
698
- input_audio_format: 'pcm16',
699
- input_audio_transcription: { model: 'whisper-1' },
700
- turn_detection: { type: 'server_vad' },
701
- },
702
- }))
703
- }
704
- })
705
-
706
- upstream.addEventListener('message', (event) => {
707
- // Пересылаем текстовые сообщения от upstream к клиенту
708
- const msg = typeof event.data === 'string' ? event.data : event.data.toString()
709
- try {
710
- const parsed = JSON.parse(msg)
711
- // Фильтруем и пересылаем только полезные события
712
- if (parsed.type === 'conversation.item.input_audio_transcription.completed'
713
- || parsed.type === 'response.audio_transcript.delta'
714
- || parsed.type === 'response.audio_transcript.done'
715
- || parsed.type === 'input_audio_buffer.speech_started'
716
- || parsed.type === 'input_audio_buffer.speech_stopped'
717
- || parsed.type === 'error') {
718
- writeWsFrame(clientSocket, msg)
719
- }
720
- } catch {
721
- // Не JSON — пересылаем как есть
722
- writeWsFrame(clientSocket, msg)
723
- }
724
- })
725
-
726
- upstream.addEventListener('error', () => {
727
- writeWsFrame(clientSocket, JSON.stringify({ type: 'error', message: 'upstream connection error' }))
728
- })
729
-
730
- upstream.addEventListener('close', () => {
731
- writeWsFrame(clientSocket, JSON.stringify({ type: 'session.closed' }))
732
- try { clientSocket.end() } catch { /* socket уже закрыт */ }
733
- realtimeSessions.delete(sessionId)
734
- })
735
-
736
- // Парсим входящие WS фреймы от клиента
737
- let frameBuf = Buffer.alloc(0)
738
- clientSocket.on('data', (chunk) => {
739
- frameBuf = Buffer.concat([frameBuf, chunk])
740
- while (frameBuf.length >= 2) {
741
- const opcode = frameBuf[0] & 0x0f
742
- const masked = !!(frameBuf[1] & 0x80)
743
- let payloadLen = frameBuf[1] & 0x7f
744
- let offset = 2
745
- if (payloadLen === 126) {
746
- if (frameBuf.length < 4) return
747
- payloadLen = frameBuf.readUInt16BE(2)
748
- offset = 4
749
- } else if (payloadLen === 127) {
750
- if (frameBuf.length < 10) return
751
- payloadLen = Number(frameBuf.readBigUInt64BE(2))
752
- offset = 10
753
- }
754
- if (masked) offset += 4
755
- if (frameBuf.length < offset + payloadLen) return
756
-
757
- let payload = frameBuf.subarray(offset, offset + payloadLen)
758
- if (masked) {
759
- const mask = frameBuf.subarray(offset - 4, offset)
760
- payload = Buffer.from(payload)
761
- for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3]
762
- }
763
- frameBuf = frameBuf.subarray(offset + payloadLen)
764
-
765
- if (opcode === 0x08) {
766
- // Close frame
767
- upstream.close()
768
- try { clientSocket.end() } catch { /* ok */ }
769
- realtimeSessions.delete(sessionId)
770
- return
771
- }
772
- if (opcode === 0x09) {
773
- // Ping → Pong
774
- writeWsFrame(clientSocket, payload, 0x0a)
775
- continue
776
- }
777
- if (opcode === 0x01) {
778
- // Текст: JSON команда от клиента → upstream
779
- try { upstream.send(payload.toString('utf8')) } catch { /* upstream закрыт */ }
780
- } else if (opcode === 0x02) {
781
- // Бинарные данные: аудио-чанк → upstream как input_audio_buffer.append
782
- if (provider === 'openai') {
783
- upstream.send(JSON.stringify({
784
- type: 'input_audio_buffer.append',
785
- audio: payload.toString('base64'),
786
- }))
787
- } else {
788
- // Для sherpa-onnx — отправляем бинарные данные напрямую
789
- try { upstream.send(payload) } catch { /* upstream закрыт */ }
790
- }
791
- }
792
- }
793
- })
794
-
795
- clientSocket.on('close', () => {
796
- try { upstream.close() } catch { /* ok */ }
797
- realtimeSessions.delete(sessionId)
798
- })
799
- clientSocket.on('error', () => {
800
- try { upstream.close() } catch { /* ok */ }
801
- realtimeSessions.delete(sessionId)
802
- })
803
- },
804
- }), 'dsh-voice: /realtime route')
805
-
806
580
  ctx.effect(() => () => {
807
581
  if (child) { try { child.kill && child.kill() } catch { /* уже мёртв */ } }
808
582
  if (sensevoiceChild) { try { sensevoiceChild.kill && sensevoiceChild.kill() } catch { /* уже мёртв */ } }
809
- for (const sid of realtimeSessions) realtimeSessions.delete(sid)
810
583
  }, 'dsh-voice: stop child processes')
811
584
  }
package/lib/normalize.js CHANGED
@@ -29,7 +29,7 @@ const NUM_WORDS = {
29
29
  }
30
30
 
31
31
  const NUM_NAMES = Object.keys(NUM_WORDS).sort((a, b) => b.length - a.length).join('|')
32
- const NUM_SEQ = new RegExp('(' + NUM_NAMES + ')(?:\\s+(' + NUM_NAMES + '))*', 'gi')
32
+ const NUM_SEQ = new RegExp('(?<![а-яёa-z0-9])(?:' + NUM_NAMES + ')(?:\\s+(?:' + NUM_NAMES + '))*(?![а-яёa-z0-9])', 'gi')
33
33
 
34
34
  export function wordsToDigits(text) {
35
35
  return text.replace(NUM_SEQ, (m) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.8.16",
3
+ "version": "0.8.18",
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",