@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 +6 -1
- package/README.ru.md +6 -1
- package/README.zh.md +3 -0
- package/lib/client.js +60 -1
- package/lib/index.js +284 -1
- package/lib/providers.js +62 -2
- package/package.json +1 -1
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
|
-
* `
|
|
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
|
-
* `
|
|
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
|
-
|
|
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.
|
|
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",
|