@goodandready/dsh-voice 0.8.10 → 0.8.12

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,9 @@ 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.
79
+ * 🌊 **Liquid Wave & Dynamic Orb Visualizer** *(0.8.12)*: Smooth animated audio visualization in the recording pill with real-time mic volume reactivity. Switch between organic multi-layer liquid waves, pulsating radiant orb, classic bars, or off.
77
80
 
78
81
  ---
79
82
 
@@ -100,6 +103,7 @@ graph LR
100
103
  | `groq` | Groq Whisper | `whisper-large-v3-turbo` | `GROQ_API_KEY` | Near-instant inference speed |
101
104
  | `hf` | HuggingFace Inference | `openai/whisper-large-v3` | `HF_TOKEN` | High-accuracy open Whisper |
102
105
  | `local-whisper` | Local whisper.cpp | Server defined | *None* | 100% private, offline, no internet needed |
106
+ | `sensevoice` | SenseVoice-ONNX / Sherpa-ONNX | `SenseVoiceSmall` | *None* | Ultra-fast (~50ms) local non-autoregressive STT |
103
107
 
104
108
  ### 🚀 Ready-Made Presets (Plug & Play)
105
109
 
@@ -158,7 +162,9 @@ Registers `transcribe_audio(file_path, language?)` in `ctx.tools`, allowing agen
158
162
 
159
163
  ### Internal HTTP Endpoints
160
164
  * `POST /dsh-voice/transcribe` — `{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
161
- * `GET /dsh-voice/status` — Returns whisper daemon status and active fallback chains.
165
+ * `POST /dsh-voice/polish` — `{ text }` `{ ok, text }`
166
+ * `GET /dsh-voice/status` — Returns daemon status, active chains, SenseVoice and realtime config.
167
+ * `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
168
 
163
169
  ---
164
170
 
package/README.ru.md CHANGED
@@ -74,6 +74,9 @@ 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-ключи остаются на хосте.
79
+ * 🌊 **Анимированные визуализаторы Liquid Wave и Dynamic Orb** *(0.8.12)*: живая интерактивная анимация звуковой волны в полоске записи с откликом на громкость микрофона. Выбор стиля: текучая волна, пульсирующая сфера, классические столбики или выключен.
77
80
 
78
81
  ---
79
82
 
@@ -100,6 +103,7 @@ graph LR
100
103
  | `groq` | Groq Whisper | `whisper-large-v3-turbo` | `GROQ_API_KEY` | Мгновенная скорость генерации |
101
104
  | `hf` | HuggingFace Inference | `openai/whisper-large-v3` | `HF_TOKEN` | Высокоточный облачный Whisper |
102
105
  | `local-whisper` | Локальный whisper.cpp | из параметров сервера | *Не требуется* | 100% приватность, оффлайн, без интернета |
106
+ | `sensevoice` | SenseVoice-ONNX / Sherpa-ONNX | `SenseVoiceSmall` | *Не требуется* | Сверхбыстрый (~50мс) локальный неавторегрессивный STT |
103
107
 
104
108
  ### 🚀 Готовые пресеты (Plug & Play)
105
109
 
@@ -158,7 +162,9 @@ dsh plugin --profile web add @goodandready/dsh-voice
158
162
 
159
163
  ### Внутренние HTTP эндпоинты
