@goodandready/dsh-voice 0.8.31 → 0.8.33
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/README.md +25 -1
- package/README.ru.md +25 -1
- package/lib/client.js +451 -12
- package/lib/index.js +51 -5
- package/lib/normalize.js +88 -0
- package/lib/sensevoice-installer.js +227 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -84,12 +84,36 @@ graph LR
|
|
|
84
84
|
* 📊 **Provider Latency & Health Dashboard**: Live visual telemetry of provider latency (ms), success rates, and errors directly within the settings UI.
|
|
85
85
|
* 🔒 **Zero API Key Leakage**: Keys are resolved on the host via `ctx.credentials` (`credentialRef`) and never transmitted to browser clients.
|
|
86
86
|
* 🖥️ **Offline Local Whisper Server**: Automatically boots and manages [whisper.cpp](https://github.com/ggerganov/whisper.cpp) (`whisper-server`) with on-the-fly `ffmpeg` transcode.
|
|
87
|
-
* ⚡ **SenseVoice-ONNX / Sherpa-ONNX** *(0.8.11)*: Ultra-fast (~50–100ms) non-autoregressive local STT engine with automatic
|
|
87
|
+
* ⚡ **SenseVoice-ONNX / Sherpa-ONNX** *(0.8.11, updated 0.8.32)*: Ultra-fast (~50–100ms) non-autoregressive local STT engine with 1-click automatic model installation to `~/.dsh/models/sensevoice`.
|
|
88
|
+
* 🎙️ **Gated Turn-Taking & Echo Prevention** *(0.8.32)*: Automatically mutes the mic while the assistant is speaking (`dsh:tts:start`/`stop`) and supports speech-triggered Barge-In.
|
|
89
|
+
* 👻 **Live Ghost Interim Preview** *(0.8.32)*: Visual real-time preview of spoken words before final chunk transcription.
|
|
90
|
+
* ⏱️ **SVG Silence Ring Timer** *(0.8.32)*: Circular animated countdown indicator in the composer dock during the pending message delay.
|
|
91
|
+
* 🗣️ **Hands-free Voice Actions** *(0.8.32)*: Spoken commands ("send", "cancel", "clear", "new line") trigger UI actions directly.
|
|
92
|
+
* 🧩 **Structured Prompt Voice Selection** *(0.8.32)*: Spoken options automatically select buttons in interactive assistant choice prompts.
|
|
93
|
+
* 📖 **Developer Lexicon & IT Jargon Correction** *(0.8.32)*: Phonetic normalization for developer slang (GitHub, Docker, Kubernetes, pnpm) with customizable settings dictionary.
|
|
88
94
|
* 🌐 **Realtime Audio Streaming** *(0.8.11)*: Low-latency WebSocket bridge (`/dsh-voice/realtime`) for OpenAI Realtime API or local Sherpa-ONNX streaming. API keys stay securely on the host.
|
|
89
95
|
* 🌊 **Liquid Wave & Dynamic Orb Visualizer** *(0.8.12)*: Smooth animated audio visualization in the recording pill with real-time mic volume reactivity. Switch between organic multi-layer liquid waves, pulsating radiant orb, classic bars, or off.
|
|
90
96
|
|
|
91
97
|
---
|
|
92
98
|
|
|
99
|
+
## 📝 What's New in 0.8.32
|
|
100
|
+
|
|
101
|
+
10-point feature epic & SenseVoice 1-Click Installer (Issue #129):
|
|
102
|
+
|
|
103
|
+
| Area | Feature | Description |
|
|
104
|
+
|------|---------|-------------|
|
|
105
|
+
| Local ASR | **1-Click SenseVoice-Small** | Automated download and extraction of `model.int8.onnx` and `tokens.txt` directly to `~/.dsh/models/sensevoice` via host loopback endpoint. |
|
|
106
|
+
| Turn-Taking | **Gated Mode** | Microphone input is muted when assistant audio starts (`dsh:tts:start`) and unmuted on `dsh:tts:stop`, eliminating acoustic feedback. |
|
|
107
|
+
| Interruption | **Barge-In** | Speaking immediately signals assistant TTS to pause and abort current speech playback. |
|
|
108
|
+
| Feedback | **Ghost Interim Preview** | Semi-transparent live text preview in composer pill during recognition before sentence finalizing. |
|
|
109
|
+
| Countdown | **SVG Silence Ring** | Circular SVG progress ring visually ticking down pending auto-send delay. |
|
|
110
|
+
| Control | **Hands-free Actions** | Spoken control words ("send", "cancel", "clear", "new line") trigger actions instead of becoming message text. |
|
|
111
|
+
| Prompts | **Structured Prompts** | Spoken replies automatically match and submit options in active harness interactive prompts. |
|
|
112
|
+
| Vocabulary | **IT Jargon Normalizer** | Auto-corrects spoken developer slang to canonical spelling (GitHub, Docker, Kubernetes, etc.) with custom dictionary in Settings. |
|
|
113
|
+
| Configuration | **Individual Toggles** | Dedicated switches for every enhancement in Settings card with native `--dsw-alias-*` token styling. |
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
93
117
|
## 📝 What's New in 0.8.19
|
|
94
118
|
|
|
95
119
|
Quality batch after the v0.8.18 review (Gitea #79–#87, PR #88):
|
package/README.ru.md
CHANGED
|
@@ -84,12 +84,36 @@ graph LR
|
|
|
84
84
|
* 📊 **Дашборд задержки и здоровья провайдеров**: мониторинг скорости ответа (мс), процента успешных транскрипций и ошибок в реальном времени.
|
|
85
85
|
* 🔒 **Безопасность API-ключей**: ключи читаются на сервере через `ctx.credentials` и никогда не попадают в браузер клиента.
|
|
86
86
|
* 🖥️ **Автозапуск локального whisper.cpp**: управление жизненным циклом `whisper-server` с авто-конвертацией через `ffmpeg`.
|
|
87
|
-
* ⚡ **SenseVoice-ONNX / Sherpa-ONNX** *(0.8.11)*: сверхбыстрый (~50–100мс) неавторегрессивный локальный STT с
|
|
87
|
+
* ⚡ **SenseVoice-ONNX / Sherpa-ONNX** *(0.8.11, обновлено 0.8.32)*: сверхбыстрый (~50–100мс) неавторегрессивный локальный STT с установкой модели SenseVoice-Small в 1 клик в `~/.dsh/models/sensevoice`.
|
|
88
|
+
* 🎙️ **Gated Turn-Taking и защита от эха** *(0.8.32)*: автоматическое выключение микрофона во время ответа ассистента (`dsh:tts:start`/`stop`) и поддержка прерывания речи (Barge-In).
|
|
89
|
+
* 👻 **Живой предпросмотр (Ghost Interim Preview)** *(0.8.32)*: визуальное отображение распознаваемых слов в реальном времени до фиксации фрагмента.
|
|
90
|
+
* ⏱️ **Кольцевой таймер тишины (SVG Silence Ring)** *(0.8.32)*: круговая анимация обратного отсчёта до авто-отправки в полоске композера.
|
|
91
|
+
* 🗣️ **Голосовые команды управления (Hands-free Actions)** *(0.8.32)*: фразы «отправь», «отмена», «очисти», «с новой строки» выполняют действия напрямую, не попадая в текст сообщения.
|
|
92
|
+
* 🧩 **Голосовой выбор в интерактивах (Structured Prompts)** *(0.8.32)*: произнесение вариантов ответов нажимает соответствующие кнопки в активных модальных окнах ассистента.
|
|
93
|
+
* 📖 **Словарь IT-жаргона и сленга разработчиков** *(0.8.32)*: фонетическое исправление сленга (гитхаб -> GitHub, кубер -> Kubernetes, пнпм -> pnpm) с возможностью добавления своих правил в настройках.
|
|
88
94
|
* 🌐 **Потоковое аудио в реальном времени** *(0.8.11)*: низколатентный WebSocket-мост (`/dsh-voice/realtime`) для OpenAI Realtime API или локального Sherpa-ONNX. API-ключи остаются на хосте.
|
|
89
95
|
* 🌊 **Анимированные визуализаторы Liquid Wave и Dynamic Orb** *(0.8.12)*: живая интерактивная анимация звуковой волны в полоске записи с откликом на громкость микрофона. Выбор стиля: текучая волна, пульсирующая сфера, классические столбики или выключен.
|
|
90
96
|
|
|
91
97
|
---
|
|
92
98
|
|
|
99
|
+
## 📝 Что нового в 0.8.32
|
|
100
|
+
|
|
101
|
+
Пакет из 10 улучшений и установка SenseVoice в 1 клик (Issue #129):
|
|
102
|
+
|
|
103
|
+
| Область | Функция | Описание |
|
|
104
|
+
|---------|---------|----------|
|
|
105
|
+
| Локальный STT | **Установка SenseVoice в 1 клик** | Автоматическая загрузка и распаковка `model.int8.onnx` и `tokens.txt` в `~/.dsh/models/sensevoice` через защищённый loopback-маршрут. |
|
|
106
|
+
| Координация речи | **Gated Turn-Taking** | Микрофон глушится при воспроизведении ответа ассистента (`dsh:tts:start`) и включается обратно при `dsh:tts:stop`, исключая эхо и зацикливание. |
|
|
107
|
+
| Прерывание | **Barge-In** | Начало речи пользователя мгновенно прерывает воспроизведение ответа синтезатора речи. |
|
|
108
|
+
| Обратная связь | **Ghost-предпросмотр** | Полупрозрачный текст живого распознавания прямо в пилюле композера до окончания фразы. |
|
|
109
|
+
| Обратный отсчёт | **SVG Silence Ring** | Круговой векторный индикатор оставшегося времени до автоматической отправки сообщения. |
|
|
110
|
+
| Управление | **Голосовые действия** | Команды «отправь», «отмена», «очисти» выполняют команды UI без вставки текста в поле ввода. |
|
|
111
|
+
| Интерактивы | **Структурированные промпты** | Голосовой выбор вариантов ответов во всплывающих вопросах ассистента. |
|
|
112
|
+
| Словарь | **Коррекция IT-жаргона** | Автоматическая замена сленга программистов на правильное написание с редактором правил в настройках. |
|
|
113
|
+
| Конфигурация | **Индивидуальные переключатели** | Каждая функция отдельно включается и выключается в карточке настроек. |
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
93
117
|
## 📝 Что нового в 0.8.19
|
|
94
118
|
|
|
95
119
|
Quality-батч после ревью v0.8.18 (Gitea #79–#87, PR #88):
|
package/lib/client.js
CHANGED
|
@@ -204,6 +204,27 @@ window.__ModuleLoader__.load({
|
|
|
204
204
|
'updating': 'Updating…',
|
|
205
205
|
'updateSuccess': 'Updated successfully! Restart DSH to apply.',
|
|
206
206
|
'updateFailed': 'Update failed. Check logs.',
|
|
207
|
+
'sensevoiceTitle': 'SenseVoice Local ASR (1-Click Setup)',
|
|
208
|
+
'sensevoiceDesc': 'Ultra-fast offline speech recognition on CPU (50–100ms latency, Russian/English).',
|
|
209
|
+
'installSensevoice': 'Install SenseVoice-Small (1-Click)',
|
|
210
|
+
'installingSensevoice': 'Installing SenseVoice…',
|
|
211
|
+
'sensevoiceReady': 'Ready',
|
|
212
|
+
'sensevoiceNotInstalled': 'Not Installed',
|
|
213
|
+
'sensevoiceDownloading': 'Downloading model…',
|
|
214
|
+
'turnTakingTitle': 'Turn-Taking & Audio Coordination',
|
|
215
|
+
'turnTakingDesc': 'Coordinate speaking and listening so assistant audio does not feed back into the microphone.',
|
|
216
|
+
'gatedTurnTaking': 'Gated Mode (mute mic while assistant is speaking)',
|
|
217
|
+
'bargeInDesc': 'True Barge-In (speaking interrupts assistant speech)',
|
|
218
|
+
'smartActionsTitle': 'Hands-free & Smart Actions',
|
|
219
|
+
'autoSendVisualRing': 'Silence Ring countdown animation',
|
|
220
|
+
'voiceActionsLabel': 'Voice action commands ("send", "cancel", "clear", "new line")',
|
|
221
|
+
'liveInterimPreview': 'Live speech preview (ghost text preview)',
|
|
222
|
+
'structuredPromptVoice': 'Spoken answers for interactive choice prompts',
|
|
223
|
+
'jargonTitle': 'Developer Lexicon & IT Jargon',
|
|
224
|
+
'techJargonCorrection': 'Auto-correct technical jargon (GitHub, Docker, pnpm, Kubernetes, etc.)',
|
|
225
|
+
'jargonAdd': 'Add replacement',
|
|
226
|
+
'jargonFrom': 'Spoken slang',
|
|
227
|
+
'jargonTo': 'Correct spelling',
|
|
207
228
|
}
|
|
208
229
|
|
|
209
230
|
const zh = {
|
|
@@ -376,6 +397,27 @@ window.__ModuleLoader__.load({
|
|
|
376
397
|
'updating': '正在更新…',
|
|
377
398
|
'updateSuccess': '更新成功!请重启 DSH 生效。',
|
|
378
399
|
'updateFailed': '更新失败,请查看日志。',
|
|
400
|
+
'sensevoiceTitle': 'SenseVoice 本地 ASR (一键部署)',
|
|
401
|
+
'sensevoiceDesc': 'CPU 上超低延迟离线语音识别 (50–100ms,中英俄多语种)。',
|
|
402
|
+
'installSensevoice': '一键安装 SenseVoice-Small',
|
|
403
|
+
'installingSensevoice': '正在安装 SenseVoice…',
|
|
404
|
+
'sensevoiceReady': '已就绪',
|
|
405
|
+
'sensevoiceNotInstalled': '未安装',
|
|
406
|
+
'sensevoiceDownloading': '正在下载模型…',
|
|
407
|
+
'turnTakingTitle': '轮流对话与音频协同',
|
|
408
|
+
'turnTakingDesc': '协调听与说,避免扬声器播放的声音被麦克风二次拾音。',
|
|
409
|
+
'gatedTurnTaking': '门控模式 (助手播报时麦克风自动静音)',
|
|
410
|
+
'bargeInDesc': '实时打断 (说话时立即打断助手朗读)',
|
|
411
|
+
'smartActionsTitle': '免提与智能指令',
|
|
412
|
+
'autoSendVisualRing': '环形倒计时动画指示器',
|
|
413
|
+
'voiceActionsLabel': '语音动作指令 ("发送", "取消", "清空", "换行")',
|
|
414
|
+
'liveInterimPreview': '实时语音文本预览 (幽灵文本)',
|
|
415
|
+
'structuredPromptVoice': '语音应答交互式选择提示',
|
|
416
|
+
'jargonTitle': '开发者词汇与 IT 术语',
|
|
417
|
+
'techJargonCorrection': '自动校正开发术语 (GitHub, Docker, pnpm, Kubernetes 等)',
|
|
418
|
+
'jargonAdd': '添加替换项',
|
|
419
|
+
'jargonFrom': '口语词汇',
|
|
420
|
+
'jargonTo': '标准拼写',
|
|
379
421
|
}
|
|
380
422
|
|
|
381
423
|
// Strings are also needed outside components — in recording handlers and
|
|
@@ -416,7 +458,17 @@ window.__ModuleLoader__.load({
|
|
|
416
458
|
'.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)}' +
|
|
417
459
|
'.dvo-meter-bar{flex:1;height:8px;background:var(--dsw-alias-bg-layer-3);border-radius:4px;overflow:hidden;position:relative}' +
|
|
418
460
|
'.dvo-meter-fill{height:100%;background:var(--dsw-alias-state-success-primary);border-radius:4px;transition:width 60ms ease-out}' +
|
|
419
|
-
'.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}'
|
|
461
|
+
'.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}' +
|
|
462
|
+
'.dvo-silence-ring{width:34px;height:34px;transform:rotate(-90deg);flex:none}' +
|
|
463
|
+
'.dvo-ring-bg{fill:none;stroke:var(--dsw-alias-border-l1);stroke-width:2.5}' +
|
|
464
|
+
'.dvo-ring-fg{fill:none;stroke:var(--dsw-alias-state-info-primary);stroke-width:2.5;stroke-linecap:round;transition:stroke-dashoffset 100ms linear}' +
|
|
465
|
+
'.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}' +
|
|
466
|
+
'.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}' +
|
|
467
|
+
'.dvo-installer-row{display:flex;align-items:center;justify-content:space-between;gap:8px}' +
|
|
468
|
+
'.dvo-progress-bar{height:6px;width:100%;background:var(--dsw-alias-bg-layer-3);border-radius:3px;overflow:hidden}' +
|
|
469
|
+
'.dvo-progress-fill{height:100%;background:var(--dsw-alias-state-info-primary);transition:width 150ms ease}' +
|
|
470
|
+
'.dvo-jargon-table{width:100%;margin-top:6px;border-collapse:collapse;font-size:12px}' +
|
|
471
|
+
'.dvo-jargon-table td{padding:4px 6px;border-bottom:1px solid var(--dsw-alias-border-l1)}'
|
|
420
472
|
const cssId = 'dsh-voice/client.module.css'
|
|
421
473
|
if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + cssId + '"]')) {
|
|
422
474
|
const tag = document.createElement('style')
|
|
@@ -435,15 +487,88 @@ window.__ModuleLoader__.load({
|
|
|
435
487
|
pending: null, // {text, leftMs} — message-mode cancel window
|
|
436
488
|
lastNote: null, // {blob, url, mime, text} — last recording
|
|
437
489
|
showPlayer: false,
|
|
490
|
+
interimTranscript: '', // real-time live preview (ghost text)
|
|
491
|
+
isTtsSpeaking: false, // turn-taking: true while assistant speech is active
|
|
492
|
+
actionTriggered: null, // 'send' | 'cancel' | 'clear' | 'newline' | null
|
|
438
493
|
inputActions: null,
|
|
439
494
|
input: null,
|
|
440
|
-
settings: {
|
|
495
|
+
settings: {
|
|
496
|
+
vadSilenceMs: 700, autoSendMs: 4000, stream: false, streamChunkMs: 1200, vadAdapt: 0,
|
|
497
|
+
noiseSuppression: true, contextGlossary: true,
|
|
498
|
+
gatedTurnTaking: true, autoSendVisualRing: true, voiceActions: true,
|
|
499
|
+
techJargonCorrection: true, jargonDictionary: [], liveInterimPreview: true,
|
|
500
|
+
structuredPromptVoice: true,
|
|
501
|
+
},
|
|
441
502
|
listeners: new Set(),
|
|
442
503
|
notify() { this.listeners.forEach((l) => l()) },
|
|
443
504
|
set(patch) { Object.assign(this, patch); this.notify() },
|
|
444
505
|
subscribe(l) { this.listeners.add(l); return () => this.listeners.delete(l) },
|
|
445
506
|
}
|
|
446
507
|
|
|
508
|
+
if (typeof window !== 'undefined') {
|
|
509
|
+
window.addEventListener('dsh:tts:start', () => {
|
|
510
|
+
voice.set({ isTtsSpeaking: true })
|
|
511
|
+
})
|
|
512
|
+
window.addEventListener('dsh:tts:stop', () => {
|
|
513
|
+
voice.set({ isTtsSpeaking: false })
|
|
514
|
+
})
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function triggerBargeIn() {
|
|
518
|
+
if (typeof window === 'undefined') return
|
|
519
|
+
try {
|
|
520
|
+
window.dispatchEvent(new CustomEvent('dsh:tts:cancel', { bubbles: true }))
|
|
521
|
+
window.dispatchEvent(new CustomEvent('dsh:tts:stop', { bubbles: true }))
|
|
522
|
+
const audios = document.querySelectorAll('audio')
|
|
523
|
+
for (const a of audios) {
|
|
524
|
+
if (!a.paused) { try { a.pause() } catch {} }
|
|
525
|
+
}
|
|
526
|
+
} catch { /* best effort */ }
|
|
527
|
+
voice.set({ isTtsSpeaking: false })
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function findActiveStructuredPrompt() {
|
|
531
|
+
if (typeof document === 'undefined') return null
|
|
532
|
+
try {
|
|
533
|
+
const modal = document.querySelector('.dsh-structured-question, [data-dsh-dialog="question"], .dialog-prompt, .cb-modal-question')
|
|
534
|
+
if (modal) {
|
|
535
|
+
const input = modal.querySelector('textarea, input[type="text"]')
|
|
536
|
+
const buttons = Array.from(modal.querySelectorAll('button:not([disabled])'))
|
|
537
|
+
return { modal, input, buttons }
|
|
538
|
+
}
|
|
539
|
+
} catch { /* best effort */ }
|
|
540
|
+
return null
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function submitToStructuredPrompt(text, promptInfo) {
|
|
544
|
+
if (!promptInfo) return false
|
|
545
|
+
const trimmed = String(text || '').trim().toLowerCase()
|
|
546
|
+
const { buttons, input } = promptInfo
|
|
547
|
+
if (buttons && buttons.length > 0) {
|
|
548
|
+
if (/^(1|первый|first)$/i.test(trimmed) && buttons[0]) { buttons[0].click(); return true }
|
|
549
|
+
if (/^(2|второй|second)$/i.test(trimmed) && buttons[1]) { buttons[1].click(); return true }
|
|
550
|
+
if (/^(3|третий|third)$/i.test(trimmed) && buttons[2]) { buttons[2].click(); return true }
|
|
551
|
+
if (/^(да|yes|ok|confirm|согласен)$/i.test(trimmed)) {
|
|
552
|
+
const yesBtn = buttons.find((b) => /да|yes|ok|confirm/i.test(b.textContent || ''))
|
|
553
|
+
if (yesBtn) { yesBtn.click(); return true }
|
|
554
|
+
}
|
|
555
|
+
if (/^(нет|no|отмена|cancel)$/i.test(trimmed)) {
|
|
556
|
+
const noBtn = buttons.find((b) => /нет|no|cancel|отмена/i.test(b.textContent || ''))
|
|
557
|
+
if (noBtn) { noBtn.click(); return true }
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (input) {
|
|
561
|
+
input.value = text
|
|
562
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
563
|
+
const form = input.closest('form')
|
|
564
|
+
if (form) {
|
|
565
|
+
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
|
566
|
+
}
|
|
567
|
+
return true
|
|
568
|
+
}
|
|
569
|
+
return false
|
|
570
|
+
}
|
|
571
|
+
|
|
447
572
|
function useVoice() {
|
|
448
573
|
const [, force] = React.useReducer((x) => x + 1, 0)
|
|
449
574
|
React.useEffect(() => voice.subscribe(force), [])
|
|
@@ -909,6 +1034,37 @@ window.__ModuleLoader__.load({
|
|
|
909
1034
|
const out = await sendAudio(blob, rec.mime, 'dictation')
|
|
910
1035
|
if (rec.cancelled) return
|
|
911
1036
|
const text = out && out.text ? out.text : ''
|
|
1037
|
+
const action = out && out.action ? out.action : null
|
|
1038
|
+
|
|
1039
|
+
if (voice.settings.structuredPromptVoice !== false && text) {
|
|
1040
|
+
const promptInfo = findActiveStructuredPrompt()
|
|
1041
|
+
if (promptInfo && submitToStructuredPrompt(text, promptInfo)) {
|
|
1042
|
+
return
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
if (action === 'send') {
|
|
1047
|
+
if (text) appendDraft(text)
|
|
1048
|
+
if (voice.inputActions && typeof voice.inputActions.send === 'function') {
|
|
1049
|
+
voice.inputActions.send()
|
|
1050
|
+
}
|
|
1051
|
+
return
|
|
1052
|
+
}
|
|
1053
|
+
if (action === 'cancel') {
|
|
1054
|
+
cancelCurrent()
|
|
1055
|
+
return
|
|
1056
|
+
}
|
|
1057
|
+
if (action === 'clear') {
|
|
1058
|
+
if (voice.inputActions && typeof voice.inputActions.clear === 'function') {
|
|
1059
|
+
voice.inputActions.clear()
|
|
1060
|
+
}
|
|
1061
|
+
return
|
|
1062
|
+
}
|
|
1063
|
+
if (action === 'newline') {
|
|
1064
|
+
appendDraft(text ? (text + '\n') : '\n')
|
|
1065
|
+
return
|
|
1066
|
+
}
|
|
1067
|
+
|
|
912
1068
|
const delay = Number(voice.settings.sendDelayMs) || 0
|
|
913
1069
|
if (text && delay > 0 && !voice.holding) {
|
|
914
1070
|
// Delayed insert with a cancel window (#29-5): text is already
|
|
@@ -937,7 +1093,11 @@ window.__ModuleLoader__.load({
|
|
|
937
1093
|
lang: language,
|
|
938
1094
|
continuous: true,
|
|
939
1095
|
onInterim: (text) => {
|
|
940
|
-
voice.caption = text
|
|
1096
|
+
voice.caption = text
|
|
1097
|
+
if (voice.settings.liveInterimPreview !== false) {
|
|
1098
|
+
voice.interimTranscript = text
|
|
1099
|
+
}
|
|
1100
|
+
voice.notify()
|
|
941
1101
|
// Wake-word (#45): if interim starts with the trigger phrase, stop
|
|
942
1102
|
// browser listening and switch to normal recording.
|
|
943
1103
|
const ww = String(voice.settings.wakeWord || '').trim().toLowerCase()
|
|
@@ -946,6 +1106,7 @@ window.__ModuleLoader__.load({
|
|
|
946
1106
|
if (t.startsWith(ww)) {
|
|
947
1107
|
voice.browser = null
|
|
948
1108
|
voice.caption = ''
|
|
1109
|
+
voice.interimTranscript = ''
|
|
949
1110
|
openMic(mode)
|
|
950
1111
|
.then((rec) => {
|
|
951
1112
|
if (voice.phase !== 'recording') {
|
|
@@ -962,6 +1123,7 @@ window.__ModuleLoader__.load({
|
|
|
962
1123
|
onFinal: (text) => {
|
|
963
1124
|
finals.push(text)
|
|
964
1125
|
voice.caption = ''
|
|
1126
|
+
voice.interimTranscript = ''
|
|
965
1127
|
// Dictation appends immediately; messages accumulate until release.
|
|
966
1128
|
if (mode === 'dictation') appendDraft(text)
|
|
967
1129
|
else voice.notify()
|
|
@@ -969,6 +1131,7 @@ window.__ModuleLoader__.load({
|
|
|
969
1131
|
onError: (reason) => {
|
|
970
1132
|
// Browser failed after start — say so instead of pretending to listen.
|
|
971
1133
|
voice.browser = null
|
|
1134
|
+
voice.interimTranscript = ''
|
|
972
1135
|
voice.set({ phase: 'error', error: t('browserFailed') + reason })
|
|
973
1136
|
},
|
|
974
1137
|
})
|
|
@@ -979,11 +1142,17 @@ window.__ModuleLoader__.load({
|
|
|
979
1142
|
|
|
980
1143
|
function startRecording(mode) {
|
|
981
1144
|
if (voice.phase !== 'idle' && voice.phase !== 'error') return
|
|
982
|
-
|
|
983
|
-
|
|
1145
|
+
if (voice.isTtsSpeaking) {
|
|
1146
|
+
if (voice.settings.bargeIn) {
|
|
1147
|
+
triggerBargeIn()
|
|
1148
|
+
} else if (voice.settings.gatedTurnTaking !== false) {
|
|
1149
|
+
voice.set({ phase: 'error', error: 'Assistant speaking (gated mode)' })
|
|
1150
|
+
return
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
984
1153
|
announceVoice('start')
|
|
985
1154
|
dictationQueue = Promise.resolve()
|
|
986
|
-
voice.set({ phase: 'recording', mode, error: '', levels: [], caption: '' })
|
|
1155
|
+
voice.set({ phase: 'recording', mode, error: '', levels: [], caption: '', interimTranscript: '' })
|
|
987
1156
|
modeChain(mode).then((info) => {
|
|
988
1157
|
// Browser leg only when it is explicitly first in the chain.
|
|
989
1158
|
if (info.chain[0] === 'browser' && startBrowserLeg(mode, info.language)) return
|
|
@@ -1138,11 +1307,38 @@ window.__ModuleLoader__.load({
|
|
|
1138
1307
|
const out = await sendAudio(blob, rec.mime, mode)
|
|
1139
1308
|
if (out.command) { runSessionCommand(out.command); return }
|
|
1140
1309
|
const text = out.text || ''
|
|
1141
|
-
|
|
1310
|
+
const action = out.action || null
|
|
1311
|
+
if (!text && !action) { voice.set({ phase: 'error', error: t('nothingHeard') }); return }
|
|
1312
|
+
|
|
1313
|
+
if (voice.settings.structuredPromptVoice !== false && text) {
|
|
1314
|
+
const promptInfo = findActiveStructuredPrompt()
|
|
1315
|
+
if (promptInfo && submitToStructuredPrompt(text, promptInfo)) {
|
|
1316
|
+
voice.set({ phase: 'idle' })
|
|
1317
|
+
return
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
if (action === 'send') {
|
|
1322
|
+
if (text) appendDraft(text)
|
|
1323
|
+
submitPending()
|
|
1324
|
+
return
|
|
1325
|
+
}
|
|
1326
|
+
if (action === 'cancel') {
|
|
1327
|
+
cancelCurrent()
|
|
1328
|
+
return
|
|
1329
|
+
}
|
|
1330
|
+
if (action === 'clear') {
|
|
1331
|
+
if (voice.inputActions && typeof voice.inputActions.clear === 'function') {
|
|
1332
|
+
voice.inputActions.clear()
|
|
1333
|
+
}
|
|
1334
|
+
voice.set({ phase: 'idle' })
|
|
1335
|
+
return
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1142
1338
|
appendDraft(text)
|
|
1143
1339
|
if (voice.lastNote) voice.lastNote.text = text
|
|
1144
1340
|
if (mode === 'message') {
|
|
1145
|
-
voice.set({ phase: 'pending', pending: { text: text, leftMs: voice.settings.autoSendMs, audioUrl: noteUrl } })
|
|
1341
|
+
voice.set({ phase: 'pending', pending: { text: text, totalMs: voice.settings.autoSendMs, leftMs: voice.settings.autoSendMs, audioUrl: noteUrl } })
|
|
1146
1342
|
} else {
|
|
1147
1343
|
voice.set({ phase: 'idle' })
|
|
1148
1344
|
}
|
|
@@ -1577,7 +1773,10 @@ window.__ModuleLoader__.load({
|
|
|
1577
1773
|
? t('holdHint')
|
|
1578
1774
|
: (inBrowser
|
|
1579
1775
|
? t('listening')
|
|
1580
|
-
: (rec && rec.hadSpeech ? (v.speaking ? t('speaking') : t('silence')) : hint)))
|
|
1776
|
+
: (rec && rec.hadSpeech ? (v.speaking ? t('speaking') : t('silence')) : hint))),
|
|
1777
|
+
v.interimTranscript && v.settings.liveInterimPreview !== false
|
|
1778
|
+
? React.createElement('span', { className: 'dvo-ghost' }, v.interimTranscript)
|
|
1779
|
+
: null),
|
|
1581
1780
|
React.createElement('button', {
|
|
1582
1781
|
type: 'button', className: 'dvo-pbtn', title: t('stop'),
|
|
1583
1782
|
'aria-label': t('stop'),
|
|
@@ -1631,6 +1830,23 @@ window.__ModuleLoader__.load({
|
|
|
1631
1830
|
React.createElement('span', { className: 'dvo-audio-time' }, t('listenBack')),
|
|
1632
1831
|
)
|
|
1633
1832
|
: null,
|
|
1833
|
+
v.settings.autoSendVisualRing !== false
|
|
1834
|
+
? (() => {
|
|
1835
|
+
const total = (voice.pending && voice.pending.totalMs) || Number(voice.settings.autoSendMs) || 4000
|
|
1836
|
+
const leftMs = (voice.pending && voice.pending.leftMs) || 0
|
|
1837
|
+
const frac = total > 0 ? Math.max(0, Math.min(1, leftMs / total)) : 0
|
|
1838
|
+
const circ = 2 * Math.PI * 14
|
|
1839
|
+
const off = circ * (1 - frac)
|
|
1840
|
+
return React.createElement('svg', { className: 'dvo-silence-ring', viewBox: '0 0 34 34' },
|
|
1841
|
+
React.createElement('circle', { cx: 17, cy: 17, r: 14, className: 'dvo-ring-bg' }),
|
|
1842
|
+
React.createElement('circle', {
|
|
1843
|
+
cx: 17, cy: 17, r: 14, className: 'dvo-ring-fg',
|
|
1844
|
+
strokeDasharray: `${circ} ${circ}`,
|
|
1845
|
+
strokeDashoffset: off,
|
|
1846
|
+
}),
|
|
1847
|
+
)
|
|
1848
|
+
})()
|
|
1849
|
+
: null,
|
|
1634
1850
|
React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' }, t('sendingIn')),
|
|
1635
1851
|
React.createElement('span', { className: 'dvo-count' }, left + t('secondsShort')),
|
|
1636
1852
|
React.createElement('button', {
|
|
@@ -1693,6 +1909,13 @@ window.__ModuleLoader__.load({
|
|
|
1693
1909
|
noiseGateDb: Number(data && data.noiseGateDb !== undefined ? data.noiseGateDb : -45),
|
|
1694
1910
|
contextGlossary: data && data.contextGlossary !== false,
|
|
1695
1911
|
visualizerStyle: (data && data.visualizerStyle) || 'liquid-wave',
|
|
1912
|
+
gatedTurnTaking: data && data.gatedTurnTaking !== false,
|
|
1913
|
+
autoSendVisualRing: data && data.autoSendVisualRing !== false,
|
|
1914
|
+
voiceActions: data && data.voiceActions !== false,
|
|
1915
|
+
techJargonCorrection: data && data.techJargonCorrection !== false,
|
|
1916
|
+
jargonDictionary: Array.isArray(data && data.jargonDictionary) ? data.jargonDictionary : [],
|
|
1917
|
+
liveInterimPreview: data && data.liveInterimPreview !== false,
|
|
1918
|
+
structuredPromptVoice: data && data.structuredPromptVoice !== false,
|
|
1696
1919
|
})
|
|
1697
1920
|
})
|
|
1698
1921
|
.catch(() => { /* no host hint — no hotkey */ })
|
|
@@ -2028,9 +2251,57 @@ window.__ModuleLoader__.load({
|
|
|
2028
2251
|
noiseGateDb: Number(val.noiseGateDb !== undefined ? val.noiseGateDb : -45),
|
|
2029
2252
|
contextGlossary: val.contextGlossary !== false,
|
|
2030
2253
|
visualizerStyle: val.visualizerStyle || 'liquid-wave',
|
|
2254
|
+
gatedTurnTaking: val.gatedTurnTaking !== false,
|
|
2255
|
+
autoSendVisualRing: val.autoSendVisualRing !== false,
|
|
2256
|
+
voiceActions: val.voiceActions !== false,
|
|
2257
|
+
techJargonCorrection: val.techJargonCorrection !== false,
|
|
2258
|
+
liveInterimPreview: val.liveInterimPreview !== false,
|
|
2259
|
+
structuredPromptVoice: val.structuredPromptVoice !== false,
|
|
2031
2260
|
})
|
|
2032
2261
|
}, [ready, value])
|
|
2033
2262
|
|
|
2263
|
+
const [sensevoiceState, setSensevoiceState] = React.useState(null)
|
|
2264
|
+
const [installingSensevoice, setInstallingSensevoice] = React.useState(false)
|
|
2265
|
+
|
|
2266
|
+
const fetchSensevoice = () => {
|
|
2267
|
+
fetch('/dsh-voice/sensevoice-installer')
|
|
2268
|
+
.then((r) => r.json())
|
|
2269
|
+
.then((d) => {
|
|
2270
|
+
setSensevoiceState(d)
|
|
2271
|
+
setInstallingSensevoice(!!(d && d.installing))
|
|
2272
|
+
})
|
|
2273
|
+
.catch(() => {})
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
React.useEffect(() => {
|
|
2277
|
+
fetchSensevoice()
|
|
2278
|
+
}, [])
|
|
2279
|
+
|
|
2280
|
+
const triggerInstallSensevoice = () => {
|
|
2281
|
+
setInstallingSensevoice(true)
|
|
2282
|
+
fetch('/dsh-voice/sensevoice-installer', {
|
|
2283
|
+
method: 'POST',
|
|
2284
|
+
headers: { 'x-dsh-plugin-update': '1' },
|
|
2285
|
+
})
|
|
2286
|
+
.then((r) => r.json())
|
|
2287
|
+
.then(() => {
|
|
2288
|
+
const timer = setInterval(() => {
|
|
2289
|
+
fetch('/dsh-voice/sensevoice-installer')
|
|
2290
|
+
.then((r) => r.json())
|
|
2291
|
+
.then((d) => {
|
|
2292
|
+
setSensevoiceState(d)
|
|
2293
|
+
if (!d.installing) {
|
|
2294
|
+
clearInterval(timer)
|
|
2295
|
+
setInstallingSensevoice(false)
|
|
2296
|
+
fetchStatus()
|
|
2297
|
+
}
|
|
2298
|
+
})
|
|
2299
|
+
.catch(() => clearInterval(timer))
|
|
2300
|
+
}, 2000)
|
|
2301
|
+
})
|
|
2302
|
+
.catch(() => setInstallingSensevoice(false))
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2034
2305
|
// Interactive microphone tester
|
|
2035
2306
|
const [testingMic, setTestingMic] = React.useState(false)
|
|
2036
2307
|
const [testLevel, setTestLevel] = React.useState(0)
|
|
@@ -2553,7 +2824,159 @@ window.__ModuleLoader__.load({
|
|
|
2553
2824
|
),
|
|
2554
2825
|
),
|
|
2555
2826
|
|
|
2556
|
-
// 6.
|
|
2827
|
+
// 6. SenseVoice Local ASR (1-Click Setup)
|
|
2828
|
+
React.createElement('div', { className: 'cb-section-card' },
|
|
2829
|
+
React.createElement('div', { className: 'cb-section-title' },
|
|
2830
|
+
React.createElement('span', null, '⚡ ' + t('sensevoiceTitle')),
|
|
2831
|
+
sensevoiceState && sensevoiceState.installed
|
|
2832
|
+
? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, t('sensevoiceReady') + ' (small-int8)')
|
|
2833
|
+
: React.createElement('span', { className: 'cb-badge cb-badge-warn' },
|
|
2834
|
+
installingSensevoice ? t('sensevoiceDownloading') : t('sensevoiceNotInstalled')
|
|
2835
|
+
),
|
|
2836
|
+
),
|
|
2837
|
+
React.createElement('div', { className: 'cb-section-desc' }, t('sensevoiceDesc')),
|
|
2838
|
+
React.createElement('div', { className: 'dvo-installer-box' },
|
|
2839
|
+
React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', justifyContent: 'space-between', gap: '12px' } },
|
|
2840
|
+
React.createElement('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-content-secondary)' } },
|
|
2841
|
+
sensevoiceState && sensevoiceState.path
|
|
2842
|
+
? ('~/' + sensevoiceState.path.replace(/^.*[\\/](\.dsh[\\/]models[\\/].*)$/, '$1'))
|
|
2843
|
+
: '~/.dsh/models/sensevoice'
|
|
2844
|
+
),
|
|
2845
|
+
React.createElement('button', {
|
|
2846
|
+
type: 'button',
|
|
2847
|
+
className: 'cb-btn cb-btn-primary',
|
|
2848
|
+
style: { fontSize: '12px', padding: '6px 12px' },
|
|
2849
|
+
disabled: installingSensevoice || !writable,
|
|
2850
|
+
onClick: triggerInstallSensevoice,
|
|
2851
|
+
}, installingSensevoice ? ('⏳ ' + t('installingSensevoice')) : ('📥 ' + t('installSensevoice'))),
|
|
2852
|
+
),
|
|
2853
|
+
installingSensevoice
|
|
2854
|
+
? React.createElement('div', { style: { marginTop: '8px' } },
|
|
2855
|
+
React.createElement('div', { className: 'dvo-progress-bar' },
|
|
2856
|
+
React.createElement('div', { className: 'dvo-progress-bar-fill' })
|
|
2857
|
+
),
|
|
2858
|
+
React.createElement('div', { style: { fontSize: '11px', marginTop: '4px', color: 'var(--dsw-alias-content-secondary)' } },
|
|
2859
|
+
(sensevoiceState && sensevoiceState.progress) || t('sensevoiceDownloading')
|
|
2860
|
+
)
|
|
2861
|
+
)
|
|
2862
|
+
: null
|
|
2863
|
+
)
|
|
2864
|
+
),
|
|
2865
|
+
|
|
2866
|
+
// 7. Turn-Taking & Audio Coordination
|
|
2867
|
+
React.createElement('div', { className: 'cb-section-card' },
|
|
2868
|
+
React.createElement('div', { className: 'cb-section-title' },
|
|
2869
|
+
React.createElement('span', null, '🎙️ ' + t('turnTakingTitle')),
|
|
2870
|
+
),
|
|
2871
|
+
React.createElement('div', { className: 'cb-section-desc' }, t('turnTakingDesc')),
|
|
2872
|
+
React.createElement('div', { className: 'cb-grid-2' },
|
|
2873
|
+
React.createElement('label', { className: 'cb-check-label' },
|
|
2874
|
+
React.createElement('input', {
|
|
2875
|
+
type: 'checkbox',
|
|
2876
|
+
checked: draft ? draft.gatedTurnTaking !== false : true,
|
|
2877
|
+
disabled: !writable,
|
|
2878
|
+
onChange: (e) => setTop('gatedTurnTaking', e.target.checked),
|
|
2879
|
+
}),
|
|
2880
|
+
t('gatedTurnTaking'),
|
|
2881
|
+
),
|
|
2882
|
+
React.createElement('label', { className: 'cb-check-label' },
|
|
2883
|
+
React.createElement('input', {
|
|
2884
|
+
type: 'checkbox',
|
|
2885
|
+
checked: !!(draft && draft.bargeIn),
|
|
2886
|
+
disabled: !writable,
|
|
2887
|
+
onChange: (e) => setTop('bargeIn', e.target.checked),
|
|
2888
|
+
}),
|
|
2889
|
+
t('bargeInDesc'),
|
|
2890
|
+
),
|
|
2891
|
+
),
|
|
2892
|
+
),
|
|
2893
|
+
|
|
2894
|
+
// 8. Hands-free & Smart Actions
|
|
2895
|
+
React.createElement('div', { className: 'cb-section-card' },
|
|
2896
|
+
React.createElement('div', { className: 'cb-section-title' },
|
|
2897
|
+
React.createElement('span', null, '✨ ' + t('smartActionsTitle')),
|
|
2898
|
+
),
|
|
2899
|
+
React.createElement('div', { className: 'cb-grid-2' },
|
|
2900
|
+
React.createElement('label', { className: 'cb-check-label' },
|
|
2901
|
+
React.createElement('input', {
|
|
2902
|
+
type: 'checkbox',
|
|
2903
|
+
checked: draft ? draft.liveInterimPreview !== false : true,
|
|
2904
|
+
disabled: !writable,
|
|
2905
|
+
onChange: (e) => setTop('liveInterimPreview', e.target.checked),
|
|
2906
|
+
}),
|
|
2907
|
+
t('liveInterimPreview'),
|
|
2908
|
+
),
|
|
2909
|
+
React.createElement('label', { className: 'cb-check-label' },
|
|
2910
|
+
React.createElement('input', {
|
|
2911
|
+
type: 'checkbox',
|
|
2912
|
+
checked: draft ? draft.autoSendVisualRing !== false : true,
|
|
2913
|
+
disabled: !writable,
|
|
2914
|
+
onChange: (e) => setTop('autoSendVisualRing', e.target.checked),
|
|
2915
|
+
}),
|
|
2916
|
+
t('autoSendVisualRing'),
|
|
2917
|
+
),
|
|
2918
|
+
React.createElement('label', { className: 'cb-check-label' },
|
|
2919
|
+
React.createElement('input', {
|
|
2920
|
+
type: 'checkbox',
|
|
2921
|
+
checked: draft ? draft.voiceActions !== false : true,
|
|
2922
|
+
disabled: !writable,
|
|
2923
|
+
onChange: (e) => setTop('voiceActions', e.target.checked),
|
|
2924
|
+
}),
|
|
2925
|
+
t('voiceActionsLabel'),
|
|
2926
|
+
),
|
|
2927
|
+
React.createElement('label', { className: 'cb-check-label' },
|
|
2928
|
+
React.createElement('input', {
|
|
2929
|
+
type: 'checkbox',
|
|
2930
|
+
checked: draft ? draft.structuredPromptVoice !== false : true,
|
|
2931
|
+
disabled: !writable,
|
|
2932
|
+
onChange: (e) => setTop('structuredPromptVoice', e.target.checked),
|
|
2933
|
+
}),
|
|
2934
|
+
t('structuredPromptVoice'),
|
|
2935
|
+
),
|
|
2936
|
+
),
|
|
2937
|
+
),
|
|
2938
|
+
|
|
2939
|
+
// 9. Developer Lexicon & IT Jargon
|
|
2940
|
+
React.createElement('div', { className: 'cb-section-card' },
|
|
2941
|
+
React.createElement('div', { className: 'cb-section-title' },
|
|
2942
|
+
React.createElement('span', null, '📖 ' + t('jargonTitle')),
|
|
2943
|
+
),
|
|
2944
|
+
React.createElement('label', { className: 'cb-check-label', style: { marginBottom: '8px' } },
|
|
2945
|
+
React.createElement('input', {
|
|
2946
|
+
type: 'checkbox',
|
|
2947
|
+
checked: draft ? draft.techJargonCorrection !== false : true,
|
|
2948
|
+
disabled: !writable,
|
|
2949
|
+
onChange: (e) => setTop('techJargonCorrection', e.target.checked),
|
|
2950
|
+
}),
|
|
2951
|
+
t('techJargonCorrection'),
|
|
2952
|
+
),
|
|
2953
|
+
React.createElement('div', { className: 'cb-field' },
|
|
2954
|
+
React.createElement('label', null, t('jargonAdd') + ' (spoken -> replacement)'),
|
|
2955
|
+
React.createElement('textarea', {
|
|
2956
|
+
rows: 3,
|
|
2957
|
+
disabled: !writable,
|
|
2958
|
+
placeholder: 'кубик -> k8s\nворктри -> worktree',
|
|
2959
|
+
value: Array.isArray(draft && draft.jargonDictionary)
|
|
2960
|
+
? draft.jargonDictionary.map((item) => item && item.from ? `${item.from} -> ${item.to}` : '').filter(Boolean).join('\n')
|
|
2961
|
+
: '',
|
|
2962
|
+
onChange: (e) => {
|
|
2963
|
+
const lines = e.target.value.split('\n')
|
|
2964
|
+
const parsed = lines
|
|
2965
|
+
.map((line) => {
|
|
2966
|
+
const parts = line.split(/->|=|:/)
|
|
2967
|
+
if (parts.length >= 2) {
|
|
2968
|
+
return { from: parts[0].trim(), to: parts.slice(1).join(':').trim() }
|
|
2969
|
+
}
|
|
2970
|
+
return null
|
|
2971
|
+
})
|
|
2972
|
+
.filter(Boolean)
|
|
2973
|
+
setTop('jargonDictionary', parsed)
|
|
2974
|
+
},
|
|
2975
|
+
}),
|
|
2976
|
+
),
|
|
2977
|
+
),
|
|
2978
|
+
|
|
2979
|
+
// 10. Provider Telemetry & Latency Dashboard
|
|
2557
2980
|
React.createElement('div', { className: 'cb-section-card' },
|
|
2558
2981
|
React.createElement('div', { className: 'cb-section-title' },
|
|
2559
2982
|
React.createElement('span', null, '📊 ' + t('providerDashboard')),
|
|
@@ -2615,7 +3038,7 @@ window.__ModuleLoader__.load({
|
|
|
2615
3038
|
React.createElement('div', { className: 'cb-section-desc' }, t('versionHint')),
|
|
2616
3039
|
React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px' } },
|
|
2617
3040
|
React.createElement('span', { className: 'cb-badge cb-badge-ok' },
|
|
2618
|
-
'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.8.
|
|
3041
|
+
'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.8.32')
|
|
2619
3042
|
),
|
|
2620
3043
|
updaterStatus && updaterStatus.updateAvailable
|
|
2621
3044
|
? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
|
|
@@ -2686,7 +3109,23 @@ window.__ModuleLoader__.load({
|
|
|
2686
3109
|
}
|
|
2687
3110
|
|
|
2688
3111
|
function registerSettings(ctx) {
|
|
2689
|
-
//
|
|
3112
|
+
// List seat (plugins.item): the seat the Plugins page renders as the plugin's
|
|
3113
|
+
// own page with its configuration — the host draws the title, icon and crumb
|
|
3114
|
+
// and asks for view 'summary' (the card's one-liner) or view 'page' (the form).
|
|
3115
|
+
// The label is a static string on purpose: it is resolved while the page
|
|
3116
|
+
// renders, and a locale lookup there would take the whole client batch down.
|
|
3117
|
+
ctx.slots.inject('plugins.item', () => ctx.slots.register(
|
|
3118
|
+
{
|
|
3119
|
+
name: 'plugins.item',
|
|
3120
|
+
id: ROW_ID,
|
|
3121
|
+
order: 60,
|
|
3122
|
+
label: () => 'Voice input',
|
|
3123
|
+
locale: NS,
|
|
3124
|
+
inject: () => ({ ctx: ctx }),
|
|
3125
|
+
},
|
|
3126
|
+
PluginCard,
|
|
3127
|
+
))
|
|
3128
|
+
// Row seat and the legacy card stay as fallbacks.
|
|
2690
3129
|
ctx.slots.inject('plugins.row.config', () => ctx.slots.register(
|
|
2691
3130
|
{
|
|
2692
3131
|
name: 'plugins.row.config',
|
package/lib/index.js
CHANGED
|
@@ -20,13 +20,14 @@ import path from 'node:path'
|
|
|
20
20
|
import { runChain } from './chain.js'
|
|
21
21
|
import { makeProviders, PROVIDER_KEYS, PRESET_KEYS, KNOWN_KEYS, DEFAULT_MODELS, CUSTOM_TEMPLATES } from './providers.js'
|
|
22
22
|
import { toWav16k } from './wav.js'
|
|
23
|
-
import { normalizePhrase } from './normalize.js'
|
|
23
|
+
import { normalizePhrase, applyJargonDictionary, extractVoiceActions } from './normalize.js'
|
|
24
24
|
import { createStatsTracker, mergeContextVocabulary } from './stats.js'
|
|
25
25
|
import { writeJson, readBody, isTrustedCaller } from './http-util.js'
|
|
26
26
|
import { createPolishText } from './polish.js'
|
|
27
27
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
28
28
|
import { buildProviderOrder, sessionCommand } from './transcribe-core.js'
|
|
29
29
|
import { registerPluginUpdater } from './updater.js'
|
|
30
|
+
import { registerSensevoiceInstaller } from './sensevoice-installer.js'
|
|
30
31
|
|
|
31
32
|
function isAutoLang(lang) {
|
|
32
33
|
return !lang || lang === 'auto' || String(lang).includes(',')
|
|
@@ -164,6 +165,20 @@ export const Config = z.object({
|
|
|
164
165
|
.description('Start the local SenseVoice / sherpa-onnx server process automatically on startup.'),
|
|
165
166
|
visualizerStyle: z.union(['liquid-wave', 'dynamic-orb', 'bars', 'off']).default('liquid-wave')
|
|
166
167
|
.description('Audio visualizer animation style in the recording pill: "liquid-wave", "dynamic-orb", "bars", or "off".'),
|
|
168
|
+
gatedTurnTaking: z.boolean().default(true)
|
|
169
|
+
.description('Gated mode for speakers: pause recording or ignore input while assistant TTS is speaking to prevent acoustic feedback.'),
|
|
170
|
+
autoSendVisualRing: z.boolean().default(true)
|
|
171
|
+
.description('Show animated circular countdown ring on recording pill before auto-sending.'),
|
|
172
|
+
voiceActions: z.boolean().default(true)
|
|
173
|
+
.description('Enable spoken action triggers ("send", "cancel", "clear", "new line" in English/Russian).'),
|
|
174
|
+
techJargonCorrection: z.boolean().default(true)
|
|
175
|
+
.description('Apply developer lexicon and IT jargon normalization (GitHub, Docker, pnpm, Kubernetes, etc.).'),
|
|
176
|
+
jargonDictionary: z.array(z.object({ from: z.string(), to: z.string() })).default([])
|
|
177
|
+
.description('Custom jargon replacement dictionary.'),
|
|
178
|
+
liveInterimPreview: z.boolean().default(true)
|
|
179
|
+
.description('Display real-time interim speech preview (ghost text) in the recording pill.'),
|
|
180
|
+
structuredPromptVoice: z.boolean().default(true)
|
|
181
|
+
.description('Target voice dictation and numeric choices to active DSH structured prompt modals.'),
|
|
167
182
|
})
|
|
168
183
|
|
|
169
184
|
const MIME_BY_EXT = {
|
|
@@ -179,13 +194,14 @@ export function apply(ctx, baseConfig) {
|
|
|
179
194
|
// host registers it via settings.register the snapshot is empty and
|
|
180
195
|
// read-only. Reading through live() means edits apply to the next request
|
|
181
196
|
// instead of requiring a process restart.
|
|
197
|
+
let scope = null
|
|
182
198
|
let getConfig = () => baseConfig
|
|
183
199
|
const live = () => Config(structuredClone(getConfig() ?? {})) ?? baseConfig
|
|
184
200
|
|
|
185
201
|
ctx.inject(['settings'], (sctx) => {
|
|
186
|
-
|
|
202
|
+
scope = sctx.settings.register(NS, Config, { base: baseConfig })
|
|
187
203
|
getConfig = () => scope.get() ?? baseConfig
|
|
188
|
-
sctx.effect(() => () => { getConfig = () => baseConfig })
|
|
204
|
+
sctx.effect(() => () => { getConfig = () => baseConfig; scope = null })
|
|
189
205
|
})
|
|
190
206
|
|
|
191
207
|
async function resolveKey(ref) {
|
|
@@ -397,6 +413,13 @@ export function apply(ctx, baseConfig) {
|
|
|
397
413
|
sensevoiceAutostart: !!cfg.sensevoiceAutostart,
|
|
398
414
|
deepgramBaseUrl: cfg.deepgramBaseUrl || 'https://api.deepgram.com',
|
|
399
415
|
visualizerStyle: cfg.visualizerStyle || 'liquid-wave',
|
|
416
|
+
gatedTurnTaking: cfg.gatedTurnTaking !== false,
|
|
417
|
+
autoSendVisualRing: cfg.autoSendVisualRing !== false,
|
|
418
|
+
voiceActions: cfg.voiceActions !== false,
|
|
419
|
+
techJargonCorrection: cfg.techJargonCorrection !== false,
|
|
420
|
+
jargonDictionary: Array.isArray(cfg.jargonDictionary) ? cfg.jargonDictionary : [],
|
|
421
|
+
liveInterimPreview: cfg.liveInterimPreview !== false,
|
|
422
|
+
structuredPromptVoice: cfg.structuredPromptVoice !== false,
|
|
400
423
|
providerStats: statsTracker.get(),
|
|
401
424
|
})
|
|
402
425
|
},
|
|
@@ -476,8 +499,19 @@ export function apply(ctx, baseConfig) {
|
|
|
476
499
|
const cmd = sessionCommand(out.text)
|
|
477
500
|
if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
|
|
478
501
|
}
|
|
479
|
-
|
|
480
|
-
|
|
502
|
+
let text = await polishText(out.text, modeCfg, controller.signal)
|
|
503
|
+
if (cfg.techJargonCorrection !== false) {
|
|
504
|
+
text = applyJargonDictionary(text, cfg.jargonDictionary)
|
|
505
|
+
}
|
|
506
|
+
let action = null
|
|
507
|
+
if (cfg.voiceActions !== false) {
|
|
508
|
+
const actionRes = extractVoiceActions(text)
|
|
509
|
+
if (actionRes.action) {
|
|
510
|
+
text = actionRes.text
|
|
511
|
+
action = actionRes.action
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
writeJson(res, 200, { ok: true, text, action, provider: out.provider, tookMs: out.tookMs })
|
|
481
515
|
} catch (e) {
|
|
482
516
|
writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
|
|
483
517
|
} finally {
|
|
@@ -549,4 +583,16 @@ export function apply(ctx, baseConfig) {
|
|
|
549
583
|
packageName: '@goodandready/dsh-voice',
|
|
550
584
|
manifestUrl: new URL('../package.json', import.meta.url),
|
|
551
585
|
}), 'dsh-voice: updater route')
|
|
586
|
+
|
|
587
|
+
ctx.effect(() => registerSensevoiceInstaller(ctx, {
|
|
588
|
+
liveConfig: live,
|
|
589
|
+
isAlive: sensevoiceAlive,
|
|
590
|
+
updateConfig: async (patch) => {
|
|
591
|
+
if (scope && typeof scope.patch === 'function') {
|
|
592
|
+
scope.patch(patch)
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
startSensevoice,
|
|
596
|
+
}), 'dsh-voice: sensevoice installer route')
|
|
552
597
|
}
|
|
598
|
+
|
package/lib/normalize.js
CHANGED
|
@@ -47,11 +47,99 @@ export function wordsToDigits(text) {
|
|
|
47
47
|
})
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
export const DEFAULT_JARGON_DICTIONARY = [
|
|
51
|
+
{ from: 'гитхаб', to: 'GitHub' },
|
|
52
|
+
{ from: 'гитхабе', to: 'GitHub' },
|
|
53
|
+
{ from: 'гитхаба', to: 'GitHub' },
|
|
54
|
+
{ from: 'гитлаб', to: 'GitLab' },
|
|
55
|
+
{ from: 'гитлабе', to: 'GitLab' },
|
|
56
|
+
{ from: 'гитлаба', to: 'GitLab' },
|
|
57
|
+
{ from: 'дш', to: 'DSH' },
|
|
58
|
+
{ from: 'пнпм', to: 'pnpm' },
|
|
59
|
+
{ from: 'нпм', to: 'npm' },
|
|
60
|
+
{ from: 'докер', to: 'Docker' },
|
|
61
|
+
{ from: 'докера', to: 'Docker' },
|
|
62
|
+
{ from: 'докером', to: 'Docker' },
|
|
63
|
+
{ from: 'докер-контейнер', to: 'Docker-контейнер' },
|
|
64
|
+
{ from: 'докер-контейнеры', to: 'Docker-контейнеры' },
|
|
65
|
+
{ from: 'кубер', to: 'Kubernetes' },
|
|
66
|
+
{ from: 'кубернетис', to: 'Kubernetes' },
|
|
67
|
+
{ from: 'кубернетес', to: 'Kubernetes' },
|
|
68
|
+
{ from: 'тайпскрипт', to: 'TypeScript' },
|
|
69
|
+
{ from: 'тайпскрипте', to: 'TypeScript' },
|
|
70
|
+
{ from: 'тайпскрипта', to: 'TypeScript' },
|
|
71
|
+
{ from: 'яваскрипт', to: 'JavaScript' },
|
|
72
|
+
{ from: 'джаваскрипт', to: 'JavaScript' },
|
|
73
|
+
{ from: 'яваскрипта', to: 'JavaScript' },
|
|
74
|
+
{ from: 'джаваскрипта', to: 'JavaScript' },
|
|
75
|
+
{ from: 'нода', to: 'Node.js' },
|
|
76
|
+
{ from: 'ноде', to: 'Node.js' },
|
|
77
|
+
{ from: 'ноды', to: 'Node.js' },
|
|
78
|
+
{ from: 'пулл реквест', to: 'PR' },
|
|
79
|
+
{ from: 'пулл-реквест', to: 'PR' },
|
|
80
|
+
{ from: 'пайплайн', to: 'pipeline' },
|
|
81
|
+
{ from: 'пайплайна', to: 'pipeline' },
|
|
82
|
+
{ from: 'коммит', to: 'commit' },
|
|
83
|
+
{ from: 'коммита', to: 'commit' },
|
|
84
|
+
{ from: 'коммиты', to: 'commits' },
|
|
85
|
+
{ from: 'бранч', to: 'branch' },
|
|
86
|
+
{ from: 'бренч', to: 'branch' },
|
|
87
|
+
{ from: 'ворктри', to: 'worktree' },
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
export function applyJargonDictionary(text, customReplacements = []) {
|
|
91
|
+
if (!text || typeof text !== 'string') return text || ''
|
|
92
|
+
const dict = [...(customReplacements || []), ...DEFAULT_JARGON_DICTIONARY]
|
|
93
|
+
let result = text
|
|
94
|
+
for (const item of dict) {
|
|
95
|
+
if (!item?.from || !item?.to) continue
|
|
96
|
+
const escaped = item.from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
97
|
+
const regex = new RegExp(`(?<![a-zA-Zа-яА-ЯёЁ0-9_-])${escaped}(?![a-zA-Zа-яА-ЯёЁ0-9_-])`, 'gi')
|
|
98
|
+
result = result.replace(regex, item.to)
|
|
99
|
+
}
|
|
100
|
+
return result
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function extractVoiceActions(text) {
|
|
104
|
+
const raw = String(text || '').trim()
|
|
105
|
+
if (!raw) return { text: '', action: null }
|
|
106
|
+
|
|
107
|
+
const lower = raw.toLowerCase().replace(/[.!?…]+$/, '').trim()
|
|
108
|
+
if (/^(отправь|отправить|send|send it|send message)$/i.test(lower)) {
|
|
109
|
+
return { text: '', action: 'send' }
|
|
110
|
+
}
|
|
111
|
+
if (/^(отмена|cancel|discard)$/i.test(lower)) {
|
|
112
|
+
return { text: '', action: 'cancel' }
|
|
113
|
+
}
|
|
114
|
+
if (/^(очисти|очистить|clear|clear composer)$/i.test(lower)) {
|
|
115
|
+
return { text: '', action: 'clear' }
|
|
116
|
+
}
|
|
117
|
+
if (/^(новая строка|с новой строки|new line|newline)$/i.test(lower)) {
|
|
118
|
+
return { text: '', action: 'newline' }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const trailingSend = /[\s,]+(отправь|отправить|send|send it)[.!?…]*$/i
|
|
122
|
+
if (trailingSend.test(raw)) {
|
|
123
|
+
return { text: raw.replace(trailingSend, '').trim(), action: 'send' }
|
|
124
|
+
}
|
|
125
|
+
const trailingCancel = /[\s,]+(отмена|cancel)[.!?…]*$/i
|
|
126
|
+
if (trailingCancel.test(raw)) {
|
|
127
|
+
return { text: raw.replace(trailingCancel, '').trim(), action: 'cancel' }
|
|
128
|
+
}
|
|
129
|
+
const trailingNewline = /[\s,]+(новая строка|с новой строки|new line)[.!?…]*$/i
|
|
130
|
+
if (trailingNewline.test(raw)) {
|
|
131
|
+
return { text: raw.replace(trailingNewline, '').trim() + '\n', action: 'newline' }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { text: raw, action: null }
|
|
135
|
+
}
|
|
136
|
+
|
|
50
137
|
export function normalizePhrase(text, opts = {}) {
|
|
51
138
|
let out = String(text || '').trim()
|
|
52
139
|
if (!out) return out
|
|
53
140
|
if (opts.digits) out = wordsToDigits(out)
|
|
54
141
|
if (opts.commaSpacing) out = fixCommaSpacing(out)
|
|
142
|
+
if (opts.jargon) out = applyJargonDictionary(out, opts.customJargon)
|
|
55
143
|
if (opts.capSentences) out = capitalizeSentences(out)
|
|
56
144
|
if (opts.trailingPeriod) out = ensureTrailingPeriod(out)
|
|
57
145
|
return out
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// lib/sensevoice-installer.js — 1-Click installer for SenseVoice-Small / sherpa-onnx
|
|
2
|
+
// Downloads pre-quantized ONNX model and configures autostart on the host.
|
|
3
|
+
|
|
4
|
+
import { createWriteStream, existsSync, readdirSync } from 'node:fs'
|
|
5
|
+
import { mkdir, stat } from 'node:fs/promises'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { spawn } from 'node:child_process'
|
|
9
|
+
import { writeJson, isTrustedCaller } from './http-util.js'
|
|
10
|
+
|
|
11
|
+
export const SENSEVOICE_MODEL_URL =
|
|
12
|
+
'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'
|
|
13
|
+
export const SENSEVOICE_MODEL_MIRROR =
|
|
14
|
+
'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'
|
|
15
|
+
|
|
16
|
+
let installerState = {
|
|
17
|
+
installing: false,
|
|
18
|
+
progress: 0,
|
|
19
|
+
step: 'idle',
|
|
20
|
+
error: null,
|
|
21
|
+
modelPath: '',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getDefaultModelDir() {
|
|
25
|
+
return path.join(homedir(), '.dsh', 'models', 'sensevoice')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function findModelFile(dir) {
|
|
29
|
+
if (!dir || !existsSync(dir)) return null
|
|
30
|
+
try {
|
|
31
|
+
const entries = readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (entry.isFile() && (entry.name === 'model.int8.onnx' || entry.name === 'model.onnx')) {
|
|
34
|
+
const parent = entry.parentPath || (entry.path ? entry.path : dir)
|
|
35
|
+
return path.join(parent, entry.name)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch { /* directory read failed */ }
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function findTokensFile(dir) {
|
|
43
|
+
if (!dir || !existsSync(dir)) return null
|
|
44
|
+
try {
|
|
45
|
+
const entries = readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (entry.isFile() && entry.name === 'tokens.txt') {
|
|
48
|
+
const parent = entry.parentPath || (entry.path ? entry.path : dir)
|
|
49
|
+
return path.join(parent, entry.name)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch { /* directory read failed */ }
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function getSensevoiceStatus(cfg, isAliveFn) {
|
|
57
|
+
const modelDir = cfg?.sensevoiceModel ? path.dirname(cfg.sensevoiceModel) : getDefaultModelDir()
|
|
58
|
+
const modelFile = cfg?.sensevoiceModel && existsSync(cfg.sensevoiceModel)
|
|
59
|
+
? cfg.sensevoiceModel
|
|
60
|
+
: findModelFile(getDefaultModelDir())
|
|
61
|
+
const tokensFile = modelFile ? findTokensFile(path.dirname(modelFile)) : null
|
|
62
|
+
|
|
63
|
+
const running = typeof isAliveFn === 'function' ? await isAliveFn() : false
|
|
64
|
+
const installed = Boolean(modelFile && existsSync(modelFile))
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
installed,
|
|
68
|
+
running,
|
|
69
|
+
modelPath: modelFile || '',
|
|
70
|
+
tokensPath: tokensFile || '',
|
|
71
|
+
installing: installerState.installing,
|
|
72
|
+
progress: installerState.progress,
|
|
73
|
+
step: installerState.step,
|
|
74
|
+
error: installerState.error,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function downloadArchive(url, destPath, onProgress) {
|
|
79
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(600_000) })
|
|
80
|
+
if (!res.ok) throw new Error(`Download failed with HTTP ${res.status}`)
|
|
81
|
+
|
|
82
|
+
const totalBytes = Number(res.headers.get('content-length')) || 150_000_000
|
|
83
|
+
let receivedBytes = 0
|
|
84
|
+
|
|
85
|
+
const fileStream = createWriteStream(destPath)
|
|
86
|
+
const reader = res.body.getReader()
|
|
87
|
+
|
|
88
|
+
while (true) {
|
|
89
|
+
const { done, value } = await reader.read()
|
|
90
|
+
if (done) break
|
|
91
|
+
fileStream.write(value)
|
|
92
|
+
receivedBytes += value.length
|
|
93
|
+
if (typeof onProgress === 'function') {
|
|
94
|
+
const pct = Math.min(95, Math.round((receivedBytes / totalBytes) * 90))
|
|
95
|
+
onProgress(pct, `downloading (${Math.round(receivedBytes / 1024 / 1024)} MB)`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await new Promise((resolve, reject) => {
|
|
100
|
+
fileStream.end()
|
|
101
|
+
fileStream.on('finish', resolve)
|
|
102
|
+
fileStream.on('error', reject)
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function extractTarBz2(archivePath, targetDir) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const child = spawn('tar', ['-xjf', archivePath, '-C', targetDir])
|
|
109
|
+
let stderr = ''
|
|
110
|
+
child.stderr.on('data', (d) => { stderr += d })
|
|
111
|
+
child.on('close', (code) => {
|
|
112
|
+
if (code === 0) resolve()
|
|
113
|
+
else reject(new Error(`tar extraction failed with code ${code}: ${stderr}`))
|
|
114
|
+
})
|
|
115
|
+
child.on('error', reject)
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function startSensevoiceInstall(ctx, cfg, updateConfigFn, startSensevoiceFn) {
|
|
120
|
+
if (installerState.installing) {
|
|
121
|
+
throw new Error('SenseVoice installation is already in progress')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
installerState = {
|
|
125
|
+
installing: true,
|
|
126
|
+
progress: 5,
|
|
127
|
+
step: 'preparing',
|
|
128
|
+
error: null,
|
|
129
|
+
modelPath: '',
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const modelDir = getDefaultModelDir()
|
|
133
|
+
const archivePath = path.join(modelDir, 'sensevoice.tar.bz2')
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
await mkdir(modelDir, { recursive: true })
|
|
137
|
+
installerState.step = 'downloading'
|
|
138
|
+
installerState.progress = 10
|
|
139
|
+
|
|
140
|
+
let downloadOk = false
|
|
141
|
+
try {
|
|
142
|
+
await downloadArchive(SENSEVOICE_MODEL_URL, archivePath, (pct, status) => {
|
|
143
|
+
installerState.progress = pct
|
|
144
|
+
installerState.step = status
|
|
145
|
+
})
|
|
146
|
+
downloadOk = true
|
|
147
|
+
} catch (e1) {
|
|
148
|
+
// Fallback to mirror
|
|
149
|
+
installerState.step = 'downloading (mirror)'
|
|
150
|
+
await downloadArchive(SENSEVOICE_MODEL_MIRROR, archivePath, (pct, status) => {
|
|
151
|
+
installerState.progress = pct
|
|
152
|
+
installerState.step = status
|
|
153
|
+
})
|
|
154
|
+
downloadOk = true
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!downloadOk) throw new Error('Could not download SenseVoice model from primary or mirror URL')
|
|
158
|
+
|
|
159
|
+
installerState.step = 'extracting'
|
|
160
|
+
installerState.progress = 95
|
|
161
|
+
await extractTarBz2(archivePath, modelDir)
|
|
162
|
+
|
|
163
|
+
const modelFile = findModelFile(modelDir)
|
|
164
|
+
if (!modelFile) throw new Error('model.int8.onnx not found after extraction')
|
|
165
|
+
|
|
166
|
+
installerState.modelPath = modelFile
|
|
167
|
+
installerState.progress = 100
|
|
168
|
+
installerState.step = 'ready'
|
|
169
|
+
installerState.installing = false
|
|
170
|
+
|
|
171
|
+
if (typeof updateConfigFn === 'function') {
|
|
172
|
+
await updateConfigFn({
|
|
173
|
+
sensevoiceModel: modelFile,
|
|
174
|
+
sensevoiceAutostart: true,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (typeof startSensevoiceFn === 'function') {
|
|
179
|
+
await startSensevoiceFn()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { ok: true, modelPath: modelFile }
|
|
183
|
+
} catch (err) {
|
|
184
|
+
installerState.installing = false
|
|
185
|
+
installerState.error = String(err?.message || err)
|
|
186
|
+
installerState.step = 'failed'
|
|
187
|
+
throw err
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateConfig, startSensevoice }) {
|
|
192
|
+
if (!ctx.webServer || typeof ctx.webServer.register !== 'function') return
|
|
193
|
+
|
|
194
|
+
ctx.webServer.register({
|
|
195
|
+
kind: 'exact',
|
|
196
|
+
path: '/dsh-voice/sensevoice-installer',
|
|
197
|
+
handler: async (req, res) => {
|
|
198
|
+
if (req.method === 'GET') {
|
|
199
|
+
const status = await getSensevoiceStatus(liveConfig(), isAlive)
|
|
200
|
+
writeJson(res, 200, status)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (req.method === 'POST') {
|
|
205
|
+
if (!isTrustedCaller(req)) {
|
|
206
|
+
writeJson(res, 403, { ok: false, error: 'Forbidden: untrusted caller' })
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (installerState.installing) {
|
|
211
|
+
writeJson(res, 409, { ok: false, error: 'Installation already in progress', state: installerState })
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Start background install
|
|
216
|
+
startSensevoiceInstall(ctx, liveConfig(), updateConfig, startSensevoice).catch((err) => {
|
|
217
|
+
ctx.logger?.warn?.(`SenseVoice 1-click install error: ${String(err)}`)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
writeJson(res, 202, { ok: true, message: 'SenseVoice installation started', state: installerState })
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
writeJson(res, 405, { ok: false, error: 'Method not allowed' })
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.33",
|
|
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",
|