@goodandready/dsh-voice 0.9.34 → 0.9.36

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}',
@@ -621,61 +618,61 @@ window.__ModuleLoader__.load({
621
618
  // ------------------------------------------------------------------ css
622
619
  const CSS =
623
620
  '.dvo-btn{display:flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:0;box-sizing:border-box;user-select:none;-webkit-user-select:none;touch-action:manipulation}' +
624
- '.dvo-btn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}' +
625
- '.dvo-btn[data-err="1"]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}' +
626
- '.dvo-pill{display:flex;align-items:center;gap:10px;height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent);box-sizing:border-box;user-select:none;-webkit-user-select:none}' +
627
- '.dvo-chev-icon{width:14px;height:14px;display:block;flex:none}' +
628
- '.dvo-pbtn{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);user-select:none;-webkit-user-select:none;touch-action:manipulation}' +
629
- '.dvo-pbtn:hover{background:var(--dsw-alias-bg-layer-2)}' +
630
- '.dvo-wave{flex:1;min-width:0;max-width:100%;height:40px;width:100%;color:var(--dsw-alias-label-primary)}' +
631
- '.dvo-status{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-secondary);font-size:13px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
632
- '.dvo-err{color:var(--dsw-alias-state-error-primary)}' +
633
- '.dvo-count{font-variant-numeric:tabular-nums;font-size:13px;color:var(--dsw-alias-label-secondary)}' +
634
- '.dvo-spin{animation:dvo-spin 1s linear infinite}' +
635
- '@keyframes dvo-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
636
- '.dvo-btn-active{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}' +
637
- '.dvo-audio-wrap{display:flex;align-items:center;gap:8px;padding:2px 10px;background:var(--dsw-alias-bg-layer-2);border-radius:14px;border:1px solid var(--dsw-alias-border-l1)}' +
638
- '.dvo-audio-play{display:flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);border:0;cursor:pointer;padding:0}' +
639
- '.dvo-audio-play:hover{opacity:0.9}' +
640
- '.dvo-audio-time{font-size:12px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}' +
641
- '.dvo-dash{display:flex;flex-direction:column;gap:8px;margin-top:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:12px}' +
642
- '.dvo-dash-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px;margin-top:4px}' +
643
- '.dvo-dash-item{display:flex;flex-direction:column;gap:4px;padding:8px 10px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l1);border-radius:8px}' +
644
- '.dvo-dash-name{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
645
- '.dvo-dash-row{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--dsw-alias-label-secondary)}' +
646
- '.dvo-badge{display:inline-flex;align-items:center;font-size:11px;padding:1px 6px;border-radius:6px;font-weight:600}' +
647
- '.dvo-badge-fast{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-success-primary);border:1px solid var(--dsw-alias-state-success-primary)}' +
648
- '.dvo-badge-norm{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-warning-primary);border:1px solid var(--dsw-alias-state-warning-primary)}' +
649
- '.dvo-badge-slow{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-warning-primary);border:1px solid var(--dsw-alias-border-l2)}' +
650
- '.dvo-badge-err{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-error-primary);border:1px solid var(--dsw-alias-state-error-primary)}' +
651
- '.dvo-badge-idle{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary)}' +
652
- '.dvo-mic-test{display:flex;align-items:center;gap:10px;margin-top:8px;padding:6px 12px;background:var(--dsw-alias-bg-layer-2);border-radius:8px;border:1px solid var(--dsw-alias-border-l1)}' +
653
- '.dvo-meter-bar{flex:1;height:8px;background:var(--dsw-alias-bg-layer-3);border-radius:4px;overflow:hidden;position:relative}' +
654
- '.dvo-meter-fill{height:100%;background:var(--dsw-alias-state-success-primary);border-radius:4px;transition:width 60ms ease-out}' +
655
- '.dvo-meter-gate{position:absolute;top:0;bottom:0;width:2px;background:var(--dsw-alias-state-warning-primary);box-shadow:0 0 4px var(--dsw-alias-state-warning-primary);z-index:2;pointer-events:none}' +
656
- '.dvo-silence-ring{width:34px;height:34px;transform:rotate(-90deg);flex:none}' +
657
- '.dvo-ring-bg{fill:none;stroke:var(--dsw-alias-border-l1);stroke-width:2.5}' +
658
- '.dvo-ring-fg{fill:none;stroke:var(--dsw-alias-state-info-primary);stroke-width:2.5;stroke-linecap:round;transition:stroke-dashoffset 100ms linear}' +
659
- '.dvo-ghost{font-style:italic;color:var(--dsw-alias-label-tertiary);margin-left:6px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}' +
660
- '.dvo-installer-box{display:flex;flex-direction:column;gap:8px;padding:10px 12px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;margin-top:8px}' +
661
- '.dvo-installer-row{display:flex;align-items:center;justify-content:space-between;gap:8px}' +
662
- '.dvo-progress-bar{height:6px;width:100%;background:var(--dsw-alias-bg-layer-3);border-radius:3px;overflow:hidden}' +
663
- '.dvo-progress-fill{height:100%;background:var(--dsw-alias-state-info-primary);transition:width 150ms ease}' +
664
- '.dvo-jargon-table{width:100%;margin-top:6px;border-collapse:collapse;font-size:12px}' +
665
- '.dvo-jargon-table td{padding:4px 6px;border-bottom:1px solid var(--dsw-alias-border-l1)}' +
666
- '.dvo-pill-preview{display:flex;flex-direction:column;gap:8px;padding:10px 14px;border-radius:18px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent);box-sizing:border-box}' +
667
- '.dvo-preview-header{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%}' +
668
- '.dvo-preview-tabs{display:inline-flex;align-items:center;gap:4px;background:var(--dsw-alias-bg-layer-2);padding:2px;border-radius:12px;border:1px solid var(--dsw-alias-border-l1)}' +
669
- '.dvo-tab-btn{border:none;background:transparent;color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:500;padding:3px 10px;border-radius:10px;cursor:pointer;transition:all 150ms ease}' +
670
- '.dvo-tab-btn:hover{color:var(--dsw-alias-label-primary)}' +
671
- '.dvo-tab-btn-active{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);box-shadow:0 1px 3px color-mix(in srgb, var(--dsw-alias-label-primary) 15%, transparent)}' +
672
- '.dvo-preview-body{display:flex;align-items:center;gap:8px;width:100%}' +
673
- '.dvo-preview-text{flex:1;font-size:13px;color:var(--dsw-alias-label-primary);line-height:1.4;word-break:break-word;max-height:80px;overflow-y:auto;padding:2px 0}' +
674
- '.dvo-preview-input{flex:1;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 8px;outline:none;font-family:inherit;width:100%}' +
675
- '.dvo-preview-input:focus{border-color:var(--dsw-alias-state-info-primary)}' +
676
- '.dvo-btn-send{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:none;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);transition:opacity 150ms ease}' +
677
- '.dvo-btn-send:hover{opacity:0.9}' +
678
- '.dvo-warn-banner{font-size:12px;color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);border:1px solid var(--dsw-alias-state-error-primary);padding:4px 8px;border-radius:6px;margin-top:4px}' + '.dvo-mode-pill-btn{display:inline-flex;align-items:center;justify-content:center;height:28px;padding:0 8px;font-size:11px;font-weight:600;border-radius:14px;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);user-select:none;touch-action:manipulation;transition:all 150ms ease}' + '.dvo-mode-pill-btn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary)}' + '.dvo-mode-badge{font-size:11px;font-weight:600;padding:2px 6px;border-radius:6px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);margin-right:4px}' + '.dvo-macro-item{display:flex;flex-direction:column;gap:6px;padding:8px 10px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;margin-top:6px}' + '.dvo-macro-actions{display:flex;justify-content:flex-end;gap:6px}' + '.dvo-fallback-badge{font-size:11px;padding:2px 6px;border-radius:4px;color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 12%, transparent);border:1px solid var(--dsw-alias-state-warning-primary)}'
621
+ '.dvo-btn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}' +
622
+ '.dvo-btn[data-err="1"]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}' +
623
+ '.dvo-pill{display:flex;align-items:center;gap:10px;height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent);box-sizing:border-box;user-select:none;-webkit-user-select:none}' +
624
+ '.dvo-chev-icon{width:14px;height:14px;display:block;flex:none}' +
625
+ '.dvo-pbtn{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);user-select:none;-webkit-user-select:none;touch-action:manipulation}' +
626
+ '.dvo-pbtn:hover{background:var(--dsw-alias-bg-layer-2)}' +
627
+ '.dvo-wave{flex:1;min-width:0;max-width:100%;height:40px;width:100%;color:var(--dsw-alias-label-primary)}' +
628
+ '.dvo-status{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-secondary);font-size:13px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
629
+ '.dvo-err{color:var(--dsw-alias-state-error-primary)}' +
630
+ '.dvo-count{font-variant-numeric:tabular-nums;font-size:13px;color:var(--dsw-alias-label-secondary)}' +
631
+ '.dvo-spin{animation:dvo-spin 1s linear infinite}' +
632
+ '@keyframes dvo-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
633
+ '.dvo-btn-active{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}' +
634
+ '.dvo-audio-wrap{display:flex;align-items:center;gap:8px;padding:2px 10px;background:var(--dsw-alias-bg-layer-2);border-radius:14px;border:1px solid var(--dsw-alias-border-l1)}' +
635
+ '.dvo-audio-play{display:flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);border:0;cursor:pointer;padding:0}' +
636
+ '.dvo-audio-play:hover{opacity:0.9}' +
637
+ '.dvo-audio-time{font-size:12px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}' +
638
+ '.dvo-dash{display:flex;flex-direction:column;gap:8px;margin-top:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:12px}' +
639
+ '.dvo-dash-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px;margin-top:4px}' +
640
+ '.dvo-dash-item{display:flex;flex-direction:column;gap:4px;padding:8px 10px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l1);border-radius:8px}' +
641
+ '.dvo-dash-name{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
642
+ '.dvo-dash-row{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--dsw-alias-label-secondary)}' +
643
+ '.dvo-badge{display:inline-flex;align-items:center;font-size:11px;padding:1px 6px;border-radius:6px;font-weight:600}' +
644
+ '.dvo-badge-fast{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-success-primary);border:1px solid var(--dsw-alias-state-success-primary)}' +
645
+ '.dvo-badge-norm{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-warning-primary);border:1px solid var(--dsw-alias-state-warning-primary)}' +
646
+ '.dvo-badge-slow{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-warning-primary);border:1px solid var(--dsw-alias-border-l2)}' +
647
+ '.dvo-badge-err{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-error-primary);border:1px solid var(--dsw-alias-state-error-primary)}' +
648
+ '.dvo-badge-idle{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary)}' +
649
+ '.dvo-mic-test{display:flex;align-items:center;gap:10px;margin-top:8px;padding:6px 12px;background:var(--dsw-alias-bg-layer-2);border-radius:8px;border:1px solid var(--dsw-alias-border-l1)}' +
650
+ '.dvo-meter-bar{flex:1;height:8px;background:var(--dsw-alias-bg-layer-3);border-radius:4px;overflow:hidden;position:relative}' +
651
+ '.dvo-meter-fill{height:100%;background:var(--dsw-alias-state-success-primary);border-radius:4px;transition:width 60ms ease-out}' +
652
+ '.dvo-meter-gate{position:absolute;top:0;bottom:0;width:2px;background:var(--dsw-alias-state-warning-primary);box-shadow:0 0 4px var(--dsw-alias-state-warning-primary);z-index:2;pointer-events:none}' +
653
+ '.dvo-silence-ring{width:34px;height:34px;transform:rotate(-90deg);flex:none}' +
654
+ '.dvo-ring-bg{fill:none;stroke:var(--dsw-alias-border-l1);stroke-width:2.5}' +
655
+ '.dvo-ring-fg{fill:none;stroke:var(--dsw-alias-state-info-primary);stroke-width:2.5;stroke-linecap:round;transition:stroke-dashoffset 100ms linear}' +
656
+ '.dvo-ghost{font-style:italic;color:var(--dsw-alias-label-tertiary);margin-left:6px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}' +
657
+ '.dvo-installer-box{display:flex;flex-direction:column;gap:8px;padding:10px 12px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;margin-top:8px}' +
658
+ '.dvo-installer-row{display:flex;align-items:center;justify-content:space-between;gap:8px}' +
659
+ '.dvo-progress-bar{height:6px;width:100%;background:var(--dsw-alias-bg-layer-3);border-radius:3px;overflow:hidden}' +
660
+ '.dvo-progress-fill{height:100%;background:var(--dsw-alias-state-info-primary);transition:width 150ms ease}' +
661
+ '.dvo-jargon-table{width:100%;margin-top:6px;border-collapse:collapse;font-size:12px}' +
662
+ '.dvo-jargon-table td{padding:4px 6px;border-bottom:1px solid var(--dsw-alias-border-l1)}' +
663
+ '.dvo-pill-preview{display:flex;flex-direction:column;gap:8px;padding:10px 14px;border-radius:18px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent);box-sizing:border-box}' +
664
+ '.dvo-preview-header{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%}' +
665
+ '.dvo-preview-tabs{display:inline-flex;align-items:center;gap:4px;background:var(--dsw-alias-bg-layer-2);padding:2px;border-radius:12px;border:1px solid var(--dsw-alias-border-l1)}' +
666
+ '.dvo-tab-btn{border:none;background:transparent;color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:500;padding:3px 10px;border-radius:10px;cursor:pointer;transition:all 150ms ease}' +
667
+ '.dvo-tab-btn:hover{color:var(--dsw-alias-label-primary)}' +
668
+ '.dvo-tab-btn-active{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);box-shadow:0 1px 3px color-mix(in srgb, var(--dsw-alias-label-primary) 15%, transparent)}' +
669
+ '.dvo-preview-body{display:flex;align-items:center;gap:8px;width:100%}' +
670
+ '.dvo-preview-text{flex:1;font-size:13px;color:var(--dsw-alias-label-primary);line-height:1.4;word-break:break-word;max-height:80px;overflow-y:auto;padding:2px 0}' +
671
+ '.dvo-preview-input{flex:1;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 8px;outline:none;font-family:inherit;width:100%}' +
672
+ '.dvo-preview-input:focus{border-color:var(--dsw-alias-state-info-primary)}' +
673
+ '.dvo-btn-send{display:flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;cursor:pointer;padding:0;flex:none;box-sizing:border-box;border:none;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);transition:opacity 150ms ease}' +
674
+ '.dvo-btn-send:hover{opacity:0.9}' +
675
+ '.dvo-warn-banner{font-size:12px;color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);border:1px solid var(--dsw-alias-state-error-primary);padding:4px 8px;border-radius:6px;margin-top:4px}' + '.dvo-mode-pill-btn{display:inline-flex;align-items:center;justify-content:center;height:28px;padding:0 8px;font-size:11px;font-weight:600;border-radius:14px;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);user-select:none;touch-action:manipulation;transition:all 150ms ease}' + '.dvo-mode-pill-btn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary)}' + '.dvo-mode-badge{font-size:11px;font-weight:600;padding:2px 6px;border-radius:6px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);margin-right:4px}' + '.dvo-macro-item{display:flex;flex-direction:column;gap:6px;padding:8px 10px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;margin-top:6px}' + '.dvo-macro-actions{display:flex;justify-content:flex-end;gap:6px}' + '.dvo-fallback-badge{font-size:11px;padding:2px 6px;border-radius:4px;color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 12%, transparent);border:1px solid var(--dsw-alias-state-warning-primary)}'
679
676
  const cssId = 'dsh-voice/client.module.css'
680
677
  if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + cssId + '"]')) {
681
678
  const tag = document.createElement('style')
@@ -938,7 +935,7 @@ window.__ModuleLoader__.load({
938
935
  try {
939
936
  res = await fetch('/dsh-voice/transcribe', {
940
937
  method: 'POST',
941
- headers: { 'content-type': 'application/json' },
938
+ headers: { 'content-type': 'application/json', ...(typeof window !== 'undefined' && window.__dshVoiceToken ? { 'x-dsh-voice-token': window.__dshVoiceToken } : {}) },
942
939
  body: JSON.stringify(payload),
943
940
  signal: controller ? controller.signal : undefined,
944
941
  })
@@ -1808,7 +1805,7 @@ voice._chainsPromise = null
1808
1805
  try {
1809
1806
  const pRes = await fetch('/dsh-voice/polish', {
1810
1807
  method: 'POST',
1811
- headers: { 'content-type': 'application/json' },
1808
+ headers: { 'content-type': 'application/json', ...(typeof window !== 'undefined' && window.__dshVoiceToken ? { 'x-dsh-voice-token': window.__dshVoiceToken } : {}) },
1812
1809
  body: JSON.stringify({ text }),
1813
1810
  })
1814
1811
  const pData = await pRes.json()
@@ -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' },
@@ -4244,6 +4299,7 @@ function MacrosSection(props) {
4244
4299
  setStatusLatency(Math.round(performance.now() - start))
4245
4300
  if (data && data.ok) {
4246
4301
  setStatusData(data)
4302
+ if (data.sessionToken && typeof window !== 'undefined') window.__dshVoiceToken = data.sessionToken
4247
4303
  if (data.webgpuModel && voice.settings) {
4248
4304
  voice.settings.webgpuModel = data.webgpuModel
4249
4305
  }
@@ -1,7 +1,9 @@
1
1
  // HTTP host routes registered on ctx.webServer.
2
- import { readBody, isLoopback, isTrustedCaller } from './http-util.js'
2
+ import os from 'node:os'
3
+ import { readBody, isLoopback, isTrustedCaller, getVoiceSessionToken } 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' })
@@ -43,6 +45,7 @@ export function registerHostRoutes(ctx, deps) {
43
45
  const sensevoiceError = sensevoiceOk ? null : getSensevoiceError()
44
46
  writeJson(res, 200, {
45
47
  ok: true,
48
+ sessionToken: getVoiceSessionToken(),
46
49
  whisperRunning: whisperOk,
47
50
  whisperError: whisperOk ? null : whisperError,
48
51
  sensevoiceRunning: sensevoiceOk,
@@ -147,6 +150,78 @@ export function registerHostRoutes(ctx, deps) {
147
150
  },
148
151
  }), 'dsh-voice: /diagnostics/export route')
149
152
 
153
+ ctx.effect(() => ctx.webServer.register({
154
+ kind: 'exact',
155
+ path: '/dsh-voice/benchmark',
156
+ handler: async (req, res) => {
157
+ if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
158
+ if (!isTrustedCaller(req, { loopbackOnly: true })) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
159
+
160
+ const cfg = live()
161
+ const effectiveProvider = resolveSensevoiceProvider(cfg)
162
+ const cpus = os.cpus() || []
163
+ const numCores = cpus.length || 1
164
+ const cpuModel = (cpus[0] && cpus[0].model) || 'unknown'
165
+ const platformInfo = `${process.platform}-${process.arch}`
166
+ const isAlive = await sensevoiceAlive()
167
+
168
+ if (!isAlive) {
169
+ writeJson(res, 200, {
170
+ ok: true,
171
+ running: false,
172
+ provider: effectiveProvider,
173
+ cpuCores: numCores,
174
+ cpuModel,
175
+ platform: platformInfo,
176
+ message: 'Local ASR server is not running. Hardware detected.',
177
+ })
178
+ return
179
+ }
180
+
181
+ const sampleSec = 1.0
182
+ const wavBuf = createSyntheticWav(sampleSec, 16000)
183
+ const tStart = Date.now()
184
+ let latencyMs = 0
185
+ let recognizedText = ''
186
+
187
+ try {
188
+ const url = String(cfg.sensevoiceUrl || 'http://127.0.0.1:6006/api/v1/asr').trim()
189
+ const form = new FormData()
190
+ form.append('file', new Blob([wavBuf], { type: 'audio/wav' }), 'benchmark.wav')
191
+ const r = await fetch(url, { method: 'POST', body: form, signal: AbortSignal.timeout(10000) })
192
+ latencyMs = Date.now() - tStart
193
+ if (r.ok) {
194
+ try {
195
+ const data = await r.json()
196
+ recognizedText = String(data?.text || data?.transcript || '').trim()
197
+ } catch { /* json parse error */ }
198
+ }
199
+ } catch {
200
+ latencyMs = Date.now() - tStart
201
+ }
202
+
203
+ const rtf = Math.round((latencyMs / (sampleSec * 1000)) * 1000) / 1000
204
+ let rating = 'Fast'
205
+ if (rtf < 0.15) rating = 'Ultra-fast'
206
+ else if (rtf < 0.45) rating = 'Fast'
207
+ else if (rtf <= 1.0) rating = 'Real-time'
208
+ else rating = 'Slow'
209
+
210
+ writeJson(res, 200, {
211
+ ok: true,
212
+ running: true,
213
+ provider: effectiveProvider,
214
+ latencyMs,
215
+ rtf,
216
+ sampleSec,
217
+ rating,
218
+ cpuCores: numCores,
219
+ cpuModel,
220
+ platform: platformInfo,
221
+ })
222
+ },
223
+ }), 'dsh-voice: /benchmark route')
224
+
150
225
  ctx.effect(() => ctx.webServer.register({
151
226
  kind: 'exact',
152
227
  path: '/dsh-voice/transcribe',
package/lib/http-util.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // Pure HTTP helpers shared by host routes. No cordis, no network.
2
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
2
3
 
3
4
  export class BodyTooLargeError extends Error {
4
5
  constructor(message = 'body too large') {
@@ -9,6 +10,29 @@ export class BodyTooLargeError extends Error {
9
10
  }
10
11
  }
11
12
 
13
+ let sessionSecret = ''
14
+ export function getVoiceSessionToken() {
15
+ if (!sessionSecret) {
16
+ sessionSecret = randomBytes(24).toString('hex')
17
+ }
18
+ return sessionSecret
19
+ }
20
+
21
+ export function setVoiceSessionToken(t) {
22
+ sessionSecret = String(t || '')
23
+ }
24
+
25
+ export function verifyVoiceSessionToken(token) {
26
+ if (!token || typeof token !== 'string') return false
27
+ const secret = getVoiceSessionToken()
28
+ if (token.length !== secret.length) return false
29
+ try {
30
+ return timingSafeEqual(Buffer.from(token), Buffer.from(secret))
31
+ } catch {
32
+ return false
33
+ }
34
+ }
35
+
12
36
  export function writeJson(res, code, body) {
13
37
  try {
14
38
  const headers = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }
@@ -77,12 +101,16 @@ export function isLoopback(addr) {
77
101
  }
78
102
 
79
103
  /**
80
- * Verify that the HTTP request originates from a trusted caller (loopback or same-origin).
81
- * Fail-closed security guard for state-changing or quota-consuming POST routes.
104
+ * Verify that the HTTP request originates from a trusted caller.
105
+ * Fail-closed security guard for state-changing or quota-consuming POST routes (#117, #173, #277).
106
+ * - Loopback callers (127.0.0.1, ::1) are trusted unless rejected by browser security headers.
107
+ * - Non-loopback LAN mutating/quota-consuming POST/PUT requests require valid session token (#277).
108
+ * - Routes marked loopbackOnly strictly reject non-loopback callers.
82
109
  * @param {import('node:http').IncomingMessage} req
110
+ * @param {{ loopbackOnly?: boolean }} [options]
83
111
  * @returns {boolean}
84
112
  */
85
- export function isTrustedCaller(req) {
113
+ export function isTrustedCaller(req, options = {}) {
86
114
  if (!req) return false
87
115
 
88
116
  // 1. Browser security headers: immediately reject cross-site and same-site requests (#117, #173)
@@ -113,7 +141,6 @@ export function isTrustedCaller(req) {
113
141
  }
114
142
 
115
143
  // 4. For state-changing or data-carrying requests (POST/PUT/PATCH), require application/json or octet-stream
116
- // Cross-origin simple form submissions cannot set application/json without a CORS preflight.
117
144
  const method = String(req.method || 'GET').toUpperCase()
118
145
  if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
119
146
  const contentType = String(req.headers?.['content-type'] || '').toLowerCase()
@@ -128,7 +155,20 @@ export function isTrustedCaller(req) {
128
155
  const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || ''
129
156
  if (isLoopback(remote)) return true
130
157
 
131
- // 6. Non-loopback requests must be verified same-origin
158
+ // If route strictly requires loopback (e.g. host model installer or hardware benchmark)
159
+ if (options && options.loopbackOnly) return false
160
+
161
+ // 6. Non-loopback LAN requests: POST/PUT/PATCH quota-burning operations require session auth token (#277)
162
+ const isMutating = method === 'POST' || method === 'PUT' || method === 'PATCH'
163
+ if (isMutating) {
164
+ const incomingToken = String(req.headers?.['x-dsh-voice-token'] || req.headers?.['x-voice-token'] || '')
165
+ if (incomingToken && verifyVoiceSessionToken(incomingToken)) {
166
+ return true
167
+ }
168
+ return false
169
+ }
170
+
171
+ // 7. Non-state-changing GET requests from LAN: verify same-origin
132
172
  if (secSite === 'same-origin') return true
133
173
  if (origin) {
134
174
  try {
@@ -146,4 +186,4 @@ export function isTrustedCaller(req) {
146
186
  }
147
187
 
148
188
  return false
149
- }
189
+ }
package/lib/index.js CHANGED
@@ -114,7 +114,9 @@ export function apply(ctx, baseConfig) {
114
114
  ctx.inject(['settings'], (sctx) => {
115
115
  settingsService = sctx.settings
116
116
  try {
117
- scope = sctx.settings.register(NS, Config, { base: baseConfig })
117
+ if (typeof sctx.settings?.register === 'function') {
118
+ scope = sctx.settings.register(NS, Config, { base: baseConfig })
119
+ }
118
120
  if (scope && typeof scope.get === 'function') {
119
121
  getConfig = () => scope.get() ?? describeRow()?.config ?? readEntryConfig()
120
122
  } else {
@@ -133,16 +135,10 @@ export function apply(ctx, baseConfig) {
133
135
  triggerAutostart()
134
136
  })
135
137
 
136
- if (typeof ctx.on === 'function') {
137
- if (typeof ctx.effect === 'function') {
138
- ctx.effect(() => ctx.on('loader/volatile-update', () => triggerAutostart()), 'dsh-voice: volatile autostart')
139
- ctx.effect(() => ctx.on('settings/document-updated', () => triggerAutostart()), 'dsh-voice: settings autostart')
140
- ctx.effect(() => ctx.on('config', () => triggerAutostart()), 'dsh-voice: config autostart')
141
- } else {
142
- ctx.on('loader/volatile-update', () => triggerAutostart())
143
- ctx.on('settings/document-updated', () => triggerAutostart())
144
- ctx.on('config', () => triggerAutostart())
145
- }
138
+ if (typeof ctx.effect === 'function' && typeof ctx.on === 'function') {
139
+ ctx.effect(() => ctx.on('loader/volatile-update', () => triggerAutostart()), 'dsh-voice: volatile autostart')
140
+ ctx.effect(() => ctx.on('settings/document-updated', () => triggerAutostart()), 'dsh-voice: settings autostart')
141
+ ctx.effect(() => ctx.on('config', () => triggerAutostart()), 'dsh-voice: config autostart')
146
142
  }
147
143
 
148
144
  triggerAutostart()
@@ -35,8 +35,10 @@ export function createLocalDaemons({ live, ctx }) {
35
35
  try { child.kill() } catch { /* ignore */ }
36
36
  child = null
37
37
  }
38
+ const rawLang = cfg?.dictation?.language
39
+ const safeLang = isAutoLang(rawLang) ? 'auto' : String(rawLang || '').replace(/[^a-zA-Z0-9_-]/g, '') || 'auto'
38
40
  const command = `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
39
- + ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${isAutoLang(cfg.dictation.language) ? 'auto' : cfg.dictation.language}`
41
+ + ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${JSON.stringify(safeLang)}`
40
42
  const specOpts = { command, onExpiry: 'none', timeoutMs: 60000, stdoutMaxBytes: 4 * 1024 * 1024 }
41
43
  const spec = typeof ctx.shell?.resolve === 'function' ? ctx.shell.resolve(specOpts) : specOpts
42
44
 
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,32 +1,94 @@
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
- import { createWriteStream, existsSync, readdirSync } from 'node:fs'
4
+ import { createWriteStream, createReadStream, existsSync, readdirSync } from 'node:fs'
5
+ import { createHash } from 'node:crypto'
5
6
  import { mkdir, unlink } from 'node:fs/promises'
6
7
  import { homedir } from 'node:os'
7
8
  import path from 'node:path'
8
9
  import { spawn } from 'node:child_process'
9
10
  import { Readable, Transform } from 'node:stream'
10
11
  import { pipeline } from 'node:stream/promises'
11
- import { writeJson, isTrustedCaller } from './http-util.js'
12
+ import { writeJson, readBody, isTrustedCaller } from './http-util.js'
12
13
 
13
14
  export const SENSEVOICE_MODEL_URL =
14
15
  '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
16
  export const SENSEVOICE_MODEL_MIRROR =
16
17
  '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
18
 
19
+ export const WHISPER_TURBO_MODEL_URL =
20
+ 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-whisper-turbo.tar.bz2'
21
+ export const WHISPER_TURBO_MODEL_MIRROR =
22
+ 'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-turbo/resolve/main/sherpa-onnx-whisper-turbo.tar.bz2'
23
+
24
+ export const MODEL_SHA256 = {
25
+ 'sensevoice': 'f6b2a72ebcb1ac7a764d4cfccd886e6bcb2a95c4657c2199d0ba95ed4b9ea71a',
26
+ 'whisper-turbo': 'b11acbbcd660b44a8e0df33724feb5aaa709cf65668f2823d59f656312544f22',
27
+ }
28
+
29
+ export function verifyArchiveSha256(filePath, expectedSha256) {
30
+ if (!expectedSha256) return Promise.resolve(true)
31
+ return new Promise((resolve, reject) => {
32
+ const hash = createHash('sha256')
33
+ const stream = createReadStream(filePath)
34
+ stream.on('data', (chunk) => hash.update(chunk))
35
+ stream.on('end', () => {
36
+ const digest = hash.digest('hex').toLowerCase()
37
+ if (digest === expectedSha256.toLowerCase()) {
38
+ resolve(true)
39
+ } else {
40
+ reject(new Error(`SHA-256 verification failed: expected ${expectedSha256}, got ${digest}`))
41
+ }
42
+ })
43
+ stream.on('error', reject)
44
+ })
45
+ }
46
+
18
47
  let installerState = {
19
48
  installing: false,
20
49
  progress: 0,
21
50
  step: 'idle',
22
51
  error: null,
23
52
  modelPath: '',
53
+ modelType: 'sensevoice',
24
54
  }
25
55
 
26
- export function getDefaultModelDir() {
56
+ export function getDefaultModelDir(type = 'sensevoice') {
57
+ const norm = String(type || '').toLowerCase()
58
+ if (norm.includes('whisper') || norm.includes('turbo')) {
59
+ return path.join(homedir(), '.dsh', 'models', 'whisper-turbo')
60
+ }
27
61
  return path.join(homedir(), '.dsh', 'models', 'sensevoice')
28
62
  }
29
63
 
64
+ export function findWhisperFiles(dir) {
65
+ if (!dir || !existsSync(dir)) return null
66
+ try {
67
+ const entries = readdirSync(dir, { recursive: true, withFileTypes: true })
68
+ let encoder = null
69
+ let decoder = null
70
+ let tokens = null
71
+ for (const entry of entries) {
72
+ if (!entry.isFile()) continue
73
+ const name = entry.name.toLowerCase()
74
+ const parent = entry.parentPath || (entry.path ? entry.path : dir)
75
+ const fullPath = path.join(parent, entry.name)
76
+
77
+ if (name.includes('encoder') && name.endsWith('.onnx')) {
78
+ if (!encoder || name.includes('int8')) encoder = fullPath
79
+ } else if (name.includes('decoder') && name.endsWith('.onnx')) {
80
+ if (!decoder || name.includes('int8')) decoder = fullPath
81
+ } else if (name.endsWith('tokens.txt') || (name.includes('tokens') && name.endsWith('.txt'))) {
82
+ tokens = fullPath
83
+ }
84
+ }
85
+ if (encoder && decoder) {
86
+ return { encoder, decoder, tokens }
87
+ }
88
+ } catch { /* directory read failed */ }
89
+ return null
90
+ }
91
+
30
92
  export function findModelFile(dir) {
31
93
  if (!dir || !existsSync(dir)) return null
32
94
  try {
@@ -37,23 +99,26 @@ export function findModelFile(dir) {
37
99
  return path.join(parent, entry.name)
38
100
  }
39
101
  }
102
+ const whisper = findWhisperFiles(dir)
103
+ if (whisper) return whisper.encoder
40
104
  } catch { /* directory read failed */ }
41
105
  return null
42
106
  }
43
107
 
44
108
  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)
109
+ if (!filePath) return ''
110
+ const str = String(filePath).replace(/\\/g, '/')
111
+ const idx = str.indexOf('.dsh/models/')
112
+ if (idx !== -1) return '~/' + str.slice(idx)
49
113
  return path.basename(str)
50
114
  }
115
+
51
116
  export function findTokensFile(dir) {
52
117
  if (!dir || !existsSync(dir)) return null
53
118
  try {
54
119
  const entries = readdirSync(dir, { recursive: true, withFileTypes: true })
55
120
  for (const entry of entries) {
56
- if (entry.isFile() && entry.name === 'tokens.txt') {
121
+ if (entry.isFile() && (entry.name === 'tokens.txt' || entry.name === 'turbo-tokens.txt')) {
57
122
  const parent = entry.parentPath || (entry.path ? entry.path : dir)
58
123
  return path.join(parent, entry.name)
59
124
  }
@@ -63,21 +128,49 @@ export function findTokensFile(dir) {
63
128
  }
64
129
 
65
130
  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
131
+ const customModel = cfg?.sensevoiceModel
132
+ const customExists = Boolean(customModel && existsSync(customModel))
133
+ const isWhisperCustom = customExists && (
134
+ customModel.toLowerCase().includes('whisper') ||
135
+ customModel.toLowerCase().includes('turbo') ||
136
+ Boolean(findWhisperFiles(path.dirname(customModel)))
137
+ )
138
+
139
+ const svDir = getDefaultModelDir('sensevoice')
140
+ const svFile = findModelFile(svDir)
141
+ const turboDir = getDefaultModelDir('whisper-turbo')
142
+ const turboFiles = findWhisperFiles(turboDir)
143
+
144
+ let activeModelFile = null
145
+ let activeTokensFile = null
146
+ let activeModelType = 'sensevoice'
147
+
148
+ if (customExists) {
149
+ activeModelFile = customModel
150
+ activeTokensFile = findTokensFile(path.dirname(customModel))
151
+ activeModelType = isWhisperCustom ? 'whisper-turbo' : 'sensevoice'
152
+ } else if (turboFiles) {
153
+ activeModelFile = turboFiles.encoder
154
+ activeTokensFile = turboFiles.tokens
155
+ activeModelType = 'whisper-turbo'
156
+ } else if (svFile) {
157
+ activeModelFile = svFile
158
+ activeTokensFile = findTokensFile(path.dirname(svFile))
159
+ activeModelType = 'sensevoice'
160
+ }
71
161
 
72
162
  const running = typeof isAliveFn === 'function' ? await isAliveFn() : false
73
- const installed = Boolean(modelFile && existsSync(modelFile))
163
+ const installed = Boolean(activeModelFile && existsSync(activeModelFile))
74
164
 
75
165
  return {
76
166
  installed,
77
167
  running,
78
- modelPath: redactModelPath(modelFile),
79
- tokensPath: redactModelPath(tokensFile),
80
- path: installed ? '~/.dsh/models/sensevoice' : '',
168
+ modelType: activeModelType,
169
+ modelPath: redactModelPath(activeModelFile),
170
+ tokensPath: redactModelPath(activeTokensFile),
171
+ path: installed ? `~/.dsh/models/${activeModelType}` : '',
172
+ sensevoiceInstalled: Boolean(svFile),
173
+ whisperTurboInstalled: Boolean(turboFiles),
81
174
  installing: installerState.installing,
82
175
  progress: installerState.progress,
83
176
  step: installerState.step,
@@ -107,7 +200,7 @@ export async function downloadArchive(url, destPath, onProgress) {
107
200
 
108
201
  export function extractTarBz2(archivePath, targetDir) {
109
202
  return new Promise((resolve, reject) => {
110
- const child = spawn('tar', ['-xjf', archivePath, '-C', targetDir], { windowsHide: true })
203
+ const child = spawn('tar', ['--no-same-owner', '-xjf', archivePath, '-C', targetDir], { windowsHide: true })
111
204
  let stderr = ''
112
205
  child.stderr.on('data', (d) => { stderr += d })
113
206
  child.on('close', (code) => {
@@ -118,21 +211,28 @@ export function extractTarBz2(archivePath, targetDir) {
118
211
  })
119
212
  }
120
213
 
121
- export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSensevoiceFn) {
214
+ export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSensevoiceFn, options = {}) {
122
215
  if (installerState.installing) {
123
- throw new Error('SenseVoice installation is already in progress')
216
+ throw new Error('ASR model installation is already in progress')
124
217
  }
125
218
 
219
+ const targetModel = String(options?.model || 'sensevoice').toLowerCase().includes('whisper')
220
+ ? 'whisper-turbo'
221
+ : 'sensevoice'
222
+
126
223
  installerState = {
127
224
  installing: true,
128
225
  progress: 5,
129
226
  step: 'preparing',
130
227
  error: null,
131
228
  modelPath: '',
229
+ modelType: targetModel,
132
230
  }
133
231
 
134
- const modelDir = getDefaultModelDir()
135
- const archivePath = path.join(modelDir, 'sensevoice.tar.bz2')
232
+ const modelDir = getDefaultModelDir(targetModel)
233
+ const archivePath = path.join(modelDir, `${targetModel}.tar.bz2`)
234
+ const primaryUrl = targetModel === 'whisper-turbo' ? WHISPER_TURBO_MODEL_URL : SENSEVOICE_MODEL_URL
235
+ const mirrorUrl = targetModel === 'whisper-turbo' ? WHISPER_TURBO_MODEL_MIRROR : SENSEVOICE_MODEL_MIRROR
136
236
 
137
237
  try {
138
238
  await mkdir(modelDir, { recursive: true })
@@ -141,30 +241,43 @@ export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSens
141
241
 
142
242
  let downloadOk = false
143
243
  try {
144
- await downloadArchive(SENSEVOICE_MODEL_URL, archivePath, (pct, status) => {
244
+ await downloadArchive(primaryUrl, archivePath, (pct, status) => {
145
245
  installerState.progress = pct
146
246
  installerState.step = status
147
247
  })
148
248
  downloadOk = true
149
- } catch (e1) {
150
- // Fallback to mirror
249
+ } catch {
151
250
  installerState.step = 'downloading (mirror)'
152
- await downloadArchive(SENSEVOICE_MODEL_MIRROR, archivePath, (pct, status) => {
251
+ await downloadArchive(mirrorUrl, archivePath, (pct, status) => {
153
252
  installerState.progress = pct
154
253
  installerState.step = status
155
254
  })
156
255
  downloadOk = true
157
256
  }
158
257
 
159
- if (!downloadOk) throw new Error('Could not download SenseVoice model from primary or mirror URL')
258
+ if (!downloadOk) throw new Error(`Could not download ${targetModel} model from primary or mirror URL`)
259
+
260
+ installerState.step = 'verifying checksum'
261
+ installerState.progress = 92
262
+ const expectedHash = MODEL_SHA256[targetModel]
263
+ if (expectedHash) {
264
+ await verifyArchiveSha256(archivePath, expectedHash)
265
+ }
160
266
 
161
267
  installerState.step = 'extracting'
162
268
  installerState.progress = 95
163
269
  await extractTarBz2(archivePath, modelDir)
164
270
  await unlink(archivePath).catch(() => {})
165
271
 
166
- const modelFile = findModelFile(modelDir)
167
- if (!modelFile) throw new Error('model.int8.onnx not found after extraction')
272
+ let modelFile = null
273
+ if (targetModel === 'whisper-turbo') {
274
+ const files = findWhisperFiles(modelDir)
275
+ if (!files) throw new Error('whisper-turbo encoder/decoder not found after extraction')
276
+ modelFile = files.encoder
277
+ } else {
278
+ modelFile = findModelFile(modelDir)
279
+ if (!modelFile) throw new Error('model.int8.onnx not found after extraction')
280
+ }
168
281
 
169
282
  installerState.modelPath = modelFile
170
283
  installerState.progress = 100
@@ -182,7 +295,7 @@ export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSens
182
295
  await startSensevoiceFn()
183
296
  }
184
297
 
185
- return { ok: true, modelPath: modelFile }
298
+ return { ok: true, modelType: targetModel, modelPath: modelFile }
186
299
  } catch (err) {
187
300
  await unlink(archivePath).catch(() => {})
188
301
  installerState.installing = false
@@ -199,7 +312,7 @@ export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateCo
199
312
  kind: 'exact',
200
313
  path: '/dsh-voice/sensevoice-installer',
201
314
  handler: async (req, res) => {
202
- if (!isTrustedCaller(req)) {
315
+ if (!isTrustedCaller(req, { loopbackOnly: true })) {
203
316
  writeJson(res, 403, { ok: false, error: 'Forbidden: untrusted caller' })
204
317
  return
205
318
  }
@@ -217,12 +330,18 @@ export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateCo
217
330
  return
218
331
  }
219
332
 
333
+ let bodyObj = {}
334
+ try {
335
+ const raw = await readBody(req, 16384)
336
+ bodyObj = JSON.parse(raw.toString('utf8') || '{}')
337
+ } catch { bodyObj = {} }
338
+
220
339
  // Start background install
221
- startSensevoiceInstall(ctx, liveConfig(), updateConfig, startSensevoice).catch((err) => {
222
- ctx.logger?.warn?.(`SenseVoice 1-click install error: ${String(err)}`)
340
+ startSensevoiceInstall(ctx, liveConfig(), updateConfig, startSensevoice, bodyObj).catch((err) => {
341
+ ctx.logger?.warn?.(`ASR 1-click install error: ${String(err)}`)
223
342
  })
224
343
 
225
- writeJson(res, 202, { ok: true, message: 'SenseVoice installation started', state: safeState })
344
+ writeJson(res, 202, { ok: true, message: 'ASR installation started', state: safeState })
226
345
  return
227
346
  }
228
347
 
@@ -245,50 +364,59 @@ export function resolveSensevoiceProvider(cfg) {
245
364
  if (existsSync(rknnCandidate) || existsSync(defaultRknn)) {
246
365
  return 'rknn'
247
366
  }
248
- // Guard: ordinary ONNX model cannot be loaded by sherpa-onnx RKNN runtime (#221)
249
367
  return 'cpu'
250
368
  }
251
369
  if (ext === '.rknn') {
252
370
  if (modelPath && existsSync(modelPath)) {
253
371
  return 'rknn'
254
372
  }
255
- // Guard: missing .rknn model file cannot be claimed as running accelerator (#168)
256
373
  return 'cpu'
257
374
  }
258
375
  return 'cpu'
259
376
  }
260
377
 
378
+ if (['cpu', 'openvino', 'coreml', 'cuda'].includes(normProv)) {
379
+ return normProv
380
+ }
381
+
261
382
  return normProv || 'cpu'
262
383
  }
263
384
 
264
385
  export function buildSensevoiceArgs(cfg, port) {
265
386
  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)}` : ''
387
+ const modelDir = modelPath ? (existsSync(modelPath) ? path.dirname(modelPath) : modelPath) : getDefaultModelDir()
271
388
  const effectiveProv = resolveSensevoiceProvider(cfg)
389
+ const provider = (effectiveProv && effectiveProv !== 'cpu') ? ` --provider=${effectiveProv}` : ''
390
+ const threads = Number(cfg?.sensevoiceThreads) || 4
391
+
392
+ const whisperFiles = findWhisperFiles(modelDir) || (modelPath ? findWhisperFiles(path.dirname(modelPath)) : null)
393
+ const isWhisper = Boolean(
394
+ whisperFiles ||
395
+ modelPath.toLowerCase().includes('whisper') ||
396
+ modelPath.toLowerCase().includes('turbo')
397
+ )
398
+
399
+ if (isWhisper && whisperFiles) {
400
+ const enc = JSON.stringify(whisperFiles.encoder)
401
+ const dec = JSON.stringify(whisperFiles.decoder)
402
+ const tok = whisperFiles.tokens ? ` --tokens=${JSON.stringify(whisperFiles.tokens)}` : ''
403
+ return ` --whisper-encoder=${enc} --whisper-decoder=${dec}${tok} --port=${port} --num-threads=${threads}${provider}`
404
+ }
272
405
 
273
406
  if (effectiveProv === 'rknn') {
274
407
  const ext = path.extname(modelPath).toLowerCase()
275
408
  if (ext === '.onnx') {
276
409
  const rknnCandidate = path.join(modelDir, path.basename(modelPath, ext) + '.rknn')
277
410
  const defaultRknn = path.join(modelDir, 'model.rknn')
278
- if (existsSync(rknnCandidate)) {
279
- modelPath = rknnCandidate
280
- } else if (existsSync(defaultRknn)) {
281
- modelPath = defaultRknn
282
- }
411
+ if (existsSync(rknnCandidate)) modelPath = rknnCandidate
412
+ else if (existsSync(defaultRknn)) modelPath = defaultRknn
283
413
  }
284
414
  }
285
415
 
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
416
+ const tokensPath = (cfg?.sensevoiceTokens && existsSync(cfg.sensevoiceTokens))
417
+ ? cfg.sensevoiceTokens
418
+ : findTokensFile(modelDir)
419
+ const tokensArg = tokensPath ? ` --tokens=${JSON.stringify(tokensPath)}` : ''
420
+
421
+ return ` --sense-voice-model=${JSON.stringify(modelPath)}${tokensArg} --port=${port} --num-threads=${threads}${provider}`
294
422
  }
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.36",
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",