@goodandready/dsh-voice 0.4.2 → 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 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
- openMic(mode)
213
- .then((rec) => { voice.rec = rec; voice.notify() })
214
- .catch((err) => voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null }))
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 : 'Голосовое сообщение', onClick: startMessage,
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
- React.createElement('canvas', { className: 'dvo-wave', ref: canvasRef, width: 720, height: 40 }),
368
- React.createElement('span', { className: 'dvo-status' }, hint),
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
  }
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
- export const PROVIDER_KEYS = ['deepgram', 'groq', 'hf', 'local-whisper']
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
- const out = { deepgram, groq, hf, 'local-whisper': localWhisper }
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.4.2",
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",