@goodandready/dsh-voice 0.8.10 → 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/README.md CHANGED
@@ -74,6 +74,8 @@ graph LR
74
74
  * 📊 **Provider Latency & Health Dashboard**: Live visual telemetry of provider latency (ms), success rates, and errors directly within the settings UI.
75
75
  * 🔒 **Zero API Key Leakage**: Keys are resolved on the host via `ctx.credentials` (`credentialRef`) and never transmitted to browser clients.
76
76
  * 🖥️ **Offline Local Whisper Server**: Automatically boots and manages [whisper.cpp](https://github.com/ggerganov/whisper.cpp) (`whisper-server`) with on-the-fly `ffmpeg` transcode.
77
+ * ⚡ **SenseVoice-ONNX / Sherpa-ONNX** *(0.8.11)*: Ultra-fast (~50–100ms) non-autoregressive local STT engine with automatic emotion/event tag stripping. Supports both Sherpa-ONNX HTTP and OpenAI-compatible endpoints.
78
+ * 🌐 **Realtime Audio Streaming** *(0.8.11)*: Low-latency WebSocket bridge (`/dsh-voice/realtime`) for OpenAI Realtime API or local Sherpa-ONNX streaming. API keys stay securely on the host.
77
79
 
78
80
  ---
79
81
 
@@ -100,6 +102,7 @@ graph LR
100
102
  | `groq` | Groq Whisper | `whisper-large-v3-turbo` | `GROQ_API_KEY` | Near-instant inference speed |
101
103
  | `hf` | HuggingFace Inference | `openai/whisper-large-v3` | `HF_TOKEN` | High-accuracy open Whisper |
102
104
  | `local-whisper` | Local whisper.cpp | Server defined | *None* | 100% private, offline, no internet needed |
105
+ | `sensevoice` | SenseVoice-ONNX / Sherpa-ONNX | `SenseVoiceSmall` | *None* | Ultra-fast (~50ms) local non-autoregressive STT |
103
106
 
104
107
  ### 🚀 Ready-Made Presets (Plug & Play)
105
108
 
@@ -158,7 +161,9 @@ Registers `transcribe_audio(file_path, language?)` in `ctx.tools`, allowing agen
158
161
 
159
162
  ### Internal HTTP Endpoints
160
163
  * `POST /dsh-voice/transcribe` — `{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
161
- * `GET /dsh-voice/status` — Returns whisper daemon status and active fallback chains.
164
+ * `POST /dsh-voice/polish` — `{ text }` `{ ok, text }`
165
+ * `GET /dsh-voice/status` — Returns daemon status, active chains, SenseVoice and realtime config.
166
+ * `GET /dsh-voice/realtime` — **WebSocket upgrade** for low-latency audio streaming (OpenAI Realtime API / Sherpa-ONNX). Accepts binary audio chunks, returns JSON text deltas.
162
167
 
163
168
  ---
164
169
 
package/README.ru.md CHANGED
@@ -74,6 +74,8 @@ graph LR
74
74
  * 📊 **Дашборд задержки и здоровья провайдеров**: мониторинг скорости ответа (мс), процента успешных транскрипций и ошибок в реальном времени.
75
75
  * 🔒 **Безопасность API-ключей**: ключи читаются на сервере через `ctx.credentials` и никогда не попадают в браузер клиента.
76
76
  * 🖥️ **Автозапуск локального whisper.cpp**: управление жизненным циклом `whisper-server` с авто-конвертацией через `ffmpeg`.
77
+ * ⚡ **SenseVoice-ONNX / Sherpa-ONNX** *(0.8.11)*: сверхбыстрый (~50–100мс) неавторегрессивный локальный STT с автоматической очисткой тегов эмоций/событий. Поддержка Sherpa-ONNX HTTP и OpenAI-совместимых эндпоинтов.
78
+ * 🌐 **Потоковое аудио в реальном времени** *(0.8.11)*: низколатентный WebSocket-мост (`/dsh-voice/realtime`) для OpenAI Realtime API или локального Sherpa-ONNX. API-ключи остаются на хосте.
77
79
 
78
80
  ---
79
81
 
@@ -100,6 +102,7 @@ graph LR
100
102
  | `groq` | Groq Whisper | `whisper-large-v3-turbo` | `GROQ_API_KEY` | Мгновенная скорость генерации |
101
103
  | `hf` | HuggingFace Inference | `openai/whisper-large-v3` | `HF_TOKEN` | Высокоточный облачный Whisper |
102
104
  | `local-whisper` | Локальный whisper.cpp | из параметров сервера | *Не требуется* | 100% приватность, оффлайн, без интернета |
105
+ | `sensevoice` | SenseVoice-ONNX / Sherpa-ONNX | `SenseVoiceSmall` | *Не требуется* | Сверхбыстрый (~50мс) локальный неавторегрессивный STT |
103
106
 
104
107
  ### 🚀 Готовые пресеты (Plug & Play)
105
108
 
@@ -158,7 +161,9 @@ dsh plugin --profile web add @goodandready/dsh-voice
158
161
 
159
162
  ### Внутренние HTTP эндпоинты
160
163
  * `POST /dsh-voice/transcribe` — `{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
161
- * `GET /dsh-voice/status` — возвращает состояние демона whisper и активные цепочки.
164
+ * `POST /dsh-voice/polish` — `{ text }` `{ ok, text }`
165
+ * `GET /dsh-voice/status` — состояние демонов, цепочки, конфигурация SenseVoice и реалтайма.
166
+ * `GET /dsh-voice/realtime` — **WebSocket upgrade** для низколатентного аудио-стриминга (OpenAI Realtime API / Sherpa-ONNX). Принимает бинарные аудио-чанки, возвращает JSON-дельты текста.
162
167
 
163
168
  ---
164
169
 
package/README.zh.md CHANGED
@@ -74,6 +74,8 @@ graph LR
74
74
  * 📊 **服务商延迟与健康监控看板**:在设置界面实时掌握每个语音引擎的延迟(毫秒)、成功率与调用状态。
75
75
  * 🔒 **API 密钥安全隔离**:密钥由服务端 `ctx.credentials` 统一解析,绝不向浏览器前端泄漏。
76
76
  * 🖥️ **本地 whisper.cpp 服务端直连**:自动管理 `whisper-server` 进程,结合 `ffmpeg` 实现实时音频转码。
77
+ * ⚡ **SenseVoice-ONNX / Sherpa-ONNX** *(0.8.11)*:超快速(~50–100ms)非自回归本地语音识别引擎,自动清除情绪/事件标签。支持 Sherpa-ONNX HTTP 和 OpenAI 兼容端点。
78
+ * 🌐 **实时音频流** *(0.8.11)*:低延迟 WebSocket 桥接(`/dsh-voice/realtime`),支持 OpenAI Realtime API 或本地 Sherpa-ONNX 流式传输。API 密钥安全保留在服务端。
77
79
 
78
80
  ---
79
81
 
@@ -97,6 +99,7 @@ graph LR
97
99
  | `groq` | Groq Whisper | `whisper-large-v3-turbo` | `GROQ_API_KEY` | 毫秒级极速推理 |
98
100
  | `hf` | HuggingFace Inference | `openai/whisper-large-v3` | `HF_TOKEN` | 经典高精度开源模型 |
99
101
  | `local-whisper` | 本地 whisper.cpp | 启动参数指定 | *无需密钥* | 100% 离线私密运行 |
102
+ | `sensevoice` | SenseVoice-ONNX / Sherpa-ONNX | `SenseVoiceSmall` | *无需密钥* | 超快速(~50ms)本地非自回归 STT |
100
103
 
101
104
  ---
102
105
 
package/lib/client.js CHANGED
@@ -146,6 +146,20 @@ window.__ModuleLoader__.load({
146
146
  'pause': 'Pause',
147
147
  'listenBack': 'Listen back',
148
148
  'lastRecording': 'Last voice note',
149
+ 'sensevoiceHint': 'SenseVoice-ONNX / Sherpa-ONNX · ultra-fast local STT (~50ms)',
150
+ 'sensevoiceEndpoint': 'SenseVoice: endpoint',
151
+ 'sensevoiceEndpointHint': 'POST endpoint of sherpa-onnx or compatible server',
152
+ 'sensevoiceBin': 'SenseVoice: binary',
153
+ 'sensevoiceBinHint': 'used when autostart is on',
154
+ 'sensevoiceModel': 'SenseVoice: model path',
155
+ 'sensevoiceAutostart': 'Autostart the SenseVoice server',
156
+ 'realtimeStreaming': 'Realtime audio streaming',
157
+ 'realtimeStreamingHint': 'Low-latency streaming via WebSocket (OpenAI Realtime API / sherpa-onnx)',
158
+ 'realtimeProvider': 'Realtime provider',
159
+ 'realtimeModel': 'Realtime model',
160
+ 'realtimeActive': 'Streaming live…',
161
+ 'realtimeConnect': 'Start streaming',
162
+ 'realtimeDisconnect': 'Stop streaming',
149
163
  }
150
164
  const ru = {
151
165
  'saveFailed': 'Часть полей не сохранилась —',
@@ -271,6 +285,20 @@ window.__ModuleLoader__.load({
271
285
  'pause': 'Пауза',
272
286
  'listenBack': 'Прослушать запись',
273
287
  'lastRecording': 'Последняя запись',
288
+ 'sensevoiceHint': 'SenseVoice-ONNX / Sherpa-ONNX · сверхбыстрый локальный STT (~50мс)',
289
+ 'sensevoiceEndpoint': 'SenseVoice: endpoint',
290
+ 'sensevoiceEndpointHint': 'POST endpoint сервера sherpa-onnx или совместимого',
291
+ 'sensevoiceBin': 'SenseVoice: бинарь',
292
+ 'sensevoiceBinHint': 'используется при автозапуске',
293
+ 'sensevoiceModel': 'SenseVoice: путь к модели',
294
+ 'sensevoiceAutostart': 'Автозапуск сервера SenseVoice',
295
+ 'realtimeStreaming': 'Потоковое аудио в реальном времени',
296
+ 'realtimeStreamingHint': 'Низколатентный стриминг через WebSocket (OpenAI Realtime API / sherpa-onnx)',
297
+ 'realtimeProvider': 'Провайдер реалтайма',
298
+ 'realtimeModel': 'Модель реалтайма',
299
+ 'realtimeActive': 'Стриминг идёт…',
300
+ 'realtimeConnect': 'Начать стриминг',
301
+ 'realtimeDisconnect': 'Остановить стриминг',
274
302
  }
275
303
 
276
304
  // Строки нужны и вне компонентов — в обработчиках записи, в подписях
@@ -1271,7 +1299,7 @@ window.__ModuleLoader__.load({
1271
1299
 
1272
1300
  // ------------------------------------------------------- settings page
1273
1301
  const BUILTIN = [
1274
- 'browser', 'deepgram', 'groq', 'hf', 'local-whisper',
1302
+ 'browser', 'deepgram', 'groq', 'hf', 'local-whisper', 'sensevoice',
1275
1303
  // Заготовки: адрес и модель уже прописаны на хосте, нужен только ключ.
1276
1304
  'openai', 'siliconflow', 'deepinfra', 'fireworks', 'mistral', 'openrouter',
1277
1305
  ]
@@ -1286,6 +1314,7 @@ window.__ModuleLoader__.load({
1286
1314
  openrouter: t('openrouterHint'),
1287
1315
  deepgram: 'nova-2', groq: 'whisper-large-v3-turbo',
1288
1316
  hf: 'openai/whisper-large-v3', 'local-whisper': t('localHint'),
1317
+ sensevoice: t('sensevoiceHint'),
1289
1318
  }
1290
1319
  const LANGS = ['auto', 'ru', 'en', 'uk', 'de']
1291
1320
 
@@ -1785,6 +1814,36 @@ window.__ModuleLoader__.load({
1785
1814
  onChange: (e) => setTop('polishKeyEnv', e.target.value),
1786
1815
  })),
1787
1816
  ),
1817
+ React.createElement('div', { className: 'dvs-block' },
1818
+ React.createElement('div', { className: 'dvs-h' }, 'SenseVoice-ONNX / Sherpa-ONNX'),
1819
+ React.createElement('div', { className: 'dvs-sub' }, t('sensevoiceHint')),
1820
+ textField('sensevoiceUrl', t('sensevoiceEndpoint'), t('sensevoiceEndpointHint')),
1821
+ textField('sensevoiceBin', t('sensevoiceBin'), t('sensevoiceBinHint')),
1822
+ textField('sensevoiceModel', t('sensevoiceModel'), t('sensevoiceBinHint')),
1823
+ React.createElement('label', { className: 'dvs-field' }, t('sensevoiceAutostart'),
1824
+ React.createElement('input', {
1825
+ type: 'checkbox', checked: !!(draft && draft.sensevoiceAutostart), disabled: !writable,
1826
+ onChange: (e) => setTop('sensevoiceAutostart', e.target.checked),
1827
+ })),
1828
+ ),
1829
+ React.createElement('div', { className: 'dvs-block' },
1830
+ React.createElement('div', { className: 'dvs-h' }, t('realtimeStreaming')),
1831
+ React.createElement('div', { className: 'dvs-sub' }, t('realtimeStreamingHint')),
1832
+ React.createElement('label', { className: 'dvs-field' }, t('realtimeStreaming'),
1833
+ React.createElement('input', {
1834
+ type: 'checkbox', checked: !!(draft && draft.realtimeStreaming), disabled: !writable,
1835
+ onChange: (e) => setTop('realtimeStreaming', e.target.checked),
1836
+ })),
1837
+ React.createElement('label', { className: 'dvs-field' }, t('realtimeProvider'),
1838
+ React.createElement('select', {
1839
+ value: String((draft && draft.realtimeProvider) || 'openai'), disabled: !writable,
1840
+ onChange: (e) => setTop('realtimeProvider', e.target.value),
1841
+ },
1842
+ React.createElement('option', { value: 'openai' }, 'OpenAI Realtime API'),
1843
+ React.createElement('option', { value: 'sherpa-onnx' }, 'Sherpa-ONNX (local)'),
1844
+ )),
1845
+ textField('realtimeModel', t('realtimeModel'), ''),
1846
+ ),
1788
1847
  React.createElement('div', { className: 'dvs-block' },
1789
1848
  React.createElement('div', { className: 'dvs-h' }, t('providerDashboard')),
1790
1849
  React.createElement('div', { className: 'dvo-dash' },
package/lib/index.js CHANGED
@@ -144,6 +144,20 @@ export const Config = z.object({
144
144
  .description('Harness polish: provider id. Empty uses the agent default model selection.'),
145
145
  polishModelId: z.string().default('')
146
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.'),
147
161
  })
148
162
 
149
163
  const MIME_BY_EXT = {
@@ -232,6 +246,47 @@ export function apply(ctx, baseConfig) {
232
246
 
233
247
  startWhisper().catch(() => {})
234
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
+
235
290
  // Статистика здоровья и задержек провайдеров (Latency & Health Dashboard).
236
291
  const statsTracker = createStatsTracker()
237
292
 
@@ -379,6 +434,11 @@ export function apply(ctx, baseConfig) {
379
434
  polishBaseUrl: String(cfg.polishBaseUrl || ''),
380
435
  noiseSuppression: cfg.noiseSuppression !== false,
381
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',
382
442
  providerStats: getProviderStats(),
383
443
  })
384
444
  },
@@ -441,6 +501,10 @@ export function apply(ctx, baseConfig) {
441
501
  if ((modeCfg.chain || []).some((e) => e.provider === 'local-whisper') && !(await whisperAlive())) {
442
502
  await startWhisper()
443
503
  }
504
+ // Аналогично для SenseVoice-ONNX / Sherpa-ONNX.
505
+ if ((modeCfg.chain || []).some((e) => e.provider === 'sensevoice') && !(await sensevoiceAlive())) {
506
+ await startSensevoice()
507
+ }
444
508
 
445
509
  const controller = new AbortController()
446
510
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
@@ -515,7 +579,226 @@ export function apply(ctx, baseConfig) {
515
579
  }),
516
580
  )
517
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
+
518
799
  ctx.effect(() => () => {
519
800
  if (child) { try { child.kill && child.kill() } catch { /* уже мёртв */ } }
520
- }, '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')
521
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.8.10",
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",