@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 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
- 'wizardAreaServer': 'Local MiniPC server (SenseVoice / Whisper)',
303
- 'wizardAreaCloud': 'Remote cloud provider',
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
- 'wizardAreaServer': '本地服务器处理 (SenseVoice / Whisper)',
593
- 'wizardAreaCloud': '远程云端服务',
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
- // SenseVoice Local ASR (1-Click Setup)
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
- sensevoiceState && sensevoiceState.installed
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('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-content-secondary)' } },
3957
- sensevoiceState && sensevoiceState.path
3958
- ? ('~/' + sensevoiceState.path.replace(/^.*[\\/](\.dsh[\\/]models[\\/].*)$/, '$1'))
3959
- : '~/.dsh/models/sensevoice'
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' },
@@ -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), "openvino", "cuda".'),
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 / sherpa-onnx
2
- // Downloads pre-quantized ONNX model and configures autostart on the host.
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(".dsh/models/")
48
- if (idx !== -1) return "~/" + str.slice(idx)
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 modelDir = cfg?.sensevoiceModel ? path.dirname(cfg.sensevoiceModel) : getDefaultModelDir()
67
- const modelFile = cfg?.sensevoiceModel && existsSync(cfg.sensevoiceModel)
68
- ? cfg.sensevoiceModel
69
- : findModelFile(getDefaultModelDir())
70
- const tokensFile = modelFile ? findTokensFile(path.dirname(modelFile)) : null
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(modelFile && existsSync(modelFile))
139
+ const installed = Boolean(activeModelFile && existsSync(activeModelFile))
74
140
 
75
141
  return {
76
142
  installed,
77
143
  running,
78
- modelPath: redactModelPath(modelFile),
79
- tokensPath: redactModelPath(tokensFile),
80
- path: installed ? '~/.dsh/models/sensevoice' : '',
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('SenseVoice installation is already in progress')
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, 'sensevoice.tar.bz2')
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(SENSEVOICE_MODEL_URL, archivePath, (pct, status) => {
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 (e1) {
150
- // Fallback to mirror
225
+ } catch {
151
226
  installerState.step = 'downloading (mirror)'
152
- await downloadArchive(SENSEVOICE_MODEL_MIRROR, archivePath, (pct, status) => {
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('Could not download SenseVoice model from primary or mirror URL')
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
- const modelFile = findModelFile(modelDir)
167
- if (!modelFile) throw new Error('model.int8.onnx not found after extraction')
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?.(`SenseVoice 1-click install error: ${String(err)}`)
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: 'SenseVoice installation started', state: safeState })
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
- modelPath = rknnCandidate
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 provider = (effectiveProv && effectiveProv !== 'cpu')
287
- ? ` --provider=${effectiveProv}`
288
- : ''
289
- const threads = Number(cfg?.sensevoiceThreads) || 4
290
- return ` --sense-voice-model=${JSON.stringify(modelPath)}`
291
- + tokensArg
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.34",
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",