@goodandready/dsh-voice 0.8.33 → 0.8.35

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
@@ -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 and realtime config.
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 })
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 controller = new AbortController()
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 { return false }
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 cfg = live()
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 { return false }
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 dict) {
113
+ for (const item of customReplacements || []) {
95
114
  if (!item?.from || !item?.to) continue
96
- const escaped = item.from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
97
- const regex = new RegExp(`(?<![a-zA-Zа-яА-ЯёЁ0-9_-])${escaped}(?![a-zA-Zа-яА-ЯёЁ0-9_-])`, 'gi')
98
- result = result.replace(regex, item.to)
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,34 +80,26 @@ 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
- const fileStream = createWriteStream(destPath)
86
- const reader = res.body.getReader()
87
-
88
- while (true) {
89
- const { done, value } = await reader.read()
90
- if (done) break
91
- fileStream.write(value)
92
- receivedBytes += value.length
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) {
107
101
  return new Promise((resolve, reject) => {
108
- const child = spawn('tar', ['-xjf', archivePath, '-C', targetDir])
102
+ const child = spawn('tar', ['-xjf', archivePath, '-C', targetDir], { windowsHide: true })
109
103
  let stderr = ''
110
104
  child.stderr.on('data', (d) => { stderr += d })
111
105
  child.on('close', (code) => {
@@ -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) => {
@@ -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 === 'local-whisper')
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 not in the chain')
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/lib/wav.js CHANGED
@@ -22,7 +22,9 @@ export function toWav16k(bytes, ffmpegBin = 'ffmpeg', signal = null) {
22
22
  '-i', 'pipe:0',
23
23
  '-ar', '16000', '-ac', '1',
24
24
  '-f', 'wav', 'pipe:1',
25
- ])
25
+ ], {
26
+ windowsHide: true,
27
+ })
26
28
  const out = []
27
29
  const err = []
28
30
  let done = false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.8.33",
3
+ "version": "0.8.35",
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",