@goodandready/dsh-voice 0.8.32 → 0.8.34
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 +1 -3
- package/README.ru.md +0 -2
- package/README.zh.md +0 -1
- package/lib/client.js +18 -2
- package/lib/index.js +14 -16
- package/lib/normalize.js +28 -5
- package/lib/sensevoice-installer.js +14 -20
- package/lib/transcribe-core.js +3 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -91,7 +91,6 @@ graph LR
|
|
|
91
91
|
* 🗣️ **Hands-free Voice Actions** *(0.8.32)*: Spoken commands ("send", "cancel", "clear", "new line") trigger UI actions directly.
|
|
92
92
|
* 🧩 **Structured Prompt Voice Selection** *(0.8.32)*: Spoken options automatically select buttons in interactive assistant choice prompts.
|
|
93
93
|
* 📖 **Developer Lexicon & IT Jargon Correction** *(0.8.32)*: Phonetic normalization for developer slang (GitHub, Docker, Kubernetes, pnpm) with customizable settings dictionary.
|
|
94
|
-
* 🌐 **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.
|
|
95
94
|
* 🌊 **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.
|
|
96
95
|
|
|
97
96
|
---
|
|
@@ -217,8 +216,7 @@ Registers `transcribe_audio(file_path, language?)` in `ctx.tools`, allowing agen
|
|
|
217
216
|
### Internal HTTP Endpoints
|
|
218
217
|
* `POST /dsh-voice/transcribe` — `{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
|
|
219
218
|
* `POST /dsh-voice/polish` — `{ text }` → `{ ok, text }`
|
|
220
|
-
* `GET /dsh-voice/status` — Returns daemon status, active chains, SenseVoice
|
|
221
|
-
* `GET /dsh-voice/realtime` — **WebSocket upgrade** for low-latency audio streaming (OpenAI Realtime API / Sherpa-ONNX). Accepts binary audio chunks, returns JSON text deltas.
|
|
219
|
+
* `GET /dsh-voice/status` — Returns daemon status, active chains, SenseVoice.
|
|
222
220
|
|
|
223
221
|
---
|
|
224
222
|
|
package/README.ru.md
CHANGED
|
@@ -91,7 +91,6 @@ graph LR
|
|
|
91
91
|
* 🗣️ **Голосовые команды управления (Hands-free Actions)** *(0.8.32)*: фразы «отправь», «отмена», «очисти», «с новой строки» выполняют действия напрямую, не попадая в текст сообщения.
|
|
92
92
|
* 🧩 **Голосовой выбор в интерактивах (Structured Prompts)** *(0.8.32)*: произнесение вариантов ответов нажимает соответствующие кнопки в активных модальных окнах ассистента.
|
|
93
93
|
* 📖 **Словарь IT-жаргона и сленга разработчиков** *(0.8.32)*: фонетическое исправление сленга (гитхаб -> GitHub, кубер -> Kubernetes, пнпм -> pnpm) с возможностью добавления своих правил в настройках.
|
|
94
|
-
* 🌐 **Потоковое аудио в реальном времени** *(0.8.11)*: низколатентный WebSocket-мост (`/dsh-voice/realtime`) для OpenAI Realtime API или локального Sherpa-ONNX. API-ключи остаются на хосте.
|
|
95
94
|
* 🌊 **Анимированные визуализаторы Liquid Wave и Dynamic Orb** *(0.8.12)*: живая интерактивная анимация звуковой волны в полоске записи с откликом на громкость микрофона. Выбор стиля: текучая волна, пульсирующая сфера, классические столбики или выключен.
|
|
96
95
|
|
|
97
96
|
---
|
|
@@ -218,7 +217,6 @@ dsh plugin --profile web add @goodandready/dsh-voice
|
|
|
218
217
|
* `POST /dsh-voice/transcribe` — `{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
|
|
219
218
|
* `POST /dsh-voice/polish` — `{ text }` → `{ ok, text }`
|
|
220
219
|
* `GET /dsh-voice/status` — состояние демонов, цепочки, конфигурация SenseVoice и реалтайма.
|
|
221
|
-
* `GET /dsh-voice/realtime` — **WebSocket upgrade** для низколатентного аудио-стриминга (OpenAI Realtime API / Sherpa-ONNX). Принимает бинарные аудио-чанки, возвращает JSON-дельты текста.
|
|
222
220
|
|
|
223
221
|
---
|
|
224
222
|
|
package/README.zh.md
CHANGED
|
@@ -85,7 +85,6 @@ graph LR
|
|
|
85
85
|
* 🔒 **API 密钥安全隔离**:密钥由服务端 `ctx.credentials` 统一解析,绝不向浏览器前端泄漏。
|
|
86
86
|
* 🖥️ **本地 whisper.cpp 服务端直连**:自动管理 `whisper-server` 进程,结合 `ffmpeg` 实现实时音频转码。
|
|
87
87
|
* ⚡ **SenseVoice-ONNX / Sherpa-ONNX** *(0.8.11)*:超快速(~50–100ms)非自回归本地语音识别引擎,自动清除情绪/事件标签。支持 Sherpa-ONNX HTTP 和 OpenAI 兼容端点。
|
|
88
|
-
* 🌐 **实时音频流** *(0.8.11)*:低延迟 WebSocket 桥接(`/dsh-voice/realtime`),支持 OpenAI Realtime API 或本地 Sherpa-ONNX 流式传输。API 密钥安全保留在服务端。
|
|
89
88
|
* 🌊 **Liquid Wave 与 Dynamic Orb 动态可视化** *(0.8.12)*:录音面板内的生动波形动态可视化,实时响应麦克风音量。可选平滑流动波浪、发光脉冲球体、经典频条或关闭。
|
|
90
89
|
|
|
91
90
|
---
|
package/lib/client.js
CHANGED
|
@@ -521,7 +521,7 @@ window.__ModuleLoader__.load({
|
|
|
521
521
|
window.dispatchEvent(new CustomEvent('dsh:tts:stop', { bubbles: true }))
|
|
522
522
|
const audios = document.querySelectorAll('audio')
|
|
523
523
|
for (const a of audios) {
|
|
524
|
-
if (!a.paused) { try { a.pause() } catch {} }
|
|
524
|
+
if (!a.paused) { try { a.pause() } catch (err) { /* best effort audio pause */ } }
|
|
525
525
|
}
|
|
526
526
|
} catch { /* best effort */ }
|
|
527
527
|
voice.set({ isTtsSpeaking: false })
|
|
@@ -3109,7 +3109,23 @@ window.__ModuleLoader__.load({
|
|
|
3109
3109
|
}
|
|
3110
3110
|
|
|
3111
3111
|
function registerSettings(ctx) {
|
|
3112
|
-
//
|
|
3112
|
+
// List seat (plugins.item): the seat the Plugins page renders as the plugin's
|
|
3113
|
+
// own page with its configuration — the host draws the title, icon and crumb
|
|
3114
|
+
// and asks for view 'summary' (the card's one-liner) or view 'page' (the form).
|
|
3115
|
+
// The label is a static string on purpose: it is resolved while the page
|
|
3116
|
+
// renders, and a locale lookup there would take the whole client batch down.
|
|
3117
|
+
ctx.slots.inject('plugins.item', () => ctx.slots.register(
|
|
3118
|
+
{
|
|
3119
|
+
name: 'plugins.item',
|
|
3120
|
+
id: ROW_ID,
|
|
3121
|
+
order: 60,
|
|
3122
|
+
label: () => 'Voice input',
|
|
3123
|
+
locale: NS,
|
|
3124
|
+
inject: () => ({ ctx: ctx }),
|
|
3125
|
+
},
|
|
3126
|
+
PluginCard,
|
|
3127
|
+
))
|
|
3128
|
+
// Row seat and the legacy card stay as fallbacks.
|
|
3113
3129
|
ctx.slots.inject('plugins.row.config', () => ctx.slots.register(
|
|
3114
3130
|
{
|
|
3115
3131
|
name: 'plugins.row.config',
|
package/lib/index.js
CHANGED
|
@@ -213,16 +213,15 @@ export function apply(ctx, baseConfig) {
|
|
|
213
213
|
}
|
|
214
214
|
|
|
215
215
|
async function whisperAlive() {
|
|
216
|
+
const rawUrl = String(live().whisperUrl || '').trim()
|
|
217
|
+
const base = rawUrl.includes('/inference') ? rawUrl.split('/inference')[0] : rawUrl.replace(/\/+$/, '')
|
|
218
|
+
if (!base) return false
|
|
216
219
|
try {
|
|
217
|
-
const
|
|
218
|
-
const t = setTimeout(() => controller.abort(), 2000)
|
|
219
|
-
const rawUrl = String(live().whisperUrl || '').trim()
|
|
220
|
-
const base = rawUrl.includes('/inference') ? rawUrl.split('/inference')[0] : rawUrl.replace(/\/+$/, '')
|
|
221
|
-
if (!base) return false
|
|
222
|
-
const res = await fetch(base + '/', { signal: controller.signal })
|
|
223
|
-
clearTimeout(t)
|
|
220
|
+
const res = await fetch(base + '/', { signal: AbortSignal.timeout(2000) })
|
|
224
221
|
return res.ok
|
|
225
|
-
} catch {
|
|
222
|
+
} catch {
|
|
223
|
+
return false
|
|
224
|
+
}
|
|
226
225
|
}
|
|
227
226
|
|
|
228
227
|
let whisperError = null
|
|
@@ -270,16 +269,15 @@ export function apply(ctx, baseConfig) {
|
|
|
270
269
|
let sensevoiceError = null
|
|
271
270
|
|
|
272
271
|
async function sensevoiceAlive() {
|
|
272
|
+
const cfg = live()
|
|
273
|
+
const base = String(cfg.sensevoiceUrl || '').replace(/\/api\/.*$/, '').replace(/\/v1\/.*$/, '').replace(/\/+$/, '')
|
|
274
|
+
if (!base) return false
|
|
273
275
|
try {
|
|
274
|
-
const
|
|
275
|
-
const base = String(cfg.sensevoiceUrl || '').replace(/\/api\/.*$/, '').replace(/\/v1\/.*$/, '').replace(/\/+$/, '')
|
|
276
|
-
if (!base) return false
|
|
277
|
-
const controller = new AbortController()
|
|
278
|
-
const t = setTimeout(() => controller.abort(), 2000)
|
|
279
|
-
const res = await fetch(base + '/', { signal: controller.signal })
|
|
280
|
-
clearTimeout(t)
|
|
276
|
+
const res = await fetch(base + '/', { signal: AbortSignal.timeout(2000) })
|
|
281
277
|
return res.ok || res.status === 404 // sherpa-onnx answers 404 on / but is alive
|
|
282
|
-
} catch {
|
|
278
|
+
} catch {
|
|
279
|
+
return false
|
|
280
|
+
}
|
|
283
281
|
}
|
|
284
282
|
|
|
285
283
|
let startingSensevoice = false
|
package/lib/normalize.js
CHANGED
|
@@ -87,15 +87,38 @@ export const DEFAULT_JARGON_DICTIONARY = [
|
|
|
87
87
|
{ from: 'ворктри', to: 'worktree' },
|
|
88
88
|
]
|
|
89
89
|
|
|
90
|
+
function jargonRegex(from) {
|
|
91
|
+
const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
92
|
+
return new RegExp(`(?<![a-zA-Zа-яА-ЯёЁ0-9_-])${escaped}(?![a-zA-Zа-яА-ЯёЁ0-9_-])`, 'gi')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const DEFAULT_JARGON_REGEX = DEFAULT_JARGON_DICTIONARY
|
|
96
|
+
.filter((item) => item && item.from && item.to)
|
|
97
|
+
.map((item) => ({ re: jargonRegex(item.from), to: item.to }))
|
|
98
|
+
|
|
99
|
+
const customJargonRegex = new Map()
|
|
100
|
+
|
|
101
|
+
function cachedJargonRegex(from) {
|
|
102
|
+
let re = customJargonRegex.get(from)
|
|
103
|
+
if (!re) {
|
|
104
|
+
re = jargonRegex(from)
|
|
105
|
+
customJargonRegex.set(from, re)
|
|
106
|
+
}
|
|
107
|
+
return re
|
|
108
|
+
}
|
|
109
|
+
|
|
90
110
|
export function applyJargonDictionary(text, customReplacements = []) {
|
|
91
111
|
if (!text || typeof text !== 'string') return text || ''
|
|
92
|
-
const dict = [...(customReplacements || []), ...DEFAULT_JARGON_DICTIONARY]
|
|
93
112
|
let result = text
|
|
94
|
-
for (const item of
|
|
113
|
+
for (const item of customReplacements || []) {
|
|
95
114
|
if (!item?.from || !item?.to) continue
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
result = result.replace(
|
|
115
|
+
const re = cachedJargonRegex(item.from)
|
|
116
|
+
re.lastIndex = 0
|
|
117
|
+
result = result.replace(re, item.to)
|
|
118
|
+
}
|
|
119
|
+
for (const item of DEFAULT_JARGON_REGEX) {
|
|
120
|
+
item.re.lastIndex = 0
|
|
121
|
+
result = result.replace(item.re, item.to)
|
|
99
122
|
}
|
|
100
123
|
return result
|
|
101
124
|
}
|
|
@@ -6,6 +6,8 @@ import { mkdir, stat } from 'node:fs/promises'
|
|
|
6
6
|
import { homedir } from 'node:os'
|
|
7
7
|
import path from 'node:path'
|
|
8
8
|
import { spawn } from 'node:child_process'
|
|
9
|
+
import { Readable, Transform } from 'node:stream'
|
|
10
|
+
import { pipeline } from 'node:stream/promises'
|
|
9
11
|
import { writeJson, isTrustedCaller } from './http-util.js'
|
|
10
12
|
|
|
11
13
|
export const SENSEVOICE_MODEL_URL =
|
|
@@ -78,29 +80,21 @@ export async function getSensevoiceStatus(cfg, isAliveFn) {
|
|
|
78
80
|
export async function downloadArchive(url, destPath, onProgress) {
|
|
79
81
|
const res = await fetch(url, { signal: AbortSignal.timeout(600_000) })
|
|
80
82
|
if (!res.ok) throw new Error(`Download failed with HTTP ${res.status}`)
|
|
83
|
+
if (!res.body) throw new Error('Download failed with an empty body')
|
|
81
84
|
|
|
82
85
|
const totalBytes = Number(res.headers.get('content-length')) || 150_000_000
|
|
83
86
|
let receivedBytes = 0
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (typeof onProgress === 'function') {
|
|
94
|
-
const pct = Math.min(95, Math.round((receivedBytes / totalBytes) * 90))
|
|
95
|
-
onProgress(pct, `downloading (${Math.round(receivedBytes / 1024 / 1024)} MB)`)
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
await new Promise((resolve, reject) => {
|
|
100
|
-
fileStream.end()
|
|
101
|
-
fileStream.on('finish', resolve)
|
|
102
|
-
fileStream.on('error', reject)
|
|
87
|
+
const progress = new Transform({
|
|
88
|
+
transform(chunk, _enc, cb) {
|
|
89
|
+
receivedBytes += chunk.length
|
|
90
|
+
if (typeof onProgress === 'function') {
|
|
91
|
+
const pct = Math.min(95, Math.round((receivedBytes / totalBytes) * 90))
|
|
92
|
+
onProgress(pct, `downloading (${Math.round(receivedBytes / 1024 / 1024)} MB)`)
|
|
93
|
+
}
|
|
94
|
+
cb(null, chunk)
|
|
95
|
+
},
|
|
103
96
|
})
|
|
97
|
+
await pipeline(Readable.fromWeb(res.body), progress, createWriteStream(destPath))
|
|
104
98
|
}
|
|
105
99
|
|
|
106
100
|
export function extractTarBz2(archivePath, targetDir) {
|
|
@@ -191,7 +185,7 @@ export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSens
|
|
|
191
185
|
export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateConfig, startSensevoice }) {
|
|
192
186
|
if (!ctx.webServer || typeof ctx.webServer.register !== 'function') return
|
|
193
187
|
|
|
194
|
-
ctx.webServer.register({
|
|
188
|
+
return ctx.webServer.register({
|
|
195
189
|
kind: 'exact',
|
|
196
190
|
path: '/dsh-voice/sensevoice-installer',
|
|
197
191
|
handler: async (req, res) => {
|
package/lib/transcribe-core.js
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* @returns {{order: string[], models: Record<string, string>}}
|
|
12
12
|
*/
|
|
13
13
|
export function buildProviderOrder({ localOnly, chain, customKeys, knownKeys, defaultModels }) {
|
|
14
|
+
const localEngines = new Set(['local-whisper', 'sensevoice'])
|
|
14
15
|
const source = localOnly
|
|
15
|
-
? (chain || []).filter((e) => e && e.provider
|
|
16
|
+
? (chain || []).filter((e) => e && localEngines.has(e.provider))
|
|
16
17
|
: (chain || [])
|
|
17
18
|
const models = {}
|
|
18
19
|
const order = []
|
|
@@ -23,7 +24,7 @@ export function buildProviderOrder({ localOnly, chain, customKeys, knownKeys, de
|
|
|
23
24
|
models[entry.provider] = entry.model || defaultModels[entry.provider] || ''
|
|
24
25
|
}
|
|
25
26
|
if (localOnly && order.length === 0) {
|
|
26
|
-
throw new Error('localOnly mode is on, but local-whisper is
|
|
27
|
+
throw new Error('localOnly mode is on, but no local engine (local-whisper or sensevoice) is in the chain')
|
|
27
28
|
}
|
|
28
29
|
return { order, models }
|
|
29
30
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.34",
|
|
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",
|