@goodandready/dsh-voice 0.8.8 → 0.8.10

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 dsh-voice contributors
3
+ Copyright (c) 2026 GooDAnDReaDY
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,216 +1,167 @@
1
- # dsh-voice
2
-
3
- Voice input for the DeepSeek Harness Web GUI, in two modes, each with its own
4
- provider fallback chain.
5
-
6
- **Dictation** — press the mic, talk, and the text lands in the composer as you
7
- go: speech is cut into phrases on silence and each phrase is transcribed on its
8
- own. Press again to stop; sending stays in your hands.
9
-
10
- **Voice message** — press the wave button, record, press again. The transcript
11
- is inserted and sent to the agent after a short cancel window.
12
-
13
- Both modes fall back across providers, so one outage or rate limit does not
14
- lose your recording. API keys never reach the browser: audio is posted to the
15
- plugin's own route and the host talks to the providers.
16
-
17
- ## Install
18
-
19
- ```bash
20
- # From npm:
21
- dsh plugin --profile web add @goodandready/dsh-voice
22
-
23
- # Locally from a checkout:
24
- dsh plugin --profile web add file:/path/to/dsh-voice
1
+ # 📦 @goodandready/dsh-voice
2
+
3
+ <div align="center">
4
+
5
+ <h3>Zero-Latency Streaming Dictation & Multi-Provider Voice Input for DeepSeek Harness</h3>
6
+
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/@goodandready/dsh-voice"><img src="https://img.shields.io/npm/v/@goodandready/dsh-voice.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-10b981.svg?style=for-the-badge&color=10b981&labelColor=064e3b" alt="license"></a>
10
+ <a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-Plugin-8b5cf6.svg?style=for-the-badge&labelColor=2e1065" alt="DSH Plugin"></a>
11
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="https://goodandready.app/"><img src="https://img.shields.io/badge/All_Author_Projects-goodandready.app-ff4500.svg?style=for-the-badge&logo=rocket&logoColor=white&labelColor=1a1a2e" alt="All Author Projects"></a>
16
+ </p>
17
+
18
+ <p align="center">
19
+ <a href="README.md"><b>🇬🇧 English</b></a> •
20
+ <a href="README.ru.md"><b>🇷🇺 Русский</b></a> •
21
+ <a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
22
+ </p>
23
+
24
+ </div>
25
+
26
+ ---
27
+
28
+ ## ⚡ Overview
29
+
30
+ **`dsh-voice`** brings voice superpowers to the **DeepSeek Harness** Web UI. Whether you need hands-free real-time streaming dictation segmented on natural breath pauses or crisp voice notes with keyboard/mouse Push-to-Talk gestures, `dsh-voice` ensures your audio is never lost thanks to **automatic multi-provider fallback chains**.
31
+
32
+ ```mermaid
33
+ graph LR
34
+ subgraph Client [Browser Web UI]
35
+ Mic[🎙️ Dictation Mic] -->|VAD Cut on Pause| Stream[Audio Chunks]
36
+ Wave[🌊 Voice Message] -->|Hold / Release| PTT[Push-to-Talk]
37
+ end
38
+
39
+ subgraph Host [DSH Host Backend]
40
+ Stream --> FFMPEG[ffmpeg 16kHz Transcoder]
41
+ PTT --> FFMPEG
42
+ FFMPEG --> Chain{Fallback Chain}
43
+
44
+ Chain -->|1st Priority| P1[Deepgram / Nova-2]
45
+ Chain -.->|On Rate Limit / 429| P2[Groq / Whisper Turbo]
46
+ Chain -.->|On Failure| P3[Local whisper.cpp / Offline]
47
+ end
48
+
49
+ subgraph Output [Target]
50
+ P1 --> Composer[💬 Web Composer / Chat]
51
+ P2 --> Composer
52
+ P3 --> Composer
53
+ end
54
+
55
+ style Client fill:#1e1e2e,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4
56
+ style Host fill:#181825,stroke:#cba6f7,stroke-width:2px,color:#cdd6f4
57
+ style Output fill:#11111b,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4
25
58
  ```
26
59
 
27
- Restart the Web UI afterwards, then hard-refresh the browser.
60
+ ---
28
61
 
29
- ## Providers
62
+ ## ✨ Key Features
30
63
 
31
- | Key | Service | Default model | Credential |
32
- |---|---|---|---|
33
- | `browser` | the browser's own speech recognition | — | none, and nothing is uploaded to the host |
34
- | `deepgram` | Deepgram | `nova-2` | `DEEPGRAM_API_KEY` |
35
- | `groq` | Groq | `whisper-large-v3-turbo` | `GROQ_API_KEY` |
36
- | `hf` | HuggingFace Inference | `openai/whisper-large-v3` | `HF_TOKEN` |
37
- | `local-whisper` | local whisper.cpp server | model given at server start | none, fully offline |
64
+ * 🎙️ **Streaming Dictation with VAD**: Speech is automatically sliced at natural pauses (`vadSilenceMs`, default 700ms) and typed into the composer in real time.
65
+ * 🌊 **Voice Notes with Cancel Window**: Record your thought and have it automatically dispatched to the agent after a safety countdown (`autoSendMs`, default 4000ms).
66
+ * 🎮 **Tactile Push-to-Talk**:
67
+ * **Mouse**: Hold the wave button releasing sends the message; dragging pointer away discards.
68
+ * **Keyboard**: Hold <kbd>Ctrl</kbd> (or custom hotkey) for hands-free speaking; press <kbd>Esc</kbd> to cancel.
69
+ * ⚡ **Zero-Latency In-Browser Captions (`browser`)**: Chrome Web Speech API recognition runs 100% locally with live floating captions as you speak.
70
+ * 🛡️ **Ironclad Multi-Provider Fallbacks**: If your primary cloud provider runs out of credits or hits a 429 rate limit, requests seamlessly fail over down the chain.
71
+ * 🧠 **Context Glossary Injection**: Automatically extracts code variables and identifiers from your composer draft to steer STT model accuracy on technical jargon.
72
+ * 🎵 **Embedded Audio Player**: Preview, scrubber, and playback of your recorded voice message directly in chat and the composer dock.
73
+ * 🔇 **Hardware Noise Suppression Toggle**: Configurable in settings to toggle browser-level noise suppression, echo cancellation, and auto gain control.
74
+ * 📊 **Provider Latency & Health Dashboard**: Live visual telemetry of provider latency (ms), success rates, and errors directly within the settings UI.
75
+ * 🔒 **Zero API Key Leakage**: Keys are resolved on the host via `ctx.credentials` (`credentialRef`) and never transmitted to browser clients.
76
+ * 🖥️ **Offline Local Whisper Server**: Automatically boots and manages [whisper.cpp](https://github.com/ggerganov/whisper.cpp) (`whisper-server`) with on-the-fly `ffmpeg` transcode.
38
77
 
39
- Keys are read through the DSH credentials service (Settings → Credentials, or
40
- `$DSH_HOME/.credentials.yaml`), falling back to the process environment. A
41
- provider without a key is skipped, not fatal.
78
+ ---
42
79
 
43
- ### Ready-made providers
80
+ ## 🎮 Four Ways to Speak
44
81
 
45
- Six providers are filled in already put the name in a chain and add the key:
46
-
47
- | Name | Model | Credential |
82
+ | Mode | Gesture / Trigger | Behavior |
48
83
  |---|---|---|
49
- | `openai` | `whisper-1` | `OPENAI_API_KEY` |
50
- | `siliconflow` | `FunAudioLLM/SenseVoiceSmall` | `SILICONFLOW_API_KEY` |
51
- | `deepinfra` | `openai/whisper-large-v3-turbo` | `DEEPINFRA_API_KEY` |
52
- | `fireworks` | `whisper-v3-turbo` | `FIREWORKS_API_KEY` |
53
- | `mistral` | `voxtral-mini-latest` | `MISTRAL_API_KEY` |
54
- | `openrouter` | `google/gemini-2.5-flash` | `OPENROUTER_API_KEY` |
55
-
56
- ```yaml
57
- - id: dsh-voice
58
- config:
59
- message:
60
- chain:
61
- - provider: openai
62
- - provider: local-whisper
63
- ```
64
-
65
- Every endpoint was probed without a key before being written down: all six answered `401`, the answer of a path that exists and wants credentials. The model ids are starting points — override `model` in a chain row to change one.
84
+ | **Dictation** | Click <kbd>🎙️ Mic</kbd> | Speech is sliced on pauses (`vadSilenceMs`) and typed live into composer |
85
+ | **Voice Message** | Click <kbd>🌊 Wave</kbd> | Records until stopped, then sends after cancel window (`autoSendMs`) |
86
+ | **Mouse PTT** | Hold <kbd>🌊 Wave</kbd> | Records while held; release sends message, drag off button to discard |
87
+ | **Keyboard PTT** | Hold <kbd>Ctrl</kbd> | Hands-free recording; release sends message, press <kbd>Esc</kbd> to discard |
66
88
 
67
- A preset is the same form as a custom provider with the fields filled in, so a `customProviders` entry under the same name replaces it outright.
89
+ > [!TIP]
90
+ > You can customize the keyboard modifier in settings (`hotkey`: `Control`, `Alt`, `Shift`, or any `KeyboardEvent.code`).
68
91
 
69
- ### Your own providers
92
+ ---
70
93
 
71
- Any OpenAI-compatible API can be added as a provider and used in the chains
72
- next to the built-in ones. Two templates, because those APIs disagree on how
73
- audio is sent:
94
+ ## 🛠️ Supported Providers Matrix
74
95
 
75
- | Template | Endpoint | Request | Transcript read from |
76
- |---|---|---|---|
77
- | `openai-transcriptions` | `{baseURL}/audio/transcriptions` | multipart: file, model, language | `text` |
78
- | `openai-chat-audio` | `{baseURL}/chat/completions` | JSON with `input_audio`: base64 and format | `choices[0].message.content` |
96
+ | Provider Key | Service Backend | Default Model | Credential Ref | Features & Notes |
97
+ |---|---|---|---|---|
98
+ | `browser` | Web Speech API | Native Browser | *None* | Zero latency, floating live captions in Chrome |
99
+ | `deepgram` | Deepgram API | `nova-2` | `DEEPGRAM_API_KEY` | Ultra-fast cloud transcription |
100
+ | `groq` | Groq Whisper | `whisper-large-v3-turbo` | `GROQ_API_KEY` | Near-instant inference speed |
101
+ | `hf` | HuggingFace Inference | `openai/whisper-large-v3` | `HF_TOKEN` | High-accuracy open Whisper |
102
+ | `local-whisper` | Local whisper.cpp | Server defined | *None* | 100% private, offline, no internet needed |
79
103
 
80
- OpenRouter has no `/audio/transcriptions` endpoint at all — use the chat
81
- template there:
104
+ ### 🚀 Ready-Made Presets (Plug & Play)
82
105
 
83
- ```yaml
84
- - id: dsh-voice
85
- config:
86
- customProviders:
87
- - key: openrouter
88
- template: openai-chat-audio
89
- baseURL: https://openrouter.ai/api/v1
90
- model: google/gemini-2.5-flash
91
- keyEnv: OPENROUTER_API_KEY
92
- message:
93
- chain:
94
- - provider: openrouter
95
- - provider: local-whisper
96
- ```
106
+ Just specify the name in your fallback chain and add the corresponding API key:
107
+ * `openai` (`whisper-1`) `OPENAI_API_KEY`
108
+ * `siliconflow` (`SenseVoiceSmall`) → `SILICONFLOW_API_KEY`
109
+ * `mistral` (`voxtral-mini-latest`) → `MISTRAL_API_KEY`
110
+ * `openrouter` (`google/gemini-2.5-flash`) `OPENROUTER_API_KEY`
111
+ * `deepinfra` (`whisper-large-v3-turbo`) → `DEEPINFRA_API_KEY`
112
+ * `fireworks` (`whisper-v3-turbo`) → `FIREWORKS_API_KEY`
97
113
 
98
- Fields: `key` is the name the chains refer to (it cannot shadow a built-in
99
- one), `keyEnv` names the credential holding the API key (empty means no
100
- authorization header), and `prompt` overrides the instruction sent with the
101
- audio in the chat template. A row in a chain may still override `model`.
114
+ ---
102
115
 
103
- The chat template accepts WAV and MP3 only, while the browser records
104
- webm/opus — the plugin converts with ffmpeg, the same way the local whisper
105
- provider does, so **ffmpeg is required for `openai-chat-audio`**.
116
+ ## 📦 Quick Installation
106
117
 
107
- ## Three ways to speak
118
+ ```bash
119
+ dsh plugin --profile web add @goodandready/dsh-voice
120
+ ```
108
121
 
109
- | Gesture | What happens |
110
- |---|---|
111
- | Click the microphone | dictation: speech is cut on pauses and each phrase is appended to the composer |
112
- | Click the wave | a voice message: recording runs until you stop it, then the text is sent after a cancel window |
113
- | **Hold the wave** | records only while held; release sends it, moving the pointer off the button discards |
114
- | **Hold `Ctrl`** | the same without reaching for the mouse; `Escape` discards |
122
+ > [!IMPORTANT]
123
+ > Restart DSH Web UI after installation (`systemctl --user restart dsh-web`) and refresh your browser tab.
115
124
 
116
- The hotkey is `hotkey` in the settings — a modifier name (`Control`, `Alt`, `Shift`) or a `KeyboardEvent` code. Empty turns it off.
125
+ ---
117
126
 
118
- ## Recognition in the browser
127
+ ## ⚙️ Configuration
119
128
 
120
- Put `browser` first in a chain and speech is recognised by the browser itself: no key, no upload to this host, and the text appears **while you are still speaking** — an interim caption in the recording bar, with each finished phrase going into the composer.
129
+ Open **Settings Plugins Plugin settings Voice** in the Web UI:
121
130
 
122
131
  ```yaml
