@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 +1 -1
- package/README.md +132 -181
- package/README.ru.md +167 -0
- package/README.zh.md +113 -0
- package/lib/chain.js +18 -3
- package/lib/client.js +230 -7
- package/lib/index.js +40 -7
- package/lib/stats.js +72 -0
- package/package.json +3 -2
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,216 +1,167 @@
|
|
|
1
|
-
# dsh-voice
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
60
|
+
---
|
|
28
61
|
|
|
29
|
-
##
|
|
62
|
+
## ✨ Key Features
|
|
30
63
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
+
## 🎮 Four Ways to Speak
|
|
44
81
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
| Name | Model | Credential |
|
|
82
|
+
| Mode | Gesture / Trigger | Behavior |
|
|
48
83
|
|---|---|---|
|
|
49
|
-
|
|
|
50
|
-
|
|
|
51
|
-
|
|
|
52
|
-
|
|
|
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
|
-
|
|
89
|
+
> [!TIP]
|
|
90
|
+
> You can customize the keyboard modifier in settings (`hotkey`: `Control`, `Alt`, `Shift`, or any `KeyboardEvent.code`).
|
|
68
91
|
|
|
69
|
-
|
|
92
|
+
---
|
|
70
93
|
|
|
71
|
-
|
|
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
|
-
|
|
|
76
|
-
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
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
|
-
|
|
81
|
-
template there:
|
|
104
|
+
### 🚀 Ready-Made Presets (Plug & Play)
|
|
82
105
|
|
|
83
|
-
|
|
84
|
-
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
+
```bash
|
|
119
|
+
dsh plugin --profile web add @goodandready/dsh-voice
|
|
120
|
+
```
|
|
108
121
|
|
|
109
|
-
|
|
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
|
-
|
|
125
|
+
---
|
|
117
126
|
|
|
118
|
-
##
|
|
127
|
+
## ⚙️ Configuration
|
|
119
128
|
|
|
120
|
-
|
|
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:
|
|
128
|
-
- provider:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
558
|
-
const audio = {
|
|
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')
|
|
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:
|
|
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({
|
|
289
|
-
|
|
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.
|
|
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
|
+
}
|