@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 +142 -86
- package/lib/host-routes.js +76 -1
- package/lib/http-util.js +46 -6
- package/lib/index.js +7 -11
- package/lib/local-daemon.js +3 -1
- package/lib/schema.js +1 -1
- package/lib/sensevoice-installer.js +184 -56
- package/lib/wav.js +27 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -68,6 +68,9 @@ window.__ModuleLoader__.load({
|
|
|
68
68
|
'npuProvider': 'Hardware Acceleration (SBC NPU / Host)',
|
|
69
69
|
'npuProviderHint': 'Execution provider for SenseVoice / sherpa-onnx: CPU, Rockchip RK3588 NPU (rknpu), OpenVINO, or CUDA.',
|
|
70
70
|
'npuThreads': 'Threads (CPU/SBC)',
|
|
71
|
+
'benchmarkTitle': 'Hardware Benchmark (Speed & Latency)',
|
|
72
|
+
'runBenchmarkBtn': 'Run Benchmark', 'benchmarking': 'Running benchmark…',
|
|
73
|
+
'benchmarkHint': 'Measures real-time factor (RTF) and inference latency on 1.0s audio for current execution provider.',
|
|
71
74
|
'webgpuStatus': 'Browser WebGPU Status',
|
|
72
75
|
'webgpuAvailable': 'WebGPU Available (hardware acceleration ready)',
|
|
73
76
|
'webgpuNotAvailable': 'WebGPU Not Detected (will fallback to server)',
|
|
@@ -291,20 +294,15 @@ window.__ModuleLoader__.load({
|
|
|
291
294
|
'wizardMicPermissionBad': 'Microphone permission denied',
|
|
292
295
|
'wizardRequestMic': 'Request Microphone Access',
|
|
293
296
|
'wizardMicLabel': 'Microphone input device:',
|
|
294
|
-
'wizardStartSignalCheck': 'Start Audio Level Test',
|
|
295
|
-
'wizardStopSignalCheck': 'Stop Audio Test',
|
|
297
|
+
'wizardStartSignalCheck': 'Start Audio Level Test', 'wizardStopSignalCheck': 'Stop Audio Test',
|
|
296
298
|
'wizardSignalOk': 'Audio signal detected (RMS: {level}%)',
|
|
297
299
|
'wizardSignalZero': 'Zero audio level detected - speak into microphone or select correct device',
|
|
298
300
|
'wizardSignalTesting': 'Listening for audio signal… speak into your microphone',
|
|
299
301
|
'wizardProviderLabel': 'Active STT engine & model:',
|
|
300
302
|
'wizardProcessingArea': 'Processing area:',
|
|
301
|
-
'wizardAreaBrowser': 'In-browser (WebGPU / WebSpeech)',
|
|
302
|
-
'
|
|
303
|
-
'
|
|
304
|
-
'wizardStartTestRecord': 'Record 3-Second Test Phrase',
|
|
305
|
-
'wizardRecordingTest': 'Recording test phrase… speak now ({left}s)',
|
|
306
|
-
'wizardTranscribingTest': 'Transcribing test phrase…',
|
|
307
|
-
'wizardTestResult': 'STT Test Result:',
|
|
303
|
+
'wizardAreaBrowser': 'In-browser (WebGPU / WebSpeech)', 'wizardAreaServer': 'Local MiniPC server (SenseVoice / Whisper)', 'wizardAreaCloud': 'Remote cloud provider',
|
|
304
|
+
'wizardStartTestRecord': 'Record 3-Second Test Phrase', 'wizardRecordingTest': 'Recording test phrase… speak now ({left}s)',
|
|
305
|
+
'wizardTranscribingTest': 'Transcribing test phrase…', 'wizardTestResult': 'STT Test Result:',
|
|
308
306
|
'wizardTestSuccess': 'STT round-trip verified successfully! Latency: {latency}ms',
|
|
309
307
|
'wizardTestEmpty': 'No speech detected in test recording. Please speak clearly and try again.',
|
|
310
308
|
'wizardTestFailed': 'STT test failed: {error}',
|
|
@@ -358,6 +356,9 @@ window.__ModuleLoader__.load({
|
|
|
358
356
|
'npuProvider': '硬件加速 (SBC NPU / 主机)',
|
|
359
357
|
'npuProviderHint': 'SenseVoice / sherpa-onnx 执行加速引擎:CPU、Rockchip RK3588 NPU (rknpu)、OpenVINO 或 CUDA。',
|
|
360
358
|
'npuThreads': '计算线程 (CPU/SBC)',
|
|
359
|
+
'benchmarkTitle': '硬件基准测试 (速度与延迟)',
|
|
360
|
+
'runBenchmarkBtn': '运行基准测试', 'benchmarking': '正在运行测试…',
|
|
361
|
+
'benchmarkHint': '测试当前执行提供者在 1.0 秒音频下的实时率 (RTF) 与推理延迟。',
|
|
361
362
|
'webgpuStatus': '浏览器 WebGPU 状态',
|
|
362
363
|
'webgpuAvailable': 'WebGPU 可用 (硬件加速就绪)',
|
|
363
364
|
'webgpuNotAvailable': '未检测到 WebGPU (将自动回退到服务端)',
|
|
@@ -588,13 +589,9 @@ window.__ModuleLoader__.load({
|
|
|
588
589
|
'wizardSignalTesting': '正在监听声音信号… 请对着麦克风发声',
|
|
589
590
|
'wizardProviderLabel': '当前语音引擎与模型:',
|
|
590
591
|
'wizardProcessingArea': '处理区域:',
|
|
591
|
-
'wizardAreaBrowser': '浏览器端处理 (WebGPU / WebSpeech)',
|
|
592
|
-
'
|
|
593
|
-
'
|
|
594
|
-
'wizardStartTestRecord': '录制 3 秒测试语句',
|
|
595
|
-
'wizardRecordingTest': '正在录制测试音频… 请说话 ({left}秒)',
|
|
596
|
-
'wizardTranscribingTest': '正在请求语音识别…',
|
|
597
|
-
'wizardTestResult': 'STT 测试结果:',
|
|
592
|
+
'wizardAreaBrowser': '浏览器端处理 (WebGPU / WebSpeech)', 'wizardAreaServer': '本地服务器处理 (SenseVoice / Whisper)', 'wizardAreaCloud': '远程云端服务',
|
|
593
|
+
'wizardStartTestRecord': '录制 3 秒测试语句', 'wizardRecordingTest': '正在录制测试音频… 请说话 ({left}秒)',
|
|
594
|
+
'wizardTranscribingTest': '正在请求语音识别…', 'wizardTestResult': 'STT 测试结果:',
|
|
598
595
|
'wizardTestSuccess': 'STT 链路验证成功!耗时:{latency}毫秒',
|
|
599
596
|
'wizardTestEmpty': '录音中未检测到有效语音,请清晰发音后重试。',
|
|
600
597
|
'wizardTestFailed': 'STT 测试失败:{error}',
|
|
@@ -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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
//
|
|
3979
|
+
// Local ASR (SenseVoice-Small & Whisper Large-v3-Turbo 1-Click Setup)
|
|
3937
3980
|
function SensevoiceSection(props) {
|
|
3938
3981
|
const t = props.t || moduleT
|
|
3939
3982
|
const sensevoiceState = props.sensevoiceState
|
|
3940
3983
|
const installingSensevoice = props.installingSensevoice
|
|
3941
3984
|
const writable = props.writable
|
|
3942
3985
|
const onInstall = props.onInstall
|
|
3986
|
+
const [modelChoice, setModelChoice] = React.useState('sensevoice')
|
|
3987
|
+
|
|
3988
|
+
const isInstalled = Boolean(sensevoiceState && sensevoiceState.installed)
|
|
3989
|
+
const currentType = (sensevoiceState && sensevoiceState.modelType) || 'sensevoice'
|
|
3990
|
+
const badgeText = isInstalled
|
|
3991
|
+
? (t('sensevoiceReady') + (currentType === 'whisper-turbo' ? ' (whisper-turbo)' : ' (small-int8)'))
|
|
3992
|
+
: (installingSensevoice ? t('sensevoiceDownloading') : t('sensevoiceNotInstalled'))
|
|
3943
3993
|
|
|
3944
3994
|
return React.createElement('div', { className: 'cb-section-card' },
|
|
3945
3995
|
React.createElement('div', { className: 'cb-section-title' },
|
|
3946
3996
|
React.createElement('span', null, '⚡ ' + t('sensevoiceTitle')),
|
|
3947
|
-
|
|
3948
|
-
? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, t('sensevoiceReady') + ' (small-int8)')
|
|
3949
|
-
: React.createElement('span', { className: 'cb-badge cb-badge-warn' },
|
|
3950
|
-
installingSensevoice ? t('sensevoiceDownloading') : t('sensevoiceNotInstalled')
|
|
3951
|
-
),
|
|
3997
|
+
React.createElement('span', { className: 'cb-badge ' + (isInstalled ? 'cb-badge-ok' : 'cb-badge-warn') }, badgeText),
|
|
3952
3998
|
),
|
|
3953
3999
|
React.createElement('div', { className: 'cb-section-desc' }, t('sensevoiceDesc')),
|
|
3954
4000
|
React.createElement('div', { className: 'dvo-installer-box' },
|
|
3955
4001
|
React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', justifyContent: 'space-between', gap: '12px' } },
|
|
3956
|
-
React.createElement('
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
4002
|
+
React.createElement('select', {
|
|
4003
|
+
value: modelChoice,
|
|
4004
|
+
disabled: installingSensevoice || !writable,
|
|
4005
|
+
onChange: (e) => setModelChoice(e.target.value),
|
|
4006
|
+
style: { flex: 1, fontSize: '12px', minWidth: '180px' },
|
|
4007
|
+
},
|
|
4008
|
+
React.createElement('option', { value: 'sensevoice' }, 'SenseVoice-Small (150MB, ~50ms)'),
|
|
4009
|
+
React.createElement('option', { value: 'whisper-turbo' }, 'Whisper Large-v3-Turbo (800MB, High-Accuracy)'),
|
|
3960
4010
|
),
|
|
3961
4011
|
React.createElement('button', {
|
|
3962
4012
|
type: 'button',
|
|
3963
4013
|
className: 'cb-btn cb-btn-primary',
|
|
3964
|
-
style: { fontSize: '12px', padding: '6px 12px' },
|
|
4014
|
+
style: { fontSize: '12px', padding: '6px 12px', whiteSpace: 'nowrap' },
|
|
3965
4015
|
disabled: installingSensevoice || !writable,
|
|
3966
|
-
onClick: onInstall,
|
|
4016
|
+
onClick: () => { if (typeof onInstall === 'function') onInstall(modelChoice) },
|
|
3967
4017
|
}, installingSensevoice ? ('⏳ ' + t('installingSensevoice')) : ('📥 ' + t('installSensevoice'))),
|
|
3968
4018
|
),
|
|
4019
|
+
React.createElement('div', { style: { fontSize: '11px', marginTop: '6px', color: 'var(--dsw-alias-content-secondary)' } },
|
|
4020
|
+
sensevoiceState && sensevoiceState.path
|
|
4021
|
+
? ('~/' + sensevoiceState.path.replace(/^.*[\\/](\.dsh[\\/]models[\\/].*)$/, '$1'))
|
|
4022
|
+
: ('~/.dsh/models/' + modelChoice)
|
|
4023
|
+
),
|
|
3969
4024
|
installingSensevoice
|
|
3970
4025
|
? React.createElement('div', { style: { marginTop: '8px' } },
|
|
3971
4026
|
React.createElement('div', { className: 'dvo-progress-bar' },
|
|
@@ -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
|
}
|
package/lib/host-routes.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// HTTP host routes registered on ctx.webServer.
|
|
2
|
-
import
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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()
|
package/lib/local-daemon.js
CHANGED
|
@@ -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 ${
|
|
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)
|
|
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
|
|
2
|
-
// Downloads pre-quantized ONNX
|
|
1
|
+
// lib/sensevoice-installer.js — 1-Click installer for SenseVoice-Small & Whisper Large-v3-Turbo
|
|
2
|
+
// Downloads pre-quantized ONNX models and configures autostart on the host via sherpa-onnx.
|
|
3
3
|
|
|
4
|
-
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(
|
|
48
|
-
if (idx !== -1) return
|
|
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
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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(
|
|
163
|
+
const installed = Boolean(activeModelFile && existsSync(activeModelFile))
|
|
74
164
|
|
|
75
165
|
return {
|
|
76
166
|
installed,
|
|
77
167
|
running,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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('
|
|
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,
|
|
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(
|
|
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
|
|
150
|
-
// Fallback to mirror
|
|
249
|
+
} catch {
|
|
151
250
|
installerState.step = 'downloading (mirror)'
|
|
152
|
-
await downloadArchive(
|
|
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(
|
|
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
|
-
|
|
167
|
-
if (
|
|
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?.(`
|
|
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: '
|
|
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
|
-
|
|
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
|
|
287
|
-
?
|
|
288
|
-
:
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
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.
|
|
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",
|