123
132
  - id: dsh-voice
124
133
  config:
125
134
  dictation:
135
+ language: ru
136
+ vadSilenceMs: 700
126
137
  chain:
127
- - provider: browser
128
- - provider: local-whisper # если браузер не умеет — обычный путь
129
- ```
130
-
131
- Two things to know before choosing it:
132
-
133
- - **Chrome sends the audio to Google.** Firefox has no such API at all. Everything else in this plugin keeps audio between your browser and your own host, so this provider is the one exception — it is never used unless you put it in a chain yourself.
134
- - It needs a secure context (HTTPS or localhost), like the microphone itself.
135
-
136
- Put a normal provider after it: if the browser cannot do it, recording falls back to the chain as usual.
137
-
138
- ## Configure (Web GUI)
139
-
140
- Settings → **Plugins → Plugin settings → Voice** — the plugin's own
141
- collapsible card in the plugins tab; the sidebar keeps no separate row for it.
142
- The card has four blocks:
143
-
144
- - **Dictation** — fallback chain (provider + optional model per row, order is
145
- the order of attempts), language, and the silence threshold that ends a
146
- phrase (`vadSilenceMs`, default 700 ms).
147
- - **Voice message** — its own independent chain, language, and the cancel
148
- window before the message is sent (`autoSendMs`, default 4000 ms).
149
- - **Your own providers** — an OpenAI-compatible API per card: name, template,
150
- base URL, model, credential name. The name becomes selectable in both chains
151
- as soon as it is filled in.
152
- - **General** — local whisper endpoint, binary, model, autostart, beep, localOnly,
153
- microphone, custom vocabulary, offline polish endpoint (`polishBaseUrl`/`polishModel`/`polishKeyEnv`).
154
- - **Voice message** also has `polishSend` (polish the whole draft before sending) and
155
- `sessionCommands` ("send", "cancel", "stop", "continue" act on the session instead of text).
156
- - **Dictation** also has a wake word: browser recognition starts recording when speech
157
- begins with that phrase (empty disables it).
158
-
159
- Speed matters for dictation and accuracy for messages, which is why the chains
160
- are separate: a sensible pair is Deepgram → Groq → local for dictation and
161
- Groq → HuggingFace → local for messages.
162
-
163
- ## Local whisper.cpp
164
-
165
- The local provider needs a running [whisper.cpp](https://github.com/ggerganov/whisper.cpp)
166
- server:
167
-
168
- ```bash
169
- whisper-server -m /path/to/ggml-medium-q8_0.bin --host 127.0.0.1 --port 8001
138
+ - provider: deepgram
139
+ - provider: groq
140
+ - provider: local-whisper
141
+ message:
142
+ language: ru
143
+ autoSendMs: 4000
144
+ chain:
145
+ - provider: openai
146
+ - provider: local-whisper
147
+ hotkey: Control
148
+ autoStart: true
149
+ whisperModel: /models/ggml-medium-q8_0.bin
170
150
  ```
171
151
 
172
- Set `whisperModel` (and `whisperBin` if it is not in `PATH`) and the plugin
173
- launches the server itself when `autoStart` is on. While `whisperModel` is
174
- empty, autostart stays off.
175
-
176
- **ffmpeg is required for this provider.** whisper.cpp accepts WAV only and
177
- rejects the webm/opus the browser records, so the host converts each recording
178
- to 16 kHz mono WAV before forwarding it. Point `ffmpegBin` at your binary if it
179
- is not in `PATH`.
180
-
181
- ## Tool
152
+ ---
182
153
 
183
- The plugin also registers `transcribe_audio(file_path, language?)` for the
184
- agent, using the voice-message chain. Useful for recordings and interviews that
185
- are already files on disk.
186
-
187
- ## Routes
188
-
189
- | Route | Purpose |
190
- |---|---|
191
- | `POST /dsh-voice/transcribe` | `{dataBase64, mimeType, mode}` → `{ok, text, provider, tookMs}` |
192
- | `GET /dsh-voice/status` | whisper server state and the effective chains |
193
-
194
- ## Structure
195
-
196
- ```
197
- lib/index.js host: config, routes, transcribe_audio, whisper autostart
198
- lib/providers.js the four providers, pure functions (network injected)
199
- lib/chain.js fallback walk over a chain
200
- lib/wav.js webm/opus → WAV 16 kHz mono via ffmpeg
201
- lib/client.js browser: composer buttons, recording, settings page
202
- test/ node --test units for the chain and the providers
203
- ```
154
+ ## 🤖 Agent Tool & HTTP API
204
155
 
205
- Run the tests with `npm test` (no dependencies, Node's built-in runner).
156
+ ### Agent Tool (`transcribe_audio`)
157
+ Registers `transcribe_audio(file_path, language?)` in `ctx.tools`, allowing agents to analyze audio files, interview recordings, and voice notes directly from disk.
206
158
 
207
- ## Requirements
159
+ ### Internal HTTP Endpoints
160
+ * `POST /dsh-voice/transcribe` — `{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
161
+ * `GET /dsh-voice/status` — Returns whisper daemon status and active fallback chains.
208
162
 
209
- - DeepSeek Harness with the Web GUI
210
- - Node 20+
211
- - ffmpeg, for the local whisper provider
212
- - a microphone reachable from the browser (HTTPS or localhost)
163
+ ---
213
164
 
214
- ## License
165
+ ## 📄 License
215
166
 
216
- MIT
167
+ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
package/README.ru.md ADDED
@@ -0,0 +1,167 @@
1
+ # 📦 @goodandready/dsh-voice
2
+
3
+ <div align="center">
4
+
5
+ <h3>Потоковая диктовка без задержек и мультиязычный голосовой ввод для DeepSeek Harness</h3>
6
+
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/@goodandready/dsh-voice"><img src="https://img.shields.io/npm/v/@goodandready/dsh-voice.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-10b981.svg?style=for-the-badge&color=10b981&labelColor=064e3b" alt="license"></a>
10
+ <a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-Plugin-8b5cf6.svg?style=for-the-badge&labelColor=2e1065" alt="DSH Plugin"></a>
11
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="https://goodandready.app/"><img src="https://img.shields.io/badge/Все_проекты_автора-goodandready.app-ff4500.svg?style=for-the-badge&logo=rocket&logoColor=white&labelColor=1a1a2e" alt="Все проекты автора"></a>
16
+ </p>
17
+
18
+ <p align="center">
19
+ <a href="README.md"><b>🇬🇧 English</b></a> •
20
+ <a href="README.ru.md"><b>🇷🇺 Русский</b></a> •
21
+ <a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
22
+ </p>
23
+
24
+ </div>
25
+
26
+ ---
27
+
28
+ ## ⚡ Обзор
29
+
30
+ **`dsh-voice`** добавляет полноценные голосовые возможности в веб-интерфейс **DeepSeek Harness**. Будь то непрерывная диктовка с нарезкой фраз по естественным паузам или голосовые сообщения с удобными жестами Push-to-Talk (мышь и клавиатура) — `dsh-voice` гарантирует сохранность каждой записи благодаря **автоматическим цепочкам отказоустойчивости**.
31
+
32
+ ```mermaid
33
+ graph LR
34
+ subgraph Client [Браузер Web UI]
35
+ Mic[🎙️ Микрофон диктовки] -->|Нарезка фраз VAD| Stream[Аудио-чанки]
36
+ Wave[🌊 Голосовое сообщение] -->|Зажатие / Отпускание| PTT[Push-to-Talk]
37
+ end
38
+
39
+ subgraph Host [Бэкенд DSH Host]
40
+ Stream --> FFMPEG[Транскодер ffmpeg 16кГц]
41
+ PTT --> FFMPEG
42
+ FFMPEG --> Chain{Цепочка фолбеков}
43
+
44
+ Chain -->|1-й приоритет| P1[Deepgram / Nova-2]
45
+ Chain -.->|При лимитах / 429| P2[Groq / Whisper Turbo]
46
+ Chain -.->|При сбое| P3[Локальный whisper.cpp]
47
+ end
48
+
49
+ subgraph Output [Результат]
50
+ P1 --> Composer[💬 Строка ввода чата]
51
+ P2 --> Composer
52
+ P3 --> Composer
53
+ end
54
+
55
+ style Client fill:#1e1e2e,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4
56
+ style Host fill:#181825,stroke:#cba6f7,stroke-width:2px,color:#cdd6f4
57
+ style Output fill:#11111b,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4
58
+ ```
59
+
60
+ ---
61
+
62
+ ## ✨ Ключевые возможности
63
+
64
+ * 🎙️ **Потоковая диктовка с VAD**: аудиопоток режется по естественным паузам речи (`vadSilenceMs`, по умолчанию 700 мс) и мгновенно печатается в поле ввода.
65
+ * 🌊 **Голосовые заметки с окном отмены**: запишите законченную мысль — сообщение автоматически уйдёт агенту по истечении таймера (`autoSendMs`, по умолчанию 4000 мс).
66
+ * 🎮 **Тактильный Push-to-Talk**:
67
+ * **Мышь**: зажмите кнопку волны — запись идёт пока кнопка зажата; отпускание отправляет, увод курсора отменяет запись.
68
+ * **Клавиатура**: зажмите <kbd>Ctrl</kbd> для записи без мыши; нажмите <kbd>Esc</kbd> для отмены.
69
+ * ⚡ **Субтитры браузера в реальном времени (`browser`)**: локальное распознавание Chrome Web Speech API без отправки звука на сервер с плавающими субтитрами.
70
+ * 🛡️ **Надёжные цепочки фолбеков**: при исчерпании квоты или ошибке 429 плагин мгновенно обращается к следующему провайдеру в списке.
71
+ * 🧠 **Контекстный словарь (Context Glossary)**: автоматическое извлечение переменных и технических терминов из черновика композера для точного распознавания редких слов и кода.
72
+ * 🎵 **Встроенный аудиоплеер**: предпросмотр и воспроизведение записанного голосового сообщения в чате и доке перед отправкой или для переслушивания.
73
+ * 🔇 **Аппаратное шумоподавление**: переключатель в настройках для включения/выключения браузерного шумоподавления, эхоподавления и АРУ.
74
+ * 📊 **Дашборд задержки и здоровья провайдеров**: мониторинг скорости ответа (мс), процента успешных транскрипций и ошибок в реальном времени.
75
+ * 🔒 **Безопасность API-ключей**: ключи читаются на сервере через `ctx.credentials` и никогда не попадают в браузер клиента.
76
+ * 🖥️ **Автозапуск локального whisper.cpp**: управление жизненным циклом `whisper-server` с авто-конвертацией через `ffmpeg`.
77
+
78
+ ---
79
+
80
+ ## 🎮 4 способа голосового ввода
81
+
82
+ | Режим | Жест / Активация | Поведение |
83
+ |---|---|---|
84
+ | **Диктовка** | Клик <kbd>🎙️ Микрофон</kbd> | Речь режется по паузам (`vadSilenceMs`) и вставляется прямо в строку ввода |
85
+ | **Голосовое сообщение** | Клик <kbd>🌊 Волна</kbd> | Запись до нажатия стоп, затем отправка с окном отмены (`autoSendMs`) |
86
+ | **PTT Мышью** | Зажатие <kbd>🌊 Волна</kbd> | Запись пока зажата кнопка; отпускание отправляет, увод мыши сбрасывает |
87
+ | **PTT Клавиатурой** | Зажатие <kbd>Ctrl</kbd> | Запись без мыши; отпускание отправляет, нажатие <kbd>Esc</kbd> отменяет |
88
+
89
+ > [!TIP]
90
+ > Клавиатурную клавишу можно легко переопределить в настройках (`hotkey`: `Control`, `Alt`, `Shift` или любой код клавиши).
91
+
92
+ ---
93
+
94
+ ## 🛠️ Матрица поддерживаемых провайдеров
95
+
96
+ | Ключ | Сервис | Модель по умолчанию | Переменная секрета | Особенности |
97
+ |---|---|---|---|---|
98
+ | `browser` | Web Speech API | Нативная в браузере | *Не требуется* | Нулевая задержка, живые субтитры в Chrome |
99
+ | `deepgram` | Deepgram API | `nova-2` | `DEEPGRAM_API_KEY` | Сверхбыстрая облачная транскрипция |
100
+ | `groq` | Groq Whisper | `whisper-large-v3-turbo` | `GROQ_API_KEY` | Мгновенная скорость генерации |
101
+ | `hf` | HuggingFace Inference | `openai/whisper-large-v3` | `HF_TOKEN` | Высокоточный облачный Whisper |
102
+ | `local-whisper` | Локальный whisper.cpp | из параметров сервера | *Не требуется* | 100% приватность, оффлайн, без интернета |
103
+
104
+ ### 🚀 Готовые пресеты (Plug & Play)
105
+
106
+ Достаточно указать имя в цепочке и добавить API-ключ:
107
+ * `openai` (`whisper-1`) → `OPENAI_API_KEY`
108
+ * `siliconflow` (`SenseVoiceSmall`) → `SILICONFLOW_API_KEY`
109
+ * `mistral` (`voxtral-mini-latest`) → `MISTRAL_API_KEY`
110
+ * `openrouter` (`google/gemini-2.5-flash`) → `OPENROUTER_API_KEY`
111
+ * `deepinfra` (`whisper-large-v3-turbo`) → `DEEPINFRA_API_KEY`
112
+ * `fireworks` (`whisper-v3-turbo`) → `FIREWORKS_API_KEY`
113
+
114
+ ---
115
+
116
+ ## 📦 Быстрая установка
117
+
118
+ ```bash
119
+ dsh plugin --profile web add @goodandready/dsh-voice
120
+ ```
121
+
122
+ > [!IMPORTANT]
123
+ > Перезапустите Web UI после установки (`systemctl --user restart dsh-web`) и обновите страницу в браузере.
124
+
125
+ ---
126
+
127
+ ## ⚙️ Настройка конфигурации
128
+
129
+ Откройте **Настройки → Плагины → Настройки плагинов → Голос** в Web UI:
130
+
131
+ ```yaml
132
+ - id: dsh-voice
133
+ config:
134
+ dictation:
135
+ language: ru
136
+ vadSilenceMs: 700
137
+ chain:
138
+ - provider: deepgram
139
+ - provider: groq
140
+ - provider: local-whisper
141
+ message:
142
+ language: ru
143
+ autoSendMs: 4000
144
+ chain:
145
+ - provider: openai
146
+ - provider: local-whisper
147
+ hotkey: Control
148
+ autoStart: true
149
+ whisperModel: /models/ggml-medium-q8_0.bin
150
+ ```
151
+
152
+ ---
153
+
154
+ ## 🤖 Инструмент агента и HTTP API
155
+
156
+ ### Инструмент агента (`transcribe_audio`)
157
+ Регистрирует `transcribe_audio(file_path, language?)` в `ctx.tools`, позволяя агентам распознавать аудиофайлы, интервью и записи с диска.
158
+
159
+ ### Внутренние HTTP эндпоинты
160
+ * `POST /dsh-voice/transcribe` — `{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
161
+ * `GET /dsh-voice/status` — возвращает состояние демона whisper и активные цепочки.
162
+
163
+ ---
164
+
165
+ ## 📄 Лицензия
166
+
167
+ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
package/README.zh.md ADDED
@@ -0,0 +1,113 @@
1
+ # 📦 @goodandready/dsh-voice
2
+
3
+ <div align="center">
4
+
5
+ <h3>DeepSeek Harness 零延迟流式语音听写与多服务商对讲输入插件</h3>
6
+
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/@goodandready/dsh-voice"><img src="https://img.shields.io/npm/v/@goodandready/dsh-voice.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-10b981.svg?style=for-the-badge&color=10b981&labelColor=064e3b" alt="license"></a>
10
+ <a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-Plugin-8b5cf6.svg?style=for-the-badge&labelColor=2e1065" alt="DSH Plugin"></a>
11
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="https://goodandready.app/"><img src="https://img.shields.io/badge/作者全部项目-goodandready.app-ff4500.svg?style=for-the-badge&logo=rocket&logoColor=white&labelColor=1a1a2e" alt="作者全部项目"></a>
16
+ </p>
17
+
18
+ <p align="center">
19
+ <a href="README.md"><b>🇬🇧 English</b></a> •
20
+ <a href="README.ru.md"><b>🇷🇺 Русский</b></a> •
21
+ <a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
22
+ </p>
23
+
24
+ </div>
25
+
26
+ ---
27
+
28
+ ## ⚡ 插件概览
29
+
30
+ **`dsh-voice`** 为 **DeepSeek Harness** Web 界面带来极速语音交互体验。无论是按自然停顿切分的流式听写,还是带撤回保护的语音消息以及键盘/鼠标 Push-to-Talk 对讲,`dsh-voice` 凭借**多服务商自动故障转移备用链**确保您的录音万无一失。
31
+
32
+ ```mermaid
33
+ graph LR
34
+ subgraph Client [前端 Web 浏览器]
35
+ Mic[🎙️ 听写麦克风] -->|VAD 停顿切分| Stream[音频数据切片]
36
+ Wave[🌊 语音消息] -->|长按 / 松开| PTT[Push-to-Talk]
37
+ end
38
+
39
+ subgraph Host [DSH 服务端 Host]
40
+ Stream --> FFMPEG[ffmpeg 16kHz 转码器]
41
+ PTT --> FFMPEG
42
+ FFMPEG --> Chain{备用链轮询}
43
+
44
+ Chain -->|首选优先级| P1[Deepgram / Nova-2]
45
+ Chain -.->|遭遇限流 / 429| P2[Groq / Whisper Turbo]
46
+ Chain -.->|异常故障时| P3[本地 whisper.cpp / 离线]
47
+ end
48
+
49
+ subgraph Output [输出目标]
50
+ P1 --> Composer[💬 聊天输入框]
51
+ P2 --> Composer
52
+ P3 --> Composer
53
+ end
54
+
55
+ style Client fill:#1e1e2e,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4
56
+ style Host fill:#181825,stroke:#cba6f7,stroke-width:2px,color:#cdd6f4
57
+ style Output fill:#11111b,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4
58
+ ```
59
+
60
+ ---
61
+
62
+ ## ✨ 核心亮点
63
+
64
+ * 🎙️ **VAD 智能流式听写**:说话停顿自动切句(`vadSilenceMs`,默认 700ms),文字实时追加至输入框。
65
+ * 🌊 **带撤回窗口的语音消息**:录制完整语音,转写后在倒计时(`autoSendMs`,默认 4000ms)结束后自动发送。
66
+ * 🎮 **沉浸式 Push-to-Talk 对讲**:
67
+ * **鼠标操作**:按住声波按钮开始录音,松开发送,拖离按钮取消。
68
+ * **键盘操作**:按住 <kbd>Ctrl</kbd> 无需鼠标即刻说话,按 <kbd>Esc</kbd> 放弃本次录音。
69
+ * ⚡ **浏览器本地零延迟同声字幕 (`browser`)**:Chrome Web Speech API 本地离线解析,说话同时浮动显示实时字幕。
70
+ * 🛡️ **多服务商自动容灾切换**:首选 API 额度耗尽或遭遇 429 限流时,毫秒级顺位切换备用引擎。
71
+ * 🧠 **上下文术语注入 (Context Glossary)**:自动从输入草稿中提取代码变量名与专业术语,引导 STT 模型精准转写专业词汇。
72
+ * 🎵 **内嵌音频播放器**:在输入框与录音浮层中随时试听和回放刚刚录制的原始音频片段。
73
+ * 🔇 **硬件降噪切换开关**:在插件设置中自由开关浏览器级降噪、回声消除与自动增益控制。
74
+ * 📊 **服务商延迟与健康监控看板**:在设置界面实时掌握每个语音引擎的延迟(毫秒)、成功率与调用状态。
75
+ * 🔒 **API 密钥安全隔离**:密钥由服务端 `ctx.credentials` 统一解析,绝不向浏览器前端泄漏。
76
+ * 🖥️ **本地 whisper.cpp 服务端直连**:自动管理 `whisper-server` 进程,结合 `ffmpeg` 实现实时音频转码。
77
+
78
+ ---
79
+
80
+ ## 🎮 四种语音输入方式
81
+
82
+ | 交互模式 | 触发手势 | 行为效果 |
83
+ |---|---|---|
84
+ | **流式听写** | 单击 <kbd>🎙️ 麦克风</kbd> | 按停顿切分语音 (`vadSilenceMs`),文本实时录入输入框 |
85
+ | **语音消息** | 单击 <kbd>🌊 声波</kbd> | 持续录音至手动停止,转写后进入撤回倒计时 (`autoSendMs`) |
86
+ | **鼠标对讲** | 长按 <kbd>🌊 声波</kbd> | 按住录音;松开发送,光标拖出按钮区域取消 |
87
+ | **键盘对讲** | 按住 <kbd>Ctrl</kbd> | 免鼠标快捷 Push-to-Talk 录音;按 <kbd>Esc</kbd> 取消 |
88
+
89
+ ---
90
+
91
+ ## 🛠️ 服务商矩阵
92
+
93
+ | 服务商标识 | 对应引擎 | 默认模型 | 环境变量凭据 | 特性说明 |
94
+ |---|---|---|---|---|
95
+ | `browser` | Web Speech API | 浏览器原生引擎 | *无需密钥* | 零延迟同声字幕输出 |
96
+ | `deepgram` | Deepgram API | `nova-2` | `DEEPGRAM_API_KEY` | 极速高精云端转写 |
97
+ | `groq` | Groq Whisper | `whisper-large-v3-turbo` | `GROQ_API_KEY` | 毫秒级极速推理 |
98
+ | `hf` | HuggingFace Inference | `openai/whisper-large-v3` | `HF_TOKEN` | 经典高精度开源模型 |
99
+ | `local-whisper` | 本地 whisper.cpp | 启动参数指定 | *无需密钥* | 100% 离线私密运行 |
100
+
101
+ ---
102
+
103
+ ## 📦 安装指南
104
+
105
+ ```bash
106
+ dsh plugin --profile web add @goodandready/dsh-voice
107
+ ```
108
+
109
+ ---
110
+
111
+ ## 📄 开源协议
112
+
113
+ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
package/lib/chain.js CHANGED
@@ -9,21 +9,36 @@ function normalizeError(err) {
9
9
  /**
10
10
  * @param order {string[]} порядок ключей провайдеров
11
11
  * @param providers {Record<string, () => Promise<{ok, provider?, text?, reason?}>>}
12
+ * @param onAttempt {(provider: string, res: {ok: boolean, tookMs: number, reason?: string}) => void} опциональный колбэк статистики
12
13
  * @returns {Promise<{provider: string, text: string, tookMs: number}>}
13
14
  */
14
- export async function runChain(order, providers) {
15
+ export async function runChain(order, providers, onAttempt) {
15
16
  const t0 = Date.now()
16
17
  const keys = (Array.isArray(order) ? order : []).filter((k) => typeof providers[k] === 'function')
17
18
  const errors = []
18
19
  for (const key of keys) {
20
+ const startT = Date.now()
19
21
  try {
20
22
  const out = await providers[key]()
23
+ const tookMs = Date.now() - startT
21
24
  if (out && out.ok) {
25
+ if (typeof onAttempt === 'function') {
26
+ onAttempt(out.provider || key, { ok: true, tookMs })
27
+ }
22
28
  return { provider: out.provider || key, text: out.text, tookMs: Date.now() - t0 }
23
29
  }
24
- errors.push(`${(out && out.provider) || key}: ${(out && out.reason) || 'unknown'}`)
30
+ const reason = (out && out.reason) || 'unknown'
31
+ if (typeof onAttempt === 'function') {
32
+ onAttempt((out && out.provider) || key, { ok: false, tookMs, reason })
33
+ }
34
+ errors.push(`${(out && out.provider) || key}: ${reason}`)
25
35
  } catch (err) {
26
- errors.push(`${key}: ${normalizeError(err)}`)
36
+ const tookMs = Date.now() - startT
37
+ const normErr = normalizeError(err)
38
+ if (typeof onAttempt === 'function') {
39
+ onAttempt(key, { ok: false, tookMs, reason: normErr })
40
+ }
41
+ errors.push(`${key}: ${normErr}`)
27
42
  }
28
43
  }
29
44
  throw new Error(`all providers failed (${errors.join('; ')})`)
package/lib/client.js CHANGED
@@ -130,6 +130,22 @@ window.__ModuleLoader__.load({
130
130
  'save': 'Save',
131
131
  'saved': 'Saved ✓',
132
132
  'openrouterWarning': 'OpenRouter has no /audio/transcriptions \u2014 use the openai-chat-audio template there',
133
+ 'noiseSuppression': 'Hardware noise suppression',
134
+ 'noiseSuppressionHint': 'Enable browser noise suppression, echo cancellation, and auto gain control',
135
+ 'contextGlossary': 'Context glossary injection',
136
+ 'contextGlossaryHint': 'Auto-extract code identifiers and terms from composer to improve STT accuracy',
137
+ 'providerDashboard': 'Provider Latency & Health',
138
+ 'avgLatency': 'Avg latency',
139
+ 'successRate': 'Success',
140
+ 'fast': 'Fast',
141
+ 'normal': 'Normal',
142
+ 'slow': 'Slow',
143
+ 'error': 'Error',
144
+ 'idle': 'No calls',
145
+ 'play': 'Play',
146
+ 'pause': 'Pause',
147
+ 'listenBack': 'Listen back',
148
+ 'lastRecording': 'Last voice note',
133
149
  }
134
150
  const ru = {
135
151
  'saveFailed': 'Часть полей не сохранилась —',
@@ -239,6 +255,22 @@ window.__ModuleLoader__.load({
239
255
  'save': 'Сохранить',
240
256
  'saved': 'Сохранено ✓',
241
257
  'openrouterWarning': 'У OpenRouter нет /audio/transcriptions \u2014 там нужен шаблон openai-chat-audio',
258
+ 'noiseSuppression': 'Аппаратное шумоподавление',
259
+ 'noiseSuppressionHint': 'Включить шумоподавление, эхоподавление и АРУ микрофона в браузере',
260
+ 'contextGlossary': 'Контекстный словарь терминов',
261
+ 'contextGlossaryHint': 'Авто-извлечение кода и терминов из композера для повышения точности STT',
262
+ 'providerDashboard': 'Задержка и здоровье провайдеров',
263
+ 'avgLatency': 'Ср. задержка',
264
+ 'successRate': 'Успешность',
265
+ 'fast': 'Быстро',
266
+ 'normal': 'Норма',
267
+ 'slow': 'Медленно',
268
+ 'error': 'Сбой',
269
+ 'idle': 'Нет вызовов',
270
+ 'play': 'Слушать',
271
+ 'pause': 'Пауза',
272
+ 'listenBack': 'Прослушать запись',
273
+ 'lastRecording': 'Последняя запись',
242
274
  }
243
275
 
244
276
  // Строки нужны и вне компонентов — в обработчиках записи, в подписях
@@ -259,7 +291,23 @@ window.__ModuleLoader__.load({
259
291
  '.dvo-err{color:var(--dsw-alias-state-error-primary)}' +
260
292
  '.dvo-count{font-variant-numeric:tabular-nums;font-size:13px;color:var(--dsw-alias-label-secondary)}' +
261
293
  '.dvo-spin{animation:dvo-spin 1s linear infinite}' +
262
- '@keyframes dvo-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}'
294
+ '@keyframes dvo-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
295
+ '.dvo-btn-active{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}' +
296
+ '.dvo-audio-wrap{display:flex;align-items:center;gap:8px;padding:2px 10px;background:var(--dsw-alias-bg-layer-2);border-radius:14px;border:1px solid var(--dsw-alias-border-l1)}' +
297
+ '.dvo-audio-play{display:flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-1);border:0;cursor:pointer;padding:0}' +
298
+ '.dvo-audio-play:hover{opacity:0.9}' +
299
+ '.dvo-audio-time{font-size:12px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}' +
300
+ '.dvo-dash{display:flex;flex-direction:column;gap:8px;margin-top:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:12px}' +
301
+ '.dvo-dash-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px;margin-top:4px}' +
302
+ '.dvo-dash-item{display:flex;flex-direction:column;gap:4px;padding:8px 10px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l1);border-radius:8px}' +
303
+ '.dvo-dash-name{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
304
+ '.dvo-dash-row{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--dsw-alias-label-secondary)}' +
305
+ '.dvo-badge{display:inline-flex;align-items:center;font-size:11px;padding:1px 6px;border-radius:6px;font-weight:600}' +
306
+ '.dvo-badge-fast{background:rgba(16,185,129,0.15);color:#10b981}' +
307
+ '.dvo-badge-norm{background:rgba(245,158,11,0.15);color:#f59e0b}' +
308
+ '.dvo-badge-slow{background:rgba(249,115,22,0.15);color:#f97316}' +
309
+ '.dvo-badge-err{background:rgba(239,68,68,0.15);color:#ef4444}' +
310
+ '.dvo-badge-idle{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary)}'
263
311
  const cssId = 'dsh-voice/client.module.css'
264
312
  if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="' + cssId + '"]')) {
265
313
  const tag = document.createElement('style')
@@ -277,9 +325,11 @@ window.__ModuleLoader__.load({
277
325
  rec: null,
278
326
  levels: [],
279
327
  pending: null, // {text, leftMs} — окно отмены режима message
328
+ lastNote: null, // {blob, url, mime, text} — последняя запись
329
+ showPlayer: false,
280
330
  inputActions: null,
281
331
  input: null,
282
- settings: { vadSilenceMs: 700, autoSendMs: 4000, stream: false, streamChunkMs: 1200, vadAdapt: 0 },
332
+ settings: { vadSilenceMs: 700, autoSendMs: 4000, stream: false, streamChunkMs: 1200, vadAdapt: 0, noiseSuppression: true, contextGlossary: true },
283
333
  listeners: new Set(),
284
334
  notify() { this.listeners.forEach((l) => l()) },
285
335
  set(patch) { Object.assign(this, patch); this.notify() },
@@ -294,6 +344,11 @@ window.__ModuleLoader__.load({
294
344
 
295
345
  // ---------------------------------------------------------------- icons
296
346
  const ic = { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' }
347
+ const playIcon = () => React.createElement('svg', Object.assign({}, ic, { viewBox: '0 0 24 24' }),
348
+ React.createElement('polygon', { points: '6 4 20 12 6 20 6 4', fill: 'currentColor', stroke: 'none' }))
349
+ const pauseIcon = () => React.createElement('svg', Object.assign({}, ic, { viewBox: '0 0 24 24' }),
350
+ React.createElement('rect', { x: 6, y: 4, width: 4, height: 16, fill: 'currentColor', stroke: 'none' }),
351
+ React.createElement('rect', { x: 14, y: 4, width: 4, height: 16, fill: 'currentColor', stroke: 'none' }))
297
352
  const micIcon = () => React.createElement('svg', ic,
298
353
  React.createElement('path', { d: 'M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z' }),
299
354
  React.createElement('path', { d: 'M19 10v2a7 7 0 0 1-14 0v-2' }),
@@ -329,12 +384,34 @@ window.__ModuleLoader__.load({
329
384
  })
330
385
  }
331
386
 
387
+ function extractContextKeywords() {
388
+ if (!voice.settings || voice.settings.contextGlossary === false) return []
389
+ const text = (voice.input && typeof voice.input.draft === 'string') ? voice.input.draft : ''
390
+ if (!text || text.length < 3) return []
391
+ const matches = text.match(/\b[A-Za-z_][A-Za-z0-9_]{2,29}\b/g) || []
392
+ const stop = new Set(['the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'any', 'can', 'her', 'was', 'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how', 'man', 'new', 'now', 'old', 'see', 'two', 'way', 'who', 'boy', 'did', 'its', 'let', 'put', 'say', 'she', 'too', 'use'])
393
+ const words = []
394
+ const seen = new Set()
395
+ for (const m of matches) {
396
+ const lower = m.toLowerCase()
397
+ if (!stop.has(lower) && !seen.has(lower)) {
398
+ seen.add(lower)
399
+ words.push(m)
400
+ if (words.length >= 30) break
401
+ }
402
+ }
403
+ return words
404
+ }
405
+
332
406
  async function sendAudio(blob, mime, mode) {
333
407
  const dataBase64 = await blobToBase64(blob)
408
+ const payload = { dataBase64, mimeType: mime, mode }
409
+ const contextWords = extractContextKeywords()
410
+ if (contextWords && contextWords.length > 0) payload.contextWords = contextWords
334
411
  const res = await fetch('/dsh-voice/transcribe', {
335
412
  method: 'POST',
336
413
  headers: { 'content-type': 'application/json' },
337
- body: JSON.stringify({ dataBase64, mimeType: mime, mode }),
414
+ body: JSON.stringify(payload),
338
415
  })
339
416
  let parsed = null
340
417
  try { parsed = await res.json() } catch (e) { /* не json */ }
@@ -554,8 +631,13 @@ window.__ModuleLoader__.load({
554
631
  if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
555
632
  throw new Error(t('micUnavailable'))
556
633
  }
557
- if (typeof MediaRecorder === 'undefined') throw new Error(t('noRecorder'))
558
- const audio = { channelCount: 1, echoCancellation: true, noiseSuppression: true }
634
+ const ns = voice.settings.noiseSuppression !== false
635
+ const audio = {
636
+ channelCount: 1,
637
+ echoCancellation: ns,
638
+ noiseSuppression: ns,
639
+ autoGainControl: ns,
640
+ }
559
641
  if (voice.settings.micDeviceId) audio.deviceId = { exact: voice.settings.micDeviceId }
560
642
  const stream = await navigator.mediaDevices.getUserMedia({ audio })
561
643
  let mimeType = 'audio/webm;codecs=opus'
@@ -809,6 +891,12 @@ window.__ModuleLoader__.load({
809
891
  teardown(rec)
810
892
  voice.rec = null
811
893
  if (blob.size < 1200) { voice.set({ phase: 'idle' }); return }
894
+ if (voice.lastNote && voice.lastNote.url) {
895
+ try { URL.revokeObjectURL(voice.lastNote.url) } catch (e) { /* игнор */ }
896
+ }
897
+ let noteUrl = ''
898
+ try { noteUrl = URL.createObjectURL(blob) } catch (e) { /* игнор */ }
899
+ voice.lastNote = { blob, url: noteUrl, mime: rec.mime, text: '' }
812
900
  voice.set({ phase: 'processing' })
813
901
  try {
814
902
  const out = await sendAudio(blob, rec.mime, mode)
@@ -816,8 +904,9 @@ window.__ModuleLoader__.load({
816
904
  const text = out.text || ''
817
905
  if (!text) { voice.set({ phase: 'error', error: t('nothingHeard') }); return }
818
906
  appendDraft(text)
907
+ if (voice.lastNote) voice.lastNote.text = text
819
908
  if (mode === 'message') {
820
- voice.set({ phase: 'pending', pending: { text: text, leftMs: voice.settings.autoSendMs } })
909
+ voice.set({ phase: 'pending', pending: { text: text, leftMs: voice.settings.autoSendMs, audioUrl: noteUrl } })
821
910
  } else {
822
911
  voice.set({ phase: 'idle' })
823
912
  }
@@ -899,12 +988,21 @@ window.__ModuleLoader__.load({
899
988
  onPointerUp: () => endHold(false),
900
989
  onPointerLeave: () => { if (hold.armed) endHold(true) },
901
990
  }, waveIcon()),
991
+ voice.lastNote && voice.lastNote.url
992
+ ? React.createElement('button', {
993
+ type: 'button', className: 'dvo-btn' + (voice.showPlayer ? ' dvo-btn-active' : ''),
994
+ title: t('listenBack'),
995
+ onClick: () => voice.set({ showPlayer: !voice.showPlayer }),
996
+ }, playIcon())
997
+ : null,
902
998
  )
903
999
  }
904
1000
 
905
1001
  function RecordPill(props) {
906
1002
  const v = useVoice()
907
1003
  const canvasRef = React.useRef(null)
1004
+ const audioRef = React.useRef(null)
1005
+ const [isPlaying, setIsPlaying] = React.useState(false)
908
1006
  voice.inputActions = props.inputActions
909
1007
  voice.input = props.input
910
1008
  const ctx = props.ctx
@@ -1002,7 +1100,39 @@ window.__ModuleLoader__.load({
1002
1100
  return () => dispose()
1003
1101
  }, [v.phase])
1004
1102
 
1005
- if (v.phase === 'idle') return null
1103
+ if (v.phase === 'idle') {
1104
+ if (!voice.showPlayer || !voice.lastNote || !voice.lastNote.url) return null
1105
+ return React.createElement('div', { className: 'dvo-pill' },
1106
+ React.createElement('div', { className: 'dvo-audio-wrap' },
1107
+ React.createElement('button', {
1108
+ type: 'button', className: 'dvo-audio-play',
1109
+ title: isPlaying ? t('pause') : t('play'),
1110
+ onClick: () => {
1111
+ const el = audioRef.current
1112
+ if (!el) return
1113
+ if (el.paused) { el.play().catch(() => {}); setIsPlaying(true) }
1114
+ else { el.pause(); setIsPlaying(false) }
1115
+ },
1116
+ }, isPlaying ? pauseIcon() : playIcon()),
1117
+ React.createElement('audio', {
1118
+ ref: audioRef, src: voice.lastNote.url,
1119
+ onEnded: () => setIsPlaying(false),
1120
+ onPause: () => setIsPlaying(false),
1121
+ onPlay: () => setIsPlaying(true),
1122
+ }),
1123
+ React.createElement('span', { className: 'dvo-audio-time' }, t('lastRecording')),
1124
+ ),
1125
+ React.createElement('span', { className: 'dvo-status' }, voice.lastNote.text || ''),
1126
+ React.createElement('button', {
1127
+ type: 'button', className: 'dvo-pbtn', title: t('hide'),
1128
+ onClick: () => {
1129
+ if (audioRef.current) audioRef.current.pause()
1130
+ setIsPlaying(false)
1131
+ voice.set({ showPlayer: false })
1132
+ },
1133
+ }, xIcon()),
1134
+ )
1135
+ }
1006
1136
 
1007
1137
  if (v.phase === 'recording') {
1008
1138
  const inBrowser = !!voice.browser
@@ -1049,6 +1179,27 @@ window.__ModuleLoader__.load({
1049
1179
  )
1050
1180
  }
1051
1181
  return React.createElement('div', { className: 'dvo-pill' },
1182
+ voice.lastNote && voice.lastNote.url
1183
+ ? React.createElement('div', { className: 'dvo-audio-wrap' },
1184
+ React.createElement('button', {
1185
+ type: 'button', className: 'dvo-audio-play',
1186
+ title: isPlaying ? t('pause') : t('play'),
1187
+ onClick: () => {
1188
+ const el = audioRef.current
1189
+ if (!el) return
1190
+ if (el.paused) { el.play().catch(() => {}); setIsPlaying(true) }
1191
+ else { el.pause(); setIsPlaying(false) }
1192
+ },
1193
+ }, isPlaying ? pauseIcon() : playIcon()),
1194
+ React.createElement('audio', {
1195
+ ref: audioRef, src: voice.lastNote.url,
1196
+ onEnded: () => setIsPlaying(false),
1197
+ onPause: () => setIsPlaying(false),
1198
+ onPlay: () => setIsPlaying(true),
1199
+ }),
1200
+ React.createElement('span', { className: 'dvo-audio-time' }, t('listenBack')),
1201
+ )
1202
+ : null,
1052
1203
  React.createElement('span', { className: 'dvo-status' }, t('sendingIn')),
1053
1204
  React.createElement('span', { className: 'dvo-count' }, left + t('secondsShort')),
1054
1205
  React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('keepPending'), onClick: keepPending }, xIcon()),
@@ -1092,6 +1243,8 @@ window.__ModuleLoader__.load({
1092
1243
  bargeIn: !!(data && data.bargeIn),
1093
1244
  polishSend: !!(data && data.modes && data.modes.message && data.modes.message.polishSend),
1094
1245
  sessionCommands: !!(data && data.modes && data.modes.message && data.modes.message.sessionCommands),
1246
+ noiseSuppression: data && data.noiseSuppression !== false,
1247
+ contextGlossary: data && data.contextGlossary !== false,
1095
1248
  })
1096
1249
  })
1097
1250
  .catch(() => { /* без подсказки хоста клавиши просто не будет */ })
@@ -1362,9 +1515,25 @@ window.__ModuleLoader__.load({
1362
1515
  polishSend: !!(value && value.message && value.message.polishSend),
1363
1516
  sessionCommands: !!(value && value.message && value.message.sessionCommands),
1364
1517
  polishBaseUrl: String((snap && snap.polishBaseUrl) || ''),
1518
+ noiseSuppression: value.noiseSuppression !== false,
1519
+ contextGlossary: value.contextGlossary !== false,
1365
1520
  })
1366
1521
  }, [ready, value, snap])
1367
1522
 
1523
+ const [statsData, setStatsData] = React.useState({})
1524
+ React.useEffect(() => {
1525
+ let alive = true
1526
+ fetch('/dsh-voice/status', { cache: 'no-store' })
1527
+ .then((r) => r.json())
1528
+ .then((data) => {
1529
+ if (alive && data && data.providerStats) {
1530
+ setStatsData(data.providerStats)
1531
+ }
1532
+ })
1533
+ .catch(() => {})
1534
+ return () => { alive = false }
1535
+ }, [])
1536
+
1368
1537
  if (!ready) {
1369
1538
  const waiting = !snap || snap.status === 'loading'
1370
1539
  return React.createElement('div', { className: 'dvs-wrap' },
@@ -1412,6 +1581,8 @@ window.__ModuleLoader__.load({
1412
1581
  stream: !!(draft.dictation && draft.dictation.stream),
1413
1582
  streamChunkMs: Number(draft.dictation && draft.dictation.streamChunkMs) || 1200,
1414
1583
  vadAdapt: Number(draft.dictation && draft.dictation.vadAdapt) || 0,
1584
+ noiseSuppression: draft.noiseSuppression !== false,
1585
+ contextGlossary: draft.contextGlossary !== false,
1415
1586
  })
1416
1587
  // Композер держит обработчик клавиши: пусть перечитает настройку,
1417
1588
  // иначе новая клавиша заработает только после перезагрузки страницы.
@@ -1582,6 +1753,16 @@ window.__ModuleLoader__.load({
1582
1753
  onChange: (e) => setTop('voiceCommands', e.target.checked),
1583
1754
  })),
1584
1755
  micField(),
1756
+ React.createElement('label', { className: 'dvs-field', title: t('noiseSuppressionHint') }, t('noiseSuppression'),
1757
+ React.createElement('input', {
1758
+ type: 'checkbox', checked: draft ? draft.noiseSuppression !== false : true, disabled: !writable,
1759
+ onChange: (e) => setTop('noiseSuppression', e.target.checked),
1760
+ })),
1761
+ React.createElement('label', { className: 'dvs-field', title: t('contextGlossaryHint') }, t('contextGlossary'),
1762
+ React.createElement('input', {
1763
+ type: 'checkbox', checked: draft ? draft.contextGlossary !== false : true, disabled: !writable,
1764
+ onChange: (e) => setTop('contextGlossary', e.target.checked),
1765
+ })),
1585
1766
  React.createElement('label', { className: 'dvs-field' }, t('vocabulary'),
1586
1767
  React.createElement('textarea', {
1587
1768
  rows: 3, disabled: !writable,
@@ -1604,6 +1785,48 @@ window.__ModuleLoader__.load({
1604
1785
  onChange: (e) => setTop('polishKeyEnv', e.target.value),
1605
1786
  })),
1606
1787
  ),
1788
+ React.createElement('div', { className: 'dvs-block' },
1789
+ React.createElement('div', { className: 'dvs-h' }, t('providerDashboard')),
1790
+ React.createElement('div', { className: 'dvo-dash' },
1791
+ React.createElement('div', { className: 'dvo-dash-grid' },
1792
+ Object.keys(statsData).length === 0
1793
+ ? React.createElement('div', { className: 'dvs-sub' }, t('idle'))
1794
+ : Object.entries(statsData).map(([key, item]) => {
1795
+ const avg = item.avgTookMs || 0
1796
+ const hasErrors = item.failures > 0
1797
+ let badgeClass = 'dvo-badge-idle'
1798
+ let badgeText = t('idle')
1799
+ if (item.attempts > 0) {
1800
+ if (hasErrors && item.successes === 0) {
1801
+ badgeClass = 'dvo-badge-err'
1802
+ badgeText = t('error')
1803
+ } else if (avg > 0 && avg < 400) {
1804
+ badgeClass = 'dvo-badge-fast'
1805
+ badgeText = avg + 'ms · ' + t('fast')
1806
+ } else if (avg >= 400 && avg <= 1500) {
1807
+ badgeClass = 'dvo-badge-norm'
1808
+ badgeText = avg + 'ms · ' + t('normal')
1809
+ } else {
1810
+ badgeClass = 'dvo-badge-slow'
1811
+ badgeText = avg + 'ms · ' + t('slow')
1812
+ }
1813
+ }
1814
+ const rate = item.attempts > 0 ? Math.round((item.successes / item.attempts) * 100) : 100
1815
+ return React.createElement('div', { key, className: 'dvo-dash-item', title: item.lastError ? `Error: ${item.lastError}` : '' },
1816
+ React.createElement('div', { className: 'dvo-dash-name' }, key),
1817
+ React.createElement('div', { className: 'dvo-dash-row' },
1818
+ React.createElement('span', { className: 'dvo-badge ' + badgeClass }, badgeText),
1819
+ React.createElement('span', null, rate + '%'),
1820
+ ),
1821
+ React.createElement('div', { className: 'dvo-dash-row' },
1822
+ React.createElement('span', null, `${item.successes}/${item.attempts}`),
1823
+ React.createElement('span', null, t('successRate')),
1824
+ ),
1825
+ )
1826
+ })
1827
+ )
1828
+ )
1829
+ ),
1607
1830
  React.createElement('div', { className: 'dvs-row' },
1608
1831
  React.createElement('button', { type: 'button', className: 'dvs-save', disabled: !writable, onClick: save }, t('save')),
1609
1832
  saved ? React.createElement('span', { className: 'dvs-ok' }, t('saved')) : null,
package/lib/index.js CHANGED
@@ -21,6 +21,8 @@ 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
23
  import { normalizePhrase } from './normalize.js'
24
+ import { createStatsTracker, mergeContextVocabulary } from './stats.js'
25
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
24
26
 
25
27
  function isAutoLang(lang) {
26
28
  return !lang || lang === 'auto' || String(lang).includes(',')
@@ -122,6 +124,10 @@ export const Config = z.object({
122
124
  .description('How many recent dictation inserts to keep for undo in the browser. 0 disables history.'),
123
125
  vocabulary: z.array(z.string()).default([])
124
126
  .description('Custom words (names, terms) hinted to providers so they recognize them correctly.'),
127
+ contextGlossary: z.boolean().default(true)
128
+ .description('Automatically extract code words and terms from context and hint them to STT providers.'),
129
+ noiseSuppression: z.boolean().default(true)
130
+ .description('Enable hardware noise suppression, echo cancellation, and auto gain control.'),
125
131
  voiceCommands: z.boolean().default(false)
126
132
  .description('During dictation, spoken edit commands ("new line", "paragraph") become real line breaks instead of words.'),
127
133
  wakeWord: z.string().default('')
@@ -134,6 +140,10 @@ export const Config = z.object({
134
140
  .description('Offline polish: model id on polishBaseUrl.'),
135
141
  polishKeyEnv: z.string().default('')
136
142
  .description('Offline polish: credential name for the api key. Empty means no Authorization header.'),
143
+ polishProvider: z.string().default('')
144
+ .description('Harness polish: provider id. Empty uses the agent default model selection.'),
145
+ polishModelId: z.string().default('')
146
+ .description('Harness polish: model id. Empty uses the agent default model selection.'),
137
147
  })
138
148
 
139
149
  const MIME_BY_EXT = {
@@ -222,8 +232,11 @@ export function apply(ctx, baseConfig) {
222
232
 
223
233
  startWhisper().catch(() => {})
224
234
 
235
+ // Статистика здоровья и задержек провайдеров (Latency & Health Dashboard).
236
+ const statsTracker = createStatsTracker()
237
+
225
238
  // Общий путь распознавания: собрать провайдеров по цепочке режима и пройти её.
226
- async function transcribe(modeCfg, bytes, mime, signal) {
239
+ async function transcribe(modeCfg, bytes, mime, signal, contextWords) {
227
240
  const cfg = live()
228
241
  const customKeys = (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
229
242
  .map((c) => String(c && c.key || '').trim()).filter(Boolean)
@@ -243,11 +256,16 @@ export function apply(ctx, baseConfig) {
243
256
  if (cfg.localOnly && order.length === 0) {
244
257
  throw new Error('localOnly mode is on, but local-whisper is not in the chain')
245
258
  }
259
+
260
+ const vocab = cfg.contextGlossary
261
+ ? mergeContextVocabulary(cfg.vocabulary, contextWords)
262
+ : (Array.isArray(cfg.vocabulary) ? cfg.vocabulary : [])
263
+
246
264
  const providers = makeProviders(
247
265
  { resolveKey, fetchImpl: fetch, cfg, toWav: (b) => toWav16k(b, cfg.ffmpegBin) },
248
- { bytes, mime, lang: modeCfg.language, signal, models, vocabulary: cfg.vocabulary },
266
+ { bytes, mime, lang: modeCfg.language, signal, models, vocabulary: vocab },
249
267
  )
250
- return runChain(order, providers)
268
+ return runChain(order, providers, statsTracker.record)
251
269
  }
252
270
 
253
271
  // Полировка транскрипта (#35) с поддержкой локального LLM (#47).
@@ -281,12 +299,24 @@ export function apply(ctx, baseConfig) {
281
299
  && data.choices[0].message.content
282
300
  return (pick && String(pick).trim()) || text
283
301
  }
284
- // Штатная модель харнесса.
302
+ // Штатная модель харнесса (DSH 0.1.2-alpha.1 API).
285
303
  const llm = ctx.llm
286
304
  if (!llm || typeof llm.stream !== 'function') return text
305
+ // provider/model обязательны в GenerateOptions; берём дефолт из
306
+ // agentDefaultModel, если не заданы явно.
307
+ const sel = (ctx.get && ctx.get('agentDefaultModel') && ctx.get('agentDefaultModel').currentSelection)
308
+ ? ctx.get('agentDefaultModel').currentSelection()
309
+ : null
310
+ const provider = cfg.polishProvider || (sel && sel.provider) || ''
311
+ const model = cfg.polishModelId || (sel && sel.model) || ''
312
+ if (!provider || !model) return text
287
313
  let acc = ''
288
- for await (const chunk of llm.stream({ messages: [{ role: 'user', content: ask }], signal })) {
289
- acc += (chunk && (chunk.text || (chunk.delta && chunk.delta.text))) || ''
314
+ for await (const chunk of llm.stream({
315
+ provider, model,
316
+ messages: [createUserMessage({ content: [{ type: 'text', text: ask }], source: { kind: 'user' } })],
317
+ signal,
318
+ })) {
319
+ if (chunk && chunk.type === 'text-delta' && typeof chunk.text === 'string') acc += chunk.text
290
320
  }
291
321
  const clean = acc.trim()
292
322
  return clean || text
@@ -347,6 +377,9 @@ export function apply(ctx, baseConfig) {
347
377
  wakeWord: String(cfg.wakeWord || ''),
348
378
  bargeIn: !!cfg.bargeIn,
349
379
  polishBaseUrl: String(cfg.polishBaseUrl || ''),
380
+ noiseSuppression: cfg.noiseSuppression !== false,
381
+ contextGlossary: cfg.contextGlossary !== false,
382
+ providerStats: getProviderStats(),
350
383
  })
351
384
  },
352
385
  }), 'dsh-voice: /status route')
@@ -412,7 +445,7 @@ export function apply(ctx, baseConfig) {
412
445
  const controller = new AbortController()
413
446
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
414
447
  try {
415
- const out = await transcribe(modeCfg, bytes, mime, controller.signal)
448
+ const out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
416
449
  // Голосовые команды сессии (#48): чистые «отправь/отмени/стоп/продолжи»
417
450
  // не становятся текстом, а возвращаются командой для браузера.
418
451
  if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
package/lib/stats.js ADDED
@@ -0,0 +1,72 @@
1
+ // dsh-voice — чистые утилиты метрик провайдеров и объединения контекстного словаря.
2
+ // Без cordis и без сети — тестируются юнит-тестами.
3
+
4
+ export function createStatsTracker() {
5
+ const stats = new Map()
6
+
7
+ function record(key, res = {}) {
8
+ if (!key) return
9
+ const cur = stats.get(key) || {
10
+ attempts: 0,
11
+ successes: 0,
12
+ failures: 0,
13
+ totalTookMs: 0,
14
+ avgTookMs: 0,
15
+ lastTookMs: 0,
16
+ lastError: '',
17
+ lastSuccessAt: 0,
18
+ lastErrorAt: 0,
19
+ }
20
+ cur.attempts += 1
21
+ cur.lastTookMs = res.tookMs || 0
22
+ if (res.ok) {
23
+ cur.successes += 1
24
+ cur.totalTookMs += res.tookMs || 0
25
+ cur.avgTookMs = Math.round(cur.totalTookMs / cur.successes)
26
+ cur.lastSuccessAt = Date.now()
27
+ } else {
28
+ cur.failures += 1
29
+ cur.lastError = String(res.reason || 'unknown').slice(0, 150)
30
+ cur.lastErrorAt = Date.now()
31
+ }
32
+ stats.set(key, cur)
33
+ }
34
+
35
+ function get() {
36
+ const out = {}
37
+ for (const [k, v] of stats.entries()) {
38
+ out[k] = {
39
+ attempts: v.attempts,
40
+ successes: v.successes,
41
+ failures: v.failures,
42
+ avgTookMs: v.avgTookMs || v.lastTookMs || 0,
43
+ lastTookMs: v.lastTookMs || 0,
44
+ lastError: v.lastError,
45
+ lastSuccessAt: v.lastSuccessAt,
46
+ lastErrorAt: v.lastErrorAt,
47
+ }
48
+ }
49
+ return out
50
+ }
51
+
52
+ function clear() {
53
+ stats.clear()
54
+ }
55
+
56
+ return { record, get, clear }
57
+ }
58
+
59
+ export function mergeContextVocabulary(baseVocab = [], contextWords = [], maxWords = 50) {
60
+ const vocab = Array.isArray(baseVocab) ? [...baseVocab] : []
61
+ if (!Array.isArray(contextWords) || contextWords.length === 0) return vocab
62
+ const seen = new Set(vocab.map((w) => String(w).toLowerCase()))
63
+ for (const word of contextWords) {
64
+ const clean = String(word || '').trim()
65
+ if (clean && clean.length >= 2 && !seen.has(clean.toLowerCase())) {
66
+ seen.add(clean.toLowerCase())
67
+ vocab.push(clean)
68
+ if (vocab.length >= maxWords) break
69
+ }
70
+ }
71
+ return vocab
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.8.8",
3
+ "version": "0.8.10",
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",
@@ -58,6 +58,7 @@
58
58
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
59
59
  "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
60
60
  "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
61
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
61
62
  "@deepseek-ai/schemastery": "^3.18.1"
62
63
  }
63
- }
64
+ }