@goodandready/dsh-voice 0.4.1 → 0.5.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 +32 -0
- package/lib/client.js +250 -10
- package/lib/index.js +9 -1
- package/lib/providers.js +16 -2
- 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` |
|
|
@@ -77,6 +78,37 @@ The chat template accepts WAV and MP3 only, while the browser records
|
|
|
77
78
|
webm/opus — the plugin converts with ffmpeg, the same way the local whisper
|
|
78
79
|
provider does, so **ffmpeg is required for `openai-chat-audio`**.
|
|
79
80
|
|
|
81
|
+
## Three ways to speak
|
|
82
|
+
|
|
83
|
+
| Gesture | What happens |
|
|
84
|
+
|---|---|
|
|
85
|
+
| Click the microphone | dictation: speech is cut on pauses and each phrase is appended to the composer |
|
|
86
|
+
| Click the wave | a voice message: recording runs until you stop it, then the text is sent after a cancel window |
|
|
87
|
+
| **Hold the wave** | records only while held; release sends it, moving the pointer off the button discards |
|
|
88
|
+
| **Hold `Ctrl`** | the same without reaching for the mouse; `Escape` discards |
|
|
89
|
+
|
|
90
|
+
The hotkey is `hotkey` in the settings — a modifier name (`Control`, `Alt`, `Shift`) or a `KeyboardEvent` code. Empty turns it off.
|
|
91
|
+
|
|
92
|
+
## Recognition in the browser
|
|
93
|
+
|
|
94
|
+
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.
|
|
95
|
+
|
|
96
|
+
```yaml
|
|
97
|
+
- id: dsh-voice
|
|
98
|
+
config:
|
|
99
|
+
dictation:
|
|
100
|
+
chain:
|
|
101
|
+
- provider: browser
|
|
102
|
+
- provider: local-whisper # если браузер не умеет — обычный путь
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Two things to know before choosing it:
|
|
106
|
+
|
|
107
|
+
- **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.
|
|
108
|
+
- It needs a secure context (HTTPS or localhost), like the microphone itself.
|
|
109
|
+
|
|
110
|
+
Put a normal provider after it: if the browser cannot do it, recording falls back to the chain as usual.
|
|
111
|
+
|
|
80
112
|
## Configure (Web GUI)
|
|
81
113
|
|
|
82
114
|
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,10 @@ window.__ModuleLoader__.load({
|
|
|
403
636
|
}
|
|
404
637
|
|
|
405
638
|
// ------------------------------------------------------- settings page
|
|
406
|
-
const BUILTIN = ['deepgram', 'groq', 'hf', 'local-whisper']
|
|
639
|
+
const BUILTIN = ['browser', 'deepgram', 'groq', 'hf', 'local-whisper']
|
|
407
640
|
const TEMPLATES = ['openai-transcriptions', 'openai-chat-audio']
|
|
408
641
|
const MODEL_HINT = {
|
|
642
|
+
browser: 'распознаёт сам браузер, ключ не нужен',
|
|
409
643
|
deepgram: 'nova-2', groq: 'whisper-large-v3-turbo',
|
|
410
644
|
hf: 'openai/whisper-large-v3', 'local-whisper': 'задаётся при запуске сервера',
|
|
411
645
|
}
|
|
@@ -528,7 +762,13 @@ window.__ModuleLoader__.load({
|
|
|
528
762
|
|
|
529
763
|
function VoiceSection(props) {
|
|
530
764
|
const ctx = props.ctx
|
|
531
|
-
|
|
765
|
+
// На странице, открытой не с localhost, ядро отключает настройки целиком:
|
|
766
|
+
// общий вид документа не читается, каждый раздел получает "unavailable",
|
|
767
|
+
// запись выбрасывается. Сервер это ограничение не разделяет, и плагин
|
|
768
|
+
// dsh-lanmode поднимает ту же механику поверх тех же вызовов. Если он
|
|
769
|
+
// установлен — берём его службу, иначе ядровую: на loopback-странице она
|
|
770
|
+
// работает штатно.
|
|
771
|
+
const scope = ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).bind({ namespace: NS })
|
|
532
772
|
const [snap, setSnap] = React.useState(null)
|
|
533
773
|
const [draft, setDraft] = React.useState(null)
|
|
534
774
|
const [saved, setSaved] = React.useState(false)
|
|
@@ -565,7 +805,7 @@ window.__ModuleLoader__.load({
|
|
|
565
805
|
const timer = setInterval(() => {
|
|
566
806
|
if (tries >= 15) { clearInterval(timer); return }
|
|
567
807
|
tries += 1
|
|
568
|
-
try { ctx.settingsScope.describe().load() } catch (e) { /* сервис ещё не поднялся */ }
|
|
808
|
+
try { ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).describe().load() } catch (e) { /* сервис ещё не поднялся */ }
|
|
569
809
|
}, 1000)
|
|
570
810
|
return () => clearInterval(timer)
|
|
571
811
|
}, [ready])
|
package/lib/index.js
CHANGED
|
@@ -27,7 +27,9 @@ export const inject = ['tools', 'credentials', 'webServer', 'shell', 'settings']
|
|
|
27
27
|
const ChainEntry = z.object({
|
|
28
28
|
provider: z.string().default('local-whisper')
|
|
29
29
|
.description(`Provider key: one of ${PROVIDER_KEYS.join(', ')}, `
|
|
30
|
-
+ 'or the name of an entry from customProviders.'
|
|
30
|
+
+ 'or the name of an entry from customProviders. '
|
|
31
|
+
+ '"browser" recognises speech in the page itself — no key, no upload to this host, '
|
|
32
|
+
+ 'text appears while you speak; put a normal provider after it as a fallback.'),
|
|
31
33
|
model: z.string().default('')
|
|
32
34
|
.description('Model override. Empty means the provider default.'),
|
|
33
35
|
})
|
|
@@ -66,6 +68,10 @@ export const Config = z.object({
|
|
|
66
68
|
autoSendMs: z.number().default(4000)
|
|
67
69
|
.description('Cancel window before the recognized text is sent to the agent.'),
|
|
68
70
|
}).default({}),
|
|
71
|
+
hotkey: z.string().default('Control')
|
|
72
|
+
.description('Hold this key anywhere in the page to record a voice message; release to send, '
|
|
73
|
+
+ 'Escape to discard. Modifier names (Control, Alt, Shift) or a KeyboardEvent code. '
|
|
74
|
+
+ 'Empty disables the hotkey.'),
|
|
69
75
|
customProviders: z.array(CustomProvider).default([])
|
|
70
76
|
.description('Own recognition providers, usable in both chains next to the built-in ones.'),
|
|
71
77
|
deepgramKeyEnv: z.string().default('DEEPGRAM_API_KEY'),
|
|
@@ -203,6 +209,8 @@ export function apply(ctx, baseConfig) {
|
|
|
203
209
|
writeJson(res, 200, {
|
|
204
210
|
ok: true,
|
|
205
211
|
whisperRunning: await whisperAlive(),
|
|
212
|
+
// Клавиша нужна браузерной половине: она вешает обработчик удержания.
|
|
213
|
+
hotkey: cfg.hotkey,
|
|
206
214
|
providers: PROVIDER_KEYS.concat(
|
|
207
215
|
(Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
|
|
208
216
|
.map((c) => String(c && c.key || '').trim()).filter(Boolean),
|
package/lib/providers.js
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
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, например, нет
|
|
@@ -210,7 +214,17 @@ export function makeProviders(deps, req) {
|
|
|
210
214
|
}
|
|
211
215
|
}
|
|
212
216
|
|
|
213
|
-
|
|
217
|
+
// Если звук всё-таки доехал до хоста с 'browser' в цепочке, значит
|
|
218
|
+
// браузерная нога не сработала: отказываем понятно и идём к следующему.
|
|
219
|
+
async function browser() {
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
provider: 'browser',
|
|
223
|
+
reason: 'browser: распознавание идёт в браузере, на хосте его нет',
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const out = { browser, deepgram, groq, hf, 'local-whisper': localWhisper }
|
|
214
228
|
for (const spec of Array.isArray(cfg.customProviders) ? cfg.customProviders : []) {
|
|
215
229
|
const key = String(spec && spec.key || '').trim()
|
|
216
230
|
// Встроенные не перекрываем: иначе опечатка в имени тихо подменит рабочего
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|