160
164
  * `POST /dsh-voice/transcribe` — `{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
161
- * `GET /dsh-voice/status` — возвращает состояние демона whisper и активные цепочки.
165
+ * `POST /dsh-voice/polish` — `{ text }` `{ ok, text }`
166
+ * `GET /dsh-voice/status` — состояние демонов, цепочки, конфигурация SenseVoice и реалтайма.
167
+ * `GET /dsh-voice/realtime` — **WebSocket upgrade** для низколатентного аудио-стриминга (OpenAI Realtime API / Sherpa-ONNX). Принимает бинарные аудио-чанки, возвращает JSON-дельты текста.
162
168
 
163
169
  ---
164
170
 
package/README.zh.md CHANGED
@@ -74,6 +74,9 @@ 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 密钥安全保留在服务端。
79
+ * 🌊 **Liquid Wave 与 Dynamic Orb 动态可视化** *(0.8.12)*:录音面板内的生动波形动态可视化,实时响应麦克风音量。可选平滑流动波浪、发光脉冲球体、经典频条或关闭。
77
80
 
78
81
  ---
79
82
 
@@ -97,6 +100,7 @@ graph LR
97
100
  | `groq` | Groq Whisper | `whisper-large-v3-turbo` | `GROQ_API_KEY` | 毫秒级极速推理 |
98
101
  | `hf` | HuggingFace Inference | `openai/whisper-large-v3` | `HF_TOKEN` | 经典高精度开源模型 |
99
102
  | `local-whisper` | 本地 whisper.cpp | 启动参数指定 | *无需密钥* | 100% 离线私密运行 |
103
+ | `sensevoice` | SenseVoice-ONNX / Sherpa-ONNX | `SenseVoiceSmall` | *无需密钥* | 超快速(~50ms)本地非自回归 STT |
100
104
 
101
105
  ---
102
106
 
package/lib/client.js CHANGED
@@ -146,6 +146,26 @@ 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',
163
+ 'visualizerStyle': 'Audio visualizer style',
164
+ 'visualizerStyleHint': 'Waveform animation inside the recording pill',
165
+ 'visLiquidWave': 'Liquid Wave',
166
+ 'visDynamicOrb': 'Dynamic Orb',
167
+ 'visBars': 'Classic Bars',
168
+ 'visOff': 'Off',
149
169
  }
150
170
  const ru = {
151
171
  'saveFailed': 'Часть полей не сохранилась —',
@@ -271,6 +291,26 @@ window.__ModuleLoader__.load({
271
291
  'pause': 'Пауза',
272
292
  'listenBack': 'Прослушать запись',
273
293
  'lastRecording': 'Последняя запись',
294
+ 'sensevoiceHint': 'SenseVoice-ONNX / Sherpa-ONNX · сверхбыстрый локальный STT (~50мс)',
295
+ 'sensevoiceEndpoint': 'SenseVoice: endpoint',
296
+ 'sensevoiceEndpointHint': 'POST endpoint сервера sherpa-onnx или совместимого',
297
+ 'sensevoiceBin': 'SenseVoice: бинарь',
298
+ 'sensevoiceBinHint': 'используется при автозапуске',
299
+ 'sensevoiceModel': 'SenseVoice: путь к модели',
300
+ 'sensevoiceAutostart': 'Автозапуск сервера SenseVoice',
301
+ 'realtimeStreaming': 'Потоковое аудио в реальном времени',
302
+ 'realtimeStreamingHint': 'Низколатентный стриминг через WebSocket (OpenAI Realtime API / sherpa-onnx)',
303
+ 'realtimeProvider': 'Провайдер реалтайма',
304
+ 'realtimeModel': 'Модель реалтайма',
305
+ 'realtimeActive': 'Стриминг идёт…',
306
+ 'realtimeConnect': 'Начать стриминг',
307
+ 'realtimeDisconnect': 'Остановить стриминг',
308
+ 'visualizerStyle': 'Стиль визуализатора звука',
309
+ 'visualizerStyleHint': 'Анимация волны внутри полоски записи',
310
+ 'visLiquidWave': 'Жидкая волна (Liquid Wave)',
311
+ 'visDynamicOrb': 'Динамическая сфера (Dynamic Orb)',
312
+ 'visBars': 'Классические столбики',
313
+ 'visOff': 'Выключен',
274
314
  }
275
315
 
276
316
  // Строки нужны и вне компонентов — в обработчиках записи, в подписях
@@ -998,6 +1038,108 @@ window.__ModuleLoader__.load({
998
1038
  )
999
1039
  }
1000
1040
 
1041
+ // ----------------------------------------------------------- visualizers
1042
+ // 1. Liquid Wave: органическая текучая многослойная волна
1043
+ function drawLiquidWave(g, w, h, levels, color, time) {
1044
+ const midY = h / 2
1045
+ const curLevel = levels.length ? levels[levels.length - 1] : 0
1046
+ const smoothLevel = Math.max(0.04, Math.min(1, curLevel * 1.6))
1047
+
1048
+ const layers = [
1049
+ { amp: smoothLevel * (h * 0.42), freq: 0.024, speed: 0.08, alpha: 0.45 },
1050
+ { amp: smoothLevel * (h * 0.36), freq: 0.038, speed: -0.06, alpha: 0.75 },
1051
+ { amp: smoothLevel * (h * 0.28), freq: 0.052, speed: 0.11, alpha: 0.95 },
1052
+ ]
1053
+
1054
+ for (let layerIdx = 0; layerIdx < layers.length; layerIdx++) {
1055
+ const lyr = layers[layerIdx]
1056
+ g.beginPath()
1057
+ g.globalAlpha = lyr.alpha
1058
+
1059
+ const grad = g.createLinearGradient(0, 0, w, 0)
1060
+ grad.addColorStop(0, color)
1061
+ grad.addColorStop(0.5, '#38bdf8')
1062
+ grad.addColorStop(1, color)
1063
+ g.strokeStyle = grad
1064
+ g.lineWidth = layerIdx === 2 ? 2.5 : 1.5
1065
+
1066
+ g.moveTo(0, midY)
1067
+ const step = 6
1068
+ for (let x = 0; x <= w; x += step) {
1069
+ const edgeDist = Math.min(x, w - x) / (w * 0.25)
1070
+ const envelope = Math.min(1, Math.max(0, edgeDist))
1071
+ const phase = time * lyr.speed + x * lyr.freq
1072
+ const dy = Math.sin(phase) * lyr.amp * envelope + Math.cos(phase * 0.5) * (lyr.amp * 0.35) * envelope
1073
+ g.lineTo(x, midY + dy)
1074
+ }
1075
+ g.stroke()
1076
+ }
1077
+ g.globalAlpha = 1
1078
+ }
1079
+
1080
+ // 2. Dynamic Orb: интерактивная пульсирующая сфера по центру
1081
+ function drawDynamicOrb(g, w, h, levels, color, time) {
1082
+ const cx = w / 2
1083
+ const cy = h / 2
1084
+ const curLevel = levels.length ? levels[levels.length - 1] : 0
1085
+ const smoothLevel = Math.max(0.05, Math.min(1, curLevel * 2.0))
1086
+
1087
+ g.beginPath()
1088
+ g.globalAlpha = 0.25
1089
+ g.strokeStyle = color
1090
+ g.lineWidth = 1
1091
+ g.moveTo(10, cy)
1092
+ g.lineTo(cx - 35, cy)
1093
+ g.moveTo(cx + 35, cy)
1094
+ g.lineTo(w - 10, cy)
1095
+ g.stroke()
1096
+
1097
+ const rRing = 14 + smoothLevel * 14 + Math.sin(time * 0.08) * 3
1098
+ g.beginPath()
1099
+ g.arc(cx, cy, rRing, 0, Math.PI * 2)
1100
+ g.strokeStyle = '#38bdf8'
1101
+ g.globalAlpha = 0.35 + smoothLevel * 0.4
1102
+ g.lineWidth = 1.5
1103
+ g.stroke()
1104
+
1105
+ if (smoothLevel > 0.25) {
1106
+ g.beginPath()
1107
+ g.arc(cx, cy, rRing + 7 + Math.cos(time * 0.06) * 4, 0, Math.PI * 2)
1108
+ g.strokeStyle = color
1109
+ g.globalAlpha = 0.2 + smoothLevel * 0.3
1110
+ g.lineWidth = 1
1111
+ g.stroke()
1112
+ }
1113
+
1114
+ const rCore = 6 + smoothLevel * 8 + Math.sin(time * 0.12) * 1.5
1115
+ const radial = g.createRadialGradient(cx, cy, 1, cx, cy, rCore + 4)
1116
+ radial.addColorStop(0, '#ffffff')
1117
+ radial.addColorStop(0.4, '#38bdf8')
1118
+ radial.addColorStop(1, color)
1119
+ g.beginPath()
1120
+ g.arc(cx, cy, rCore, 0, Math.PI * 2)
1121
+ g.fillStyle = radial
1122
+ g.globalAlpha = 0.95
1123
+ g.fill()
1124
+
1125
+ g.globalAlpha = 1
1126
+ }
1127
+
1128
+ // 3. Classic Bars: классические вертикальные столбики
1129
+ function drawClassicBars(g, w, h, levels, color) {
1130
+ const midY = h / 2
1131
+ for (let i = 0; i < levels.length && i * 7 < w; i++) {
1132
+ const level = levels[levels.length - 1 - i]
1133
+ const age = i / levels.length
1134
+ const x = w - 10 - i * 7
1135
+ const hh = Math.max(2.5, level * (h - 6) * 0.5 * (1 - age * 0.35))
1136
+ g.globalAlpha = 1 - age * 0.75
1137
+ g.fillStyle = color
1138
+ g.fillRect(x, midY - hh, 3.5, hh * 2)
1139
+ }
1140
+ g.globalAlpha = 1
1141
+ }
1142
+
1001
1143
  function RecordPill(props) {
1002
1144
  const v = useVoice()
1003
1145
  const canvasRef = React.useRef(null)
@@ -1050,9 +1192,10 @@ window.__ModuleLoader__.load({
1050
1192
  return () => dispose()
1051
1193
  }, [v.phase])
1052
1194
 
1053
- // Осциллограмма.
1195
+ // Анимация осциллограммы / визуализатора.
1054
1196
  React.useEffect(() => {
1055
1197
  if (v.phase !== 'recording') return
1198
+ let frame = 0
1056
1199
  const dispose = ctx.interval(() => {
1057
1200
  const canvas = canvasRef.current
1058
1201
  if (!canvas) return
@@ -1063,18 +1206,30 @@ window.__ModuleLoader__.load({
1063
1206
  try { voice.waveColor = getComputedStyle(canvas).color || '#fff' } catch (e) { voice.waveColor = '#fff' }
1064
1207
  }
1065
1208
  const levels = voice.levels
1066
- const midY = h / 2
1067
- for (let i = 0; i < levels.length && i * 7 < w; i++) {
1068
- const level = levels[levels.length - 1 - i]
1069
- const age = i / levels.length
1070
- const x = w - 10 - i * 7
1071
- const hh = Math.max(2.5, level * (h - 6) * 0.5 * (1 - age * 0.35))
1072
- g.globalAlpha = 1 - age * 0.75
1073
- g.fillStyle = voice.waveColor
1074
- g.fillRect(x, midY - hh, 3.5, hh * 2)
1209
+ const style = (voice.settings && voice.settings.visualizerStyle) || 'liquid-wave'
1210
+ frame++
1211
+
1212
+ if (style === 'off') {
1213
+ g.beginPath()
1214
+ g.globalAlpha = 0.25
1215
+ g.strokeStyle = voice.waveColor
1216
+ g.lineWidth = 1
1217
+ g.moveTo(10, h / 2)
1218
+ g.lineTo(w - 10, h / 2)
1219
+ g.stroke()
1220
+ g.globalAlpha = 1
1221
+ return
1075
1222
  }
1076
- g.globalAlpha = 1
1077
- }, 50)
1223
+
1224
+ if (style === 'dynamic-orb') {
1225
+ drawDynamicOrb(g, w, h, levels, voice.waveColor, frame)
1226
+ } else if (style === 'bars') {
1227
+ drawClassicBars(g, w, h, levels, voice.waveColor)
1228
+ } else {
1229
+ // default: liquid-wave
1230
+ drawLiquidWave(g, w, h, levels, voice.waveColor, frame)
1231
+ }
1232
+ }, 33)
1078
1233
  return () => dispose()
1079
1234
  }, [v.phase])
1080
1235
 
@@ -1245,6 +1400,7 @@ window.__ModuleLoader__.load({
1245
1400
  sessionCommands: !!(data && data.modes && data.modes.message && data.modes.message.sessionCommands),
1246
1401
  noiseSuppression: data && data.noiseSuppression !== false,
1247
1402
  contextGlossary: data && data.contextGlossary !== false,
1403
+ visualizerStyle: (data && data.visualizerStyle) || 'liquid-wave',
1248
1404
  })
1249
1405
  })
1250
1406
  .catch(() => { /* без подсказки хоста клавиши просто не будет */ })
@@ -1271,7 +1427,7 @@ window.__ModuleLoader__.load({
1271
1427
 
1272
1428
  // ------------------------------------------------------- settings page
1273
1429
  const BUILTIN = [
1274
- 'browser', 'deepgram', 'groq', 'hf', 'local-whisper',
1430
+ 'browser', 'deepgram', 'groq', 'hf', 'local-whisper', 'sensevoice',
1275
1431
  // Заготовки: адрес и модель уже прописаны на хосте, нужен только ключ.
1276
1432
  'openai', 'siliconflow', 'deepinfra', 'fireworks', 'mistral', 'openrouter',
1277
1433
  ]
@@ -1286,6 +1442,7 @@ window.__ModuleLoader__.load({
1286
1442
  openrouter: t('openrouterHint'),
1287
1443
  deepgram: 'nova-2', groq: 'whisper-large-v3-turbo',
1288
1444
  hf: 'openai/whisper-large-v3', 'local-whisper': t('localHint'),
1445
+ sensevoice: t('sensevoiceHint'),
1289
1446
  }
1290
1447
  const LANGS = ['auto', 'ru', 'en', 'uk', 'de']
1291
1448
 
@@ -1583,6 +1740,7 @@ window.__ModuleLoader__.load({
1583
1740
  vadAdapt: Number(draft.dictation && draft.dictation.vadAdapt) || 0,
1584
1741
  noiseSuppression: draft.noiseSuppression !== false,
1585
1742
  contextGlossary: draft.contextGlossary !== false,
1743
+ visualizerStyle: draft.visualizerStyle || 'liquid-wave',
1586
1744
  })
1587
1745
  // Композер держит обработчик клавиши: пусть перечитает настройку,
1588
1746
  // иначе новая клавиша заработает только после перезагрузки страницы.
@@ -1763,6 +1921,16 @@ window.__ModuleLoader__.load({
1763
1921
  type: 'checkbox', checked: draft ? draft.contextGlossary !== false : true, disabled: !writable,
1764
1922
  onChange: (e) => setTop('contextGlossary', e.target.checked),
1765
1923
  })),
1924
+ React.createElement('label', { className: 'dvs-field', title: t('visualizerStyleHint') }, t('visualizerStyle'),
1925
+ React.createElement('select', {
1926
+ value: String((draft && draft.visualizerStyle) || 'liquid-wave'), disabled: !writable,
1927
+ onChange: (e) => setTop('visualizerStyle', e.target.value),
1928
+ },
1929
+ React.createElement('option', { value: 'liquid-wave' }, t('visLiquidWave')),
1930
+ React.createElement('option', { value: 'dynamic-orb' }, t('visDynamicOrb')),
1931
+ React.createElement('option', { value: 'bars' }, t('visBars')),
1932
+ React.createElement('option', { value: 'off' }, t('visOff')),
1933
+ )),
1766
1934
  React.createElement('label', { className: 'dvs-field' }, t('vocabulary'),
1767
1935
  React.createElement('textarea', {
1768
1936
  rows: 3, disabled: !writable,
@@ -1785,6 +1953,36 @@ window.__ModuleLoader__.load({
1785
1953
  onChange: (e) => setTop('polishKeyEnv', e.target.value),
1786
1954
  })),
1787
1955
  ),
1956
+ React.createElement('div', { className: 'dvs-block' },
1957
+ React.createElement('div', { className: 'dvs-h' }, 'SenseVoice-ONNX / Sherpa-ONNX'),
1958
+ React.createElement('div', { className: 'dvs-sub' }, t('sensevoiceHint')),
1959
+ textField('sensevoiceUrl', t('sensevoiceEndpoint'), t('sensevoiceEndpointHint')),
1960
+ textField('sensevoiceBin', t('sensevoiceBin'), t('sensevoiceBinHint')),
1961
+ textField('sensevoiceModel', t('sensevoiceModel'), t('sensevoiceBinHint')),
1962
+ React.createElement('label', { className: 'dvs-field' }, t('sensevoiceAutostart'),
1963
+ React.createElement('input', {
1964
+ type: 'checkbox', checked: !!(draft && draft.sensevoiceAutostart), disabled: !writable,
1965
+ onChange: (e) => setTop('sensevoiceAutostart', e.target.checked),
1966
+ })),
1967
+ ),
1968
+ React.createElement('div', { className: 'dvs-block' },
1969
+ React.createElement('div', { className: 'dvs-h' }, t('realtimeStreaming')),
1970
+ React.createElement('div', { className: 'dvs-sub' }, t('realtimeStreamingHint')),
1971
+ React.createElement('label', { className: 'dvs-field' }, t('realtimeStreaming'),
1972
+ React.createElement('input', {
1973
+ type: 'checkbox', checked: !!(draft && draft.realtimeStreaming), disabled: !writable,
1974
+ onChange: (e) => setTop('realtimeStreaming', e.target.checked),
1975
+ })),
1976
+ React.createElement('label', { className: 'dvs-field' }, t('realtimeProvider'),
1977
+ React.createElement('select', {
1978
+ value: String((draft && draft.realtimeProvider) || 'openai'), disabled: !writable,
1979
+ onChange: (e) => setTop('realtimeProvider', e.target.value),
1980
+ },
1981
+ React.createElement('option', { value: 'openai' }, 'OpenAI Realtime API'),
1982
+ React.createElement('option', { value: 'sherpa-onnx' }, 'Sherpa-ONNX (local)'),
1983
+ )),
1984
+ textField('realtimeModel', t('realtimeModel'), ''),
1985
+ ),
1788
1986
  React.createElement('div', { className: 'dvs-block' },
1789
1987
  React.createElement('div', { className: 'dvs-h' }, t('providerDashboard')),
1790
1988
  React.createElement('div', { className: 'dvo-dash' },
package/lib/index.js CHANGED
@@ -144,6 +144,22 @@ 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.'),
161
+ visualizerStyle: z.enum(['liquid-wave', 'dynamic-orb', 'bars', 'off']).default('liquid-wave')
162
+ .description('Audio visualizer animation style in the recording pill: "liquid-wave", "dynamic-orb", "bars", or "off".'),
147
163
  })
148
164
 
149
165
  const MIME_BY_EXT = {
@@ -232,6 +248,47 @@ export function apply(ctx, baseConfig) {
232
248
 
233
249
  startWhisper().catch(() => {})
234
250
 
251
+ // SenseVoice-ONNX / Sherpa-ONNX: автозапуск и проверка здоровья.
252
+ let sensevoiceChild = null
253
+
254
+ async function sensevoiceAlive() {
255
+ try {
256
+ const cfg = live()
257
+ const base = String(cfg.sensevoiceUrl || '').replace(/\/api\/.*$/, '').replace(/\/v1\/.*$/, '').replace(/\/+$/, '')
258
+ if (!base) return false
259
+ const controller = new AbortController()
260
+ const t = setTimeout(() => controller.abort(), 2000)
261
+ const res = await fetch(base + '/', { signal: controller.signal })
262
+ clearTimeout(t)
263
+ return res.ok || res.status === 404 // sherpa-onnx отвечает 404 на /, но живой
264
+ } catch { return false }
265
+ }
266
+
267
+ async function startSensevoice() {
268
+ const cfg = live()
269
+ if (!cfg.sensevoiceAutostart) return false
270
+ if (!cfg.sensevoiceModel) return false
271
+ if (await sensevoiceAlive()) return true
272
+ try {
273
+ const port = new URL(cfg.sensevoiceUrl).port || '6006'
274
+ const spec = ctx.shell.resolve({
275
+ command: `${JSON.stringify(cfg.sensevoiceBin)}`
276
+ + ` --sense-voice-model=${JSON.stringify(cfg.sensevoiceModel)}`
277
+ + ` --port=${port} --num-threads=4`,
278
+ timeoutMs: 0,
279
+ stdoutMaxBytes: 4 * 1024 * 1024,
280
+ })
281
+ sensevoiceChild = ctx.shell.start(spec)
282
+ for (let i = 0; i < 20; i++) {
283
+ await new Promise((r) => setTimeout(r, 500))
284
+ if (await sensevoiceAlive()) return true
285
+ }
286
+ return false
287
+ } catch { return false }
288
+ }
289
+
290
+ startSensevoice().catch(() => {})
291
+
235
292
  // Статистика здоровья и задержек провайдеров (Latency & Health Dashboard).
236
293
  const statsTracker = createStatsTracker()
237
294
 
@@ -379,6 +436,12 @@ export function apply(ctx, baseConfig) {
379
436
  polishBaseUrl: String(cfg.polishBaseUrl || ''),
380
437
  noiseSuppression: cfg.noiseSuppression !== false,
381
438
  contextGlossary: cfg.contextGlossary !== false,
439
+ sensevoiceUrl: cfg.sensevoiceUrl,
440
+ sensevoiceAutostart: !!cfg.sensevoiceAutostart,
441
+ realtimeStreaming: !!cfg.realtimeStreaming,
442
+ realtimeProvider: cfg.realtimeProvider || 'openai',
443
+ realtimeModel: cfg.realtimeModel || 'gpt-4o-realtime-preview',
444
+ visualizerStyle: cfg.visualizerStyle || 'liquid-wave',
382
445
  providerStats: getProviderStats(),
383
446
  })
384
447
  },
@@ -441,6 +504,10 @@ export function apply(ctx, baseConfig) {
441
504
  if ((modeCfg.chain || []).some((e) => e.provider === 'local-whisper') && !(await whisperAlive())) {
442
505
  await startWhisper()
443
506
  }
507
+ // Аналогично для SenseVoice-ONNX / Sherpa-ONNX.
508
+ if ((modeCfg.chain || []).some((e) => e.provider === 'sensevoice') && !(await sensevoiceAlive())) {
509
+ await startSensevoice()
510
+ }
444
511
 
445
512
  const controller = new AbortController()
446
513
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
@@ -515,7 +582,226 @@ export function apply(ctx, baseConfig) {
515
582
  }),
516
583
  )
517
584
 
585
+ // ─── Realtime WebSocket Bridge ───────────────────────────────────────
586
+ // Клиент открывает WS на /dsh-voice/realtime, шлёт бинарные аудио-чанки.
587
+ // Хост открывает WS к провайдеру (OpenAI Realtime API или совместимому),
588
+ // пересылает аудио и получает текстовые дельты обратно клиенту.
589
+ // API-ключи никогда не покидают хост.
590
+ const realtimeSessions = new Set()
591
+
592
+ ctx.effect(() => ctx.webServer.register({
593
+ kind: 'exact',
594
+ path: '/dsh-voice/realtime',
595
+ handler: async (req, res) => {
596
+ const cfg = live()
597
+ if (!cfg.realtimeStreaming) {
598
+ writeJson(res, 403, { ok: false, error: { code: 'disabled', message: 'realtime streaming is disabled' } })
599
+ return
600
+ }
601
+ // Только WebSocket upgrade
602
+ if (!req.headers.upgrade || req.headers.upgrade.toLowerCase() !== 'websocket') {
603
+ writeJson(res, 426, { ok: false, error: { code: 'upgrade', message: 'WebSocket upgrade required' } })
604
+ return
605
+ }
606
+
607
+ // Получаем API-ключ для upstream-провайдера
608
+ const provider = cfg.realtimeProvider || 'openai'
609
+ let apiKey = ''
610
+ if (provider === 'openai') {
611
+ apiKey = await resolveKey('OPENAI_API_KEY')
612
+ }
613
+
614
+ // Определяем upstream WebSocket URL
615
+ let upstreamUrl
616
+ const model = cfg.realtimeModel || 'gpt-4o-realtime-preview'
617
+ if (provider === 'openai') {
618
+ upstreamUrl = `wss://api.openai.com/v1/realtime?model=${encodeURIComponent(model)}`
619
+ } else if (provider === 'sherpa-onnx') {
620
+ // Локальный sherpa-onnx WebSocket endpoint
621
+ const base = String(cfg.sensevoiceUrl || '').replace(/^http/, 'ws').replace(/\/api\/.*$/, '').replace(/\/v1\/.*$/, '')
622
+ upstreamUrl = base + '/ws/transcribe'
623
+ } else {
624
+ writeJson(res, 400, { ok: false, error: { code: 'provider', message: `unknown realtime provider: ${provider}` } })
625
+ return
626
+ }
627
+
628
+ // Upgrade клиентский запрос в WebSocket через ручной handshake
629
+ // Node.js http.Server не имеет встроенного WS — используем raw socket
630
+ const crypto = await import('node:crypto')
631
+ const wsKey = req.headers['sec-websocket-key']
632
+ if (!wsKey) {
633
+ res.writeHead(400); res.end('Missing Sec-WebSocket-Key'); return
634
+ }
635
+ const accept = crypto.createHash('sha1')
636
+ .update(wsKey + '258EAFA5-E914-47DA-95CA-5AB5ADF35F20')
637
+ .digest('base64')
638
+
639
+ res.writeHead(101, {
640
+ Upgrade: 'websocket',
641
+ Connection: 'Upgrade',
642
+ 'Sec-WebSocket-Accept': accept,
643
+ })
644
+
645
+ const clientSocket = req.socket
646
+
647
+ // Простой WS frame parser/writer для бинарных и текстовых сообщений
648
+ function writeWsFrame(socket, data, opcode = 0x01) {
649
+ const payload = typeof data === 'string' ? Buffer.from(data, 'utf8') : data
650
+ const len = payload.length
651
+ let header
652
+ if (len < 126) {
653
+ header = Buffer.alloc(2)
654
+ header[0] = 0x80 | opcode
655
+ header[1] = len
656
+ } else if (len < 65536) {
657
+ header = Buffer.alloc(4)
658
+ header[0] = 0x80 | opcode
659
+ header[1] = 126
660
+ header.writeUInt16BE(len, 2)
661
+ } else {
662
+ header = Buffer.alloc(10)
663
+ header[0] = 0x80 | opcode
664
+ header[1] = 127
665
+ header.writeBigUInt64BE(BigInt(len), 2)
666
+ }
667
+ socket.write(Buffer.concat([header, payload]))
668
+ }
669
+
670
+ // Открываем upstream WebSocket через глобальный WebSocket (Node 22+)
671
+ let upstream = null
672
+ try {
673
+ const headers = {}
674
+ if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
675
+ if (provider === 'openai') headers['OpenAI-Beta'] = 'realtime=v1'
676
+ upstream = new WebSocket(upstreamUrl, { headers })
677
+ } catch (e) {
678
+ writeWsFrame(clientSocket, JSON.stringify({ type: 'error', message: String(e && e.message || e) }))
679
+ clientSocket.end()
680
+ return
681
+ }
682
+
683
+ const sessionId = crypto.randomUUID()
684
+ realtimeSessions.add(sessionId)
685
+
686
+ upstream.addEventListener('open', () => {
687
+ writeWsFrame(clientSocket, JSON.stringify({ type: 'session.created', sessionId }))
688
+ // Для OpenAI Realtime — отправить session.update с конфигурацией
689
+ if (provider === 'openai') {
690
+ upstream.send(JSON.stringify({
691
+ type: 'session.update',
692
+ session: {
693
+ modalities: ['text'],
694
+ input_audio_format: 'pcm16',
695
+ input_audio_transcription: { model: 'whisper-1' },
696
+ turn_detection: { type: 'server_vad' },
697
+ },
698
+ }))
699
+ }
700
+ })
701
+
702
+ upstream.addEventListener('message', (event) => {
703
+ // Пересылаем текстовые сообщения от upstream к клиенту
704
+ const msg = typeof event.data === 'string' ? event.data : event.data.toString()
705
+ try {
706
+ const parsed = JSON.parse(msg)
707
+ // Фильтруем и пересылаем только полезные события
708
+ if (parsed.type === 'conversation.item.input_audio_transcription.completed'
709
+ || parsed.type === 'response.audio_transcript.delta'
710
+ || parsed.type === 'response.audio_transcript.done'
711
+ || parsed.type === 'input_audio_buffer.speech_started'
712
+ || parsed.type === 'input_audio_buffer.speech_stopped'
713
+ || parsed.type === 'error') {
714
+ writeWsFrame(clientSocket, msg)
715
+ }
716
+ } catch {
717
+ // Не JSON — пересылаем как есть
718
+ writeWsFrame(clientSocket, msg)
719
+ }
720
+ })
721
+
722
+ upstream.addEventListener('error', () => {
723
+ writeWsFrame(clientSocket, JSON.stringify({ type: 'error', message: 'upstream connection error' }))
724
+ })
725
+
726
+ upstream.addEventListener('close', () => {
727
+ writeWsFrame(clientSocket, JSON.stringify({ type: 'session.closed' }))
728
+ try { clientSocket.end() } catch { /* socket уже закрыт */ }
729
+ realtimeSessions.delete(sessionId)
730
+ })
731
+
732
+ // Парсим входящие WS фреймы от клиента
733
+ let frameBuf = Buffer.alloc(0)
734
+ clientSocket.on('data', (chunk) => {
735
+ frameBuf = Buffer.concat([frameBuf, chunk])
736
+ while (frameBuf.length >= 2) {
737
+ const opcode = frameBuf[0] & 0x0f
738
+ const masked = !!(frameBuf[1] & 0x80)
739
+ let payloadLen = frameBuf[1] & 0x7f
740
+ let offset = 2
741
+ if (payloadLen === 126) {
742
+ if (frameBuf.length < 4) return
743
+ payloadLen = frameBuf.readUInt16BE(2)
744
+ offset = 4
745
+ } else if (payloadLen === 127) {
746
+ if (frameBuf.length < 10) return
747
+ payloadLen = Number(frameBuf.readBigUInt64BE(2))
748
+ offset = 10
749
+ }
750
+ if (masked) offset += 4
751
+ if (frameBuf.length < offset + payloadLen) return
752
+
753
+ let payload = frameBuf.subarray(offset, offset + payloadLen)
754
+ if (masked) {
755
+ const mask = frameBuf.subarray(offset - 4, offset)
756
+ payload = Buffer.from(payload)
757
+ for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3]
758
+ }
759
+ frameBuf = frameBuf.subarray(offset + payloadLen)
760
+
761
+ if (opcode === 0x08) {
762
+ // Close frame
763
+ upstream.close()
764
+ try { clientSocket.end() } catch { /* ok */ }
765
+ realtimeSessions.delete(sessionId)
766
+ return
767
+ }
768
+ if (opcode === 0x09) {
769
+ // Ping → Pong
770
+ writeWsFrame(clientSocket, payload, 0x0a)
771
+ continue
772
+ }
773
+ if (opcode === 0x01) {
774
+ // Текст: JSON команда от клиента → upstream
775
+ try { upstream.send(payload.toString('utf8')) } catch { /* upstream закрыт */ }
776
+ } else if (opcode === 0x02) {
777
+ // Бинарные данные: аудио-чанк → upstream как input_audio_buffer.append
778
+ if (provider === 'openai') {
779
+ upstream.send(JSON.stringify({
780
+ type: 'input_audio_buffer.append',
781
+ audio: payload.toString('base64'),
782
+ }))
783
+ } else {
784
+ // Для sherpa-onnx — отправляем бинарные данные напрямую
785
+ try { upstream.send(payload) } catch { /* upstream закрыт */ }
786
+ }
787
+ }
788
+ }
789
+ })
790
+
791
+ clientSocket.on('close', () => {
792
+ try { upstream.close() } catch { /* ok */ }
793
+ realtimeSessions.delete(sessionId)
794
+ })
795
+ clientSocket.on('error', () => {
796
+ try { upstream.close() } catch { /* ok */ }
797
+ realtimeSessions.delete(sessionId)
798
+ })
799
+ },
800
+ }), 'dsh-voice: /realtime route')
801
+
518
802
  ctx.effect(() => () => {
519
803
  if (child) { try { child.kill && child.kill() } catch { /* уже мёртв */ } }
520
- }, 'dsh-voice: stop whisper child')
804
+ if (sensevoiceChild) { try { sensevoiceChild.kill && sensevoiceChild.kill() } catch { /* уже мёртв */ } }
805
+ for (const sid of realtimeSessions) realtimeSessions.delete(sid)
806
+ }, 'dsh-voice: stop child processes')
521
807
  }
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.12",
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",