@goodandready/dsh-voice 0.9.27 → 0.9.29

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
@@ -219,7 +219,7 @@ Registers `transcribe_audio(file_path, language?)` in `ctx.tools`, allowing agen
219
219
  ### Internal HTTP Endpoints
220
220
  * `POST /dsh-voice/transcribe` — `{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
221
221
  * `POST /dsh-voice/polish` — `{ text }` → `{ ok, text }`
222
- * `GET /dsh-voice/status` — Returns daemon status, active chains, SenseVoice.
222
+ * `GET /dsh-voice/status` — Returns daemon status, active chains, SenseVoice and `effectiveSensevoiceProvider`.
223
223
  * `GET /dsh-voice/config` — Returns live plugin configuration snapshot.
224
224
  * `PUT /dsh-voice/config` — Updates and persists plugin configuration across network.
225
225
  * `GET/POST /dsh-voice/sensevoice-installer` — SenseVoice 1-click model installation status and trigger (protected by `isTrustedCaller`, model paths redacted).
package/README.ru.md CHANGED
@@ -219,7 +219,7 @@ dsh plugin --profile web add @goodandready/dsh-voice
219
219
  ### Внутренние HTTP эндпоинты
220
220
  * `POST /dsh-voice/transcribe` — `{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
221
221
  * `POST /dsh-voice/polish` — `{ text }` → `{ ok, text }`
222
- * `GET /dsh-voice/status` — состояние демонов, цепочки, конфигурация SenseVoice и реалтайма.
222
+ * `GET /dsh-voice/status` — состояние демонов, цепочки, конфигурация SenseVoice, реалтайма и effectiveSensevoiceProvider.
223
223
  * `GET/PUT /dsh-voice/config` — получение и сохранение конфигурации плагина.
224
224
  * `GET/POST /dsh-voice/sensevoice-installer` — статус и установка модели SenseVoice в 1 клик.
225
225
  * `POST /api/dsh-voice/update` — запуск обновления плагина до актуальной версии из npm (с проверкой прав и блокировки профиля).
package/README.zh.md CHANGED
@@ -138,7 +138,7 @@ v0.8.18 质量审查后的修复批次(Gitea #79–#87,PR #88):
138
138
 
139
139
  * `POST /dsh-voice/transcribe` — 音频转写:`{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
140
140
  * `POST /dsh-voice/polish` — 文本模型润色:`{ text }` → `{ ok, text }`
141
- * `GET /dsh-voice/status` — 查询后端状态、服务商健康度与 SenseVoice
141
+ * `GET /dsh-voice/status` — 查询后端状态、服务商健康度、SenseVoice 及 effectiveSensevoiceProvider
142
142
  * `GET /dsh-voice/config` — 获取插件运行时配置快照
143
143
  * `PUT /dsh-voice/config` — 跨网络安全持久化更新插件配置
144
144
  * `GET/POST /dsh-voice/sensevoice-installer` — SenseVoice 一键模型安装状态与触发接口(受 `isTrustedCaller` 保护,绝对路径脱敏)
package/lib/client.js CHANGED
@@ -1080,39 +1080,73 @@ window.__ModuleLoader__.load({
1080
1080
  // Fetch the mode chain from the host once. Only needed to decide whether
1081
1081
 
1082
1082
  voice._core = { tidyPhrase, applyVoiceCommands, VOICE_COMMANDS, undoLastInsert, insertHistory, insertDraftText, appendDraft, resolveBrowserRecognitionLang }
1083
- // lib/client-src/35-webgpu.js — Client-side WebGPU Whisper offline recognition
1084
- // Detects WebGPU availability in browser and provides offline transcription with graceful fallback.
1083
+ // lib/client-src/35-webgpu.js — Client-side WebGPU/WASM Whisper offline recognition
1084
+ // Detects WebGPU and WebAssembly availability in browser and provides offline transcription with graceful WASM fallback.
1085
1085
 
1086
1086
  function isWebGpuSupported() {
1087
1087
  if (typeof navigator === 'undefined' || !navigator.gpu) return false
1088
1088
  return true
1089
1089
  }
1090
1090
 
1091
+ function isWasmSupported() {
1092
+ return typeof WebAssembly !== 'undefined'
1093
+ }
1094
+
1095
+ function isOfflineSupported() {
1096
+ return isWebGpuSupported() || isWasmSupported()
1097
+ }
1098
+
1091
1099
  const _webGpuPipelines = new Map()
1092
1100
 
1093
- function loadWebGpuWhisper(modelName) {
1101
+ function loadWebGpuWhisper(modelName, options) {
1094
1102
  const targetModel = modelName || (voice.settings && voice.settings.webgpuModel) || 'onnx-community/whisper-tiny'
1095
- if (_webGpuPipelines.has(targetModel)) {
1096
- return _webGpuPipelines.get(targetModel)
1103
+ const forceDevice = options?.device
1104
+ const key = `${targetModel}:${forceDevice || 'auto'}`
1105
+ if (_webGpuPipelines.has(key)) {
1106
+ return _webGpuPipelines.get(key)
1097
1107
  }
1098
1108
  const promise = (async () => {
1099
- if (!isWebGpuSupported()) {
1100
- throw new Error('WebGPU is not supported in this browser environment')
1109
+ if (!isOfflineSupported()) {
1110
+ throw new Error('Neither WebGPU nor WebAssembly is supported in this browser environment')
1101
1111
  }
1102
1112
  const mod = typeof globalThis !== 'undefined' && globalThis.__mockTransformers
1103
1113
  ? globalThis.__mockTransformers
1104
1114
  : (await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.3.3'))
1105
1115
  const pipeline = mod.pipeline || (mod.default && mod.default.pipeline)
1106
1116
  if (!pipeline) throw new Error('Transformers pipeline not found in loaded bundle')
1107
- return pipeline('automatic-speech-recognition', targetModel, {
1108
- device: 'webgpu',
1109
- dtype: 'fp32',
1110
- })
1117
+
1118
+ // Prefer WebGPU if available and not forced to wasm; fall back to WASM on error or when WebGPU is absent
1119
+ const canGpu = forceDevice === 'webgpu' || (!forceDevice && isWebGpuSupported())
1120
+ if (canGpu) {
1121
+ try {
1122
+ return await pipeline('automatic-speech-recognition', targetModel, {
1123
+ device: 'webgpu',
1124
+ dtype: 'fp32',
1125
+ })
1126
+ } catch (gpuErr) {
1127
+ if (isWasmSupported()) {
1128
+ return await pipeline('automatic-speech-recognition', targetModel, {
1129
+ device: 'wasm',
1130
+ dtype: 'q8',
1131
+ })
1132
+ }
1133
+ throw gpuErr
1134
+ }
1135
+ }
1136
+
1137
+ if (isWasmSupported()) {
1138
+ return await pipeline('automatic-speech-recognition', targetModel, {
1139
+ device: 'wasm',
1140
+ dtype: 'q8',
1141
+ })
1142
+ }
1143
+
1144
+ throw new Error('Offline speech recognition failed: no WebGPU or WebAssembly available')
1111
1145
  })().catch((err) => {
1112
- _webGpuPipelines.delete(targetModel)
1146
+ _webGpuPipelines.delete(key)
1113
1147
  throw err
1114
1148
  })
1115
- _webGpuPipelines.set(targetModel, promise)
1149
+ _webGpuPipelines.set(key, promise)
1116
1150
  return promise
1117
1151
  }
1118
1152
 
@@ -1131,10 +1165,10 @@ async function audioBlobToFloat32Array(blob) {
1131
1165
  }
1132
1166
 
1133
1167
  async function transcribeWithWebGpu(blob, mime, options) {
1134
- if (!isWebGpuSupported()) {
1135
- throw new Error('WebGPU unavailable in browser')
1168
+ if (!isOfflineSupported()) {
1169
+ throw new Error('Offline recognition (WebGPU/WASM) unavailable in browser')
1136
1170
  }
1137
- const transcriber = await loadWebGpuWhisper(options && options.model)
1171
+ const transcriber = await loadWebGpuWhisper(options && options.model, options)
1138
1172
  const audioData = await audioBlobToFloat32Array(blob)
1139
1173
  const generateArgs = {}
1140
1174
  if (options && options.language && options.language !== 'auto') {
@@ -1146,7 +1180,9 @@ async function transcribeWithWebGpu(blob, mime, options) {
1146
1180
  }
1147
1181
 
1148
1182
  voice.webGpu = {
1149
- isSupported: isWebGpuSupported,
1183
+ isSupported: isOfflineSupported,
1184
+ isWebGpuSupported,
1185
+ isWasmSupported,
1150
1186
  transcribe: transcribeWithWebGpu,
1151
1187
  load: loadWebGpuWhisper,
1152
1188
  clearCache: () => { _webGpuPipelines.clear() },
@@ -3997,6 +4033,13 @@ voice._chainsPromise = null
3997
4033
  stopCurrent,
3998
4034
  submitPending,
3999
4035
  loadWebGpuWhisper,
4036
+ isWebGpuSupported,
4037
+ isWasmSupported,
4038
+ isOfflineSupported,
4039
+ waitStop,
4040
+ currentLevel,
4041
+ accentColor,
4042
+ softColor,
4000
4043
  }
4001
4044
  return module.exports
4002
4045
  },
@@ -1,5 +1,6 @@
1
1
  // lib/host-routes.js — Host HTTP routes for dsh-voice status, polish, and transcribe
2
2
  import { writeJson, readBody, isTrustedCaller } from './http-util.js'
3
+ import { resolveSensevoiceProvider } from './sensevoice-installer.js'
3
4
 
4
5
  export function registerHostRoutes(ctx, {
5
6
  live,
@@ -63,6 +64,7 @@ export function registerHostRoutes(ctx, {
63
64
  sensevoiceUrl: cfg.sensevoiceUrl,
64
65
  sensevoiceAutostart: !!cfg.sensevoiceAutostart,
65
66
  sensevoiceProvider: cfg.sensevoiceProvider || 'cpu',
67
+ effectiveSensevoiceProvider: resolveSensevoiceProvider(cfg),
66
68
  sensevoiceThreads: cfg.sensevoiceThreads || 4,
67
69
  webgpuModel: cfg.webgpuModel || 'onnx-community/whisper-tiny',
68
70
  deepgramBaseUrl: cfg.deepgramBaseUrl || 'https://api.deepgram.com',
package/lib/index.js CHANGED
@@ -47,6 +47,23 @@ export const Config = typeof z.number().volatile === 'function'
47
47
  ? makeVolatile(BaseConfig)
48
48
  : BaseConfig
49
49
 
50
+ export function isVolatileRef(value) {
51
+ return value !== null && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
52
+ }
53
+
54
+ export function plainConfig(value) {
55
+ while (typeof value === 'function') {
56
+ value = value()
57
+ }
58
+ if (isVolatileRef(value)) return plainConfig(value.get())
59
+ if (value === null || typeof value !== 'object') return value
60
+ if (value instanceof Date || value instanceof RegExp || (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))) {
61
+ return value
62
+ }
63
+ if (Array.isArray(value)) return value.map(plainConfig)
64
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]))
65
+ }
66
+
50
67
  export function apply(ctx, baseConfig) {
51
68
  let settingsService = null
52
69
  let scope = null
@@ -55,7 +72,25 @@ export function apply(ctx, baseConfig) {
55
72
  const readEntryConfig = createConfigReader(baseConfig, ctx)
56
73
  getConfig = readEntryConfig
57
74
 
58
- const live = () => BaseConfig(structuredClone(getConfig() ?? {})) ?? readEntryConfig()
75
+ const describeRow = () => {
76
+ if (typeof settingsService?.describe !== 'function') return null
77
+ try {
78
+ return settingsService.describe().find((r) => r && r.ns === NS) || null
79
+ } catch {
80
+ return null
81
+ }
82
+ }
83
+
84
+ const live = () => {
85
+ try {
86
+ const raw = getConfig() ?? {}
87
+ const unwrapped = plainConfig(raw)
88
+ const validated = BaseConfig(unwrapped)
89
+ return plainConfig(validated) ?? plainConfig(readEntryConfig())
90
+ } catch {
91
+ return plainConfig(readEntryConfig())
92
+ }
93
+ }
59
94
 
60
95
  const resolveKey = async (ref) => {
61
96
  if (!ref || !ref.type) return ''
@@ -81,14 +116,19 @@ export function apply(ctx, baseConfig) {
81
116
  try {
82
117
  scope = sctx.settings.register(NS, Config, { base: baseConfig })
83
118
  if (scope && typeof scope.get === 'function') {
84
- getConfig = () => scope.get() ?? readEntryConfig()
119
+ getConfig = () => scope.get() ?? describeRow()?.config ?? readEntryConfig()
120
+ } else {
121
+ getConfig = () => describeRow()?.config ?? readEntryConfig()
85
122
  }
86
123
  if (scope && typeof scope.watch === 'function') {
87
124
  scope.watch(() => {
88
125
  triggerAutostart()
89
126
  })
90
127
  }
91
- } catch { /* modern DSH handles settings via SettingsForms */ }
128
+ } catch {
129
+ // Modern DSH 0.2 handles settings via SettingsForms and describe/update/replace
130
+ getConfig = () => describeRow()?.config ?? readEntryConfig()
131
+ }
92
132
  sctx.effect(() => () => { getConfig = readEntryConfig; scope = null; settingsService = null })
93
133
  triggerAutostart()
94
134
  })
@@ -231,6 +231,36 @@ export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateCo
231
231
  })
232
232
  }
233
233
 
234
+ export function resolveSensevoiceProvider(cfg) {
235
+ let modelPath = String(cfg?.sensevoiceModel || '')
236
+ const rawProv = String(cfg?.sensevoiceProvider || '').trim().toLowerCase()
237
+ let normProv = rawProv === 'rknpu' ? 'rknn' : rawProv
238
+
239
+ if (normProv === 'rknn') {
240
+ const ext = path.extname(modelPath).toLowerCase()
241
+ if (ext === '.onnx') {
242
+ const modelDir = modelPath ? path.dirname(modelPath) : getDefaultModelDir()
243
+ const rknnCandidate = path.join(modelDir, path.basename(modelPath, ext) + '.rknn')
244
+ const defaultRknn = path.join(modelDir, 'model.rknn')
245
+ if (existsSync(rknnCandidate) || existsSync(defaultRknn)) {
246
+ return 'rknn'
247
+ }
248
+ // Guard: ordinary ONNX model cannot be loaded by sherpa-onnx RKNN runtime (#221)
249
+ return 'cpu'
250
+ }
251
+ if (ext === '.rknn') {
252
+ if (modelPath && existsSync(modelPath)) {
253
+ return 'rknn'
254
+ }
255
+ // Guard: missing .rknn model file cannot be claimed as running accelerator (#168)
256
+ return 'cpu'
257
+ }
258
+ return 'cpu'
259
+ }
260
+
261
+ return normProv || 'cpu'
262
+ }
263
+
234
264
  export function buildSensevoiceArgs(cfg, port) {
235
265
  let modelPath = String(cfg?.sensevoiceModel || '')
236
266
  const modelDir = modelPath ? path.dirname(modelPath) : getDefaultModelDir()
@@ -238,10 +268,9 @@ export function buildSensevoiceArgs(cfg, port) {
238
268
  ? cfg.sensevoiceTokens
239
269
  : findTokensFile(modelDir)
240
270
  const tokensArg = tokensPath ? ` --tokens=${JSON.stringify(tokensPath)}` : ''
241
- const rawProv = String(cfg?.sensevoiceProvider || '').trim().toLowerCase()
242
- let normProv = rawProv === 'rknpu' ? 'rknn' : rawProv
271
+ const effectiveProv = resolveSensevoiceProvider(cfg)
243
272
 
244
- if (normProv === 'rknn') {
273
+ if (effectiveProv === 'rknn') {
245
274
  const ext = path.extname(modelPath).toLowerCase()
246
275
  if (ext === '.onnx') {
247
276
  const rknnCandidate = path.join(modelDir, path.basename(modelPath, ext) + '.rknn')
@@ -250,15 +279,12 @@ export function buildSensevoiceArgs(cfg, port) {
250
279
  modelPath = rknnCandidate
251
280
  } else if (existsSync(defaultRknn)) {
252
281
  modelPath = defaultRknn
253
- } else {
254
- // Guard: ordinary ONNX model cannot be loaded by sherpa-onnx RKNN runtime (#221)
255
- normProv = 'cpu'
256
282
  }
257
283
  }
258
284
  }
259
285
 
260
- const provider = (normProv && normProv !== 'cpu')
261
- ? ` --provider=${normProv}`
286
+ const provider = (effectiveProv && effectiveProv !== 'cpu')
287
+ ? ` --provider=${effectiveProv}`
262
288
  : ''
263
289
  const threads = Number(cfg?.sensevoiceThreads) || 4
264
290
  return ` --sense-voice-model=${JSON.stringify(modelPath)}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.9.27",
3
+ "version": "0.9.29",
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",