@goodandready/dsh-voice 0.9.34 → 0.9.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/lib/client.js +84 -29
- package/lib/host-routes.js +74 -0
- package/lib/schema.js +1 -1
- package/lib/sensevoice-installer.js +150 -53
- package/lib/wav.js +27 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -68,6 +68,9 @@ window.__ModuleLoader__.load({
|
|
|
68
68
|
'npuProvider': 'Hardware Acceleration (SBC NPU / Host)',
|
|
69
69
|
'npuProviderHint': 'Execution provider for SenseVoice / sherpa-onnx: CPU, Rockchip RK3588 NPU (rknpu), OpenVINO, or CUDA.',
|
|
70
70
|
'npuThreads': 'Threads (CPU/SBC)',
|
|
71
|
+
'benchmarkTitle': 'Hardware Benchmark (Speed & Latency)',
|
|
72
|
+
'runBenchmarkBtn': 'Run Benchmark', 'benchmarking': 'Running benchmark…',
|
|
73
|
+
'benchmarkHint': 'Measures real-time factor (RTF) and inference latency on 1.0s audio for current execution provider.',
|
|
71
74
|
'webgpuStatus': 'Browser WebGPU Status',
|
|
72
75
|
'webgpuAvailable': 'WebGPU Available (hardware acceleration ready)',
|
|
73
76
|
'webgpuNotAvailable': 'WebGPU Not Detected (will fallback to server)',
|
|
@@ -291,20 +294,15 @@ window.__ModuleLoader__.load({
|
|
|
291
294
|
'wizardMicPermissionBad': 'Microphone permission denied',
|
|
292
295
|
'wizardRequestMic': 'Request Microphone Access',
|
|
293
296
|
'wizardMicLabel': 'Microphone input device:',
|
|
294
|
-
'wizardStartSignalCheck': 'Start Audio Level Test',
|
|
295
|
-
'wizardStopSignalCheck': 'Stop Audio Test',
|
|
297
|
+
'wizardStartSignalCheck': 'Start Audio Level Test', 'wizardStopSignalCheck': 'Stop Audio Test',
|
|
296
298
|
'wizardSignalOk': 'Audio signal detected (RMS: {level}%)',
|
|
297
299
|
'wizardSignalZero': 'Zero audio level detected - speak into microphone or select correct device',
|
|
298
300
|
'wizardSignalTesting': 'Listening for audio signal… speak into your microphone',
|
|
299
301
|
'wizardProviderLabel': 'Active STT engine & model:',
|
|
300
302
|
'wizardProcessingArea': 'Processing area:',
|
|
301
|
-
'wizardAreaBrowser': 'In-browser (WebGPU / WebSpeech)',
|
|
302
|
-
'
|
|
303
|
-
'
|
|
304
|
-
'wizardStartTestRecord': 'Record 3-Second Test Phrase',
|
|
305
|
-
'wizardRecordingTest': 'Recording test phrase… speak now ({left}s)',
|
|
306
|
-
'wizardTranscribingTest': 'Transcribing test phrase…',
|
|
307
|
-
'wizardTestResult': 'STT Test Result:',
|
|
303
|
+
'wizardAreaBrowser': 'In-browser (WebGPU / WebSpeech)', 'wizardAreaServer': 'Local MiniPC server (SenseVoice / Whisper)', 'wizardAreaCloud': 'Remote cloud provider',
|
|
304
|
+
'wizardStartTestRecord': 'Record 3-Second Test Phrase', 'wizardRecordingTest': 'Recording test phrase… speak now ({left}s)',
|
|
305
|
+
'wizardTranscribingTest': 'Transcribing test phrase…', 'wizardTestResult': 'STT Test Result:',
|
|
308
306
|
'wizardTestSuccess': 'STT round-trip verified successfully! Latency: {latency}ms',
|
|
309
307
|
'wizardTestEmpty': 'No speech detected in test recording. Please speak clearly and try again.',
|
|
310
308
|
'wizardTestFailed': 'STT test failed: {error}',
|
|
@@ -358,6 +356,9 @@ window.__ModuleLoader__.load({
|
|
|
358
356
|
'npuProvider': '硬件加速 (SBC NPU / 主机)',
|
|
359
357
|
'npuProviderHint': 'SenseVoice / sherpa-onnx 执行加速引擎:CPU、Rockchip RK3588 NPU (rknpu)、OpenVINO 或 CUDA。',
|
|
360
358
|
'npuThreads': '计算线程 (CPU/SBC)',
|
|
359
|
+
'benchmarkTitle': '硬件基准测试 (速度与延迟)',
|
|
360
|
+
'runBenchmarkBtn': '运行基准测试', 'benchmarking': '正在运行测试…',
|
|
361
|
+
'benchmarkHint': '测试当前执行提供者在 1.0 秒音频下的实时率 (RTF) 与推理延迟。',
|
|
361
362
|
'webgpuStatus': '浏览器 WebGPU 状态',
|
|
362
363
|
'webgpuAvailable': 'WebGPU 可用 (硬件加速就绪)',
|
|
363
364
|
'webgpuNotAvailable': '未检测到 WebGPU (将自动回退到服务端)',
|
|
@@ -588,13 +589,9 @@ window.__ModuleLoader__.load({
|
|
|
588
589
|
'wizardSignalTesting': '正在监听声音信号… 请对着麦克风发声',
|
|
589
590
|
'wizardProviderLabel': '当前语音引擎与模型:',
|
|
590
591
|
'wizardProcessingArea': '处理区域:',
|
|
591
|
-
'wizardAreaBrowser': '浏览器端处理 (WebGPU / WebSpeech)',
|
|
592
|
-
'
|
|
593
|
-
'
|
|
594
|
-
'wizardStartTestRecord': '录制 3 秒测试语句',
|
|
595
|
-
'wizardRecordingTest': '正在录制测试音频… 请说话 ({left}秒)',
|
|
596
|
-
'wizardTranscribingTest': '正在请求语音识别…',
|
|
597
|
-
'wizardTestResult': 'STT 测试结果:',
|
|
592
|
+
'wizardAreaBrowser': '浏览器端处理 (WebGPU / WebSpeech)', 'wizardAreaServer': '本地服务器处理 (SenseVoice / Whisper)', 'wizardAreaCloud': '远程云端服务',
|
|
593
|
+
'wizardStartTestRecord': '录制 3 秒测试语句', 'wizardRecordingTest': '正在录制测试音频… 请说话 ({left}秒)',
|
|
594
|
+
'wizardTranscribingTest': '正在请求语音识别…', 'wizardTestResult': 'STT 测试结果:',
|
|
598
595
|
'wizardTestSuccess': 'STT 链路验证成功!耗时:{latency}毫秒',
|
|
599
596
|
'wizardTestEmpty': '录音中未检测到有效语音,请清晰发音后重试。',
|
|
600
597
|
'wizardTestFailed': 'STT 测试失败:{error}',
|
|
@@ -3394,6 +3391,32 @@ function DictationModesSection(props) {
|
|
|
3394
3391
|
const gateThresholdPercent = props.gateThresholdPercent
|
|
3395
3392
|
const onClearKey = props.onClearKey
|
|
3396
3393
|
|
|
3394
|
+
const [benchmarking, setBenchmarking] = React.useState(false)
|
|
3395
|
+
const [benchmarkResult, setBenchmarkResult] = React.useState(null)
|
|
3396
|
+
const [benchmarkErr, setBenchmarkErr] = React.useState('')
|
|
3397
|
+
|
|
3398
|
+
const runBenchmark = () => {
|
|
3399
|
+
setBenchmarking(true)
|
|
3400
|
+
setBenchmarkErr('')
|
|
3401
|
+
fetch('/dsh-voice/benchmark', {
|
|
3402
|
+
method: 'POST',
|
|
3403
|
+
headers: {
|
|
3404
|
+
'x-dsh-plugin-update': '1',
|
|
3405
|
+
'Content-Type': 'application/json',
|
|
3406
|
+
},
|
|
3407
|
+
})
|
|
3408
|
+
.then((r) => r.json())
|
|
3409
|
+
.then((d) => {
|
|
3410
|
+
setBenchmarking(false)
|
|
3411
|
+
if (d && d.ok) setBenchmarkResult(d)
|
|
3412
|
+
else setBenchmarkErr((d && d.error && d.error.message) || 'Benchmark failed')
|
|
3413
|
+
})
|
|
3414
|
+
.catch((e) => {
|
|
3415
|
+
setBenchmarking(false)
|
|
3416
|
+
setBenchmarkErr((e && e.message) || 'Network error')
|
|
3417
|
+
})
|
|
3418
|
+
}
|
|
3419
|
+
|
|
3397
3420
|
const textField = (key, label, hint) => React.createElement('div', { className: 'cb-field' },
|
|
3398
3421
|
React.createElement('label', null, label),
|
|
3399
3422
|
React.createElement('input', {
|
|
@@ -3564,6 +3587,7 @@ function DictationModesSection(props) {
|
|
|
3564
3587
|
},
|
|
3565
3588
|
React.createElement('option', { value: 'cpu' }, 'CPU (Standard)'),
|
|
3566
3589
|
React.createElement('option', { value: 'rknn' }, 'Rockchip RK3588 NPU (RKNN)'),
|
|
3590
|
+
React.createElement('option', { value: 'coreml' }, 'Apple Silicon CoreML'),
|
|
3567
3591
|
React.createElement('option', { value: 'openvino' }, 'Intel OpenVINO'),
|
|
3568
3592
|
React.createElement('option', { value: 'cuda' }, 'NVIDIA CUDA'),
|
|
3569
3593
|
),
|
|
@@ -3580,6 +3604,24 @@ function DictationModesSection(props) {
|
|
|
3580
3604
|
),
|
|
3581
3605
|
React.createElement('span', { className: 'dvs-sub' }, t('npuProviderHint')),
|
|
3582
3606
|
),
|
|
3607
|
+
// Hardware Benchmark
|
|
3608
|
+
React.createElement('div', { className: 'cb-field' },
|
|
3609
|
+
React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', justifyContent: 'space-between' } },
|
|
3610
|
+
React.createElement('label', { style: { marginBottom: 0 } }, t('benchmarkTitle')),
|
|
3611
|
+
React.createElement('button', {
|
|
3612
|
+
type: 'button', className: 'cb-btn cb-btn-secondary', style: { fontSize: '12px', padding: '4px 10px' },
|
|
3613
|
+
disabled: benchmarking || !writable, onClick: runBenchmark,
|
|
3614
|
+
}, benchmarking ? ('⏳ ' + t('benchmarking')) : ('⚡ ' + t('runBenchmarkBtn'))),
|
|
3615
|
+
),
|
|
3616
|
+
benchmarkResult
|
|
3617
|
+
? React.createElement('div', {
|
|
3618
|
+
style: { marginTop: '8px', padding: '6px 10px', borderRadius: '6px', fontSize: '12px', backgroundColor: 'var(--dsw-alias-fill-quaternary)' }
|
|
3619
|
+
}, benchmarkResult.running
|
|
3620
|
+
? `⚡ RTF: ${benchmarkResult.rtf}x · ${benchmarkResult.latencyMs}ms (${benchmarkResult.rating}) · ${String(benchmarkResult.provider || 'cpu').toUpperCase()} · Cores: ${benchmarkResult.cpuCores}`
|
|
3621
|
+
: `ℹ ${benchmarkResult.message || 'Offline'} (${benchmarkResult.cpuCores} cores, ${benchmarkResult.platform})`)
|
|
3622
|
+
: (benchmarkErr ? React.createElement('div', { style: { color: 'var(--dsw-alias-state-danger)', fontSize: '12px', marginTop: '4px' } }, benchmarkErr) : null),
|
|
3623
|
+
React.createElement('span', { className: 'dvs-sub' }, t('benchmarkHint')),
|
|
3624
|
+
),
|
|
3583
3625
|
// WebGPU Status Indicator
|
|
3584
3626
|
React.createElement('div', { className: 'cb-field' },
|
|
3585
3627
|
React.createElement('label', null, t('webgpuStatus')),
|
|
@@ -3627,7 +3669,7 @@ function DictationModesSection(props) {
|
|
|
3627
3669
|
fetchSensevoice()
|
|
3628
3670
|
}, [])
|
|
3629
3671
|
|
|
3630
|
-
const triggerInstallSensevoice = () => {
|
|
3672
|
+
const triggerInstallSensevoice = (modelType = 'sensevoice') => {
|
|
3631
3673
|
setInstallingSensevoice(true)
|
|
3632
3674
|
fetch('/dsh-voice/sensevoice-installer', {
|
|
3633
3675
|
method: 'POST',
|
|
@@ -3635,6 +3677,7 @@ function DictationModesSection(props) {
|
|
|
3635
3677
|
'x-dsh-plugin-update': '1',
|
|
3636
3678
|
'Content-Type': 'application/json',
|
|
3637
3679
|
},
|
|
3680
|
+
body: JSON.stringify({ model: modelType }),
|
|
3638
3681
|
})
|
|
3639
3682
|
.then((r) => r.json())
|
|
3640
3683
|
.then(() => {
|
|
@@ -3933,39 +3976,51 @@ function MacrosSection(props) {
|
|
|
3933
3976
|
),
|
|
3934
3977
|
)
|
|
3935
3978
|
}
|
|
3936
|
-
//
|
|
3979
|
+
// Local ASR (SenseVoice-Small & Whisper Large-v3-Turbo 1-Click Setup)
|
|
3937
3980
|
function SensevoiceSection(props) {
|
|
3938
3981
|
const t = props.t || moduleT
|
|
3939
3982
|
const sensevoiceState = props.sensevoiceState
|
|
3940
3983
|
const installingSensevoice = props.installingSensevoice
|
|
3941
3984
|
const writable = props.writable
|
|
3942
3985
|
const onInstall = props.onInstall
|
|
3986
|
+
const [modelChoice, setModelChoice] = React.useState('sensevoice')
|
|
3987
|
+
|
|
3988
|
+
const isInstalled = Boolean(sensevoiceState && sensevoiceState.installed)
|
|
3989
|
+
const currentType = (sensevoiceState && sensevoiceState.modelType) || 'sensevoice'
|
|
3990
|
+
const badgeText = isInstalled
|
|
3991
|
+
? (t('sensevoiceReady') + (currentType === 'whisper-turbo' ? ' (whisper-turbo)' : ' (small-int8)'))
|
|
3992
|
+
: (installingSensevoice ? t('sensevoiceDownloading') : t('sensevoiceNotInstalled'))
|
|
3943
3993
|
|
|
3944
3994
|
return React.createElement('div', { className: 'cb-section-card' },
|
|
3945
3995
|
React.createElement('div', { className: 'cb-section-title' },
|
|
3946
3996
|
React.createElement('span', null, '⚡ ' + t('sensevoiceTitle')),
|
|
3947
|
-
|
|
3948
|
-
? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, t('sensevoiceReady') + ' (small-int8)')
|
|
3949
|
-
: React.createElement('span', { className: 'cb-badge cb-badge-warn' },
|
|
3950
|
-
installingSensevoice ? t('sensevoiceDownloading') : t('sensevoiceNotInstalled')
|
|
3951
|
-
),
|
|
3997
|
+
React.createElement('span', { className: 'cb-badge ' + (isInstalled ? 'cb-badge-ok' : 'cb-badge-warn') }, badgeText),
|
|
3952
3998
|
),
|
|
3953
3999
|
React.createElement('div', { className: 'cb-section-desc' }, t('sensevoiceDesc')),
|
|
3954
4000
|
React.createElement('div', { className: 'dvo-installer-box' },
|
|
3955
4001
|
React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', justifyContent: 'space-between', gap: '12px' } },
|
|
3956
|
-
React.createElement('
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
4002
|
+
React.createElement('select', {
|
|
4003
|
+
value: modelChoice,
|
|
4004
|
+
disabled: installingSensevoice || !writable,
|
|
4005
|
+
onChange: (e) => setModelChoice(e.target.value),
|
|
4006
|
+
style: { flex: 1, fontSize: '12px', minWidth: '180px' },
|
|
4007
|
+
},
|
|
4008
|
+
React.createElement('option', { value: 'sensevoice' }, 'SenseVoice-Small (150MB, ~50ms)'),
|
|
4009
|
+
React.createElement('option', { value: 'whisper-turbo' }, 'Whisper Large-v3-Turbo (800MB, High-Accuracy)'),
|
|
3960
4010
|
),
|
|
3961
4011
|
React.createElement('button', {
|
|
3962
4012
|
type: 'button',
|
|
3963
4013
|
className: 'cb-btn cb-btn-primary',
|
|
3964
|
-
style: { fontSize: '12px', padding: '6px 12px' },
|
|
4014
|
+
style: { fontSize: '12px', padding: '6px 12px', whiteSpace: 'nowrap' },
|
|
3965
4015
|
disabled: installingSensevoice || !writable,
|
|
3966
|
-
onClick: onInstall,
|
|
4016
|
+
onClick: () => { if (typeof onInstall === 'function') onInstall(modelChoice) },
|
|
3967
4017
|
}, installingSensevoice ? ('⏳ ' + t('installingSensevoice')) : ('📥 ' + t('installSensevoice'))),
|
|
3968
4018
|
),
|
|
4019
|
+
React.createElement('div', { style: { fontSize: '11px', marginTop: '6px', color: 'var(--dsw-alias-content-secondary)' } },
|
|
4020
|
+
sensevoiceState && sensevoiceState.path
|
|
4021
|
+
? ('~/' + sensevoiceState.path.replace(/^.*[\\/](\.dsh[\\/]models[\\/].*)$/, '$1'))
|
|
4022
|
+
: ('~/.dsh/models/' + modelChoice)
|
|
4023
|
+
),
|
|
3969
4024
|
installingSensevoice
|
|
3970
4025
|
? React.createElement('div', { style: { marginTop: '8px' } },
|
|
3971
4026
|
React.createElement('div', { className: 'dvo-progress-bar' },
|
package/lib/host-routes.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// HTTP host routes registered on ctx.webServer.
|
|
2
|
+
import os from 'node:os'
|
|
2
3
|
import { readBody, isLoopback, isTrustedCaller } from './http-util.js'
|
|
3
4
|
import { resolveSensevoiceProvider } from './sensevoice-installer.js'
|
|
4
5
|
import { applyVoiceMacros } from './macros.js'
|
|
6
|
+
import { createSyntheticWav } from './wav.js'
|
|
5
7
|
|
|
6
8
|
function writeJson(res, status, obj) {
|
|
7
9
|
res.writeHead(status, { 'content-type': 'application/json' })
|
|
@@ -147,6 +149,78 @@ export function registerHostRoutes(ctx, deps) {
|
|
|
147
149
|
},
|
|
148
150
|
}), 'dsh-voice: /diagnostics/export route')
|
|
149
151
|
|
|
152
|
+
ctx.effect(() => ctx.webServer.register({
|
|
153
|
+
kind: 'exact',
|
|
154
|
+
path: '/dsh-voice/benchmark',
|
|
155
|
+
handler: async (req, res) => {
|
|
156
|
+
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
157
|
+
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
158
|
+
|
|
159
|
+
const cfg = live()
|
|
160
|
+
const effectiveProvider = resolveSensevoiceProvider(cfg)
|
|
161
|
+
const cpus = os.cpus() || []
|
|
162
|
+
const numCores = cpus.length || 1
|
|
163
|
+
const cpuModel = (cpus[0] && cpus[0].model) || 'unknown'
|
|
164
|
+
const platformInfo = `${process.platform}-${process.arch}`
|
|
165
|
+
const isAlive = await sensevoiceAlive()
|
|
166
|
+
|
|
167
|
+
if (!isAlive) {
|
|
168
|
+
writeJson(res, 200, {
|
|
169
|
+
ok: true,
|
|
170
|
+
running: false,
|
|
171
|
+
provider: effectiveProvider,
|
|
172
|
+
cpuCores: numCores,
|
|
173
|
+
cpuModel,
|
|
174
|
+
platform: platformInfo,
|
|
175
|
+
message: 'Local ASR server is not running. Hardware detected.',
|
|
176
|
+
})
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const sampleSec = 1.0
|
|
181
|
+
const wavBuf = createSyntheticWav(sampleSec, 16000)
|
|
182
|
+
const tStart = Date.now()
|
|
183
|
+
let latencyMs = 0
|
|
184
|
+
let recognizedText = ''
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const url = String(cfg.sensevoiceUrl || 'http://127.0.0.1:6006/api/v1/asr').trim()
|
|
188
|
+
const form = new FormData()
|
|
189
|
+
form.append('file', new Blob([wavBuf], { type: 'audio/wav' }), 'benchmark.wav')
|
|
190
|
+
const r = await fetch(url, { method: 'POST', body: form, signal: AbortSignal.timeout(10000) })
|
|
191
|
+
latencyMs = Date.now() - tStart
|
|
192
|
+
if (r.ok) {
|
|
193
|
+
try {
|
|
194
|
+
const data = await r.json()
|
|
195
|
+
recognizedText = String(data?.text || data?.transcript || '').trim()
|
|
196
|
+
} catch { /* json parse error */ }
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
latencyMs = Date.now() - tStart
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const rtf = Math.round((latencyMs / (sampleSec * 1000)) * 1000) / 1000
|
|
203
|
+
let rating = 'Fast'
|
|
204
|
+
if (rtf < 0.15) rating = 'Ultra-fast'
|
|
205
|
+
else if (rtf < 0.45) rating = 'Fast'
|
|
206
|
+
else if (rtf <= 1.0) rating = 'Real-time'
|
|
207
|
+
else rating = 'Slow'
|
|
208
|
+
|
|
209
|
+
writeJson(res, 200, {
|
|
210
|
+
ok: true,
|
|
211
|
+
running: true,
|
|
212
|
+
provider: effectiveProvider,
|
|
213
|
+
latencyMs,
|
|
214
|
+
rtf,
|
|
215
|
+
sampleSec,
|
|
216
|
+
rating,
|
|
217
|
+
cpuCores: numCores,
|
|
218
|
+
cpuModel,
|
|
219
|
+
platform: platformInfo,
|
|
220
|
+
})
|
|
221
|
+
},
|
|
222
|
+
}), 'dsh-voice: /benchmark route')
|
|
223
|
+
|
|
150
224
|
ctx.effect(() => ctx.webServer.register({
|
|
151
225
|
kind: 'exact',
|
|
152
226
|
path: '/dsh-voice/transcribe',
|
package/lib/schema.js
CHANGED
|
@@ -139,7 +139,7 @@ export const BaseConfig = z.object({
|
|
|
139
139
|
sensevoiceAutostart: z.boolean().default(false)
|
|
140
140
|
.description('Start the local SenseVoice / sherpa-onnx server process automatically on startup.'),
|
|
141
141
|
sensevoiceProvider: z.string().default('cpu')
|
|
142
|
-
.description('Execution provider for sherpa-onnx: "cpu", "rknn" (Rockchip RK3588/RK3568 NPU on ARM SBC with RKNN models)
|
|
142
|
+
.description('Execution provider for sherpa-onnx: "cpu", "coreml" (Apple Silicon), "openvino", "cuda", "rknn" (Rockchip RK3588/RK3568 NPU on ARM SBC with RKNN models).'),
|
|
143
143
|
sensevoiceThreads: z.number().default(4)
|
|
144
144
|
.description('Execution thread count for local SenseVoice / sherpa-onnx on CPU/SBC.'),
|
|
145
145
|
webgpuModel: z.string().default('onnx-community/whisper-tiny')
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// lib/sensevoice-installer.js — 1-Click installer for SenseVoice-Small
|
|
2
|
-
// Downloads pre-quantized ONNX
|
|
1
|
+
// lib/sensevoice-installer.js — 1-Click installer for SenseVoice-Small & Whisper Large-v3-Turbo
|
|
2
|
+
// Downloads pre-quantized ONNX models and configures autostart on the host via sherpa-onnx.
|
|
3
3
|
|
|
4
4
|
import { createWriteStream, existsSync, readdirSync } from 'node:fs'
|
|
5
5
|
import { mkdir, unlink } from 'node:fs/promises'
|
|
@@ -8,25 +8,63 @@ import path from 'node:path'
|
|
|
8
8
|
import { spawn } from 'node:child_process'
|
|
9
9
|
import { Readable, Transform } from 'node:stream'
|
|
10
10
|
import { pipeline } from 'node:stream/promises'
|
|
11
|
-
import { writeJson, isTrustedCaller } from './http-util.js'
|
|
11
|
+
import { writeJson, readBody, isTrustedCaller } from './http-util.js'
|
|
12
12
|
|
|
13
13
|
export const SENSEVOICE_MODEL_URL =
|
|
14
14
|
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17.tar.bz2'
|
|
15
15
|
export const SENSEVOICE_MODEL_MIRROR =
|
|
16
16
|
'https://huggingface.co/csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17/resolve/main/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17.tar.bz2'
|
|
17
17
|
|
|
18
|
+
export const WHISPER_TURBO_MODEL_URL =
|
|
19
|
+
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-whisper-turbo.tar.bz2'
|
|
20
|
+
export const WHISPER_TURBO_MODEL_MIRROR =
|
|
21
|
+
'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-turbo/resolve/main/sherpa-onnx-whisper-turbo.tar.bz2'
|
|
22
|
+
|
|
18
23
|
let installerState = {
|
|
19
24
|
installing: false,
|
|
20
25
|
progress: 0,
|
|
21
26
|
step: 'idle',
|
|
22
27
|
error: null,
|
|
23
28
|
modelPath: '',
|
|
29
|
+
modelType: 'sensevoice',
|
|
24
30
|
}
|
|
25
31
|
|
|
26
|
-
export function getDefaultModelDir() {
|
|
32
|
+
export function getDefaultModelDir(type = 'sensevoice') {
|
|
33
|
+
const norm = String(type || '').toLowerCase()
|
|
34
|
+
if (norm.includes('whisper') || norm.includes('turbo')) {
|
|
35
|
+
return path.join(homedir(), '.dsh', 'models', 'whisper-turbo')
|
|
36
|
+
}
|
|
27
37
|
return path.join(homedir(), '.dsh', 'models', 'sensevoice')
|
|
28
38
|
}
|
|
29
39
|
|
|
40
|
+
export function findWhisperFiles(dir) {
|
|
41
|
+
if (!dir || !existsSync(dir)) return null
|
|
42
|
+
try {
|
|
43
|
+
const entries = readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
44
|
+
let encoder = null
|
|
45
|
+
let decoder = null
|
|
46
|
+
let tokens = null
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (!entry.isFile()) continue
|
|
49
|
+
const name = entry.name.toLowerCase()
|
|
50
|
+
const parent = entry.parentPath || (entry.path ? entry.path : dir)
|
|
51
|
+
const fullPath = path.join(parent, entry.name)
|
|
52
|
+
|
|
53
|
+
if (name.includes('encoder') && name.endsWith('.onnx')) {
|
|
54
|
+
if (!encoder || name.includes('int8')) encoder = fullPath
|
|
55
|
+
} else if (name.includes('decoder') && name.endsWith('.onnx')) {
|
|
56
|
+
if (!decoder || name.includes('int8')) decoder = fullPath
|
|
57
|
+
} else if (name.endsWith('tokens.txt') || (name.includes('tokens') && name.endsWith('.txt'))) {
|
|
58
|
+
tokens = fullPath
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (encoder && decoder) {
|
|
62
|
+
return { encoder, decoder, tokens }
|
|
63
|
+
}
|
|
64
|
+
} catch { /* directory read failed */ }
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
30
68
|
export function findModelFile(dir) {
|
|
31
69
|
if (!dir || !existsSync(dir)) return null
|
|
32
70
|
try {
|
|
@@ -37,23 +75,26 @@ export function findModelFile(dir) {
|
|
|
37
75
|
return path.join(parent, entry.name)
|
|
38
76
|
}
|
|
39
77
|
}
|
|
78
|
+
const whisper = findWhisperFiles(dir)
|
|
79
|
+
if (whisper) return whisper.encoder
|
|
40
80
|
} catch { /* directory read failed */ }
|
|
41
81
|
return null
|
|
42
82
|
}
|
|
43
83
|
|
|
44
84
|
export function redactModelPath(filePath) {
|
|
45
|
-
if (!filePath) return
|
|
46
|
-
const str = String(filePath).replace(/\\/g,
|
|
47
|
-
const idx = str.indexOf(
|
|
48
|
-
if (idx !== -1) return
|
|
85
|
+
if (!filePath) return ''
|
|
86
|
+
const str = String(filePath).replace(/\\/g, '/')
|
|
87
|
+
const idx = str.indexOf('.dsh/models/')
|
|
88
|
+
if (idx !== -1) return '~/' + str.slice(idx)
|
|
49
89
|
return path.basename(str)
|
|
50
90
|
}
|
|
91
|
+
|
|
51
92
|
export function findTokensFile(dir) {
|
|
52
93
|
if (!dir || !existsSync(dir)) return null
|
|
53
94
|
try {
|
|
54
95
|
const entries = readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
55
96
|
for (const entry of entries) {
|
|
56
|
-
if (entry.isFile() && entry.name === 'tokens.txt') {
|
|
97
|
+
if (entry.isFile() && (entry.name === 'tokens.txt' || entry.name === 'turbo-tokens.txt')) {
|
|
57
98
|
const parent = entry.parentPath || (entry.path ? entry.path : dir)
|
|
58
99
|
return path.join(parent, entry.name)
|
|
59
100
|
}
|
|
@@ -63,21 +104,49 @@ export function findTokensFile(dir) {
|
|
|
63
104
|
}
|
|
64
105
|
|
|
65
106
|
export async function getSensevoiceStatus(cfg, isAliveFn) {
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
107
|
+
const customModel = cfg?.sensevoiceModel
|
|
108
|
+
const customExists = Boolean(customModel && existsSync(customModel))
|
|
109
|
+
const isWhisperCustom = customExists && (
|
|
110
|
+
customModel.toLowerCase().includes('whisper') ||
|
|
111
|
+
customModel.toLowerCase().includes('turbo') ||
|
|
112
|
+
Boolean(findWhisperFiles(path.dirname(customModel)))
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const svDir = getDefaultModelDir('sensevoice')
|
|
116
|
+
const svFile = findModelFile(svDir)
|
|
117
|
+
const turboDir = getDefaultModelDir('whisper-turbo')
|
|
118
|
+
const turboFiles = findWhisperFiles(turboDir)
|
|
119
|
+
|
|
120
|
+
let activeModelFile = null
|
|
121
|
+
let activeTokensFile = null
|
|
122
|
+
let activeModelType = 'sensevoice'
|
|
123
|
+
|
|
124
|
+
if (customExists) {
|
|
125
|
+
activeModelFile = customModel
|
|
126
|
+
activeTokensFile = findTokensFile(path.dirname(customModel))
|
|
127
|
+
activeModelType = isWhisperCustom ? 'whisper-turbo' : 'sensevoice'
|
|
128
|
+
} else if (turboFiles) {
|
|
129
|
+
activeModelFile = turboFiles.encoder
|
|
130
|
+
activeTokensFile = turboFiles.tokens
|
|
131
|
+
activeModelType = 'whisper-turbo'
|
|
132
|
+
} else if (svFile) {
|
|
133
|
+
activeModelFile = svFile
|
|
134
|
+
activeTokensFile = findTokensFile(path.dirname(svFile))
|
|
135
|
+
activeModelType = 'sensevoice'
|
|
136
|
+
}
|
|
71
137
|
|
|
72
138
|
const running = typeof isAliveFn === 'function' ? await isAliveFn() : false
|
|
73
|
-
const installed = Boolean(
|
|
139
|
+
const installed = Boolean(activeModelFile && existsSync(activeModelFile))
|
|
74
140
|
|
|
75
141
|
return {
|
|
76
142
|
installed,
|
|
77
143
|
running,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
144
|
+
modelType: activeModelType,
|
|
145
|
+
modelPath: redactModelPath(activeModelFile),
|
|
146
|
+
tokensPath: redactModelPath(activeTokensFile),
|
|
147
|
+
path: installed ? `~/.dsh/models/${activeModelType}` : '',
|
|
148
|
+
sensevoiceInstalled: Boolean(svFile),
|
|
149
|
+
whisperTurboInstalled: Boolean(turboFiles),
|
|
81
150
|
installing: installerState.installing,
|
|
82
151
|
progress: installerState.progress,
|
|
83
152
|
step: installerState.step,
|
|
@@ -118,21 +187,28 @@ export function extractTarBz2(archivePath, targetDir) {
|
|
|
118
187
|
})
|
|
119
188
|
}
|
|
120
189
|
|
|
121
|
-
export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSensevoiceFn) {
|
|
190
|
+
export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSensevoiceFn, options = {}) {
|
|
122
191
|
if (installerState.installing) {
|
|
123
|
-
throw new Error('
|
|
192
|
+
throw new Error('ASR model installation is already in progress')
|
|
124
193
|
}
|
|
125
194
|
|
|
195
|
+
const targetModel = String(options?.model || 'sensevoice').toLowerCase().includes('whisper')
|
|
196
|
+
? 'whisper-turbo'
|
|
197
|
+
: 'sensevoice'
|
|
198
|
+
|
|
126
199
|
installerState = {
|
|
127
200
|
installing: true,
|
|
128
201
|
progress: 5,
|
|
129
202
|
step: 'preparing',
|
|
130
203
|
error: null,
|
|
131
204
|
modelPath: '',
|
|
205
|
+
modelType: targetModel,
|
|
132
206
|
}
|
|
133
207
|
|
|
134
|
-
const modelDir = getDefaultModelDir()
|
|
135
|
-
const archivePath = path.join(modelDir,
|
|
208
|
+
const modelDir = getDefaultModelDir(targetModel)
|
|
209
|
+
const archivePath = path.join(modelDir, `${targetModel}.tar.bz2`)
|
|
210
|
+
const primaryUrl = targetModel === 'whisper-turbo' ? WHISPER_TURBO_MODEL_URL : SENSEVOICE_MODEL_URL
|
|
211
|
+
const mirrorUrl = targetModel === 'whisper-turbo' ? WHISPER_TURBO_MODEL_MIRROR : SENSEVOICE_MODEL_MIRROR
|
|
136
212
|
|
|
137
213
|
try {
|
|
138
214
|
await mkdir(modelDir, { recursive: true })
|
|
@@ -141,30 +217,36 @@ export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSens
|
|
|
141
217
|
|
|
142
218
|
let downloadOk = false
|
|
143
219
|
try {
|
|
144
|
-
await downloadArchive(
|
|
220
|
+
await downloadArchive(primaryUrl, archivePath, (pct, status) => {
|
|
145
221
|
installerState.progress = pct
|
|
146
222
|
installerState.step = status
|
|
147
223
|
})
|
|
148
224
|
downloadOk = true
|
|
149
|
-
} catch
|
|
150
|
-
// Fallback to mirror
|
|
225
|
+
} catch {
|
|
151
226
|
installerState.step = 'downloading (mirror)'
|
|
152
|
-
await downloadArchive(
|
|
227
|
+
await downloadArchive(mirrorUrl, archivePath, (pct, status) => {
|
|
153
228
|
installerState.progress = pct
|
|
154
229
|
installerState.step = status
|
|
155
230
|
})
|
|
156
231
|
downloadOk = true
|
|
157
232
|
}
|
|
158
233
|
|
|
159
|
-
if (!downloadOk) throw new Error(
|
|
234
|
+
if (!downloadOk) throw new Error(`Could not download ${targetModel} model from primary or mirror URL`)
|
|
160
235
|
|
|
161
236
|
installerState.step = 'extracting'
|
|
162
237
|
installerState.progress = 95
|
|
163
238
|
await extractTarBz2(archivePath, modelDir)
|
|
164
239
|
await unlink(archivePath).catch(() => {})
|
|
165
240
|
|
|
166
|
-
|
|
167
|
-
if (
|
|
241
|
+
let modelFile = null
|
|
242
|
+
if (targetModel === 'whisper-turbo') {
|
|
243
|
+
const files = findWhisperFiles(modelDir)
|
|
244
|
+
if (!files) throw new Error('whisper-turbo encoder/decoder not found after extraction')
|
|
245
|
+
modelFile = files.encoder
|
|
246
|
+
} else {
|
|
247
|
+
modelFile = findModelFile(modelDir)
|
|
248
|
+
if (!modelFile) throw new Error('model.int8.onnx not found after extraction')
|
|
249
|
+
}
|
|
168
250
|
|
|
169
251
|
installerState.modelPath = modelFile
|
|
170
252
|
installerState.progress = 100
|
|
@@ -182,7 +264,7 @@ export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSens
|
|
|
182
264
|
await startSensevoiceFn()
|
|
183
265
|
}
|
|
184
266
|
|
|
185
|
-
return { ok: true, modelPath: modelFile }
|
|
267
|
+
return { ok: true, modelType: targetModel, modelPath: modelFile }
|
|
186
268
|
} catch (err) {
|
|
187
269
|
await unlink(archivePath).catch(() => {})
|
|
188
270
|
installerState.installing = false
|
|
@@ -217,12 +299,18 @@ export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateCo
|
|
|
217
299
|
return
|
|
218
300
|
}
|
|
219
301
|
|
|
302
|
+
let bodyObj = {}
|
|
303
|
+
try {
|
|
304
|
+
const raw = await readBody(req, 16384)
|
|
305
|
+
bodyObj = JSON.parse(raw.toString('utf8') || '{}')
|
|
306
|
+
} catch { bodyObj = {} }
|
|
307
|
+
|
|
220
308
|
// Start background install
|
|
221
|
-
startSensevoiceInstall(ctx, liveConfig(), updateConfig, startSensevoice).catch((err) => {
|
|
222
|
-
ctx.logger?.warn?.(`
|
|
309
|
+
startSensevoiceInstall(ctx, liveConfig(), updateConfig, startSensevoice, bodyObj).catch((err) => {
|
|
310
|
+
ctx.logger?.warn?.(`ASR 1-click install error: ${String(err)}`)
|
|
223
311
|
})
|
|
224
312
|
|
|
225
|
-
writeJson(res, 202, { ok: true, message: '
|
|
313
|
+
writeJson(res, 202, { ok: true, message: 'ASR installation started', state: safeState })
|
|
226
314
|
return
|
|
227
315
|
}
|
|
228
316
|
|
|
@@ -245,50 +333,59 @@ export function resolveSensevoiceProvider(cfg) {
|
|
|
245
333
|
if (existsSync(rknnCandidate) || existsSync(defaultRknn)) {
|
|
246
334
|
return 'rknn'
|
|
247
335
|
}
|
|
248
|
-
// Guard: ordinary ONNX model cannot be loaded by sherpa-onnx RKNN runtime (#221)
|
|
249
336
|
return 'cpu'
|
|
250
337
|
}
|
|
251
338
|
if (ext === '.rknn') {
|
|
252
339
|
if (modelPath && existsSync(modelPath)) {
|
|
253
340
|
return 'rknn'
|
|
254
341
|
}
|
|
255
|
-
// Guard: missing .rknn model file cannot be claimed as running accelerator (#168)
|
|
256
342
|
return 'cpu'
|
|
257
343
|
}
|
|
258
344
|
return 'cpu'
|
|
259
345
|
}
|
|
260
346
|
|
|
347
|
+
if (['cpu', 'openvino', 'coreml', 'cuda'].includes(normProv)) {
|
|
348
|
+
return normProv
|
|
349
|
+
}
|
|
350
|
+
|
|
261
351
|
return normProv || 'cpu'
|
|
262
352
|
}
|
|
263
353
|
|
|
264
354
|
export function buildSensevoiceArgs(cfg, port) {
|
|
265
355
|
let modelPath = String(cfg?.sensevoiceModel || '')
|
|
266
|
-
const modelDir = modelPath ? path.dirname(modelPath) : getDefaultModelDir()
|
|
267
|
-
const tokensPath = (cfg?.sensevoiceTokens && existsSync(cfg.sensevoiceTokens))
|
|
268
|
-
? cfg.sensevoiceTokens
|
|
269
|
-
: findTokensFile(modelDir)
|
|
270
|
-
const tokensArg = tokensPath ? ` --tokens=${JSON.stringify(tokensPath)}` : ''
|
|
356
|
+
const modelDir = modelPath ? (existsSync(modelPath) ? path.dirname(modelPath) : modelPath) : getDefaultModelDir()
|
|
271
357
|
const effectiveProv = resolveSensevoiceProvider(cfg)
|
|
358
|
+
const provider = (effectiveProv && effectiveProv !== 'cpu') ? ` --provider=${effectiveProv}` : ''
|
|
359
|
+
const threads = Number(cfg?.sensevoiceThreads) || 4
|
|
360
|
+
|
|
361
|
+
const whisperFiles = findWhisperFiles(modelDir) || (modelPath ? findWhisperFiles(path.dirname(modelPath)) : null)
|
|
362
|
+
const isWhisper = Boolean(
|
|
363
|
+
whisperFiles ||
|
|
364
|
+
modelPath.toLowerCase().includes('whisper') ||
|
|
365
|
+
modelPath.toLowerCase().includes('turbo')
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
if (isWhisper && whisperFiles) {
|
|
369
|
+
const enc = JSON.stringify(whisperFiles.encoder)
|
|
370
|
+
const dec = JSON.stringify(whisperFiles.decoder)
|
|
371
|
+
const tok = whisperFiles.tokens ? ` --tokens=${JSON.stringify(whisperFiles.tokens)}` : ''
|
|
372
|
+
return ` --whisper-encoder=${enc} --whisper-decoder=${dec}${tok} --port=${port} --num-threads=${threads}${provider}`
|
|
373
|
+
}
|
|
272
374
|
|
|
273
375
|
if (effectiveProv === 'rknn') {
|
|
274
376
|
const ext = path.extname(modelPath).toLowerCase()
|
|
275
377
|
if (ext === '.onnx') {
|
|
276
378
|
const rknnCandidate = path.join(modelDir, path.basename(modelPath, ext) + '.rknn')
|
|
277
379
|
const defaultRknn = path.join(modelDir, 'model.rknn')
|
|
278
|
-
if (existsSync(rknnCandidate))
|
|
279
|
-
|
|
280
|
-
} else if (existsSync(defaultRknn)) {
|
|
281
|
-
modelPath = defaultRknn
|
|
282
|
-
}
|
|
380
|
+
if (existsSync(rknnCandidate)) modelPath = rknnCandidate
|
|
381
|
+
else if (existsSync(defaultRknn)) modelPath = defaultRknn
|
|
283
382
|
}
|
|
284
383
|
}
|
|
285
384
|
|
|
286
|
-
const
|
|
287
|
-
?
|
|
288
|
-
:
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
+ ` --port=${port} --num-threads=${threads}`
|
|
293
|
-
+ provider
|
|
385
|
+
const tokensPath = (cfg?.sensevoiceTokens && existsSync(cfg.sensevoiceTokens))
|
|
386
|
+
? cfg.sensevoiceTokens
|
|
387
|
+
: findTokensFile(modelDir)
|
|
388
|
+
const tokensArg = tokensPath ? ` --tokens=${JSON.stringify(tokensPath)}` : ''
|
|
389
|
+
|
|
390
|
+
return ` --sense-voice-model=${JSON.stringify(modelPath)}${tokensArg} --port=${port} --num-threads=${threads}${provider}`
|
|
294
391
|
}
|
package/lib/wav.js
CHANGED
|
@@ -70,3 +70,30 @@ export function toWav16k(bytes, ffmpegBin = 'ffmpeg', signal = null) {
|
|
|
70
70
|
proc.stdin.end(Buffer.from(bytes))
|
|
71
71
|
})
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Generate a synthetic PCM 16-bit mono WAV buffer (silence).
|
|
76
|
+
* Used for express hardware benchmarking and latency profiling without external files.
|
|
77
|
+
* @param {number} durationSeconds
|
|
78
|
+
* @param {number} sampleRate
|
|
79
|
+
* @returns {Buffer}
|
|
80
|
+
*/
|
|
81
|
+
export function createSyntheticWav(durationSeconds = 1.0, sampleRate = 16000) {
|
|
82
|
+
const numSamples = Math.max(1, Math.floor(durationSeconds * sampleRate))
|
|
83
|
+
const dataSize = numSamples * 2
|
|
84
|
+
const buf = Buffer.alloc(44 + dataSize)
|
|
85
|
+
buf.write('RIFF', 0)
|
|
86
|
+
buf.writeUInt32LE(36 + dataSize, 4)
|
|
87
|
+
buf.write('WAVE', 8)
|
|
88
|
+
buf.write('fmt ', 12)
|
|
89
|
+
buf.writeUInt32LE(16, 16)
|
|
90
|
+
buf.writeUInt16LE(1, 20) // PCM format
|
|
91
|
+
buf.writeUInt16LE(1, 22) // Mono
|
|
92
|
+
buf.writeUInt32LE(sampleRate, 24)
|
|
93
|
+
buf.writeUInt32LE(sampleRate * 2, 28) // Byte rate
|
|
94
|
+
buf.writeUInt16LE(2, 32) // Block align
|
|
95
|
+
buf.writeUInt16LE(16, 34) // 16-bit
|
|
96
|
+
buf.write('data', 36)
|
|
97
|
+
buf.writeUInt32LE(dataSize, 40)
|
|
98
|
+
return buf
|
|
99
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.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",
|