@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 +1 -1
- package/README.ru.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +60 -17
- package/lib/host-routes.js +2 -0
- package/lib/index.js +43 -3
- package/lib/sensevoice-installer.js +34 -8
- package/package.json +1 -1
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` —
|
|
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
|
-
|
|
1096
|
-
|
|
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 (!
|
|
1100
|
-
throw new Error('WebGPU is
|
|
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
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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(
|
|
1146
|
+
_webGpuPipelines.delete(key)
|
|
1113
1147
|
throw err
|
|
1114
1148
|
})
|
|
1115
|
-
_webGpuPipelines.set(
|
|
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 (!
|
|
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:
|
|
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
|
},
|
package/lib/host-routes.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
|
242
|
-
let normProv = rawProv === 'rknpu' ? 'rknn' : rawProv
|
|
271
|
+
const effectiveProv = resolveSensevoiceProvider(cfg)
|
|
243
272
|
|
|
244
|
-
if (
|
|
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 = (
|
|
261
|
-
? ` --provider=${
|
|
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.
|
|
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",
|