@goodandready/dsh-voice 0.9.33 → 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,19 +68,16 @@ 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)',
74
- 'openaiHint': 'whisper-1 · key OPENAI_API_KEY',
75
- 'siliconflowHint': 'FunAudioLLM/SenseVoiceSmall · key SILICONFLOW_API_KEY',
76
- 'deepinfraHint': 'openai/whisper-large-v3-turbo · key DEEPINFRA_API_KEY',
77
- 'fireworksHint': 'whisper-v3-turbo · key FIREWORKS_API_KEY',
78
- 'mistralHint': 'voxtral-mini-latest · key MISTRAL_API_KEY',
79
- 'openrouterHint': 'google/gemini-2.5-flash · key OPENROUTER_API_KEY',
80
- 'localHint': 'set when the server is started',
81
- 'up': 'Up',
82
- 'down': 'Down',
83
- 'remove': 'Remove',
77
+ 'openaiHint': 'whisper-1 · key OPENAI_API_KEY', 'siliconflowHint': 'FunAudioLLM/SenseVoiceSmall · key SILICONFLOW_API_KEY',
78
+ 'deepinfraHint': 'openai/whisper-large-v3-turbo · key DEEPINFRA_API_KEY', 'fireworksHint': 'whisper-v3-turbo · key FIREWORKS_API_KEY',
79
+ 'mistralHint': 'voxtral-mini-latest · key MISTRAL_API_KEY', 'openrouterHint': 'google/gemini-2.5-flash · key OPENROUTER_API_KEY',
80
+ 'localHint': 'set when the server is started', 'up': 'Up', 'down': 'Down', 'remove': 'Remove',
84
81
  'addProvider': 'Add provider',
85
82
  'chainHint': 'Top to bottom is the order they are tried in',
86
83
  'customName': 'name used in the chain',
