@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 +7 -1
- package/README.ru.md +7 -1
- package/README.zh.md +4 -0
- package/lib/client.js +211 -13
- package/lib/index.js +287 -1
- package/lib/providers.js +62 -2
- package/package.json +1 -1
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
|
-
* `
|
|
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
|
-
* `
|
|
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
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
g.
|
|
1073
|
-
g.
|
|
1074
|
-
g.
|
|
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
|
-
|
|
1077
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|