@goodandready/dsh-voice 0.8.31 → 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 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 emotion/event tag stripping. Supports both Sherpa-ONNX HTTP and OpenAI-compatible endpoints.
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 с автоматической очисткой тегов эмоций/событий. Поддержка Sherpa-ONNX HTTP и OpenAI-совместимых эндпоинтов.
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: { vadSilenceMs: 700, autoSendMs: 4000, stream: false, streamChunkMs: 1200, vadAdapt: 0, noiseSuppression: true, contextGlossary: true },
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; voice.notify()
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
- // Announce before opening the mic: the sooner playback mutes, the less
983
- // of it ends up in the recording.
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
- if (!text) { voice.set({ phase: 'error', error: t('nothingHeard') }); return }
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. Provider Telemetry & Latency Dashboard
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.29')
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' },
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
- const scope = sctx.settings.register(NS, Config, { base: baseConfig })
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
- const text = await polishText(out.text, modeCfg, controller.signal)
480
- writeJson(res, 200, { ok: true, text, provider: out.provider, tookMs: out.tookMs })
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.31",
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",