@@ -297,20 +294,15 @@ window.__ModuleLoader__.load({
297
294
  'wizardMicPermissionBad': 'Microphone permission denied',
298
295
  'wizardRequestMic': 'Request Microphone Access',
299
296
  'wizardMicLabel': 'Microphone input device:',
300
- 'wizardStartSignalCheck': 'Start Audio Level Test',
301
- 'wizardStopSignalCheck': 'Stop Audio Test',
297
+ 'wizardStartSignalCheck': 'Start Audio Level Test', 'wizardStopSignalCheck': 'Stop Audio Test',
302
298
  'wizardSignalOk': 'Audio signal detected (RMS: {level}%)',
303
299
  'wizardSignalZero': 'Zero audio level detected - speak into microphone or select correct device',
304
300
  'wizardSignalTesting': 'Listening for audio signal… speak into your microphone',
305
301
  'wizardProviderLabel': 'Active STT engine & model:',
306
302
  'wizardProcessingArea': 'Processing area:',
307
- 'wizardAreaBrowser': 'In-browser (WebGPU / WebSpeech)',
308
- 'wizardAreaServer': 'Local MiniPC server (SenseVoice / Whisper)',
309
- 'wizardAreaCloud': 'Remote cloud provider',
310
- 'wizardStartTestRecord': 'Record 3-Second Test Phrase',
311
- 'wizardRecordingTest': 'Recording test phrase… speak now ({left}s)',
312
- 'wizardTranscribingTest': 'Transcribing test phrase…',
313
- '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:',
314
306
  'wizardTestSuccess': 'STT round-trip verified successfully! Latency: {latency}ms',
315
307
  'wizardTestEmpty': 'No speech detected in test recording. Please speak clearly and try again.',
316
308
  'wizardTestFailed': 'STT test failed: {error}',
@@ -322,6 +314,12 @@ window.__ModuleLoader__.load({
322
314
  'wizardFinishSuccess': 'Setup & Verification Complete',
323
315
  'wizardSkip': 'Skip Wizard',
324
316
  'wizardStatusCompleted': 'Microphone and STT verified',
317
+ 'exportDiagnosticsBtn': 'Export Diagnostics Report', 'exportingDiagnostics': 'Exporting report…',
318
+ 'diagnosticsExportSuccess': 'Report downloaded', 'recentRecognitionsTitle': 'Recent Recognition Telemetry',
319
+ 'noRecognitionsYet': 'No recent recognition records yet.', 'diagLatencyBreakdown': 'Latency breakdown:',
320
+ 'diagVad': 'VAD: {ms}ms', 'diagStt': 'STT: {ms}ms', 'diagPolish': 'Polish: {ms}ms',
321
+ 'diagTotal': 'Total: {ms}ms', 'diagBytes': 'Audio: {bytes} B', 'diagWords': 'Words: {count}',
322
+ 'diagExportNotice': 'Exported JSON contains anonymized timings, provider chain, MIME format, and error codes without raw audio or speech transcripts.',
325
323
  }
326
324
 
327
325
  const zh = {
@@ -358,19 +356,16 @@ 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 (将自动回退到服务端)',
364
- 'openaiHint': 'whisper-1 · 密钥 OPENAI_API_KEY',
365
- 'siliconflowHint': 'FunAudioLLM/SenseVoiceSmall · 密钥 SILICONFLOW_API_KEY',
366
- 'deepinfraHint': 'openai/whisper-large-v3-turbo · 密钥 DEEPINFRA_API_KEY',
367
- 'fireworksHint': 'whisper-v3-turbo · 密钥 FIREWORKS_API_KEY',
368
- 'mistralHint': 'voxtral-mini-latest · 密钥 MISTRAL_API_KEY',
369
- 'openrouterHint': 'google/gemini-2.5-flash · 密钥 OPENROUTER_API_KEY',
370
- 'localHint': '服务器启动时配置',
371
- 'up': '上移',
372
- 'down': '下移',
373
- 'remove': '移除',
365
+ 'openaiHint': 'whisper-1 · 密钥 OPENAI_API_KEY', 'siliconflowHint': 'FunAudioLLM/SenseVoiceSmall · 密钥 SILICONFLOW_API_KEY',
366
+ 'deepinfraHint': 'openai/whisper-large-v3-turbo · 密钥 DEEPINFRA_API_KEY', 'fireworksHint': 'whisper-v3-turbo · 密钥 FIREWORKS_API_KEY',
367
+ 'mistralHint': 'voxtral-mini-latest · 密钥 MISTRAL_API_KEY', 'openrouterHint': 'google/gemini-2.5-flash · 密钥 OPENROUTER_API_KEY',
368
+ 'localHint': '服务器启动时配置', 'up': '上移', 'down': '下移', 'remove': '移除',
374
369
  'addProvider': '添加服务商',
375
370
  'chainHint': '自上而下为故障转移尝试顺序',
376
371
  'customName': '备用链中使用的名称',
@@ -594,13 +589,9 @@ window.__ModuleLoader__.load({
594
589
  'wizardSignalTesting': '正在监听声音信号… 请对着麦克风发声',
595
590
  'wizardProviderLabel': '当前语音引擎与模型:',
596
591
  'wizardProcessingArea': '处理区域:',
597
- 'wizardAreaBrowser': '浏览器端处理 (WebGPU / WebSpeech)',
598
- 'wizardAreaServer': '本地服务器处理 (SenseVoice / Whisper)',
599
- 'wizardAreaCloud': '远程云端服务',
600
- 'wizardStartTestRecord': '录制 3 秒测试语句',
601
- 'wizardRecordingTest': '正在录制测试音频… 请说话 ({left}秒)',
602
- 'wizardTranscribingTest': '正在请求语音识别…',
603
- 'wizardTestResult': 'STT 测试结果:',
592
+ 'wizardAreaBrowser': '浏览器端处理 (WebGPU / WebSpeech)', 'wizardAreaServer': '本地服务器处理 (SenseVoice / Whisper)', 'wizardAreaCloud': '远程云端服务',
593
+ 'wizardStartTestRecord': '录制 3 秒测试语句', 'wizardRecordingTest': '正在录制测试音频… 请说话 ({left}秒)',
594
+ 'wizardTranscribingTest': '正在请求语音识别…', 'wizardTestResult': 'STT 测试结果:',
604
595
  'wizardTestSuccess': 'STT 链路验证成功!耗时:{latency}毫秒',
605
596
  'wizardTestEmpty': '录音中未检测到有效语音,请清晰发音后重试。',
606
597
  'wizardTestFailed': 'STT 测试失败:{error}',
@@ -612,6 +603,12 @@ window.__ModuleLoader__.load({
612
603
  'wizardFinishSuccess': '设置与验证已完成',
613
604
  'wizardSkip': '跳过向导',
614
605
  'wizardStatusCompleted': '麦克风与 STT 诊断已就绪',
606
+ 'exportDiagnosticsBtn': '导出诊断报告', 'exportingDiagnostics': '正在导出报告…',
607
+ 'diagnosticsExportSuccess': '诊断报告已下载', 'recentRecognitionsTitle': '近期识别遥测详情',
608
+ 'noRecognitionsYet': '暂无近期识别记录。', 'diagLatencyBreakdown': '耗时分布:',
609
+ 'diagVad': 'VAD:{ms}毫秒', 'diagStt': 'STT:{ms}毫秒', 'diagPolish': '后处理:{ms}毫秒',
610
+ 'diagTotal': '总耗时:{ms}毫秒', 'diagBytes': '音频大小:{bytes} 字节', 'diagWords': '词数:{count}',
611
+ 'diagExportNotice': '导出的 JSON 报告包含脱敏的耗时统计、备用链尝试、MIME 格式与错误码,不含任何原始音频或语音文本。',
615
612
  }
616
613
 
617
614
  // Strings are also needed outside components — in recording handlers and
@@ -3160,18 +3157,61 @@ function cycleDictationMode() {
3160
3157
  ),
3161
3158
  )
3162
3159
  }
3163
- // Provider Telemetry & Latency Dashboard
3160
+ // Provider Telemetry & Latency Dashboard (#266)
3164
3161
  function ProviderDashboard(props) {
3165
3162
  const t = props.t || moduleT
3166
3163
  const statsData = props.statsData || {}
3164
+ const recentDiagnostics = props.recentDiagnostics || []
3165
+ const [exporting, setExporting] = React.useState(false)
3166
+ const [exportMsg, setExportMsg] = React.useState('')
3167
+
3168
+ const exportReport = async () => {
3169
+ setExporting(true)
3170
+ setExportMsg('')
3171
+ try {
3172
+ const res = await fetch('/dsh-voice/diagnostics/export')
3173
+ const data = await res.json()
3174
+ if (!res.ok || !data || !data.ok) {
3175
+ throw new Error((data && data.error && data.error.message) || `HTTP ${res.status}`)
3176
+ }
3177
+ const blob = new Blob([JSON.stringify(data.report || {}, null, 2)], { type: 'application/json' })
3178
+ const url = URL.createObjectURL(blob)
3179
+ const a = document.createElement('a')
3180
+ a.href = url
3181
+ a.download = `dsh-voice-diagnostics-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
3182
+ document.body.appendChild(a)
3183
+ a.click()
3184
+ document.body.removeChild(a)
3185
+ URL.revokeObjectURL(url)
3186
+ setExportMsg('✓ ' + t('diagnosticsExportSuccess'))
3187
+ } catch (err) {
3188
+ setExportMsg(String(err && err.message ? err.message : err))
3189
+ } finally {
3190
+ setExporting(false)
3191
+ }
3192
+ }
3193
+
3167
3194
  return React.createElement('div', { className: 'cb-section-card' },
3168
3195
  React.createElement('div', { className: 'cb-section-title' },
3169
3196
  React.createElement('span', null, '📊 ' + t('providerDashboard')),
3170
- React.createElement('button', {
3171
- type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
3172
- onClick: props.onRefresh,
3173
- }, '🔄 ' + t('refreshStats')),
3197
+ React.createElement('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
3198
+ React.createElement('button', {
3199
+ type: 'button',
3200
+ className: 'cb-btn',
3201
+ style: { fontSize: '12px', padding: '4px 10px' },
3202
+ disabled: exporting,
3203
+ onClick: exportReport,
3204
+ }, exporting ? t('exportingDiagnostics') : ('📥 ' + t('exportDiagnosticsBtn'))),
3205
+ React.createElement('button', {
3206
+ type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
3207
+ onClick: props.onRefresh,
3208
+ }, '🔄 ' + t('refreshStats')),
3209
+ ),
3174
3210
  ),
3211
+ exportMsg ? React.createElement('div', {
3212
+ className: exportMsg.startsWith('✓') ? 'dvs-ok' : 'cb-alert-err',
3213
+ style: { fontSize: '12px' },
3214
+ }, exportMsg) : null,
3175
3215
  React.createElement('div', { className: 'dvo-dash' },
3176
3216
  React.createElement('div', { className: 'dvo-dash-grid' },
3177
3217
  Object.keys(statsData).length === 0
@@ -3211,6 +3251,41 @@ function cycleDictationMode() {
3211
3251
  }),
3212
3252
  ),
3213
3253
  ),
3254
+ // Per-recognition telemetry breakdown list (#266)
3255
+ React.createElement('div', { style: { marginTop: '12px', borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: '10px' } },
3256
+ React.createElement('div', { className: 'dvs-h', style: { marginBottom: '8px' } }, t('recentRecognitionsTitle')),
3257
+ recentDiagnostics.length === 0
3258
+ ? React.createElement('div', { className: 'dvs-sub' }, t('noRecognitionsYet'))
3259
+ : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } },
3260
+ recentDiagnostics.slice(-5).reverse().map((d) => React.createElement('div', {
3261
+ key: d.id,
3262
+ className: 'dvs-card',
3263
+ style: { padding: '8px 12px', fontSize: '12px' },
3264
+ },
3265
+ React.createElement('div', { className: 'cb-row', style: { justifyContent: 'space-between', alignItems: 'center' } },
3266
+ React.createElement('div', { style: { display: 'flex', gap: '6px', alignItems: 'center' } },
3267
+ React.createElement('span', { className: 'cb-badge ' + (d.success ? 'cb-badge-ok' : 'cb-badge-err') },
3268
+ d.success ? '✓ ' + (d.provider || 'STT') : '✗ ' + (d.error ? d.error.slice(0, 30) : 'fail')
3269
+ ),
3270
+ React.createElement('span', { className: 'dvs-sub' }, `[${d.mode}/${d.subMode}]`),
3271
+ d.audioBytes ? React.createElement('span', { className: 'dvs-sub' }, t('diagBytes').replace('{bytes}', d.audioBytes)) : null,
3272
+ ),
3273
+ React.createElement('span', { style: { fontWeight: '600' } },
3274
+ t('diagTotal').replace('{ms}', d.totalTookMs)
3275
+ ),
3276
+ ),
3277
+ React.createElement('div', { className: 'cb-row', style: { gap: '12px', marginTop: '4px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } },
3278
+ React.createElement('span', null, t('diagVad').replace('{ms}', (d.breakdown && d.breakdown.vadMs) || 0)),
3279
+ React.createElement('span', null, t('diagStt').replace('{ms}', (d.breakdown && d.breakdown.sttMs) || 0)),
3280
+ d.breakdown && d.breakdown.polishMs ? React.createElement('span', null, t('diagPolish').replace('{ms}', d.breakdown.polishMs)) : null,
3281
+ typeof d.wordCount === 'number' ? React.createElement('span', null, t('diagWords').replace('{count}', d.wordCount)) : null,
3282
+ ),
3283
+ ))
3284
+ ),
3285
+ React.createElement('div', { className: 'dvs-sub', style: { marginTop: '8px', fontStyle: 'italic' } },
3286
+ t('diagExportNotice')
3287
+ ),
3288
+ ),
3214
3289
  )
3215
3290
  }
3216
3291
  // ------------------------------------------------------------------ 72-dictation-modes
@@ -3316,6 +3391,32 @@ function DictationModesSection(props) {
3316
3391
  const gateThresholdPercent = props.gateThresholdPercent
3317
3392
  const onClearKey = props.onClearKey
3318
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
+
3319
3420
  const textField = (key, label, hint) => React.createElement('div', { className: 'cb-field' },
3320
3421
  React.createElement('label', null, label),
3321
3422
  React.createElement('input', {
@@ -3486,6 +3587,7 @@ function DictationModesSection(props) {
3486
3587
  },
3487
3588
  React.createElement('option', { value: 'cpu' }, 'CPU (Standard)'),
3488
3589
  React.createElement('option', { value: 'rknn' }, 'Rockchip RK3588 NPU (RKNN)'),
3590
+ React.createElement('option', { value: 'coreml' }, 'Apple Silicon CoreML'),
3489
3591
  React.createElement('option', { value: 'openvino' }, 'Intel OpenVINO'),
3490
3592
  React.createElement('option', { value: 'cuda' }, 'NVIDIA CUDA'),
3491
3593
  ),
@@ -3502,6 +3604,24 @@ function DictationModesSection(props) {
3502
3604
  ),
3503
3605
  React.createElement('span', { className: 'dvs-sub' }, t('npuProviderHint')),
3504
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
+ ),
3505
3625
  // WebGPU Status Indicator
3506
3626
  React.createElement('div', { className: 'cb-field' },
3507
3627
  React.createElement('label', null, t('webgpuStatus')),
@@ -3549,7 +3669,7 @@ function DictationModesSection(props) {
3549
3669
  fetchSensevoice()
3550
3670
  }, [])
3551
3671
 
3552
- const triggerInstallSensevoice = () => {
3672
+ const triggerInstallSensevoice = (modelType = 'sensevoice') => {
3553
3673
  setInstallingSensevoice(true)
3554
3674
  fetch('/dsh-voice/sensevoice-installer', {
3555
3675
  method: 'POST',
@@ -3557,6 +3677,7 @@ function DictationModesSection(props) {
3557
3677
  'x-dsh-plugin-update': '1',
3558
3678
  'Content-Type': 'application/json',
3559
3679
  },
3680
+ body: JSON.stringify({ model: modelType }),
3560
3681
  })
3561
3682
  .then((r) => r.json())
3562
3683
  .then(() => {
@@ -3855,39 +3976,51 @@ function MacrosSection(props) {
3855
3976
  ),
3856
3977
  )
3857
3978
  }
3858
- // SenseVoice Local ASR (1-Click Setup)
3979
+ // Local ASR (SenseVoice-Small & Whisper Large-v3-Turbo 1-Click Setup)
3859
3980
  function SensevoiceSection(props) {
3860
3981
  const t = props.t || moduleT
3861
3982
  const sensevoiceState = props.sensevoiceState
3862
3983
  const installingSensevoice = props.installingSensevoice
3863
3984
  const writable = props.writable
3864
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'))
3865
3993
 
3866
3994
  return React.createElement('div', { className: 'cb-section-card' },
3867
3995
  React.createElement('div', { className: 'cb-section-title' },
3868
3996
  React.createElement('span', null, '⚡ ' + t('sensevoiceTitle')),
3869
- sensevoiceState && sensevoiceState.installed
3870
- ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, t('sensevoiceReady') + ' (small-int8)')
3871
- : React.createElement('span', { className: 'cb-badge cb-badge-warn' },
3872
- installingSensevoice ? t('sensevoiceDownloading') : t('sensevoiceNotInstalled')
3873
- ),
3997
+ React.createElement('span', { className: 'cb-badge ' + (isInstalled ? 'cb-badge-ok' : 'cb-badge-warn') }, badgeText),
3874
3998
  ),
3875
3999
  React.createElement('div', { className: 'cb-section-desc' }, t('sensevoiceDesc')),
3876
4000
  React.createElement('div', { className: 'dvo-installer-box' },
3877
4001
  React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', justifyContent: 'space-between', gap: '12px' } },
3878
- React.createElement('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-content-secondary)' } },
3879
- sensevoiceState && sensevoiceState.path
3880
- ? ('~/' + sensevoiceState.path.replace(/^.*[\\/](\.dsh[\\/]models[\\/].*)$/, '$1'))
3881
- : '~/.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)'),
3882
4010
  ),
3883
4011
  React.createElement('button', {
3884
4012
  type: 'button',
3885
4013
  className: 'cb-btn cb-btn-primary',
3886
- style: { fontSize: '12px', padding: '6px 12px' },
4014
+ style: { fontSize: '12px', padding: '6px 12px', whiteSpace: 'nowrap' },
3887
4015
  disabled: installingSensevoice || !writable,
3888
- onClick: onInstall,
4016
+ onClick: () => { if (typeof onInstall === 'function') onInstall(modelChoice) },
3889
4017
  }, installingSensevoice ? ('⏳ ' + t('installingSensevoice')) : ('📥 ' + t('installSensevoice'))),
3890
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
+ ),
3891
4024
  installingSensevoice
3892
4025
  ? React.createElement('div', { style: { marginTop: '8px' } },
3893
4026
  React.createElement('div', { className: 'dvo-progress-bar' },
@@ -4303,6 +4436,7 @@ function MacrosSection(props) {
4303
4436
  React.createElement('span', { className: 'dvs-sub' }, hint),
4304
4437
  )
4305
4438
  const statsData = (statusData && statusData.providerStats) || {}
4439
+ const recentDiagnostics = (statusData && statusData.recentDiagnostics) || []
4306
4440
  const hostOnline = statusLatency !== null
4307
4441
  const whisperRunning = !!(statusData && statusData.whisperRunning)
4308
4442
  const providerCount = (statusData && Array.isArray(statusData.providers)) ? statusData.providers.length : chainOptions.length
@@ -4535,6 +4669,7 @@ function MacrosSection(props) {
4535
4669
  React.createElement(ProviderDashboard, {
4536
4670
  t: t,
4537
4671
  statsData: statsData,
4672
+ recentDiagnostics: recentDiagnostics,
4538
4673
  onRefresh: fetchStatus,
4539
4674
  }),
4540
4675
  React.createElement(UpdaterCard, {
@@ -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' })
@@ -96,6 +98,7 @@ export function registerHostRoutes(ctx, deps) {
96
98
  dictationPrompts: cfg.dictationPrompts || {},
97
99
  voiceMacros: Array.isArray(cfg.voiceMacros) ? cfg.voiceMacros : [],
98
100
  providerStats: statsTracker.get(),
101
+ recentDiagnostics: typeof statsTracker.getDiagnostics === 'function' ? statsTracker.getDiagnostics() : [],
99
102
  })
100
103
  },
101
104
  }), 'dsh-voice: /status route')
@@ -133,6 +136,91 @@ export function registerHostRoutes(ctx, deps) {
133
136
  },
134
137
  }), 'dsh-voice: /polish route')
135
138
 
139
+ ctx.effect(() => ctx.webServer.register({
140
+ kind: 'exact',
141
+ path: '/dsh-voice/diagnostics/export',
142
+ handler: async (req, res) => {
143
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
144
+ if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
145
+ const report = typeof statsTracker.exportAnonymizedReport === 'function'
146
+ ? statsTracker.exportAnonymizedReport({ version: '0.9.34', environment: 'production' })
147
+ : { summary: statsTracker.get(), recentDiagnostics: [] }
148
+ writeJson(res, 200, { ok: true, report })
149
+ },
150
+ }), 'dsh-voice: /diagnostics/export route')
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
+
136
224
  ctx.effect(() => ctx.webServer.register({
137
225
  kind: 'exact',
138
226
  path: '/dsh-voice/transcribe',
@@ -178,13 +266,31 @@ export function registerHostRoutes(ctx, deps) {
178
266
  await startSensevoice()
179
267
  }
180
268
 
269
+ const reqStart = Date.now()
181
270
  const controller = new AbortController()
182
271
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
272
+ let out = null
273
+ let polishTookMs = 0
183
274
  try {
184
- const out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
275
+ out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
185
276
  if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
186
277
  const cmd = sessionCommand(out.text)
187
- if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
278
+ if (cmd) {
279
+ if (typeof statsTracker.recordDiagnostic === 'function') {
280
+ statsTracker.recordDiagnostic({
281
+ mode: payload.mode || 'dictation',
282
+ subMode: 'command',
283
+ mimeType: mime,
284
+ audioBytes: bytes.length,
285
+ provider: out.provider,
286
+ success: true,
287
+ totalTookMs: Date.now() - reqStart,
288
+ sttMs: out.tookMs || 0,
289
+ vadMs: Number(payload.vadMs) || 0,
290
+ })
291
+ }
292
+ writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return
293
+ }
188
294
  }
189
295
  const subMode = payload.subMode || payload.dictationMode || cfg.dictationMode || 'clean'
190
296
  const rawText = out.text
@@ -192,7 +298,9 @@ export function registerHostRoutes(ctx, deps) {
192
298
 
193
299
  // Dictation modes & polish (#264): verbatim skips LLM entirely (0 LLM calls)
194
300
  if (subMode !== 'verbatim' && (modeCfg.polish === true || payload.polish === true)) {
301
+ const polishStart = Date.now()
195
302
  text = await polishText(rawText, { ...modeCfg, subMode, prompt: payload.prompt }, controller.signal)
303
+ polishTookMs = Date.now() - polishStart
196
304
  }
197
305
 
198
306
  // Voice action macros (#167)
@@ -211,8 +319,37 @@ export function registerHostRoutes(ctx, deps) {
211
319
  action = actionRes.action
212
320
  }
213
321
  }
214
- writeJson(res, 200, { ok: true, text, rawText, subMode, action, provider: out.provider, tookMs: out.tookMs })
322
+ const totalTookMs = Date.now() - reqStart
323
+ if (typeof statsTracker.recordDiagnostic === 'function') {
324
+ statsTracker.recordDiagnostic({
325
+ mode: payload.mode || 'dictation',
326
+ subMode,
327
+ mimeType: mime,
328
+ audioBytes: bytes.length,
329
+ provider: out.provider,
330
+ success: true,
331
+ totalTookMs,
332
+ sttMs: out.tookMs || 0,
333
+ polishMs: polishTookMs,
334
+ vadMs: Number(payload.vadMs) || 0,
335
+ wordCount: text ? text.split(/\s+/).filter(Boolean).length : 0,
336
+ })
337
+ }
338
+ writeJson(res, 200, { ok: true, text, rawText, subMode, action, provider: out.provider, tookMs: out.tookMs, polishTookMs, totalTookMs })
215
339
  } catch (e) {
340
+ if (typeof statsTracker.recordDiagnostic === 'function') {
341
+ statsTracker.recordDiagnostic({
342
+ mode: payload.mode || 'dictation',
343
+ subMode: payload.subMode || 'clean',
344
+ mimeType: mime,
345
+ audioBytes: bytes.length,
346
+ provider: (out && out.provider) || 'unknown',
347
+ success: false,
348
+ totalTookMs: Date.now() - reqStart,
349
+ vadMs: Number(payload.vadMs) || 0,
350
+ error: String(e && e.message || e),
351
+ })
352
+ }
216
353
  writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
217
354
  } finally {
218
355
  clearTimeout(timer)
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/stats.js CHANGED
@@ -3,6 +3,55 @@
3
3
 
4
4
  export function createStatsTracker() {
5
5
  const stats = new Map()
6
+ const diagnostics = []
7
+ const MAX_DIAGNOSTICS = 50
8
+
9
+ function recordDiagnostic(entry = {}) {
10
+ diagnostics.push({
11
+ id: Math.random().toString(36).slice(2, 10),
12
+ timestamp: Date.now(),
13
+ mode: entry.mode || 'dictation',
14
+ subMode: entry.subMode || 'clean',
15
+ mimeType: entry.mimeType || 'audio/webm',
16
+ audioBytes: Number(entry.audioBytes) || 0,
17
+ provider: entry.provider || 'unknown',
18
+ success: !!entry.success,
19
+ totalTookMs: Number(entry.totalTookMs) || 0,
20
+ breakdown: {
21
+ vadMs: Number(entry.vadMs) || 0,
22
+ sttMs: Number(entry.sttMs) || 0,
23
+ polishMs: Number(entry.polishMs) || 0,
24
+ },
25
+ chainAttempts: Array.isArray(entry.chainAttempts) ? entry.chainAttempts.map((a) => ({
26
+ provider: a.provider || 'unknown',
27
+ ok: !!a.ok,
28
+ tookMs: Number(a.tookMs) || 0,
29
+ reason: a.reason ? String(a.reason).slice(0, 150) : undefined,
30
+ })) : [],
31
+ error: entry.error ? String(entry.error).slice(0, 200) : undefined,
32
+ wordCount: typeof entry.wordCount === 'number' ? entry.wordCount : undefined,
33
+ })
34
+ if (diagnostics.length > MAX_DIAGNOSTICS) {
35
+ diagnostics.shift()
36
+ }
37
+ }
38
+
39
+ function getDiagnostics() {
40
+ return diagnostics.slice()
41
+ }
42
+
43
+ function exportAnonymizedReport(appInfo = {}) {
44
+ return {
45
+ exportedAt: new Date().toISOString(),
46
+ app: {
47
+ name: '@goodandready/dsh-voice',
48
+ version: appInfo.version || '0.9.34',
49
+ environment: appInfo.environment || 'production',
50
+ },
51
+ summary: get(),
52
+ recentDiagnostics: getDiagnostics(),
53
+ }
54
+ }
6
55
 
7
56
  function record(key, res = {}) {
8
57
  if (!key) return
@@ -51,9 +100,10 @@ export function createStatsTracker() {
51
100
 
52
101
  function clear() {
53
102
  stats.clear()
103
+ diagnostics.length = 0
54
104
  }
55
105
 
56
- return { record, get, clear }
106
+ return { record, get, clear, recordDiagnostic, getDiagnostics, exportAnonymizedReport }
57
107
  }
58
108
 
59
109
  export function mergeContextVocabulary(baseVocab = [], contextWords = [], maxWords = 50) {
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.33",
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",