@goodandready/dsh-voice 0.8.17 → 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 +18 -35
- package/lib/index.js +0 -227
- package/lib/normalize.js +1 -1
- package/package.json +1 -1
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
|
-
|
|
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) => {
|
|
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) => {
|
|
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' },
|
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.
|
|
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",
|