@goodandready/dsh-voice 0.9.33 → 0.9.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +189 -54
- package/lib/host-routes.js +140 -3
- package/lib/schema.js +1 -1
- package/lib/sensevoice-installer.js +150 -53
- package/lib/stats.js +51 -1
- package/lib/wav.js +27 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -68,19 +68,16 @@ window.__ModuleLoader__.load({
|
|
|
68
68
|
'npuProvider': 'Hardware Acceleration (SBC NPU / Host)',
|
|
69
69
|
'npuProviderHint': 'Execution provider for SenseVoice / sherpa-onnx: CPU, Rockchip RK3588 NPU (rknpu), OpenVINO, or CUDA.',
|
|
70
70
|
'npuThreads': 'Threads (CPU/SBC)',
|
|
71
|
+
'benchmarkTitle': 'Hardware Benchmark (Speed & Latency)',
|
|
72
|
+
'runBenchmarkBtn': 'Run Benchmark', 'benchmarking': 'Running benchmark…',
|
|
73
|
+
'benchmarkHint': 'Measures real-time factor (RTF) and inference latency on 1.0s audio for current execution provider.',
|
|
71
74
|
'webgpuStatus': 'Browser WebGPU Status',
|
|
72
75
|
'webgpuAvailable': 'WebGPU Available (hardware acceleration ready)',
|
|
73
76
|
'webgpuNotAvailable': 'WebGPU Not Detected (will fallback to server)',
|
|
74
|
-
'openaiHint': 'whisper-1 · key OPENAI_API_KEY',
|
|
75
|
-
'
|
|
76
|
-
'
|
|
77
|
-
'
|
|
78
|
-
'mistralHint': 'voxtral-mini-latest · key MISTRAL_API_KEY',
|
|
79
|
-
'openrouterHint': 'google/gemini-2.5-flash · key OPENROUTER_API_KEY',
|
|
80
|
-
'localHint': 'set when the server is started',
|
|
81
|
-
'up': 'Up',
|
|
82
|
-
'down': 'Down',
|
|
83
|
-
'remove': 'Remove',
|
|
77
|
+
'openaiHint': 'whisper-1 · key OPENAI_API_KEY', 'siliconflowHint': 'FunAudioLLM/SenseVoiceSmall · key SILICONFLOW_API_KEY',
|
|
78
|
+
'deepinfraHint': 'openai/whisper-large-v3-turbo · key DEEPINFRA_API_KEY', 'fireworksHint': 'whisper-v3-turbo · key FIREWORKS_API_KEY',
|
|
79
|
+
'mistralHint': 'voxtral-mini-latest · key MISTRAL_API_KEY', 'openrouterHint': 'google/gemini-2.5-flash · key OPENROUTER_API_KEY',
|
|
80
|
+
'localHint': 'set when the server is started', 'up': 'Up', 'down': 'Down', 'remove': 'Remove',
|
|
84
81
|
'addProvider': 'Add provider',
|
|
85
82
|
'chainHint': 'Top to bottom is the order they are tried in',
|
|
86
83
|
'customName': 'name used in the chain',
|
|
@@ -297,20 +294,15 @@ window.__ModuleLoader__.load({
|
|
|
297
294
|
'wizardMicPermissionBad': 'Microphone permission denied',
|
|
298
295
|
'wizardRequestMic': 'Request Microphone Access',
|
|
299
296
|
'wizardMicLabel': 'Microphone input device:',
|
|
300
|
-
'wizardStartSignalCheck': 'Start Audio Level Test',
|
|
301
|
-
'wizardStopSignalCheck': 'Stop Audio Test',
|
|
297
|
+
'wizardStartSignalCheck': 'Start Audio Level Test', 'wizardStopSignalCheck': 'Stop Audio Test',
|
|
302
298
|
'wizardSignalOk': 'Audio signal detected (RMS: {level}%)',
|
|
303
299
|
'wizardSignalZero': 'Zero audio level detected - speak into microphone or select correct device',
|
|
304
300
|
'wizardSignalTesting': 'Listening for audio signal… speak into your microphone',
|
|
305
301
|
'wizardProviderLabel': 'Active STT engine & model:',
|
|
306
302
|
'wizardProcessingArea': 'Processing area:',
|
|
307
|
-
'wizardAreaBrowser': 'In-browser (WebGPU / WebSpeech)',
|
|
308
|
-
'
|
|
309
|
-
'
|
|
310
|
-
'wizardStartTestRecord': 'Record 3-Second Test Phrase',
|
|
311
|
-
'wizardRecordingTest': 'Recording test phrase… speak now ({left}s)',
|
|
312
|
-
'wizardTranscribingTest': 'Transcribing test phrase…',
|
|
313
|
-
'wizardTestResult': 'STT Test Result:',
|
|
303
|
+
'wizardAreaBrowser': 'In-browser (WebGPU / WebSpeech)', 'wizardAreaServer': 'Local MiniPC server (SenseVoice / Whisper)', 'wizardAreaCloud': 'Remote cloud provider',
|
|
304
|
+
'wizardStartTestRecord': 'Record 3-Second Test Phrase', 'wizardRecordingTest': 'Recording test phrase… speak now ({left}s)',
|
|
305
|
+
'wizardTranscribingTest': 'Transcribing test phrase…', 'wizardTestResult': 'STT Test Result:',
|
|
314
306
|
'wizardTestSuccess': 'STT round-trip verified successfully! Latency: {latency}ms',
|
|
315
307
|
'wizardTestEmpty': 'No speech detected in test recording. Please speak clearly and try again.',
|
|
316
308
|
'wizardTestFailed': 'STT test failed: {error}',
|
|
@@ -322,6 +314,12 @@ window.__ModuleLoader__.load({
|
|
|
322
314
|
'wizardFinishSuccess': 'Setup & Verification Complete',
|
|
323
315
|
'wizardSkip': 'Skip Wizard',
|
|
324
316
|
'wizardStatusCompleted': 'Microphone and STT verified',
|
|
317
|
+
'exportDiagnosticsBtn': 'Export Diagnostics Report', 'exportingDiagnostics': 'Exporting report…',
|
|
318
|
+
'diagnosticsExportSuccess': 'Report downloaded', 'recentRecognitionsTitle': 'Recent Recognition Telemetry',
|
|
319
|
+
'noRecognitionsYet': 'No recent recognition records yet.', 'diagLatencyBreakdown': 'Latency breakdown:',
|
|
320
|
+
'diagVad': 'VAD: {ms}ms', 'diagStt': 'STT: {ms}ms', 'diagPolish': 'Polish: {ms}ms',
|
|
321
|
+
'diagTotal': 'Total: {ms}ms', 'diagBytes': 'Audio: {bytes} B', 'diagWords': 'Words: {count}',
|
|
322
|
+
'diagExportNotice': 'Exported JSON contains anonymized timings, provider chain, MIME format, and error codes without raw audio or speech transcripts.',
|
|
325
323
|
}
|
|
326
324
|
|
|
327
325
|
const zh = {
|
|
@@ -358,19 +356,16 @@ window.__ModuleLoader__.load({
|
|
|
358
356
|
'npuProvider': '硬件加速 (SBC NPU / 主机)',
|
|
359
357
|
'npuProviderHint': 'SenseVoice / sherpa-onnx 执行加速引擎:CPU、Rockchip RK3588 NPU (rknpu)、OpenVINO 或 CUDA。',
|
|
360
358
|
'npuThreads': '计算线程 (CPU/SBC)',
|
|
359
|
+
'benchmarkTitle': '硬件基准测试 (速度与延迟)',
|
|
360
|
+
'runBenchmarkBtn': '运行基准测试', 'benchmarking': '正在运行测试…',
|
|
361
|
+
'benchmarkHint': '测试当前执行提供者在 1.0 秒音频下的实时率 (RTF) 与推理延迟。',
|
|
361
362
|
'webgpuStatus': '浏览器 WebGPU 状态',
|
|
362
363
|
'webgpuAvailable': 'WebGPU 可用 (硬件加速就绪)',
|
|
363
364
|
'webgpuNotAvailable': '未检测到 WebGPU (将自动回退到服务端)',
|
|
364
|
-
'openaiHint': 'whisper-1 · 密钥 OPENAI_API_KEY',
|
|
365
|
-
'
|
|
366
|
-
'
|
|
367
|
-
'
|
|
368
|
-
'mistralHint': 'voxtral-mini-latest · 密钥 MISTRAL_API_KEY',
|
|
369
|
-
'openrouterHint': 'google/gemini-2.5-flash · 密钥 OPENROUTER_API_KEY',
|
|
370
|
-
'localHint': '服务器启动时配置',
|
|
371
|
-
'up': '上移',
|
|
372
|
-
'down': '下移',
|
|
373
|
-
'remove': '移除',
|
|
365
|
+
'openaiHint': 'whisper-1 · 密钥 OPENAI_API_KEY', 'siliconflowHint': 'FunAudioLLM/SenseVoiceSmall · 密钥 SILICONFLOW_API_KEY',
|
|
366
|
+
'deepinfraHint': 'openai/whisper-large-v3-turbo · 密钥 DEEPINFRA_API_KEY', 'fireworksHint': 'whisper-v3-turbo · 密钥 FIREWORKS_API_KEY',
|
|
367
|
+
'mistralHint': 'voxtral-mini-latest · 密钥 MISTRAL_API_KEY', 'openrouterHint': 'google/gemini-2.5-flash · 密钥 OPENROUTER_API_KEY',
|
|
368
|
+
'localHint': '服务器启动时配置', 'up': '上移', 'down': '下移', 'remove': '移除',
|
|
374
369
|
'addProvider': '添加服务商',
|
|
375
370
|
'chainHint': '自上而下为故障转移尝试顺序',
|
|
376
371
|
'customName': '备用链中使用的名称',
|
|
@@ -594,13 +589,9 @@ window.__ModuleLoader__.load({
|
|
|
594
589
|
'wizardSignalTesting': '正在监听声音信号… 请对着麦克风发声',
|
|
595
590
|
'wizardProviderLabel': '当前语音引擎与模型:',
|
|
596
591
|
'wizardProcessingArea': '处理区域:',
|
|
597
|
-
'wizardAreaBrowser': '浏览器端处理 (WebGPU / WebSpeech)',
|
|
598
|
-
'
|
|
599
|
-
'
|
|
600
|
-
'wizardStartTestRecord': '录制 3 秒测试语句',
|
|
601
|
-
'wizardRecordingTest': '正在录制测试音频… 请说话 ({left}秒)',
|
|
602
|
-
'wizardTranscribingTest': '正在请求语音识别…',
|
|
603
|
-
'wizardTestResult': 'STT 测试结果:',
|
|
592
|
+
'wizardAreaBrowser': '浏览器端处理 (WebGPU / WebSpeech)', 'wizardAreaServer': '本地服务器处理 (SenseVoice / Whisper)', 'wizardAreaCloud': '远程云端服务',
|
|
593
|
+
'wizardStartTestRecord': '录制 3 秒测试语句', 'wizardRecordingTest': '正在录制测试音频… 请说话 ({left}秒)',
|
|
594
|
+
'wizardTranscribingTest': '正在请求语音识别…', 'wizardTestResult': 'STT 测试结果:',
|
|
604
595
|
'wizardTestSuccess': 'STT 链路验证成功!耗时:{latency}毫秒',
|
|
605
596
|
'wizardTestEmpty': '录音中未检测到有效语音,请清晰发音后重试。',
|
|
606
597
|
'wizardTestFailed': 'STT 测试失败:{error}',
|
|
@@ -612,6 +603,12 @@ window.__ModuleLoader__.load({
|
|
|
612
603
|
'wizardFinishSuccess': '设置与验证已完成',
|
|
613
604
|
'wizardSkip': '跳过向导',
|
|
614
605
|
'wizardStatusCompleted': '麦克风与 STT 诊断已就绪',
|
|
606
|
+
'exportDiagnosticsBtn': '导出诊断报告', 'exportingDiagnostics': '正在导出报告…',
|
|
607
|
+
'diagnosticsExportSuccess': '诊断报告已下载', 'recentRecognitionsTitle': '近期识别遥测详情',
|
|
608
|
+
'noRecognitionsYet': '暂无近期识别记录。', 'diagLatencyBreakdown': '耗时分布:',
|
|
609
|
+
'diagVad': 'VAD:{ms}毫秒', 'diagStt': 'STT:{ms}毫秒', 'diagPolish': '后处理:{ms}毫秒',
|
|
610
|
+
'diagTotal': '总耗时:{ms}毫秒', 'diagBytes': '音频大小:{bytes} 字节', 'diagWords': '词数:{count}',
|
|
611
|
+
'diagExportNotice': '导出的 JSON 报告包含脱敏的耗时统计、备用链尝试、MIME 格式与错误码,不含任何原始音频或语音文本。',
|
|
615
612
|
}
|
|
616
613
|
|
|
617
614
|
// Strings are also needed outside components — in recording handlers and
|
|
@@ -3160,18 +3157,61 @@ function cycleDictationMode() {
|
|
|
3160
3157
|
),
|
|
3161
3158
|
)
|
|
3162
3159
|
}
|
|
3163
|
-
// Provider Telemetry & Latency Dashboard
|
|
3160
|
+
// Provider Telemetry & Latency Dashboard (#266)
|
|
3164
3161
|
function ProviderDashboard(props) {
|
|
3165
3162
|
const t = props.t || moduleT
|
|
3166
3163
|
const statsData = props.statsData || {}
|
|
3164
|
+
const recentDiagnostics = props.recentDiagnostics || []
|
|
3165
|
+
const [exporting, setExporting] = React.useState(false)
|
|
3166
|
+
const [exportMsg, setExportMsg] = React.useState('')
|
|
3167
|
+
|
|
3168
|
+
const exportReport = async () => {
|
|
3169
|
+
setExporting(true)
|
|
3170
|
+
setExportMsg('')
|
|
3171
|
+
try {
|
|
3172
|
+
const res = await fetch('/dsh-voice/diagnostics/export')
|
|
3173
|
+
const data = await res.json()
|
|
3174
|
+
if (!res.ok || !data || !data.ok) {
|
|
3175
|
+
throw new Error((data && data.error && data.error.message) || `HTTP ${res.status}`)
|
|
3176
|
+
}
|
|
3177
|
+
const blob = new Blob([JSON.stringify(data.report || {}, null, 2)], { type: 'application/json' })
|
|
3178
|
+
const url = URL.createObjectURL(blob)
|
|
3179
|
+
const a = document.createElement('a')
|
|
3180
|
+
a.href = url
|
|
3181
|
+
a.download = `dsh-voice-diagnostics-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
|
|
3182
|
+
document.body.appendChild(a)
|
|
3183
|
+
a.click()
|
|
3184
|
+
document.body.removeChild(a)
|
|
3185
|
+
URL.revokeObjectURL(url)
|
|
3186
|
+
setExportMsg('✓ ' + t('diagnosticsExportSuccess'))
|
|
3187
|
+
} catch (err) {
|
|
3188
|
+
setExportMsg(String(err && err.message ? err.message : err))
|
|
3189
|
+
} finally {
|
|
3190
|
+
setExporting(false)
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3167
3194
|
return React.createElement('div', { className: 'cb-section-card' },
|
|
3168
3195
|
React.createElement('div', { className: 'cb-section-title' },
|
|
3169
3196
|
React.createElement('span', null, '📊 ' + t('providerDashboard')),
|
|
3170
|
-
React.createElement('
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3197
|
+
React.createElement('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
|
|
3198
|
+
React.createElement('button', {
|
|
3199
|
+
type: 'button',
|
|
3200
|
+
className: 'cb-btn',
|
|
3201
|
+
style: { fontSize: '12px', padding: '4px 10px' },
|
|
3202
|
+
disabled: exporting,
|
|
3203
|
+
onClick: exportReport,
|
|
3204
|
+
}, exporting ? t('exportingDiagnostics') : ('📥 ' + t('exportDiagnosticsBtn'))),
|
|
3205
|
+
React.createElement('button', {
|
|
3206
|
+
type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
|
|
3207
|
+
onClick: props.onRefresh,
|
|
3208
|
+
}, '🔄 ' + t('refreshStats')),
|
|
3209
|
+
),
|
|
3174
3210
|
),
|
|
3211
|
+
exportMsg ? React.createElement('div', {
|
|
3212
|
+
className: exportMsg.startsWith('✓') ? 'dvs-ok' : 'cb-alert-err',
|
|
3213
|
+
style: { fontSize: '12px' },
|
|
3214
|
+
}, exportMsg) : null,
|
|
3175
3215
|
React.createElement('div', { className: 'dvo-dash' },
|
|
3176
3216
|
React.createElement('div', { className: 'dvo-dash-grid' },
|
|
3177
3217
|
Object.keys(statsData).length === 0
|
|
@@ -3211,6 +3251,41 @@ function cycleDictationMode() {
|
|
|
3211
3251
|
}),
|
|
3212
3252
|
),
|
|
3213
3253
|
),
|
|
3254
|
+
// Per-recognition telemetry breakdown list (#266)
|
|
3255
|
+
React.createElement('div', { style: { marginTop: '12px', borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: '10px' } },
|
|
3256
|
+
React.createElement('div', { className: 'dvs-h', style: { marginBottom: '8px' } }, t('recentRecognitionsTitle')),
|
|
3257
|
+
recentDiagnostics.length === 0
|
|
3258
|
+
? React.createElement('div', { className: 'dvs-sub' }, t('noRecognitionsYet'))
|
|
3259
|
+
: React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } },
|
|
3260
|
+
recentDiagnostics.slice(-5).reverse().map((d) => React.createElement('div', {
|
|
3261
|
+
key: d.id,
|
|
3262
|
+
className: 'dvs-card',
|
|
3263
|
+
style: { padding: '8px 12px', fontSize: '12px' },
|
|
3264
|
+
},
|
|
3265
|
+
React.createElement('div', { className: 'cb-row', style: { justifyContent: 'space-between', alignItems: 'center' } },
|
|
3266
|
+
React.createElement('div', { style: { display: 'flex', gap: '6px', alignItems: 'center' } },
|
|
3267
|
+
React.createElement('span', { className: 'cb-badge ' + (d.success ? 'cb-badge-ok' : 'cb-badge-err') },
|
|
3268
|
+
d.success ? '✓ ' + (d.provider || 'STT') : '✗ ' + (d.error ? d.error.slice(0, 30) : 'fail')
|
|
3269
|
+
),
|
|
3270
|
+
React.createElement('span', { className: 'dvs-sub' }, `[${d.mode}/${d.subMode}]`),
|
|
3271
|
+
d.audioBytes ? React.createElement('span', { className: 'dvs-sub' }, t('diagBytes').replace('{bytes}', d.audioBytes)) : null,
|
|
3272
|
+
),
|
|
3273
|
+
React.createElement('span', { style: { fontWeight: '600' } },
|
|
3274
|
+
t('diagTotal').replace('{ms}', d.totalTookMs)
|
|
3275
|
+
),
|
|
3276
|
+
),
|
|
3277
|
+
React.createElement('div', { className: 'cb-row', style: { gap: '12px', marginTop: '4px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } },
|
|
3278
|
+
React.createElement('span', null, t('diagVad').replace('{ms}', (d.breakdown && d.breakdown.vadMs) || 0)),
|
|
3279
|
+
React.createElement('span', null, t('diagStt').replace('{ms}', (d.breakdown && d.breakdown.sttMs) || 0)),
|
|
3280
|
+
d.breakdown && d.breakdown.polishMs ? React.createElement('span', null, t('diagPolish').replace('{ms}', d.breakdown.polishMs)) : null,
|
|
3281
|
+
typeof d.wordCount === 'number' ? React.createElement('span', null, t('diagWords').replace('{count}', d.wordCount)) : null,
|
|
3282
|
+
),
|
|
3283
|
+
))
|
|
3284
|
+
),
|
|
3285
|
+
React.createElement('div', { className: 'dvs-sub', style: { marginTop: '8px', fontStyle: 'italic' } },
|
|
3286
|
+
t('diagExportNotice')
|
|
3287
|
+
),
|
|
3288
|
+
),
|
|
3214
3289
|
)
|
|
3215
3290
|
}
|
|
3216
3291
|
// ------------------------------------------------------------------ 72-dictation-modes
|
|
@@ -3316,6 +3391,32 @@ function DictationModesSection(props) {
|
|
|
3316
3391
|
const gateThresholdPercent = props.gateThresholdPercent
|
|
3317
3392
|
const onClearKey = props.onClearKey
|
|
3318
3393
|
|
|
3394
|
+
const [benchmarking, setBenchmarking] = React.useState(false)
|
|
3395
|
+
const [benchmarkResult, setBenchmarkResult] = React.useState(null)
|
|
3396
|
+
const [benchmarkErr, setBenchmarkErr] = React.useState('')
|
|
3397
|
+
|
|
3398
|
+
const runBenchmark = () => {
|
|
3399
|
+
setBenchmarking(true)
|
|
3400
|
+
setBenchmarkErr('')
|
|
3401
|
+
fetch('/dsh-voice/benchmark', {
|
|
3402
|
+
method: 'POST',
|
|
3403
|
+
headers: {
|
|
3404
|
+
'x-dsh-plugin-update': '1',
|
|
3405
|
+
'Content-Type': 'application/json',
|
|
3406
|
+
},
|
|
3407
|
+
})
|
|
3408
|
+
.then((r) => r.json())
|
|
3409
|
+
.then((d) => {
|
|
3410
|
+
setBenchmarking(false)
|
|
3411
|
+
if (d && d.ok) setBenchmarkResult(d)
|
|
3412
|
+
else setBenchmarkErr((d && d.error && d.error.message) || 'Benchmark failed')
|
|
3413
|
+
})
|
|
3414
|
+
.catch((e) => {
|
|
3415
|
+
setBenchmarking(false)
|
|
3416
|
+
setBenchmarkErr((e && e.message) || 'Network error')
|
|
3417
|
+
})
|
|
3418
|
+
}
|
|
3419
|
+
|
|
3319
3420
|
const textField = (key, label, hint) => React.createElement('div', { className: 'cb-field' },
|
|
3320
3421
|
React.createElement('label', null, label),
|
|
3321
3422
|
React.createElement('input', {
|
|
@@ -3486,6 +3587,7 @@ function DictationModesSection(props) {
|
|
|
3486
3587
|
},
|
|
3487
3588
|
React.createElement('option', { value: 'cpu' }, 'CPU (Standard)'),
|
|
3488
3589
|
React.createElement('option', { value: 'rknn' }, 'Rockchip RK3588 NPU (RKNN)'),
|
|
3590
|
+
React.createElement('option', { value: 'coreml' }, 'Apple Silicon CoreML'),
|
|
3489
3591
|
React.createElement('option', { value: 'openvino' }, 'Intel OpenVINO'),
|
|
3490
3592
|
React.createElement('option', { value: 'cuda' }, 'NVIDIA CUDA'),
|
|
3491
3593
|
),
|
|
@@ -3502,6 +3604,24 @@ function DictationModesSection(props) {
|
|
|
3502
3604
|
),
|
|
3503
3605
|
React.createElement('span', { className: 'dvs-sub' }, t('npuProviderHint')),
|
|
3504
3606
|
),
|
|
3607
|
+
// Hardware Benchmark
|
|
3608
|
+
React.createElement('div', { className: 'cb-field' },
|
|
3609
|
+
React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', justifyContent: 'space-between' } },
|
|
3610
|
+
React.createElement('label', { style: { marginBottom: 0 } }, t('benchmarkTitle')),
|
|
3611
|
+
React.createElement('button', {
|
|
3612
|
+
type: 'button', className: 'cb-btn cb-btn-secondary', style: { fontSize: '12px', padding: '4px 10px' },
|
|
3613
|
+
disabled: benchmarking || !writable, onClick: runBenchmark,
|
|
3614
|
+
}, benchmarking ? ('⏳ ' + t('benchmarking')) : ('⚡ ' + t('runBenchmarkBtn'))),
|
|
3615
|
+
),
|
|
3616
|
+
benchmarkResult
|
|
3617
|
+
? React.createElement('div', {
|
|
3618
|
+
style: { marginTop: '8px', padding: '6px 10px', borderRadius: '6px', fontSize: '12px', backgroundColor: 'var(--dsw-alias-fill-quaternary)' }
|
|
3619
|
+
}, benchmarkResult.running
|
|
3620
|
+
? `⚡ RTF: ${benchmarkResult.rtf}x · ${benchmarkResult.latencyMs}ms (${benchmarkResult.rating}) · ${String(benchmarkResult.provider || 'cpu').toUpperCase()} · Cores: ${benchmarkResult.cpuCores}`
|
|
3621
|
+
: `ℹ ${benchmarkResult.message || 'Offline'} (${benchmarkResult.cpuCores} cores, ${benchmarkResult.platform})`)
|
|
3622
|
+
: (benchmarkErr ? React.createElement('div', { style: { color: 'var(--dsw-alias-state-danger)', fontSize: '12px', marginTop: '4px' } }, benchmarkErr) : null),
|
|
3623
|
+
React.createElement('span', { className: 'dvs-sub' }, t('benchmarkHint')),
|
|
3624
|
+
),
|
|
3505
3625
|
// WebGPU Status Indicator
|
|
3506
3626
|
React.createElement('div', { className: 'cb-field' },
|
|
3507
3627
|
React.createElement('label', null, t('webgpuStatus')),
|
|
@@ -3549,7 +3669,7 @@ function DictationModesSection(props) {
|
|
|
3549
3669
|
fetchSensevoice()
|
|
3550
3670
|
}, [])
|
|
3551
3671
|
|
|
3552
|
-
const triggerInstallSensevoice = () => {
|
|
3672
|
+
const triggerInstallSensevoice = (modelType = 'sensevoice') => {
|
|
3553
3673
|
setInstallingSensevoice(true)
|
|
3554
3674
|
fetch('/dsh-voice/sensevoice-installer', {
|
|
3555
3675
|
method: 'POST',
|
|
@@ -3557,6 +3677,7 @@ function DictationModesSection(props) {
|
|
|
3557
3677
|
'x-dsh-plugin-update': '1',
|
|
3558
3678
|
'Content-Type': 'application/json',
|
|
3559
3679
|
},
|
|
3680
|
+
body: JSON.stringify({ model: modelType }),
|
|
3560
3681
|
})
|
|
3561
3682
|
.then((r) => r.json())
|
|
3562
3683
|
.then(() => {
|
|
@@ -3855,39 +3976,51 @@ function MacrosSection(props) {
|
|
|
3855
3976
|
),
|
|
3856
3977
|
)
|
|
3857
3978
|
}
|
|
3858
|
-
//
|
|
3979
|
+
// Local ASR (SenseVoice-Small & Whisper Large-v3-Turbo 1-Click Setup)
|
|
3859
3980
|
function SensevoiceSection(props) {
|
|
3860
3981
|
const t = props.t || moduleT
|
|
3861
3982
|
const sensevoiceState = props.sensevoiceState
|
|
3862
3983
|
const installingSensevoice = props.installingSensevoice
|
|
3863
3984
|
const writable = props.writable
|
|
3864
3985
|
const onInstall = props.onInstall
|
|
3986
|
+
const [modelChoice, setModelChoice] = React.useState('sensevoice')
|
|
3987
|
+
|
|
3988
|
+
const isInstalled = Boolean(sensevoiceState && sensevoiceState.installed)
|
|
3989
|
+
const currentType = (sensevoiceState && sensevoiceState.modelType) || 'sensevoice'
|
|
3990
|
+
const badgeText = isInstalled
|
|
3991
|
+
? (t('sensevoiceReady') + (currentType === 'whisper-turbo' ? ' (whisper-turbo)' : ' (small-int8)'))
|
|
3992
|
+
: (installingSensevoice ? t('sensevoiceDownloading') : t('sensevoiceNotInstalled'))
|
|
3865
3993
|
|
|
3866
3994
|
return React.createElement('div', { className: 'cb-section-card' },
|
|
3867
3995
|
React.createElement('div', { className: 'cb-section-title' },
|
|
3868
3996
|
React.createElement('span', null, '⚡ ' + t('sensevoiceTitle')),
|
|
3869
|
-
|
|
3870
|
-
? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, t('sensevoiceReady') + ' (small-int8)')
|
|
3871
|
-
: React.createElement('span', { className: 'cb-badge cb-badge-warn' },
|
|
3872
|
-
installingSensevoice ? t('sensevoiceDownloading') : t('sensevoiceNotInstalled')
|
|
3873
|
-
),
|
|
3997
|
+
React.createElement('span', { className: 'cb-badge ' + (isInstalled ? 'cb-badge-ok' : 'cb-badge-warn') }, badgeText),
|
|
3874
3998
|
),
|
|
3875
3999
|
React.createElement('div', { className: 'cb-section-desc' }, t('sensevoiceDesc')),
|
|
3876
4000
|
React.createElement('div', { className: 'dvo-installer-box' },
|
|
3877
4001
|
React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', justifyContent: 'space-between', gap: '12px' } },
|
|
3878
|
-
React.createElement('
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
4002
|
+
React.createElement('select', {
|
|
4003
|
+
value: modelChoice,
|
|
4004
|
+
disabled: installingSensevoice || !writable,
|
|
4005
|
+
onChange: (e) => setModelChoice(e.target.value),
|
|
4006
|
+
style: { flex: 1, fontSize: '12px', minWidth: '180px' },
|
|
4007
|
+
},
|
|
4008
|
+
React.createElement('option', { value: 'sensevoice' }, 'SenseVoice-Small (150MB, ~50ms)'),
|
|
4009
|
+
React.createElement('option', { value: 'whisper-turbo' }, 'Whisper Large-v3-Turbo (800MB, High-Accuracy)'),
|
|
3882
4010
|
),
|
|
3883
4011
|
React.createElement('button', {
|
|
3884
4012
|
type: 'button',
|
|
3885
4013
|
className: 'cb-btn cb-btn-primary',
|
|
3886
|
-
style: { fontSize: '12px', padding: '6px 12px' },
|
|
4014
|
+
style: { fontSize: '12px', padding: '6px 12px', whiteSpace: 'nowrap' },
|
|
3887
4015
|
disabled: installingSensevoice || !writable,
|
|
3888
|
-
onClick: onInstall,
|
|
4016
|
+
onClick: () => { if (typeof onInstall === 'function') onInstall(modelChoice) },
|
|
3889
4017
|
}, installingSensevoice ? ('⏳ ' + t('installingSensevoice')) : ('📥 ' + t('installSensevoice'))),
|
|
3890
4018
|
),
|
|
4019
|
+
React.createElement('div', { style: { fontSize: '11px', marginTop: '6px', color: 'var(--dsw-alias-content-secondary)' } },
|
|
4020
|
+
sensevoiceState && sensevoiceState.path
|
|
4021
|
+
? ('~/' + sensevoiceState.path.replace(/^.*[\\/](\.dsh[\\/]models[\\/].*)$/, '$1'))
|
|
4022
|
+
: ('~/.dsh/models/' + modelChoice)
|
|
4023
|
+
),
|
|
3891
4024
|
installingSensevoice
|
|
3892
4025
|
? React.createElement('div', { style: { marginTop: '8px' } },
|
|
3893
4026
|
React.createElement('div', { className: 'dvo-progress-bar' },
|
|
@@ -4303,6 +4436,7 @@ function MacrosSection(props) {
|
|
|
4303
4436
|
React.createElement('span', { className: 'dvs-sub' }, hint),
|
|
4304
4437
|
)
|
|
4305
4438
|
const statsData = (statusData && statusData.providerStats) || {}
|
|
4439
|
+
const recentDiagnostics = (statusData && statusData.recentDiagnostics) || []
|
|
4306
4440
|
const hostOnline = statusLatency !== null
|
|
4307
4441
|
const whisperRunning = !!(statusData && statusData.whisperRunning)
|
|
4308
4442
|
const providerCount = (statusData && Array.isArray(statusData.providers)) ? statusData.providers.length : chainOptions.length
|
|
@@ -4535,6 +4669,7 @@ function MacrosSection(props) {
|
|
|
4535
4669
|
React.createElement(ProviderDashboard, {
|
|
4536
4670
|
t: t,
|
|
4537
4671
|
statsData: statsData,
|
|
4672
|
+
recentDiagnostics: recentDiagnostics,
|
|
4538
4673
|
onRefresh: fetchStatus,
|
|
4539
4674
|
}),
|
|
4540
4675
|
React.createElement(UpdaterCard, {
|
package/lib/host-routes.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// HTTP host routes registered on ctx.webServer.
|
|
2
|
+
import os from 'node:os'
|
|
2
3
|
import { readBody, isLoopback, isTrustedCaller } from './http-util.js'
|
|
3
4
|
import { resolveSensevoiceProvider } from './sensevoice-installer.js'
|
|
4
5
|
import { applyVoiceMacros } from './macros.js'
|
|
6
|
+
import { createSyntheticWav } from './wav.js'
|
|
5
7
|
|
|
6
8
|
function writeJson(res, status, obj) {
|
|
7
9
|
res.writeHead(status, { 'content-type': 'application/json' })
|
|
@@ -96,6 +98,7 @@ export function registerHostRoutes(ctx, deps) {
|
|
|
96
98
|
dictationPrompts: cfg.dictationPrompts || {},
|
|
97
99
|
voiceMacros: Array.isArray(cfg.voiceMacros) ? cfg.voiceMacros : [],
|
|
98
100
|
providerStats: statsTracker.get(),
|
|
101
|
+
recentDiagnostics: typeof statsTracker.getDiagnostics === 'function' ? statsTracker.getDiagnostics() : [],
|
|
99
102
|
})
|
|
100
103
|
},
|
|
101
104
|
}), 'dsh-voice: /status route')
|
|
@@ -133,6 +136,91 @@ export function registerHostRoutes(ctx, deps) {
|
|
|
133
136
|
},
|
|
134
137
|
}), 'dsh-voice: /polish route')
|
|
135
138
|
|
|
139
|
+
ctx.effect(() => ctx.webServer.register({
|
|
140
|
+
kind: 'exact',
|
|
141
|
+
path: '/dsh-voice/diagnostics/export',
|
|
142
|
+
handler: async (req, res) => {
|
|
143
|
+
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
144
|
+
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
145
|
+
const report = typeof statsTracker.exportAnonymizedReport === 'function'
|
|
146
|
+
? statsTracker.exportAnonymizedReport({ version: '0.9.34', environment: 'production' })
|
|
147
|
+
: { summary: statsTracker.get(), recentDiagnostics: [] }
|
|
148
|
+
writeJson(res, 200, { ok: true, report })
|
|
149
|
+
},
|
|
150
|
+
}), 'dsh-voice: /diagnostics/export route')
|
|
151
|
+
|
|
152
|
+
ctx.effect(() => ctx.webServer.register({
|
|
153
|
+
kind: 'exact',
|
|
154
|
+
path: '/dsh-voice/benchmark',
|
|
155
|
+
handler: async (req, res) => {
|
|
156
|
+
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
157
|
+
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
158
|
+
|
|
159
|
+
const cfg = live()
|
|
160
|
+
const effectiveProvider = resolveSensevoiceProvider(cfg)
|
|
161
|
+
const cpus = os.cpus() || []
|
|
162
|
+
const numCores = cpus.length || 1
|
|
163
|
+
const cpuModel = (cpus[0] && cpus[0].model) || 'unknown'
|
|
164
|
+
const platformInfo = `${process.platform}-${process.arch}`
|
|
165
|
+
const isAlive = await sensevoiceAlive()
|
|
166
|
+
|
|
167
|
+
if (!isAlive) {
|
|
168
|
+
writeJson(res, 200, {
|
|
169
|
+
ok: true,
|
|
170
|
+
running: false,
|
|
171
|
+
provider: effectiveProvider,
|
|
172
|
+
cpuCores: numCores,
|
|
173
|
+
cpuModel,
|
|
174
|
+
platform: platformInfo,
|
|
175
|
+
message: 'Local ASR server is not running. Hardware detected.',
|
|
176
|
+
})
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const sampleSec = 1.0
|
|
181
|
+
const wavBuf = createSyntheticWav(sampleSec, 16000)
|
|
182
|
+
const tStart = Date.now()
|
|
183
|
+
let latencyMs = 0
|
|
184
|
+
let recognizedText = ''
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const url = String(cfg.sensevoiceUrl || 'http://127.0.0.1:6006/api/v1/asr').trim()
|
|
188
|
+
const form = new FormData()
|
|
189
|
+
form.append('file', new Blob([wavBuf], { type: 'audio/wav' }), 'benchmark.wav')
|
|
190
|
+
const r = await fetch(url, { method: 'POST', body: form, signal: AbortSignal.timeout(10000) })
|
|
191
|
+
latencyMs = Date.now() - tStart
|
|
192
|
+
if (r.ok) {
|
|
193
|
+
try {
|
|
194
|
+
const data = await r.json()
|
|
195
|
+
recognizedText = String(data?.text || data?.transcript || '').trim()
|
|
196
|
+
} catch { /* json parse error */ }
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
latencyMs = Date.now() - tStart
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const rtf = Math.round((latencyMs / (sampleSec * 1000)) * 1000) / 1000
|
|
203
|
+
let rating = 'Fast'
|
|
204
|
+
if (rtf < 0.15) rating = 'Ultra-fast'
|
|
205
|
+
else if (rtf < 0.45) rating = 'Fast'
|
|
206
|
+
else if (rtf <= 1.0) rating = 'Real-time'
|
|
207
|
+
else rating = 'Slow'
|
|
208
|
+
|
|
209
|
+
writeJson(res, 200, {
|
|
210
|
+
ok: true,
|
|
211
|
+
running: true,
|
|
212
|
+
provider: effectiveProvider,
|
|
213
|
+
latencyMs,
|
|
214
|
+
rtf,
|
|
215
|
+
sampleSec,
|
|
216
|
+
rating,
|
|
217
|
+
cpuCores: numCores,
|
|
218
|
+
cpuModel,
|
|
219
|
+
platform: platformInfo,
|
|
220
|
+
})
|
|
221
|
+
},
|
|
222
|
+
}), 'dsh-voice: /benchmark route')
|
|
223
|
+
|
|
136
224
|
ctx.effect(() => ctx.webServer.register({
|
|
137
225
|
kind: 'exact',
|
|
138
226
|
path: '/dsh-voice/transcribe',
|
|
@@ -178,13 +266,31 @@ export function registerHostRoutes(ctx, deps) {
|
|
|
178
266
|
await startSensevoice()
|
|
179
267
|
}
|
|
180
268
|
|
|
269
|
+
const reqStart = Date.now()
|
|
181
270
|
const controller = new AbortController()
|
|
182
271
|
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
272
|
+
let out = null
|
|
273
|
+
let polishTookMs = 0
|
|
183
274
|
try {
|
|
184
|
-
|
|
275
|
+
out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
|
|
185
276
|
if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
|
|
186
277
|
const cmd = sessionCommand(out.text)
|
|
187
|
-
if (cmd) {
|
|
278
|
+
if (cmd) {
|
|
279
|
+
if (typeof statsTracker.recordDiagnostic === 'function') {
|
|
280
|
+
statsTracker.recordDiagnostic({
|
|
281
|
+
mode: payload.mode || 'dictation',
|
|
282
|
+
subMode: 'command',
|
|
283
|
+
mimeType: mime,
|
|
284
|
+
audioBytes: bytes.length,
|
|
285
|
+
provider: out.provider,
|
|
286
|
+
success: true,
|
|
287
|
+
totalTookMs: Date.now() - reqStart,
|
|
288
|
+
sttMs: out.tookMs || 0,
|
|
289
|
+
vadMs: Number(payload.vadMs) || 0,
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return
|
|
293
|
+
}
|
|
188
294
|
}
|
|
189
295
|
const subMode = payload.subMode || payload.dictationMode || cfg.dictationMode || 'clean'
|
|
190
296
|
const rawText = out.text
|
|
@@ -192,7 +298,9 @@ export function registerHostRoutes(ctx, deps) {
|
|
|
192
298
|
|
|
193
299
|
// Dictation modes & polish (#264): verbatim skips LLM entirely (0 LLM calls)
|
|
194
300
|
if (subMode !== 'verbatim' && (modeCfg.polish === true || payload.polish === true)) {
|
|
301
|
+
const polishStart = Date.now()
|
|
195
302
|
text = await polishText(rawText, { ...modeCfg, subMode, prompt: payload.prompt }, controller.signal)
|
|
303
|
+
polishTookMs = Date.now() - polishStart
|
|
196
304
|
}
|
|
197
305
|
|
|
198
306
|
// Voice action macros (#167)
|
|
@@ -211,8 +319,37 @@ export function registerHostRoutes(ctx, deps) {
|
|
|
211
319
|
action = actionRes.action
|
|
212
320
|
}
|
|
213
321
|
}
|
|
214
|
-
|
|
322
|
+
const totalTookMs = Date.now() - reqStart
|
|
323
|
+
if (typeof statsTracker.recordDiagnostic === 'function') {
|
|
324
|
+
statsTracker.recordDiagnostic({
|
|
325
|
+
mode: payload.mode || 'dictation',
|
|
326
|
+
subMode,
|
|
327
|
+
mimeType: mime,
|
|
328
|
+
audioBytes: bytes.length,
|
|
329
|
+
provider: out.provider,
|
|
330
|
+
success: true,
|
|
331
|
+
totalTookMs,
|
|
332
|
+
sttMs: out.tookMs || 0,
|
|
333
|
+
polishMs: polishTookMs,
|
|
334
|
+
vadMs: Number(payload.vadMs) || 0,
|
|
335
|
+
wordCount: text ? text.split(/\s+/).filter(Boolean).length : 0,
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
writeJson(res, 200, { ok: true, text, rawText, subMode, action, provider: out.provider, tookMs: out.tookMs, polishTookMs, totalTookMs })
|
|
215
339
|
} catch (e) {
|
|
340
|
+
if (typeof statsTracker.recordDiagnostic === 'function') {
|
|
341
|
+
statsTracker.recordDiagnostic({
|
|
342
|
+
mode: payload.mode || 'dictation',
|
|
343
|
+
subMode: payload.subMode || 'clean',
|
|
344
|
+
mimeType: mime,
|
|
345
|
+
audioBytes: bytes.length,
|
|
346
|
+
provider: (out && out.provider) || 'unknown',
|
|
347
|
+
success: false,
|
|
348
|
+
totalTookMs: Date.now() - reqStart,
|
|
349
|
+
vadMs: Number(payload.vadMs) || 0,
|
|
350
|
+
error: String(e && e.message || e),
|
|
351
|
+
})
|
|
352
|
+
}
|
|
216
353
|
writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
|
|
217
354
|
} finally {
|
|
218
355
|
clearTimeout(timer)
|
package/lib/schema.js
CHANGED
|
@@ -139,7 +139,7 @@ export const BaseConfig = z.object({
|
|
|
139
139
|
sensevoiceAutostart: z.boolean().default(false)
|
|
140
140
|
.description('Start the local SenseVoice / sherpa-onnx server process automatically on startup.'),
|
|
141
141
|
sensevoiceProvider: z.string().default('cpu')
|
|
142
|
-
.description('Execution provider for sherpa-onnx: "cpu", "rknn" (Rockchip RK3588/RK3568 NPU on ARM SBC with RKNN models)
|
|
142
|
+
.description('Execution provider for sherpa-onnx: "cpu", "coreml" (Apple Silicon), "openvino", "cuda", "rknn" (Rockchip RK3588/RK3568 NPU on ARM SBC with RKNN models).'),
|
|
143
143
|
sensevoiceThreads: z.number().default(4)
|
|
144
144
|
.description('Execution thread count for local SenseVoice / sherpa-onnx on CPU/SBC.'),
|
|
145
145
|
webgpuModel: z.string().default('onnx-community/whisper-tiny')
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// lib/sensevoice-installer.js — 1-Click installer for SenseVoice-Small
|
|
2
|
-
// Downloads pre-quantized ONNX
|
|
1
|
+
// lib/sensevoice-installer.js — 1-Click installer for SenseVoice-Small & Whisper Large-v3-Turbo
|
|
2
|
+
// Downloads pre-quantized ONNX models and configures autostart on the host via sherpa-onnx.
|
|
3
3
|
|
|
4
4
|
import { createWriteStream, existsSync, readdirSync } from 'node:fs'
|
|
5
5
|
import { mkdir, unlink } from 'node:fs/promises'
|
|
@@ -8,25 +8,63 @@ import path from 'node:path'
|
|
|
8
8
|
import { spawn } from 'node:child_process'
|
|
9
9
|
import { Readable, Transform } from 'node:stream'
|
|
10
10
|
import { pipeline } from 'node:stream/promises'
|
|
11
|
-
import { writeJson, isTrustedCaller } from './http-util.js'
|
|
11
|
+
import { writeJson, readBody, isTrustedCaller } from './http-util.js'
|
|
12
12
|
|
|
13
13
|
export const SENSEVOICE_MODEL_URL =
|
|
14
14
|
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17.tar.bz2'
|
|
15
15
|
export const SENSEVOICE_MODEL_MIRROR =
|
|
16
16
|
'https://huggingface.co/csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17/resolve/main/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17.tar.bz2'
|
|
17
17
|
|
|
18
|
+
export const WHISPER_TURBO_MODEL_URL =
|
|
19
|
+
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-whisper-turbo.tar.bz2'
|
|
20
|
+
export const WHISPER_TURBO_MODEL_MIRROR =
|
|
21
|
+
'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-turbo/resolve/main/sherpa-onnx-whisper-turbo.tar.bz2'
|
|
22
|
+
|
|
18
23
|
let installerState = {
|
|
19
24
|
installing: false,
|
|
20
25
|
progress: 0,
|
|
21
26
|
step: 'idle',
|
|
22
27
|
error: null,
|
|
23
28
|
modelPath: '',
|
|
29
|
+
modelType: 'sensevoice',
|
|
24
30
|
}
|
|
25
31
|
|
|
26
|
-
export function getDefaultModelDir() {
|
|
32
|
+
export function getDefaultModelDir(type = 'sensevoice') {
|
|
33
|
+
const norm = String(type || '').toLowerCase()
|
|
34
|
+
if (norm.includes('whisper') || norm.includes('turbo')) {
|
|
35
|
+
return path.join(homedir(), '.dsh', 'models', 'whisper-turbo')
|
|
36
|
+
}
|
|
27
37
|
return path.join(homedir(), '.dsh', 'models', 'sensevoice')
|
|
28
38
|
}
|
|
29
39
|
|
|
40
|
+
export function findWhisperFiles(dir) {
|
|
41
|
+
if (!dir || !existsSync(dir)) return null
|
|
42
|
+
try {
|
|
43
|
+
const entries = readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
44
|
+
let encoder = null
|
|
45
|
+
let decoder = null
|
|
46
|
+
let tokens = null
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (!entry.isFile()) continue
|
|
49
|
+
const name = entry.name.toLowerCase()
|
|
50
|
+
const parent = entry.parentPath || (entry.path ? entry.path : dir)
|
|
51
|
+
const fullPath = path.join(parent, entry.name)
|
|
52
|
+
|
|
53
|
+
if (name.includes('encoder') && name.endsWith('.onnx')) {
|
|
54
|
+
if (!encoder || name.includes('int8')) encoder = fullPath
|
|
55
|
+
} else if (name.includes('decoder') && name.endsWith('.onnx')) {
|
|
56
|
+
if (!decoder || name.includes('int8')) decoder = fullPath
|
|
57
|
+
} else if (name.endsWith('tokens.txt') || (name.includes('tokens') && name.endsWith('.txt'))) {
|
|
58
|
+
tokens = fullPath
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (encoder && decoder) {
|
|
62
|
+
return { encoder, decoder, tokens }
|
|
63
|
+
}
|
|
64
|
+
} catch { /* directory read failed */ }
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
30
68
|
export function findModelFile(dir) {
|
|
31
69
|
if (!dir || !existsSync(dir)) return null
|
|
32
70
|
try {
|
|
@@ -37,23 +75,26 @@ export function findModelFile(dir) {
|
|
|
37
75
|
return path.join(parent, entry.name)
|
|
38
76
|
}
|
|
39
77
|
}
|
|
78
|
+
const whisper = findWhisperFiles(dir)
|
|
79
|
+
if (whisper) return whisper.encoder
|
|
40
80
|
} catch { /* directory read failed */ }
|
|
41
81
|
return null
|
|
42
82
|
}
|
|
43
83
|
|
|
44
84
|
export function redactModelPath(filePath) {
|
|
45
|
-
if (!filePath) return
|
|
46
|
-
const str = String(filePath).replace(/\\/g,
|
|
47
|
-
const idx = str.indexOf(
|
|
48
|
-
if (idx !== -1) return
|
|
85
|
+
if (!filePath) return ''
|
|
86
|
+
const str = String(filePath).replace(/\\/g, '/')
|
|
87
|
+
const idx = str.indexOf('.dsh/models/')
|
|
88
|
+
if (idx !== -1) return '~/' + str.slice(idx)
|
|
49
89
|
return path.basename(str)
|
|
50
90
|
}
|
|
91
|
+
|
|
51
92
|
export function findTokensFile(dir) {
|
|
52
93
|
if (!dir || !existsSync(dir)) return null
|
|
53
94
|
try {
|
|
54
95
|
const entries = readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
55
96
|
for (const entry of entries) {
|
|
56
|
-
if (entry.isFile() && entry.name === 'tokens.txt') {
|
|
97
|
+
if (entry.isFile() && (entry.name === 'tokens.txt' || entry.name === 'turbo-tokens.txt')) {
|
|
57
98
|
const parent = entry.parentPath || (entry.path ? entry.path : dir)
|
|
58
99
|
return path.join(parent, entry.name)
|
|
59
100
|
}
|
|
@@ -63,21 +104,49 @@ export function findTokensFile(dir) {
|
|
|
63
104
|
}
|
|
64
105
|
|
|
65
106
|
export async function getSensevoiceStatus(cfg, isAliveFn) {
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
107
|
+
const customModel = cfg?.sensevoiceModel
|
|
108
|
+
const customExists = Boolean(customModel && existsSync(customModel))
|
|
109
|
+
const isWhisperCustom = customExists && (
|
|
110
|
+
customModel.toLowerCase().includes('whisper') ||
|
|
111
|
+
customModel.toLowerCase().includes('turbo') ||
|
|
112
|
+
Boolean(findWhisperFiles(path.dirname(customModel)))
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const svDir = getDefaultModelDir('sensevoice')
|
|
116
|
+
const svFile = findModelFile(svDir)
|
|
117
|
+
const turboDir = getDefaultModelDir('whisper-turbo')
|
|
118
|
+
const turboFiles = findWhisperFiles(turboDir)
|
|
119
|
+
|
|
120
|
+
let activeModelFile = null
|
|
121
|
+
let activeTokensFile = null
|
|
122
|
+
let activeModelType = 'sensevoice'
|
|
123
|
+
|
|
124
|
+
if (customExists) {
|
|
125
|
+
activeModelFile = customModel
|
|
126
|
+
activeTokensFile = findTokensFile(path.dirname(customModel))
|
|
127
|
+
activeModelType = isWhisperCustom ? 'whisper-turbo' : 'sensevoice'
|
|
128
|
+
} else if (turboFiles) {
|
|
129
|
+
activeModelFile = turboFiles.encoder
|
|
130
|
+
activeTokensFile = turboFiles.tokens
|
|
131
|
+
activeModelType = 'whisper-turbo'
|
|
132
|
+
} else if (svFile) {
|
|
133
|
+
activeModelFile = svFile
|
|
134
|
+
activeTokensFile = findTokensFile(path.dirname(svFile))
|
|
135
|
+
activeModelType = 'sensevoice'
|
|
136
|
+
}
|
|
71
137
|
|
|
72
138
|
const running = typeof isAliveFn === 'function' ? await isAliveFn() : false
|
|
73
|
-
const installed = Boolean(
|
|
139
|
+
const installed = Boolean(activeModelFile && existsSync(activeModelFile))
|
|
74
140
|
|
|
75
141
|
return {
|
|
76
142
|
installed,
|
|
77
143
|
running,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
144
|
+
modelType: activeModelType,
|
|
145
|
+
modelPath: redactModelPath(activeModelFile),
|
|
146
|
+
tokensPath: redactModelPath(activeTokensFile),
|
|
147
|
+
path: installed ? `~/.dsh/models/${activeModelType}` : '',
|
|
148
|
+
sensevoiceInstalled: Boolean(svFile),
|
|
149
|
+
whisperTurboInstalled: Boolean(turboFiles),
|
|
81
150
|
installing: installerState.installing,
|
|
82
151
|
progress: installerState.progress,
|
|
83
152
|
step: installerState.step,
|
|
@@ -118,21 +187,28 @@ export function extractTarBz2(archivePath, targetDir) {
|
|
|
118
187
|
})
|
|
119
188
|
}
|
|
120
189
|
|
|
121
|
-
export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSensevoiceFn) {
|
|
190
|
+
export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSensevoiceFn, options = {}) {
|
|
122
191
|
if (installerState.installing) {
|
|
123
|
-
throw new Error('
|
|
192
|
+
throw new Error('ASR model installation is already in progress')
|
|
124
193
|
}
|
|
125
194
|
|
|
195
|
+
const targetModel = String(options?.model || 'sensevoice').toLowerCase().includes('whisper')
|
|
196
|
+
? 'whisper-turbo'
|
|
197
|
+
: 'sensevoice'
|
|
198
|
+
|
|
126
199
|
installerState = {
|
|
127
200
|
installing: true,
|
|
128
201
|
progress: 5,
|
|
129
202
|
step: 'preparing',
|
|
130
203
|
error: null,
|
|
131
204
|
modelPath: '',
|
|
205
|
+
modelType: targetModel,
|
|
132
206
|
}
|
|
133
207
|
|
|
134
|
-
const modelDir = getDefaultModelDir()
|
|
135
|
-
const archivePath = path.join(modelDir,
|
|
208
|
+
const modelDir = getDefaultModelDir(targetModel)
|
|
209
|
+
const archivePath = path.join(modelDir, `${targetModel}.tar.bz2`)
|
|
210
|
+
const primaryUrl = targetModel === 'whisper-turbo' ? WHISPER_TURBO_MODEL_URL : SENSEVOICE_MODEL_URL
|
|
211
|
+
const mirrorUrl = targetModel === 'whisper-turbo' ? WHISPER_TURBO_MODEL_MIRROR : SENSEVOICE_MODEL_MIRROR
|
|
136
212
|
|
|
137
213
|
try {
|
|
138
214
|
await mkdir(modelDir, { recursive: true })
|
|
@@ -141,30 +217,36 @@ export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSens
|
|
|
141
217
|
|
|
142
218
|
let downloadOk = false
|
|
143
219
|
try {
|
|
144
|
-
await downloadArchive(
|
|
220
|
+
await downloadArchive(primaryUrl, archivePath, (pct, status) => {
|
|
145
221
|
installerState.progress = pct
|
|
146
222
|
installerState.step = status
|
|
147
223
|
})
|
|
148
224
|
downloadOk = true
|
|
149
|
-
} catch
|
|
150
|
-
// Fallback to mirror
|
|
225
|
+
} catch {
|
|
151
226
|
installerState.step = 'downloading (mirror)'
|
|
152
|
-
await downloadArchive(
|
|
227
|
+
await downloadArchive(mirrorUrl, archivePath, (pct, status) => {
|
|
153
228
|
installerState.progress = pct
|
|
154
229
|
installerState.step = status
|
|
155
230
|
})
|
|
156
231
|
downloadOk = true
|
|
157
232
|
}
|
|
158
233
|
|
|
159
|
-
if (!downloadOk) throw new Error(
|
|
234
|
+
if (!downloadOk) throw new Error(`Could not download ${targetModel} model from primary or mirror URL`)
|
|
160
235
|
|
|
161
236
|
installerState.step = 'extracting'
|
|
162
237
|
installerState.progress = 95
|
|
163
238
|
await extractTarBz2(archivePath, modelDir)
|
|
164
239
|
await unlink(archivePath).catch(() => {})
|
|
165
240
|
|
|
166
|
-
|
|
167
|
-
if (
|
|
241
|
+
let modelFile = null
|
|
242
|
+
if (targetModel === 'whisper-turbo') {
|
|
243
|
+
const files = findWhisperFiles(modelDir)
|
|
244
|
+
if (!files) throw new Error('whisper-turbo encoder/decoder not found after extraction')
|
|
245
|
+
modelFile = files.encoder
|
|
246
|
+
} else {
|
|
247
|
+
modelFile = findModelFile(modelDir)
|
|
248
|
+
if (!modelFile) throw new Error('model.int8.onnx not found after extraction')
|
|
249
|
+
}
|
|
168
250
|
|
|
169
251
|
installerState.modelPath = modelFile
|
|
170
252
|
installerState.progress = 100
|
|
@@ -182,7 +264,7 @@ export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSens
|
|
|
182
264
|
await startSensevoiceFn()
|
|
183
265
|
}
|
|
184
266
|
|
|
185
|
-
return { ok: true, modelPath: modelFile }
|
|
267
|
+
return { ok: true, modelType: targetModel, modelPath: modelFile }
|
|
186
268
|
} catch (err) {
|
|
187
269
|
await unlink(archivePath).catch(() => {})
|
|
188
270
|
installerState.installing = false
|
|
@@ -217,12 +299,18 @@ export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateCo
|
|
|
217
299
|
return
|
|
218
300
|
}
|
|
219
301
|
|
|
302
|
+
let bodyObj = {}
|
|
303
|
+
try {
|
|
304
|
+
const raw = await readBody(req, 16384)
|
|
305
|
+
bodyObj = JSON.parse(raw.toString('utf8') || '{}')
|
|
306
|
+
} catch { bodyObj = {} }
|
|
307
|
+
|
|
220
308
|
// Start background install
|
|
221
|
-
startSensevoiceInstall(ctx, liveConfig(), updateConfig, startSensevoice).catch((err) => {
|
|
222
|
-
ctx.logger?.warn?.(`
|
|
309
|
+
startSensevoiceInstall(ctx, liveConfig(), updateConfig, startSensevoice, bodyObj).catch((err) => {
|
|
310
|
+
ctx.logger?.warn?.(`ASR 1-click install error: ${String(err)}`)
|
|
223
311
|
})
|
|
224
312
|
|
|
225
|
-
writeJson(res, 202, { ok: true, message: '
|
|
313
|
+
writeJson(res, 202, { ok: true, message: 'ASR installation started', state: safeState })
|
|
226
314
|
return
|
|
227
315
|
}
|
|
228
316
|
|
|
@@ -245,50 +333,59 @@ export function resolveSensevoiceProvider(cfg) {
|
|
|
245
333
|
if (existsSync(rknnCandidate) || existsSync(defaultRknn)) {
|
|
246
334
|
return 'rknn'
|
|
247
335
|
}
|
|
248
|
-
// Guard: ordinary ONNX model cannot be loaded by sherpa-onnx RKNN runtime (#221)
|
|
249
336
|
return 'cpu'
|
|
250
337
|
}
|
|
251
338
|
if (ext === '.rknn') {
|
|
252
339
|
if (modelPath && existsSync(modelPath)) {
|
|
253
340
|
return 'rknn'
|
|
254
341
|
}
|
|
255
|
-
// Guard: missing .rknn model file cannot be claimed as running accelerator (#168)
|
|
256
342
|
return 'cpu'
|
|
257
343
|
}
|
|
258
344
|
return 'cpu'
|
|
259
345
|
}
|
|
260
346
|
|
|
347
|
+
if (['cpu', 'openvino', 'coreml', 'cuda'].includes(normProv)) {
|
|
348
|
+
return normProv
|
|
349
|
+
}
|
|
350
|
+
|
|
261
351
|
return normProv || 'cpu'
|
|
262
352
|
}
|
|
263
353
|
|
|
264
354
|
export function buildSensevoiceArgs(cfg, port) {
|
|
265
355
|
let modelPath = String(cfg?.sensevoiceModel || '')
|
|
266
|
-
const modelDir = modelPath ? path.dirname(modelPath) : getDefaultModelDir()
|
|
267
|
-
const tokensPath = (cfg?.sensevoiceTokens && existsSync(cfg.sensevoiceTokens))
|
|
268
|
-
? cfg.sensevoiceTokens
|
|
269
|
-
: findTokensFile(modelDir)
|
|
270
|
-
const tokensArg = tokensPath ? ` --tokens=${JSON.stringify(tokensPath)}` : ''
|
|
356
|
+
const modelDir = modelPath ? (existsSync(modelPath) ? path.dirname(modelPath) : modelPath) : getDefaultModelDir()
|
|
271
357
|
const effectiveProv = resolveSensevoiceProvider(cfg)
|
|
358
|
+
const provider = (effectiveProv && effectiveProv !== 'cpu') ? ` --provider=${effectiveProv}` : ''
|
|
359
|
+
const threads = Number(cfg?.sensevoiceThreads) || 4
|
|
360
|
+
|
|
361
|
+
const whisperFiles = findWhisperFiles(modelDir) || (modelPath ? findWhisperFiles(path.dirname(modelPath)) : null)
|
|
362
|
+
const isWhisper = Boolean(
|
|
363
|
+
whisperFiles ||
|
|
364
|
+
modelPath.toLowerCase().includes('whisper') ||
|
|
365
|
+
modelPath.toLowerCase().includes('turbo')
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
if (isWhisper && whisperFiles) {
|
|
369
|
+
const enc = JSON.stringify(whisperFiles.encoder)
|
|
370
|
+
const dec = JSON.stringify(whisperFiles.decoder)
|
|
371
|
+
const tok = whisperFiles.tokens ? ` --tokens=${JSON.stringify(whisperFiles.tokens)}` : ''
|
|
372
|
+
return ` --whisper-encoder=${enc} --whisper-decoder=${dec}${tok} --port=${port} --num-threads=${threads}${provider}`
|
|
373
|
+
}
|
|
272
374
|
|
|
273
375
|
if (effectiveProv === 'rknn') {
|
|
274
376
|
const ext = path.extname(modelPath).toLowerCase()
|
|
275
377
|
if (ext === '.onnx') {
|
|
276
378
|
const rknnCandidate = path.join(modelDir, path.basename(modelPath, ext) + '.rknn')
|
|
277
379
|
const defaultRknn = path.join(modelDir, 'model.rknn')
|
|
278
|
-
if (existsSync(rknnCandidate))
|
|
279
|
-
|
|
280
|
-
} else if (existsSync(defaultRknn)) {
|
|
281
|
-
modelPath = defaultRknn
|
|
282
|
-
}
|
|
380
|
+
if (existsSync(rknnCandidate)) modelPath = rknnCandidate
|
|
381
|
+
else if (existsSync(defaultRknn)) modelPath = defaultRknn
|
|
283
382
|
}
|
|
284
383
|
}
|
|
285
384
|
|
|
286
|
-
const
|
|
287
|
-
?
|
|
288
|
-
:
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
+ ` --port=${port} --num-threads=${threads}`
|
|
293
|
-
+ provider
|
|
385
|
+
const tokensPath = (cfg?.sensevoiceTokens && existsSync(cfg.sensevoiceTokens))
|
|
386
|
+
? cfg.sensevoiceTokens
|
|
387
|
+
: findTokensFile(modelDir)
|
|
388
|
+
const tokensArg = tokensPath ? ` --tokens=${JSON.stringify(tokensPath)}` : ''
|
|
389
|
+
|
|
390
|
+
return ` --sense-voice-model=${JSON.stringify(modelPath)}${tokensArg} --port=${port} --num-threads=${threads}${provider}`
|
|
294
391
|
}
|
package/lib/stats.js
CHANGED
|
@@ -3,6 +3,55 @@
|
|
|
3
3
|
|
|
4
4
|
export function createStatsTracker() {
|
|
5
5
|
const stats = new Map()
|
|
6
|
+
const diagnostics = []
|
|
7
|
+
const MAX_DIAGNOSTICS = 50
|
|
8
|
+
|
|
9
|
+
function recordDiagnostic(entry = {}) {
|
|
10
|
+
diagnostics.push({
|
|
11
|
+
id: Math.random().toString(36).slice(2, 10),
|
|
12
|
+
timestamp: Date.now(),
|
|
13
|
+
mode: entry.mode || 'dictation',
|
|
14
|
+
subMode: entry.subMode || 'clean',
|
|
15
|
+
mimeType: entry.mimeType || 'audio/webm',
|
|
16
|
+
audioBytes: Number(entry.audioBytes) || 0,
|
|
17
|
+
provider: entry.provider || 'unknown',
|
|
18
|
+
success: !!entry.success,
|
|
19
|
+
totalTookMs: Number(entry.totalTookMs) || 0,
|
|
20
|
+
breakdown: {
|
|
21
|
+
vadMs: Number(entry.vadMs) || 0,
|
|
22
|
+
sttMs: Number(entry.sttMs) || 0,
|
|
23
|
+
polishMs: Number(entry.polishMs) || 0,
|
|
24
|
+
},
|
|
25
|
+
chainAttempts: Array.isArray(entry.chainAttempts) ? entry.chainAttempts.map((a) => ({
|
|
26
|
+
provider: a.provider || 'unknown',
|
|
27
|
+
ok: !!a.ok,
|
|
28
|
+
tookMs: Number(a.tookMs) || 0,
|
|
29
|
+
reason: a.reason ? String(a.reason).slice(0, 150) : undefined,
|
|
30
|
+
})) : [],
|
|
31
|
+
error: entry.error ? String(entry.error).slice(0, 200) : undefined,
|
|
32
|
+
wordCount: typeof entry.wordCount === 'number' ? entry.wordCount : undefined,
|
|
33
|
+
})
|
|
34
|
+
if (diagnostics.length > MAX_DIAGNOSTICS) {
|
|
35
|
+
diagnostics.shift()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getDiagnostics() {
|
|
40
|
+
return diagnostics.slice()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function exportAnonymizedReport(appInfo = {}) {
|
|
44
|
+
return {
|
|
45
|
+
exportedAt: new Date().toISOString(),
|
|
46
|
+
app: {
|
|
47
|
+
name: '@goodandready/dsh-voice',
|
|
48
|
+
version: appInfo.version || '0.9.34',
|
|
49
|
+
environment: appInfo.environment || 'production',
|
|
50
|
+
},
|
|
51
|
+
summary: get(),
|
|
52
|
+
recentDiagnostics: getDiagnostics(),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
6
55
|
|
|
7
56
|
function record(key, res = {}) {
|
|
8
57
|
if (!key) return
|
|
@@ -51,9 +100,10 @@ export function createStatsTracker() {
|
|
|
51
100
|
|
|
52
101
|
function clear() {
|
|
53
102
|
stats.clear()
|
|
103
|
+
diagnostics.length = 0
|
|
54
104
|
}
|
|
55
105
|
|
|
56
|
-
return { record, get, clear }
|
|
106
|
+
return { record, get, clear, recordDiagnostic, getDiagnostics, exportAnonymizedReport }
|
|
57
107
|
}
|
|
58
108
|
|
|
59
109
|
export function mergeContextVocabulary(baseVocab = [], contextWords = [], maxWords = 50) {
|
package/lib/wav.js
CHANGED
|
@@ -70,3 +70,30 @@ export function toWav16k(bytes, ffmpegBin = 'ffmpeg', signal = null) {
|
|
|
70
70
|
proc.stdin.end(Buffer.from(bytes))
|
|
71
71
|
})
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Generate a synthetic PCM 16-bit mono WAV buffer (silence).
|
|
76
|
+
* Used for express hardware benchmarking and latency profiling without external files.
|
|
77
|
+
* @param {number} durationSeconds
|
|
78
|
+
* @param {number} sampleRate
|
|
79
|
+
* @returns {Buffer}
|
|
80
|
+
*/
|
|
81
|
+
export function createSyntheticWav(durationSeconds = 1.0, sampleRate = 16000) {
|
|
82
|
+
const numSamples = Math.max(1, Math.floor(durationSeconds * sampleRate))
|
|
83
|
+
const dataSize = numSamples * 2
|
|
84
|
+
const buf = Buffer.alloc(44 + dataSize)
|
|
85
|
+
buf.write('RIFF', 0)
|
|
86
|
+
buf.writeUInt32LE(36 + dataSize, 4)
|
|
87
|
+
buf.write('WAVE', 8)
|
|
88
|
+
buf.write('fmt ', 12)
|
|
89
|
+
buf.writeUInt32LE(16, 16)
|
|
90
|
+
buf.writeUInt16LE(1, 20) // PCM format
|
|
91
|
+
buf.writeUInt16LE(1, 22) // Mono
|
|
92
|
+
buf.writeUInt32LE(sampleRate, 24)
|
|
93
|
+
buf.writeUInt32LE(sampleRate * 2, 28) // Byte rate
|
|
94
|
+
buf.writeUInt16LE(2, 32) // Block align
|
|
95
|
+
buf.writeUInt16LE(16, 34) // 16-bit
|
|
96
|
+
buf.write('data', 36)
|
|
97
|
+
buf.writeUInt32LE(dataSize, 40)
|
|
98
|
+
return buf
|
|
99
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.35",
|
|
4
4
|
"description": "Voice input for DeepSeek Harness: dictation chunked by pauses and voice messages, each with its own provider fallback chain (Deepgram, Groq, HuggingFace, local whisper.cpp, plus any OpenAI-compatible API of your own).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|