@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 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
@@ -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: { 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
+ },
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; voice.notify()
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
- // Announce before opening the mic: the sooner playback mutes, the less
979
- // 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
+ }
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
- 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
+
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. 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
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.29')
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 [open, setOpen] = React.useState(false)
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
- 'aria-expanded': open ? 'true' : 'false',
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
- 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.30",
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",