@goodandready/dsh-voice 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -0
- package/lib/client.js +252 -8
- package/lib/index.js +14 -5
- package/lib/providers.js +81 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ Restart the Web UI afterwards, then hard-refresh the browser.
|
|
|
30
30
|
|
|
31
31
|
| Key | Service | Default model | Credential |
|
|
32
32
|
|---|---|---|---|
|
|
33
|
+
| `browser` | the browser's own speech recognition | — | none, and nothing is uploaded to the host |
|
|
33
34
|
| `deepgram` | Deepgram | `nova-2` | `DEEPGRAM_API_KEY` |
|
|
34
35
|
| `groq` | Groq | `whisper-large-v3-turbo` | `GROQ_API_KEY` |
|
|
35
36
|
| `hf` | HuggingFace Inference | `openai/whisper-large-v3` | `HF_TOKEN` |
|
|
@@ -39,6 +40,32 @@ Keys are read through the DSH credentials service (Settings → Credentials, or
|
|
|
39
40
|
`$DSH_HOME/.credentials.yaml`), falling back to the process environment. A
|
|
40
41
|
provider without a key is skipped, not fatal.
|
|
41
42
|
|
|
43
|
+
### Ready-made providers
|
|
44
|
+
|
|
45
|
+
Six providers are filled in already — put the name in a chain and add the key:
|
|
46
|
+
|
|
47
|
+
| Name | Model | Credential |
|
|
48
|
+
|---|---|---|
|
|
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.
|
|
66
|
+
|
|
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.
|
|
68
|
+
|
|
42
69
|
### Your own providers
|
|
43
70
|
|
|
44
71
|
Any OpenAI-compatible API can be added as a provider and used in the chains
|
|
@@ -77,6 +104,37 @@ The chat template accepts WAV and MP3 only, while the browser records
|
|
|
77
104
|
webm/opus — the plugin converts with ffmpeg, the same way the local whisper
|
|
78
105
|
provider does, so **ffmpeg is required for `openai-chat-audio`**.
|
|
79
106
|
|
|
107
|
+
## Three ways to speak
|
|
108
|
+
|
|
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 |
|
|
115
|
+
|
|
116
|
+
The hotkey is `hotkey` in the settings — a modifier name (`Control`, `Alt`, `Shift`) or a `KeyboardEvent` code. Empty turns it off.
|
|
117
|
+
|
|
118
|
+
## Recognition in the browser
|
|
119
|
+
|
|
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.
|
|
121
|
+
|
|
122
|
+
```yaml
|
|
123
|
+
- id: dsh-voice
|
|
124
|
+
config:
|
|
125
|
+
dictation:
|
|
126
|
+
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
|
+
|
|
80
138
|
## Configure (Web GUI)
|
|
81
139
|
|
|
82
140
|
Settings → **Голос** (Voice) has four blocks:
|
package/lib/client.js
CHANGED
|
@@ -125,6 +125,87 @@ window.__ModuleLoader__.load({
|
|
|
125
125
|
actions.setDraft(draft ? draft + ' ' + text : text)
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
// ------------------------------------------------- распознавание в браузере
|
|
129
|
+
//
|
|
130
|
+
// Отдельная нога, не похожая на все остальные: речь распознаёт сам браузер,
|
|
131
|
+
// на хост ничего не уходит, ключи не нужны, а текст появляется по словам
|
|
132
|
+
// прямо во время речи.
|
|
133
|
+
//
|
|
134
|
+
// Плата за это: в Chrome звук уходит на серверы Google. Поэтому провайдер
|
|
135
|
+
// никогда не включается сам — только если его прямо поставили в цепочку.
|
|
136
|
+
function speechRecognitionCtor() {
|
|
137
|
+
if (typeof window === 'undefined') return null
|
|
138
|
+
return window.SpeechRecognition || window.webkitSpeechRecognition || null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function browserRecognitionAvailable() {
|
|
142
|
+
return speechRecognitionCtor() !== null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param options {{lang: string, continuous: boolean, onInterim, onFinal, onError}}
|
|
147
|
+
* @returns {{stop: Function, abort: Function}}
|
|
148
|
+
*/
|
|
149
|
+
function startBrowserRecognition(options) {
|
|
150
|
+
const Ctor = speechRecognitionCtor()
|
|
151
|
+
const recognition = new Ctor()
|
|
152
|
+
recognition.lang = options.lang && options.lang !== 'auto' ? options.lang : 'ru-RU'
|
|
153
|
+
recognition.continuous = options.continuous !== false
|
|
154
|
+
recognition.interimResults = true
|
|
155
|
+
let stopped = false
|
|
156
|
+
|
|
157
|
+
recognition.onresult = (event) => {
|
|
158
|
+
let interim = ''
|
|
159
|
+
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
160
|
+
const result = event.results[i]
|
|
161
|
+
const text = String(result[0] && result[0].transcript || '').trim()
|
|
162
|
+
if (!text) continue
|
|
163
|
+
if (result.isFinal) options.onFinal(text)
|
|
164
|
+
else interim += (interim ? ' ' : '') + text
|
|
165
|
+
}
|
|
166
|
+
options.onInterim(interim)
|
|
167
|
+
}
|
|
168
|
+
recognition.onerror = (event) => {
|
|
169
|
+
// no-speech и aborted — обычная жизнь, а не поломка.
|
|
170
|
+
const code = event && event.error
|
|
171
|
+
if (code === 'no-speech' || code === 'aborted') return
|
|
172
|
+
options.onError(code || 'ошибка распознавания')
|
|
173
|
+
}
|
|
174
|
+
// Браузер обрывает распознавание сам: на паузах, по таймауту. Пока нас не
|
|
175
|
+
// остановили — поднимаем заново, иначе диктовка молча умрёт на первой паузе.
|
|
176
|
+
recognition.onend = () => {
|
|
177
|
+
if (stopped) return
|
|
178
|
+
try { recognition.start() } catch (alreadyRunning) { /* уже поднято */ }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try { recognition.start() } catch (cannotStart) {
|
|
182
|
+
options.onError(String(cannotStart && cannotStart.message || cannotStart))
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
stop() { stopped = true; try { recognition.stop() } catch (already) { /* уже стоит */ } },
|
|
186
|
+
abort() { stopped = true; try { recognition.abort() } catch (already) { /* уже стоит */ } },
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Какие провайдеры стоят в цепочке режима — узнаём у хоста один раз.
|
|
191
|
+
// Нужно только чтобы понять, идти в браузер или писать файл.
|
|
192
|
+
let chainsPromise = null
|
|
193
|
+
function modeChain(mode) {
|
|
194
|
+
if (!chainsPromise) {
|
|
195
|
+
chainsPromise = fetch('/dsh-voice/status', { cache: 'no-store' })
|
|
196
|
+
.then((res) => res.json())
|
|
197
|
+
.then((data) => (data && data.modes) || {})
|
|
198
|
+
.catch(() => ({}))
|
|
199
|
+
}
|
|
200
|
+
return chainsPromise.then((modes) => {
|
|
201
|
+
const row = modes[mode] || {}
|
|
202
|
+
return {
|
|
203
|
+
chain: Array.isArray(row.chain) ? row.chain.map((e) => e && e.provider) : [],
|
|
204
|
+
language: row.language || 'ru',
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
128
209
|
// ------------------------------------------------------------ recording
|
|
129
210
|
function teardown(rec) {
|
|
130
211
|
if (!rec) return
|
|
@@ -206,18 +287,125 @@ window.__ModuleLoader__.load({
|
|
|
206
287
|
})
|
|
207
288
|
}
|
|
208
289
|
|
|
290
|
+
// Распознавание браузером вместо записи файла. Возвращает false, если
|
|
291
|
+
// браузер этого не умеет, — тогда идём обычным путём.
|
|
292
|
+
function startBrowserLeg(mode, language) {
|
|
293
|
+
if (!browserRecognitionAvailable()) return false
|
|
294
|
+
const finals = []
|
|
295
|
+
voice.caption = ''
|
|
296
|
+
voice.browser = startBrowserRecognition({
|
|
297
|
+
lang: language,
|
|
298
|
+
continuous: true,
|
|
299
|
+
onInterim: (text) => { voice.caption = text; voice.notify() },
|
|
300
|
+
onFinal: (text) => {
|
|
301
|
+
finals.push(text)
|
|
302
|
+
voice.caption = ''
|
|
303
|
+
// Диктовка дописывает сразу, голосовое копит до отпускания.
|
|
304
|
+
if (mode === 'dictation') appendDraft(text)
|
|
305
|
+
else voice.notify()
|
|
306
|
+
},
|
|
307
|
+
onError: (reason) => {
|
|
308
|
+
// Браузер отказал уже после старта — честно говорим об этом, а не
|
|
309
|
+
// делаем вид, что слушаем.
|
|
310
|
+
voice.browser = null
|
|
311
|
+
voice.set({ phase: 'error', error: 'Браузер не распознал: ' + reason })
|
|
312
|
+
},
|
|
313
|
+
})
|
|
314
|
+
voice.browserFinals = finals
|
|
315
|
+
voice.notify()
|
|
316
|
+
return true
|
|
317
|
+
}
|
|
318
|
+
|
|
209
319
|
function startRecording(mode) {
|
|
210
320
|
if (voice.phase !== 'idle' && voice.phase !== 'error') return
|
|
211
|
-
voice.set({ phase: 'recording', mode, error: '', levels: [] })
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
321
|
+
voice.set({ phase: 'recording', mode, error: '', levels: [], caption: '' })
|
|
322
|
+
modeChain(mode).then((info) => {
|
|
323
|
+
// Браузерная нога — только если её прямо поставили первой в цепочке.
|
|
324
|
+
if (info.chain[0] === 'browser' && startBrowserLeg(mode, info.language)) return
|
|
325
|
+
openMic(mode)
|
|
326
|
+
.then((rec) => { voice.rec = rec; voice.notify() })
|
|
327
|
+
.catch((err) => voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null }))
|
|
328
|
+
})
|
|
215
329
|
}
|
|
216
330
|
|
|
217
331
|
function startDictation() { startRecording('dictation') }
|
|
218
332
|
function startMessage() { startRecording('message') }
|
|
219
333
|
|
|
334
|
+
// --------------------------------------------------------- удержание
|
|
335
|
+
//
|
|
336
|
+
// Два жеста одной кнопкой: короткое нажатие включает запись до второго
|
|
337
|
+
// нажатия (как было), удержание пишет ровно пока держишь. Отпустил — ушло.
|
|
338
|
+
//
|
|
339
|
+
// Различаем по времени: если отпустили раньше порога — это клик.
|
|
340
|
+
const HOLD_THRESHOLD_MS = 350
|
|
341
|
+
|
|
342
|
+
const hold = { active: false, mode: null, startedAt: 0, armed: false }
|
|
343
|
+
|
|
344
|
+
function beginHold(mode) {
|
|
345
|
+
if (hold.armed || (voice.phase !== 'idle' && voice.phase !== 'error')) return
|
|
346
|
+
hold.armed = true
|
|
347
|
+
hold.mode = mode
|
|
348
|
+
hold.startedAt = Date.now()
|
|
349
|
+
hold.active = false
|
|
350
|
+
// Запись начинаем сразу: ждать порога — значит потерять первое слово.
|
|
351
|
+
startRecording(mode)
|
|
352
|
+
voice.holding = true
|
|
353
|
+
voice.notify()
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function endHold(cancelled) {
|
|
357
|
+
if (!hold.armed) return
|
|
358
|
+
const heldMs = Date.now() - hold.startedAt
|
|
359
|
+
hold.armed = false
|
|
360
|
+
hold.active = false
|
|
361
|
+
voice.holding = false
|
|
362
|
+
// Короткое нажатие — это клик: запись уже идёт, оставляем её включённой,
|
|
363
|
+
// остановит второе нажатие.
|
|
364
|
+
if (!cancelled && heldMs < HOLD_THRESHOLD_MS) { voice.notify(); return }
|
|
365
|
+
if (cancelled) cancelCurrent()
|
|
366
|
+
else stopCurrent()
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Горячая клавиша: держать её удобнее, чем целиться мышью. Пока клавиша
|
|
370
|
+
// зажата — идёт запись, Esc отменяет.
|
|
371
|
+
function hotkeyMatches(event, name) {
|
|
372
|
+
if (name === 'Control') return event.key === 'Control'
|
|
373
|
+
if (name === 'Alt') return event.key === 'Alt'
|
|
374
|
+
if (name === 'Shift') return event.key === 'Shift'
|
|
375
|
+
return event.code === name || event.key === name
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function installHotkey(ctx, keyName, mode) {
|
|
379
|
+
if (typeof document === 'undefined' || !keyName) return () => {}
|
|
380
|
+
const down = (event) => {
|
|
381
|
+
if (event.repeat) return
|
|
382
|
+
// В поле ввода горячая клавиша-модификатор не мешает: она сама по себе
|
|
383
|
+
// ничего не печатает. А вот обычную букву перехватывать нельзя.
|
|
384
|
+
if (hotkeyMatches(event, keyName)) beginHold(mode)
|
|
385
|
+
}
|
|
386
|
+
const up = (event) => {
|
|
387
|
+
if (hotkeyMatches(event, keyName)) endHold(false)
|
|
388
|
+
else if (event.key === 'Escape' && hold.armed) endHold(true)
|
|
389
|
+
}
|
|
390
|
+
const blur = () => { if (hold.armed) endHold(true) }
|
|
391
|
+
document.addEventListener('keydown', down, true)
|
|
392
|
+
document.addEventListener('keyup', up, true)
|
|
393
|
+
window.addEventListener('blur', blur)
|
|
394
|
+
return () => {
|
|
395
|
+
document.removeEventListener('keydown', down, true)
|
|
396
|
+
document.removeEventListener('keyup', up, true)
|
|
397
|
+
window.removeEventListener('blur', blur)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
220
401
|
function cancelCurrent() {
|
|
402
|
+
if (voice.browser) {
|
|
403
|
+
voice.browser.abort()
|
|
404
|
+
voice.browser = null
|
|
405
|
+
voice.browserFinals = null
|
|
406
|
+
voice.set({ phase: 'idle', error: '', caption: '' })
|
|
407
|
+
return
|
|
408
|
+
}
|
|
221
409
|
const rec = voice.rec
|
|
222
410
|
voice.pending = null
|
|
223
411
|
if (!rec) { voice.set({ phase: 'idle', error: '' }); return }
|
|
@@ -230,6 +418,23 @@ window.__ModuleLoader__.load({
|
|
|
230
418
|
// Останов по второму нажатию: диктовка досылает хвост, голосовое —
|
|
231
419
|
// отправляет всю запись и открывает окно отмены.
|
|
232
420
|
function stopCurrent() {
|
|
421
|
+
if (voice.browser) {
|
|
422
|
+
const mode = voice.mode
|
|
423
|
+
const said = (voice.browserFinals || []).join(' ').trim()
|
|
424
|
+
voice.browser.stop()
|
|
425
|
+
voice.browser = null
|
|
426
|
+
voice.browserFinals = null
|
|
427
|
+
voice.caption = ''
|
|
428
|
+
if (!said) { voice.set({ phase: 'idle' }); return }
|
|
429
|
+
if (mode === 'message') {
|
|
430
|
+
appendDraft(said)
|
|
431
|
+
voice.set({ phase: 'pending', pending: { text: said, leftMs: voice.settings.autoSendMs } })
|
|
432
|
+
} else {
|
|
433
|
+
// Диктовка дописывала по ходу — добавлять нечего.
|
|
434
|
+
voice.set({ phase: 'idle' })
|
|
435
|
+
}
|
|
436
|
+
return
|
|
437
|
+
}
|
|
233
438
|
const rec = voice.rec
|
|
234
439
|
if (!rec || rec.closing) return
|
|
235
440
|
rec.closing = true
|
|
@@ -285,7 +490,11 @@ window.__ModuleLoader__.load({
|
|
|
285
490
|
}, micIcon()),
|
|
286
491
|
React.createElement('button', {
|
|
287
492
|
type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
|
|
288
|
-
title: err ? v.error : 'Голосовое сообщение
|
|
493
|
+
title: err ? v.error : 'Голосовое сообщение — нажать или удерживать',
|
|
494
|
+
// Удержание: пишет, пока держишь; увёл курсор далеко вверх — отмена.
|
|
495
|
+
onPointerDown: (e) => { e.preventDefault(); beginHold('message') },
|
|
496
|
+
onPointerUp: () => endHold(false),
|
|
497
|
+
onPointerLeave: () => { if (hold.armed) endHold(true) },
|
|
289
498
|
}, waveIcon()),
|
|
290
499
|
)
|
|
291
500
|
}
|
|
@@ -361,11 +570,22 @@ window.__ModuleLoader__.load({
|
|
|
361
570
|
if (v.phase === 'idle') return null
|
|
362
571
|
|
|
363
572
|
if (v.phase === 'recording') {
|
|
573
|
+
const inBrowser = !!voice.browser
|
|
364
574
|
const hint = v.mode === 'dictation' ? 'Диктовка — текст дописывается в строку' : 'Запись голосового'
|
|
575
|
+
// Живая подпись: что слышно прямо сейчас. Пока браузер не выдал
|
|
576
|
+
// окончательный кусок, текст черновой и меняется на глазах.
|
|
577
|
+
const caption = voice.caption || (inBrowser ? '' : null)
|
|
365
578
|
return React.createElement('div', { className: 'dvo-pill' },
|
|
366
579
|
React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: 'Отмена', onClick: cancelCurrent }, xIcon()),
|
|
367
|
-
|
|
368
|
-
|
|
580
|
+
inBrowser
|
|
581
|
+
? null
|
|
582
|
+
: React.createElement('canvas', { className: 'dvo-wave', ref: canvasRef, width: 720, height: 40 }),
|
|
583
|
+
React.createElement('span', { className: 'dvo-status' },
|
|
584
|
+
caption
|
|
585
|
+
? caption
|
|
586
|
+
: (voice.holding
|
|
587
|
+
? 'Держите — отпустите, чтобы отправить'
|
|
588
|
+
: (inBrowser ? 'Слушаю в браузере…' : hint))),
|
|
369
589
|
React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: 'Стоп', onClick: stopCurrent }, stopIcon()),
|
|
370
590
|
)
|
|
371
591
|
}
|
|
@@ -392,6 +612,19 @@ window.__ModuleLoader__.load({
|
|
|
392
612
|
|
|
393
613
|
// --------------------------------------------------------------- slots
|
|
394
614
|
function registerComposer(ctx) {
|
|
615
|
+
// Горячая клавиша живёт всё время, пока плагин применён.
|
|
616
|
+
ctx.effect(() => {
|
|
617
|
+
let dispose = () => {}
|
|
618
|
+
fetch('/dsh-voice/status', { cache: 'no-store' })
|
|
619
|
+
.then((res) => res.json())
|
|
620
|
+
.then((data) => {
|
|
621
|
+
const key = data && data.hotkey
|
|
622
|
+
if (key) dispose = installHotkey(ctx, key, 'message')
|
|
623
|
+
})
|
|
624
|
+
.catch(() => { /* без подсказки хоста горячей клавиши просто не будет */ })
|
|
625
|
+
return () => dispose()
|
|
626
|
+
}, 'dsh-voice: горячая клавиша удержания')
|
|
627
|
+
|
|
395
628
|
ctx.slots.inject('conversation.input.right', () => ctx.slots.register(
|
|
396
629
|
{ name: 'conversation.input.right', id: '@goodandready/dsh-voice', order: 6, label: () => 'Голос' },
|
|
397
630
|
(props) => React.createElement(VoiceButtons, { input: props.input, inputActions: props.inputActions }),
|
|
@@ -403,9 +636,20 @@ window.__ModuleLoader__.load({
|
|
|
403
636
|
}
|
|
404
637
|
|
|
405
638
|
// ------------------------------------------------------- settings page
|
|
406
|
-
const BUILTIN = [
|
|
639
|
+
const BUILTIN = [
|
|
640
|
+
'browser', 'deepgram', 'groq', 'hf', 'local-whisper',
|
|
641
|
+
// Заготовки: адрес и модель уже прописаны на хосте, нужен только ключ.
|
|
642
|
+
'openai', 'siliconflow', 'deepinfra', 'fireworks', 'mistral', 'openrouter',
|
|
643
|
+
]
|
|
407
644
|
const TEMPLATES = ['openai-transcriptions', 'openai-chat-audio']
|
|
408
645
|
const MODEL_HINT = {
|
|
646
|
+
browser: 'распознаёт сам браузер, ключ не нужен',
|
|
647
|
+
openai: 'whisper-1 · ключ OPENAI_API_KEY',
|
|
648
|
+
siliconflow: 'FunAudioLLM/SenseVoiceSmall · ключ SILICONFLOW_API_KEY',
|
|
649
|
+
deepinfra: 'openai/whisper-large-v3-turbo · ключ DEEPINFRA_API_KEY',
|
|
650
|
+
fireworks: 'whisper-v3-turbo · ключ FIREWORKS_API_KEY',
|
|
651
|
+
mistral: 'voxtral-mini-latest · ключ MISTRAL_API_KEY',
|
|
652
|
+
openrouter: 'google/gemini-2.5-flash · ключ OPENROUTER_API_KEY',
|
|
409
653
|
deepgram: 'nova-2', groq: 'whisper-large-v3-turbo',
|
|
410
654
|
hf: 'openai/whisper-large-v3', 'local-whisper': 'задаётся при запуске сервера',
|
|
411
655
|
}
|
package/lib/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
|
18
18
|
import { readFile, stat } from 'node:fs/promises'
|
|
19
19
|
import path from 'node:path'
|
|
20
20
|
import { runChain } from './chain.js'
|
|
21
|
-
import { makeProviders, PROVIDER_KEYS, DEFAULT_MODELS, CUSTOM_TEMPLATES } from './providers.js'
|
|
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
|
|
|
24
24
|
export const name = 'dsh-voice'
|
|
@@ -26,8 +26,11 @@ export const inject = ['tools', 'credentials', 'webServer', 'shell', 'settings']
|
|
|
26
26
|
|
|
27
27
|
const ChainEntry = z.object({
|
|
28
28
|
provider: z.string().default('local-whisper')
|
|
29
|
-
.description(`Provider key
|
|
30
|
-
+
|
|
29
|
+
.description(`Provider key. Built in: ${PROVIDER_KEYS.join(', ')}. `
|
|
30
|
+
+ `Ready-made, just add the key: ${PRESET_KEYS.join(', ')}. `
|
|
31
|
+
+ 'Or the name of an entry from customProviders. '
|
|
32
|
+
+ '"browser" recognises speech in the page itself — no key, no upload to this host, '
|
|
33
|
+
+ 'text appears while you speak; put a normal provider after it as a fallback.'),
|
|
31
34
|
model: z.string().default('')
|
|
32
35
|
.description('Model override. Empty means the provider default.'),
|
|
33
36
|
})
|
|
@@ -66,6 +69,10 @@ export const Config = z.object({
|
|
|
66
69
|
autoSendMs: z.number().default(4000)
|
|
67
70
|
.description('Cancel window before the recognized text is sent to the agent.'),
|
|
68
71
|
}).default({}),
|
|
72
|
+
hotkey: z.string().default('Control')
|
|
73
|
+
.description('Hold this key anywhere in the page to record a voice message; release to send, '
|
|
74
|
+
+ 'Escape to discard. Modifier names (Control, Alt, Shift) or a KeyboardEvent code. '
|
|
75
|
+
+ 'Empty disables the hotkey.'),
|
|
69
76
|
customProviders: z.array(CustomProvider).default([])
|
|
70
77
|
.description('Own recognition providers, usable in both chains next to the built-in ones.'),
|
|
71
78
|
deepgramKeyEnv: z.string().default('DEEPGRAM_API_KEY'),
|
|
@@ -181,7 +188,7 @@ export function apply(ctx, baseConfig) {
|
|
|
181
188
|
const models = {}
|
|
182
189
|
const order = []
|
|
183
190
|
for (const entry of Array.isArray(modeCfg.chain) ? modeCfg.chain : []) {
|
|
184
|
-
if (!
|
|
191
|
+
if (!KNOWN_KEYS.includes(entry.provider) && !customKeys.includes(entry.provider)) continue
|
|
185
192
|
order.push(entry.provider)
|
|
186
193
|
// Для своего провайдера модель по умолчанию живёт в его описании,
|
|
187
194
|
// подставит makeProviders — здесь пусто означает «бери оттуда».
|
|
@@ -203,7 +210,9 @@ export function apply(ctx, baseConfig) {
|
|
|
203
210
|
writeJson(res, 200, {
|
|
204
211
|
ok: true,
|
|
205
212
|
whisperRunning: await whisperAlive(),
|
|
206
|
-
|
|
213
|
+
// Клавиша нужна браузерной половине: она вешает обработчик удержания.
|
|
214
|
+
hotkey: cfg.hotkey,
|
|
215
|
+
providers: KNOWN_KEYS.concat(
|
|
207
216
|
(Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
|
|
208
217
|
.map((c) => String(c && c.key || '').trim()).filter(Boolean),
|
|
209
218
|
),
|
package/lib/providers.js
CHANGED
|
@@ -2,13 +2,71 @@
|
|
|
2
2
|
// в настройках. Чистые функции: сеть приходит параметром (fetchImpl), ключи —
|
|
3
3
|
// через resolveKey, поэтому всё проверяется без реальных запросов.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// 'browser' стоит в этом списке, но работает не здесь: речь распознаёт сам
|
|
6
|
+
// браузер, до хоста звук не доходит. Ключ нужен, чтобы такую цепочку принимал
|
|
7
|
+
// и валидатор настроек, и перебор ниже — иначе цепочка ['browser', 'groq'] на
|
|
8
|
+
// хосте оборвалась бы на первом же шаге вместо перехода к groq.
|
|
9
|
+
export const PROVIDER_KEYS = ['browser', 'deepgram', 'groq', 'hf', 'local-whisper']
|
|
6
10
|
|
|
7
11
|
// Свой провайдер описывается одним из двух шаблонов, потому что
|
|
8
12
|
// OpenAI-совместимые API разошлись: у OpenRouter, например, нет
|
|
9
13
|
// /audio/transcriptions вовсе, и распознавание там идёт через чат.
|
|
10
14
|
export const CUSTOM_TEMPLATES = ['openai-transcriptions', 'openai-chat-audio']
|
|
11
15
|
|
|
16
|
+
// Готовые провайдеры: то же самое, что свой провайдер, только адрес, модель и
|
|
17
|
+
// имя ключа уже проставлены. Нужен лишь ключ.
|
|
18
|
+
//
|
|
19
|
+
// Каждый адрес проверен запросом без ключа: все отвечают 401 «дайте ключ», то
|
|
20
|
+
// есть путь существует. Модель — разумная отправная точка, её можно заменить
|
|
21
|
+
// в строке цепочки, не трогая остальное.
|
|
22
|
+
//
|
|
23
|
+
// Своим провайдером с тем же именем можно перекрыть любую заготовку целиком:
|
|
24
|
+
// это не встроенный движок, а всего лишь заранее заполненная анкета.
|
|
25
|
+
export const PRESET_PROVIDERS = {
|
|
26
|
+
openai: {
|
|
27
|
+
template: 'openai-transcriptions',
|
|
28
|
+
baseURL: 'https://api.openai.com/v1',
|
|
29
|
+
model: 'whisper-1',
|
|
30
|
+
keyEnv: 'OPENAI_API_KEY',
|
|
31
|
+
},
|
|
32
|
+
siliconflow: {
|
|
33
|
+
template: 'openai-transcriptions',
|
|
34
|
+
baseURL: 'https://api.siliconflow.cn/v1',
|
|
35
|
+
model: 'FunAudioLLM/SenseVoiceSmall',
|
|
36
|
+
keyEnv: 'SILICONFLOW_API_KEY',
|
|
37
|
+
},
|
|
38
|
+
deepinfra: {
|
|
39
|
+
template: 'openai-transcriptions',
|
|
40
|
+
baseURL: 'https://api.deepinfra.com/v1/openai',
|
|
41
|
+
model: 'openai/whisper-large-v3-turbo',
|
|
42
|
+
keyEnv: 'DEEPINFRA_API_KEY',
|
|
43
|
+
},
|
|
44
|
+
fireworks: {
|
|
45
|
+
template: 'openai-transcriptions',
|
|
46
|
+
baseURL: 'https://api.fireworks.ai/inference/v1',
|
|
47
|
+
model: 'whisper-v3-turbo',
|
|
48
|
+
keyEnv: 'FIREWORKS_API_KEY',
|
|
49
|
+
},
|
|
50
|
+
mistral: {
|
|
51
|
+
template: 'openai-transcriptions',
|
|
52
|
+
baseURL: 'https://api.mistral.ai/v1',
|
|
53
|
+
model: 'voxtral-mini-latest',
|
|
54
|
+
keyEnv: 'MISTRAL_API_KEY',
|
|
55
|
+
},
|
|
56
|
+
// У OpenRouter нет /audio/transcriptions вовсе — только через чат.
|
|
57
|
+
openrouter: {
|
|
58
|
+
template: 'openai-chat-audio',
|
|
59
|
+
baseURL: 'https://openrouter.ai/api/v1',
|
|
60
|
+
model: 'google/gemini-2.5-flash',
|
|
61
|
+
keyEnv: 'OPENROUTER_API_KEY',
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const PRESET_KEYS = Object.keys(PRESET_PROVIDERS)
|
|
66
|
+
|
|
67
|
+
/** Все имена, которые можно ставить в цепочку без объявления своего провайдера. */
|
|
68
|
+
export const KNOWN_KEYS = PROVIDER_KEYS.concat(PRESET_KEYS)
|
|
69
|
+
|
|
12
70
|
const CHAT_AUDIO_PROMPT =
|
|
13
71
|
'Transcribe the audio verbatim. Reply with the transcript text only, '
|
|
14
72
|
+ 'without comments, quotes or formatting.'
|
|
@@ -210,12 +268,30 @@ export function makeProviders(deps, req) {
|
|
|
210
268
|
}
|
|
211
269
|
}
|
|
212
270
|
|
|
213
|
-
|
|
271
|
+
// Если звук всё-таки доехал до хоста с 'browser' в цепочке, значит
|
|
272
|
+
// браузерная нога не сработала: отказываем понятно и идём к следующему.
|
|
273
|
+
async function browser() {
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
provider: 'browser',
|
|
277
|
+
reason: 'browser: распознавание идёт в браузере, на хосте его нет',
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const out = { browser, deepgram, groq, hf, 'local-whisper': localWhisper }
|
|
282
|
+
|
|
283
|
+
// Заготовки: те же свои провайдеры, только анкета заполнена заранее.
|
|
284
|
+
for (const key of PRESET_KEYS) {
|
|
285
|
+
if (out[key]) continue
|
|
286
|
+
out[key] = customProvider({ ...PRESET_PROVIDERS[key], key })
|
|
287
|
+
}
|
|
288
|
+
|
|
214
289
|
for (const spec of Array.isArray(cfg.customProviders) ? cfg.customProviders : []) {
|
|
215
290
|
const key = String(spec && spec.key || '').trim()
|
|
216
|
-
// Встроенные не перекрываем:
|
|
217
|
-
// провайдера в чужой цепочке.
|
|
218
|
-
|
|
291
|
+
// Встроенные движки не перекрываем: опечатка в имени тихо подменила бы
|
|
292
|
+
// рабочего провайдера в чужой цепочке. А заготовку перекрыть можно — она
|
|
293
|
+
// для того и заготовка, чтобы её правили.
|
|
294
|
+
if (!key || PROVIDER_KEYS.includes(key)) continue
|
|
219
295
|
out[key] = customProvider({ ...spec, key })
|
|
220
296
|
}
|
|
221
297
|
return out
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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",
|