@goodandready/dsh-voice 0.8.30 → 0.8.32
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 +465 -18
- 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
|
@@ -26,6 +26,10 @@ window.__ModuleLoader__.load({
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
const NS = 'dsh-voice'
|
|
29
|
+
// Plugins page row seat (DSH 0.1.6-alpha.2): key = '<package name>#<row id>'.
|
|
30
|
+
const PKG = '@goodandready/dsh-voice'
|
|
31
|
+
const ROW_ID = 'dsh-voice'
|
|
32
|
+
const ROW_CONFIG_KEY = PKG + '#' + ROW_ID
|
|
29
33
|
|
|
30
34
|
// UI strings live in the locale registry so a separate package can
|
|
31
35
|
// translate them without touching this plugin. English is the source
|
|
@@ -200,6 +204,27 @@ window.__ModuleLoader__.load({
|
|
|
200
204
|
'updating': 'Updating…',
|
|
201
205
|
'updateSuccess': 'Updated successfully! Restart DSH to apply.',
|
|
202
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',
|
|
203
228
|
}
|
|
204
229
|
|
|
205
230
|
const zh = {
|
|
@@ -372,6 +397,27 @@ window.__ModuleLoader__.load({
|
|
|
372
397
|
'updating': '正在更新…',
|
|
373
398
|
'updateSuccess': '更新成功!请重启 DSH 生效。',
|
|
374
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': '标准拼写',
|
|
375
421
|
}
|
|
376
422
|
|
|
377
423
|
// Strings are also needed outside components — in recording handlers and
|
|
@@ -412,7 +458,17 @@ window.__ModuleLoader__.load({
|
|
|
412
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)}' +
|
|
413
459
|
'.dvo-meter-bar{flex:1;height:8px;background:var(--dsw-alias-bg-layer-3);border-radius:4px;overflow:hidden;position:relative}' +
|
|
414
460
|
'.dvo-meter-fill{height:100%;background:var(--dsw-alias-state-success-primary);border-radius:4px;transition:width 60ms ease-out}' +
|
|
415
|
-
'.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)}'
|
|
416
472
|
const cssId = 'dsh-voice/client.module.css'
|
|
417
473
|
if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + cssId + '"]')) {
|
|
418
474
|
const tag = document.createElement('style')
|
|
@@ -431,15 +487,88 @@ window.__ModuleLoader__.load({
|
|
|
431
487
|
pending: null, // {text, leftMs} — message-mode cancel window
|
|
432
488
|
lastNote: null, // {blob, url, mime, text} — last recording
|
|
433
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
|
|
434
493
|
inputActions: null,
|
|
435
494
|
input: null,
|
|
436
|
-
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
|
+
},
|
|
437
502
|
listeners: new Set(),
|
|
438
503
|
notify() { this.listeners.forEach((l) => l()) },
|
|
439
504
|
set(patch) { Object.assign(this, patch); this.notify() },
|
|
440
505
|
subscribe(l) { this.listeners.add(l); return () => this.listeners.delete(l) },
|
|
441
506
|
}
|
|
442
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
|
+
|
|
443
572
|
function useVoice() {
|
|
444
573
|
const [, force] = React.useReducer((x) => x + 1, 0)
|
|
445
574
|
React.useEffect(() => voice.subscribe(force), [])
|
|
@@ -905,6 +1034,37 @@ window.__ModuleLoader__.load({
|
|
|
905
1034
|
const out = await sendAudio(blob, rec.mime, 'dictation')
|
|
906
1035
|
if (rec.cancelled) return
|
|
907
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
|
+
|
|
908
1068
|
const delay = Number(voice.settings.sendDelayMs) || 0
|
|
909
1069
|
if (text && delay > 0 && !voice.holding) {
|
|
910
1070
|
// Delayed insert with a cancel window (#29-5): text is already
|
|
@@ -933,7 +1093,11 @@ window.__ModuleLoader__.load({
|
|
|
933
1093
|
lang: language,
|
|
934
1094
|
continuous: true,
|
|
935
1095
|
onInterim: (text) => {
|
|
936
|
-
voice.caption = text
|
|
1096
|
+
voice.caption = text
|
|
1097
|
+
if (voice.settings.liveInterimPreview !== false) {
|
|
1098
|
+
voice.interimTranscript = text
|
|
1099
|
+
}
|
|
1100
|
+
voice.notify()
|
|
937
1101
|
// Wake-word (#45): if interim starts with the trigger phrase, stop
|
|
938
1102
|
// browser listening and switch to normal recording.
|
|
939
1103
|
const ww = String(voice.settings.wakeWord || '').trim().toLowerCase()
|
|
@@ -942,6 +1106,7 @@ window.__ModuleLoader__.load({
|
|
|
942
1106
|
if (t.startsWith(ww)) {
|
|
943
1107
|
voice.browser = null
|
|
944
1108
|
voice.caption = ''
|
|
1109
|
+
voice.interimTranscript = ''
|
|
945
1110
|
openMic(mode)
|
|
946
1111
|
.then((rec) => {
|
|
947
1112
|
if (voice.phase !== 'recording') {
|
|
@@ -958,6 +1123,7 @@ window.__ModuleLoader__.load({
|
|
|
958
1123
|
onFinal: (text) => {
|
|
959
1124
|
finals.push(text)
|
|
960
1125
|
voice.caption = ''
|
|
1126
|
+
voice.interimTranscript = ''
|
|
961
1127
|
// Dictation appends immediately; messages accumulate until release.
|
|
962
1128
|
if (mode === 'dictation') appendDraft(text)
|
|
963
1129
|
else voice.notify()
|
|
@@ -965,6 +1131,7 @@ window.__ModuleLoader__.load({
|
|
|
965
1131
|
onError: (reason) => {
|
|
966
1132
|
// Browser failed after start — say so instead of pretending to listen.
|
|
967
1133
|
voice.browser = null
|
|
1134
|
+
voice.interimTranscript = ''
|
|
968
1135
|
voice.set({ phase: 'error', error: t('browserFailed') + reason })
|
|
969
1136
|
},
|
|
970
1137
|
})
|
|
@@ -975,11 +1142,17 @@ window.__ModuleLoader__.load({
|
|
|
975
1142
|
|
|
976
1143
|
function startRecording(mode) {
|
|
977
1144
|
if (voice.phase !== 'idle' && voice.phase !== 'error') return
|
|
978
|
-
|
|
979
|
-
|
|
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
|
+
}
|
|
980
1153
|
announceVoice('start')
|
|
981
1154
|
dictationQueue = Promise.resolve()
|
|
982
|
-
voice.set({ phase: 'recording', mode, error: '', levels: [], caption: '' })
|
|
1155
|
+
voice.set({ phase: 'recording', mode, error: '', levels: [], caption: '', interimTranscript: '' })
|
|
983
1156
|
modeChain(mode).then((info) => {
|
|
984
1157
|
// Browser leg only when it is explicitly first in the chain.
|
|
985
1158
|
if (info.chain[0] === 'browser' && startBrowserLeg(mode, info.language)) return
|
|
@@ -1134,11 +1307,38 @@ window.__ModuleLoader__.load({
|
|
|
1134
1307
|
const out = await sendAudio(blob, rec.mime, mode)
|
|
1135
1308
|
if (out.command) { runSessionCommand(out.command); return }
|
|
1136
1309
|
const text = out.text || ''
|
|
1137
|
-
|
|
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
|
+
|
|
1138
1338
|
appendDraft(text)
|
|
1139
1339
|
if (voice.lastNote) voice.lastNote.text = text
|
|
1140
1340
|
if (mode === 'message') {
|
|
1141
|
-
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 } })
|
|
1142
1342
|
} else {
|
|
1143
1343
|
voice.set({ phase: 'idle' })
|
|
1144
1344
|
}
|
|
@@ -1573,7 +1773,10 @@ window.__ModuleLoader__.load({
|
|
|
1573
1773
|
? t('holdHint')
|
|
1574
1774
|
: (inBrowser
|
|
1575
1775
|
? t('listening')
|
|
1576
|
-
: (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),
|
|
1577
1780
|
React.createElement('button', {
|
|
1578
1781
|
type: 'button', className: 'dvo-pbtn', title: t('stop'),
|
|
1579
1782
|
'aria-label': t('stop'),
|
|
@@ -1627,6 +1830,23 @@ window.__ModuleLoader__.load({
|
|
|
1627
1830
|
React.createElement('span', { className: 'dvo-audio-time' }, t('listenBack')),
|
|
1628
1831
|
)
|
|
1629
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,
|
|
1630
1850
|
React.createElement('span', { className: 'dvo-status', 'aria-live': 'polite', 'aria-atomic': 'true' }, t('sendingIn')),
|
|
1631
1851
|
React.createElement('span', { className: 'dvo-count' }, left + t('secondsShort')),
|
|
1632
1852
|
React.createElement('button', {
|
|
@@ -1689,6 +1909,13 @@ window.__ModuleLoader__.load({
|
|
|
1689
1909
|
noiseGateDb: Number(data && data.noiseGateDb !== undefined ? data.noiseGateDb : -45),
|
|
1690
1910
|
contextGlossary: data && data.contextGlossary !== false,
|
|
1691
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,
|
|
1692
1919
|
})
|
|
1693
1920
|
})
|
|
1694
1921
|
.catch(() => { /* no host hint — no hotkey */ })
|
|
@@ -1703,12 +1930,15 @@ window.__ModuleLoader__.load({
|
|
|
1703
1930
|
}
|
|
1704
1931
|
}, 'dsh-voice: hold hotkey')
|
|
1705
1932
|
|
|
1933
|
+
// locale: NS is required — the core resolves `label` through the inject face,
|
|
1934
|
+
// and without it the label throws "cannot get property t without inject" while
|
|
1935
|
+
// the slot snapshot is built, breaking the whole client batch.
|
|
1706
1936
|
ctx.slots.inject('conversation.input.right', () => ctx.slots.register(
|
|
1707
|
-
{ name: 'conversation.input.right', id: '@goodandready/dsh-voice', order: 6, label: () => t('title') },
|
|
1937
|
+
{ name: 'conversation.input.right', id: '@goodandready/dsh-voice', order: 6, locale: NS, label: () => t('title') },
|
|
1708
1938
|
(props) => React.createElement(VoiceButtons, { input: props.input, inputActions: props.inputActions }),
|
|
1709
1939
|
))
|
|
1710
1940
|
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register(
|
|
1711
|
-
{ name: 'conversation.input.dock', id: 'dsh-voice-rec', order: 0, label: () => t('recordSlot') },
|
|
1941
|
+
{ name: 'conversation.input.dock', id: 'dsh-voice-rec', order: 0, locale: NS, label: () => t('recordSlot') },
|
|
1712
1942
|
(props) => React.createElement(RecordPill, { input: props.input, inputActions: props.inputActions, ctx: ctx }),
|
|
1713
1943
|
))
|
|
1714
1944
|
}
|
|
@@ -2021,9 +2251,57 @@ window.__ModuleLoader__.load({
|
|
|
2021
2251
|
noiseGateDb: Number(val.noiseGateDb !== undefined ? val.noiseGateDb : -45),
|
|
2022
2252
|
contextGlossary: val.contextGlossary !== false,
|
|
2023
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,
|
|
2024
2260
|
})
|
|
2025
2261
|
}, [ready, value])
|
|
2026
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
|
+
|
|
2027
2305
|
// Interactive microphone tester
|
|
2028
2306
|
const [testingMic, setTestingMic] = React.useState(false)
|
|
2029
2307
|
const [testLevel, setTestLevel] = React.useState(0)
|
|
@@ -2546,7 +2824,159 @@ window.__ModuleLoader__.load({
|
|
|
2546
2824
|
),
|
|
2547
2825
|
),
|
|
2548
2826
|
|
|
2549
|
-
// 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
|
|
2550
2980
|
React.createElement('div', { className: 'cb-section-card' },
|
|
2551
2981
|
React.createElement('div', { className: 'cb-section-title' },
|
|
2552
2982
|
React.createElement('span', null, '📊 ' + t('providerDashboard')),
|
|
@@ -2608,7 +3038,7 @@ window.__ModuleLoader__.load({
|
|
|
2608
3038
|
React.createElement('div', { className: 'cb-section-desc' }, t('versionHint')),
|
|
2609
3039
|
React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px' } },
|
|
2610
3040
|
React.createElement('span', { className: 'cb-badge cb-badge-ok' },
|
|
2611
|
-
'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.8.
|
|
3041
|
+
'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.8.32')
|
|
2612
3042
|
),
|
|
2613
3043
|
updaterStatus && updaterStatus.updateAvailable
|
|
2614
3044
|
? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
|
|
@@ -2647,16 +3077,23 @@ window.__ModuleLoader__.load({
|
|
|
2647
3077
|
}
|
|
2648
3078
|
// Card in Settings → Plugins → Plugin settings (#18)
|
|
2649
3079
|
function PluginCard(props) {
|
|
2650
|
-
const
|
|
3080
|
+
const page = !!(props && props.view === 'page')
|
|
3081
|
+
const [open, setOpen] = React.useState(!!page)
|
|
2651
3082
|
const tt = (props && props.t) || t
|
|
2652
3083
|
const title = tt('title')
|
|
3084
|
+
// Row seat (plugins.row.config): the host page draws title/icon/crumb and the
|
|
3085
|
+
// padding, so the summary is a one-liner and the page drops our card chrome.
|
|
3086
|
+
if (props && props.view === 'summary') {
|
|
3087
|
+
return React.createElement('span', { className: 'dvo-pdesc' }, tt('cardHint'))
|
|
3088
|
+
}
|
|
2653
3089
|
return React.createElement(ErrorBoundary, null,
|
|
2654
|
-
React.createElement('li',
|
|
2655
|
-
{ className: open ? 'dvo-pcard dvo-pcardOpen' : 'dvo-pcard' },
|
|
3090
|
+
React.createElement(page ? 'div' : 'li',
|
|
3091
|
+
{ className: page ? 'dvo-ppage' : (open ? 'dvo-pcard dvo-pcardOpen' : 'dvo-pcard') },
|
|
2656
3092
|
React.createElement('button',
|
|
2657
3093
|
{
|
|
2658
3094
|
type: 'button', className: 'dvo-phead',
|
|
2659
|
-
|
|
3095
|
+
style: page ? { display: 'none' } : undefined,
|
|
3096
|
+
'aria-expanded': page ? 'true' : (open ? 'true' : 'false'),
|
|
2660
3097
|
'aria-label': (open ? tt('collapse') : tt('expand')) + ': ' + title,
|
|
2661
3098
|
onClick: () => setOpen((v) => !v),
|
|
2662
3099
|
},
|
|
@@ -2666,12 +3103,22 @@ window.__ModuleLoader__.load({
|
|
|
2666
3103
|
),
|
|
2667
3104
|
React.createElement('span', { className: 'dvo-pchev' }, chevronIcon()),
|
|
2668
3105
|
),
|
|
2669
|
-
open ? React.createElement('div', { className: 'dvo-pbody' }, React.createElement(VoiceSection, props)) : null,
|
|
3106
|
+
(page || open) ? React.createElement('div', { className: 'dvo-pbody' }, React.createElement(VoiceSection, props)) : null,
|
|
2670
3107
|
),
|
|
2671
3108
|
)
|
|
2672
3109
|
}
|
|
2673
3110
|
|
|
2674
3111
|
function registerSettings(ctx) {
|
|
3112
|
+
// Row seat first (the seat the current core renders), legacy card after it.
|
|
3113
|
+
ctx.slots.inject('plugins.row.config', () => ctx.slots.register(
|
|
3114
|
+
{
|
|
3115
|
+
name: 'plugins.row.config',
|
|
3116
|
+
key: ROW_CONFIG_KEY,
|
|
3117
|
+
locale: NS,
|
|
3118
|
+
inject: () => ({ ctx: ctx }),
|
|
3119
|
+
},
|
|
3120
|
+
PluginCard,
|
|
3121
|
+
))
|
|
2675
3122
|
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
|
|
2676
3123
|
{
|
|
2677
3124
|
name: 'settings.plugin.item',
|
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.32",
|
|
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",
|