@goodandready/dsh-voice 0.8.5 → 0.8.7

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
@@ -149,7 +149,12 @@ The card has four blocks:
149
149
  - **Your own providers** — an OpenAI-compatible API per card: name, template,
150
150
  base URL, model, credential name. The name becomes selectable in both chains
151
151
  as soon as it is filled in.
152
- - **General** — local whisper endpoint, binary, model, autostart.
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).
153
158
 
154
159
  Speed matters for dictation and accuracy for messages, which is why the chains
155
160
  are separate: a sensible pair is Deepgram → Groq → local for dictation and
package/lib/client.js CHANGED
@@ -96,6 +96,22 @@ window.__ModuleLoader__.load({
96
96
  'vocabulary': 'Custom vocabulary (one word per line)',
97
97
  'polish': 'Polish transcript with model',
98
98
  'polishHint': 'Fix punctuation and fillers via the harness model before inserting',
99
+ 'stream': 'Continuous dictation',
100
+ 'streamHint': 'Cut phrases by a timer while you speak instead of waiting for a long pause',
101
+ 'streamChunkMs': 'Stream chunk (ms)',
102
+ 'vadAdapt': 'Adaptive silence',
103
+ 'vadAdaptHint': 'Auto-tune the silence threshold to the pace of your speech. 0 = fixed',
104
+ 'wakeWord': 'Wake word',
105
+ 'wakeWordHint': 'Browser recognition starts recording when speech begins with this phrase. Empty = off',
106
+ 'bargeIn': 'Barge-in',
107
+ 'polishSend': 'Polish whole draft before sending',
108
+ 'polishSendHint': 'Run the composed draft through the model right before sending',
109
+ 'sessionCommands': 'Voice session commands',
110
+ 'sessionCommandsHint': '"send", "cancel", "stop", "continue" act on the session instead of becoming text',
111
+ 'polishBaseUrl': 'Offline polish endpoint',
112
+ 'polishBaseUrlHint': 'OpenAI-compatible /chat/completions base URL, e.g. a local Ollama. Empty = harness model',
113
+ 'polishModel': 'Offline polish model',
114
+ 'polishKeyEnv': 'Offline polish key credential',
99
115
  'voiceCommandsLabel': 'Voice edit commands ("new line", "paragraph")',
100
116
  'normalizeTranscriptHint': 'transcribe_audio: spoken numbers to digits, tidy punctuation',
101
117
  'messageTitle': 'Voice message',
@@ -189,6 +205,22 @@ window.__ModuleLoader__.load({
189
205
  'vocabulary': 'Свой словарь (одно слово в строке)',
190
206
  'polish': 'Полировка текста моделью',
191
207
  'polishHint': 'Пунктуация и слова-паразиты через модель харнесса перед вставкой',
208
+ 'stream': 'Непрерывная диктовка',
209
+ 'streamHint': 'Резать фразы по таймеру во время речи, а не по длинной паузе',
210
+ 'streamChunkMs': 'Кусок потока (мс)',
211
+ 'vadAdapt': 'Адаптивная тишина',
212
+ 'vadAdaptHint': 'Авто-подстройка порога под темп речи. 0 = фикс. поведение',
213
+ 'wakeWord': 'Слово-активатор',
214
+ 'wakeWordHint': 'Распознавание в браузере начинает запись, если речь начинается с этой фразы. Пусто = выкл',
215
+ 'bargeIn': 'Перебивание',
216
+ 'polishSend': 'Полировка всего текста перед отправкой',
217
+ 'polishSendHint': 'Прогнать весь текст через модель перед отправкой агенту',
218
+ 'sessionCommands': 'Голосовые команды сессии',
219
+ 'sessionCommandsHint': '«отправь», «отмени», «стоп», «продолжи» — действия сессии, а не текст',
220
+ 'polishBaseUrl': 'Локальный ендпоинт полировки',
221
+ 'polishBaseUrlHint': 'OpenAI-совместимый /chat/completions базовый URL, напр. локальный Ollama. Пусто = модель харнесса',
222
+ 'polishModel': 'Модель офлайн-полировки',
223
+ 'polishKeyEnv': 'Ключ офлайн-полировки',
192
224
  'voiceCommandsLabel': 'Голосовые команды («с новой строки», «абзац»)',
193
225
  'normalizeTranscriptHint': 'transcribe_audio: числа словами — в цифры, аккуратная пунктуация',
194
226
  'messageTitle': 'Голосовое сообщение',
@@ -247,7 +279,7 @@ window.__ModuleLoader__.load({
247
279
  pending: null, // {text, leftMs} — окно отмены режима message
248
280
  inputActions: null,
249
281
  input: null,
250
- settings: { vadSilenceMs: 700, autoSendMs: 4000 },
282
+ settings: { vadSilenceMs: 700, autoSendMs: 4000, stream: false, streamChunkMs: 1200, vadAdapt: 0 },
251
283
  listeners: new Set(),
252
284
  notify() { this.listeners.forEach((l) => l()) },
253
285
  set(patch) { Object.assign(this, patch); this.notify() },
@@ -309,7 +341,8 @@ window.__ModuleLoader__.load({
309
341
  if (!res.ok || !parsed || !parsed.ok) {
310
342
  throw new Error((parsed && parsed.error && parsed.error.message) || `HTTP ${res.status}`)
311
343
  }
312
- return String(parsed.text || '').trim()
344
+ if (parsed.command) return { command: parsed.command, text: '' }
345
+ return { text: String(parsed.text || '').trim(), command: null }
313
346
  }
314
347
 
315
348
  function tidyPhrase(text) {
@@ -535,6 +568,7 @@ window.__ModuleLoader__.load({
535
568
  audioCtx: null, analyser: null,
536
569
  cutting: false, closing: false,
537
570
  silenceMs: 0, hadSpeech: false,
571
+ streamMs: 0,
538
572
  }
539
573
  recorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) rec.chunks.push(e.data) }
540
574
  const AC = typeof AudioContext !== 'undefined' ? AudioContext
@@ -573,6 +607,7 @@ window.__ModuleLoader__.load({
573
607
  rec.chunks = []
574
608
  rec.silenceMs = 0
575
609
  rec.hadSpeech = false
610
+ rec.streamMs = 0
576
611
  if (!rec.closing) {
577
612
  try { rec.recorder.start() } catch (e) { /* поток закрылся */ }
578
613
  }
@@ -604,7 +639,22 @@ window.__ModuleLoader__.load({
604
639
  voice.browser = startBrowserRecognition({
605
640
  lang: language,
606
641
  continuous: true,
607
- onInterim: (text) => { voice.caption = text; voice.notify() },
642
+ onInterim: (text) => {
643
+ voice.caption = text; voice.notify()
644
+ // Wake-word (#45): если interim начинается с ключевой фразы —
645
+ // обрываем прослушивание browser и уходим в обычную запись.
646
+ const ww = String(voice.settings.wakeWord || '').trim().toLowerCase()
647
+ if (ww && mode === 'dictation' && !voice.rec) {
648
+ const t = String(text || '').trim().toLowerCase()
649
+ if (t.startsWith(ww)) {
650
+ voice.browser = null
651
+ voice.caption = ''
652
+ openMic(mode)
653
+ .then((rec) => { voice.rec = rec; voice.notify() })
654
+ .catch((err) => voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null }))
655
+ }
656
+ }
657
+ },
608
658
  onFinal: (text) => {
609
659
  finals.push(text)
610
660
  voice.caption = ''
@@ -761,7 +811,9 @@ window.__ModuleLoader__.load({
761
811
  if (blob.size < 1200) { voice.set({ phase: 'idle' }); return }
762
812
  voice.set({ phase: 'processing' })
763
813
  try {
764
- const text = await sendAudio(blob, rec.mime, mode)
814
+ const out = await sendAudio(blob, rec.mime, mode)
815
+ if (out.command) { runSessionCommand(out.command); return }
816
+ const text = out.text || ''
765
817
  if (!text) { voice.set({ phase: 'error', error: t('nothingHeard') }); return }
766
818
  appendDraft(text)
767
819
  if (mode === 'message') {
@@ -779,8 +831,46 @@ window.__ModuleLoader__.load({
779
831
  voice.pending = null
780
832
  voice.set({ phase: 'idle' })
781
833
  const actions = voice.inputActions
782
- if (actions && typeof actions.submit === 'function') {
783
- setTimeout(() => { try { actions.submit() } catch (e) { /* композер занят */ } }, 0)
834
+ if (!actions || typeof actions.submit !== 'function') return
835
+ // Полировка всего draft перед отправкой (#46). Ошибка не блокирует.
836
+ if (voice.settings.polishSend) {
837
+ const run = async () => {
838
+ try {
839
+ const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
840
+ if (draft.trim()) {
841
+ const res = await fetch('/dsh-voice/polish', {
842
+ method: 'POST',
843
+ headers: { 'content-type': 'application/json' },
844
+ body: JSON.stringify({ text: draft }),
845
+ })
846
+ const parsed = await res.json().catch(() => null)
847
+ if (parsed && parsed.ok && typeof parsed.text === 'string' && parsed.text.trim()) {
848
+ actions.setDraft(parsed.text.trim())
849
+ }
850
+ }
851
+ } catch (e) { /* полировка best-effort */ }
852
+ }
853
+ run().finally(() => setTimeout(() => { try { actions.submit() } catch (e) { /* занят */ } }, 0))
854
+ return
855
+ }
856
+ setTimeout(() => { try { actions.submit() } catch (e) { /* композер занят */ } }, 0)
857
+ }
858
+
859
+ // Голосовые команды сессии (#48): чистые «отправь/отмени/стоп/продолжи».
860
+ function runSessionCommand(cmd) {
861
+ const actions = voice.inputActions
862
+ voice.set({ phase: 'idle', pending: null })
863
+ switch (cmd) {
864
+ case 'send':
865
+ case 'continue':
866
+ if (actions && typeof actions.submit === 'function') actions.submit()
867
+ break
868
+ case 'cancel':
869
+ case 'stop':
870
+ // Сбрасываем ожидание/запись; текст draft намеренно не трогаем.
871
+ break
872
+ default:
873
+ break
784
874
  }
785
875
  }
786
876
 
@@ -833,7 +923,29 @@ window.__ModuleLoader__.load({
833
923
  else if (rec.hadSpeech) rec.silenceMs += tick
834
924
  const speaking = level > 0.06
835
925
  if (voice.speaking !== speaking) { voice.speaking = speaking; voice.notify() }
836
- if (rec.mode === 'dictation' && rec.hadSpeech && rec.silenceMs >= voice.settings.vadSilenceMs) {
926
+ const adapt = Number(voice.settings.vadAdapt) || 0
927
+ let effectiveVad = Number(voice.settings.vadSilenceMs) || 700
928
+ if (adapt > 0 && rec.hadSpeech) {
929
+ // Адаптивный порог (#41): считаем плотность речи за последние
930
+ // ~1s (20 сэмплов). Плотная речь -> порог ниже (точнее режем),
931
+ // паузная -> порог растёт к базе (не режем на вдохе).
932
+ const win = voice.levels.slice(-20)
933
+ const density = win.length ? win.filter((v) => v > 0.06).length / win.length : 0
934
+ const k = adapt * (density - 0.5) * 2
935
+ effectiveVad = Math.max(150, Math.round(Number(voice.settings.vadSilenceMs) * (1 - k)))
936
+ }
937
+ // Непрерывный режим (#40): режем по таймеру непрерывной речи,
938
+ // не дожидаясь длинной паузы.
939
+ const stream = !!voice.settings.stream && rec.mode === 'dictation'
940
+ if (stream && rec.hadSpeech && !rec.cutting) {
941
+ rec.streamMs += tick
942
+ const chunk = Number(voice.settings.streamChunkMs) || 1200
943
+ if (rec.streamMs >= chunk) { cutPhrase(); return }
944
+ } else {
945
+ rec.streamMs = 0
946
+ }
947
+ if (rec.mode === 'dictation' && rec.hadSpeech && rec.silenceMs >= effectiveVad) {
948
+ rec.streamMs = 0
837
949
  cutPhrase()
838
950
  }
839
951
  }, tick)
@@ -973,6 +1085,13 @@ window.__ModuleLoader__.load({
973
1085
  historyLimit: Number(data && data.historyLimit),
974
1086
  voiceCommands: !!(data && data.voiceCommands),
975
1087
  sendDelayMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.sendDelayMs) || 0,
1088
+ stream: !!(data && data.modes && data.modes.dictation && data.modes.dictation.stream),
1089
+ streamChunkMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.streamChunkMs) || 1200,
1090
+ vadAdapt: Number(data && data.modes && data.modes.dictation && data.modes.dictation.vadAdapt) || 0,
1091
+ wakeWord: String((data && data.wakeWord) || ''),
1092
+ bargeIn: !!(data && data.bargeIn),
1093
+ polishSend: !!(data && data.modes && data.modes.message && data.modes.message.polishSend),
1094
+ sessionCommands: !!(data && data.modes && data.modes.message && data.modes.message.sessionCommands),
976
1095
  })
977
1096
  })
978
1097
  .catch(() => { /* без подсказки хоста клавиши просто не будет */ })
@@ -1227,6 +1346,14 @@ window.__ModuleLoader__.load({
1227
1346
  historyLimit: Number(snap && snap.historyLimit),
1228
1347
  voiceCommands: !!(snap && snap.voiceCommands),
1229
1348
  sendDelayMs: Number(value && value.dictation && value.dictation.sendDelayMs) || 0,
1349
+ stream: !!(value && value.dictation && value.dictation.stream),
1350
+ streamChunkMs: Number(value && value.dictation && value.dictation.streamChunkMs) || 1200,
1351
+ vadAdapt: Number(value && value.dictation && value.dictation.vadAdapt) || 0,
1352
+ wakeWord: String((snap && snap.wakeWord) || ''),
1353
+ bargeIn: !!(snap && snap.bargeIn),
1354
+ polishSend: !!(value && value.message && value.message.polishSend),
1355
+ sessionCommands: !!(value && value.message && value.message.sessionCommands),
1356
+ polishBaseUrl: String((snap && snap.polishBaseUrl) || ''),
1230
1357
  })
1231
1358
  }, [ready, value, snap])
1232
1359
 
@@ -1274,6 +1401,9 @@ window.__ModuleLoader__.load({
1274
1401
  historyLimit: Number(draft.historyLimit),
1275
1402
  voiceCommands: !!draft.voiceCommands,
1276
1403
  sendDelayMs: Number(draft.dictation && draft.dictation.sendDelayMs) || 0,
1404
+ stream: !!(draft.dictation && draft.dictation.stream),
1405
+ streamChunkMs: Number(draft.dictation && draft.dictation.streamChunkMs) || 1200,
1406
+ vadAdapt: Number(draft.dictation && draft.dictation.vadAdapt) || 0,
1277
1407
  })
1278
1408
  // Композер держит обработчик клавиши: пусть перечитает настройку,
1279
1409
  // иначе новая клавиша заработает только после перезагрузки страницы.
@@ -1370,6 +1500,23 @@ window.__ModuleLoader__.load({
1370
1500
  onChange: (e) => setIn('dictation', 'polish', e.target.checked),
1371
1501
  })),
1372
1502
  React.createElement('div', { className: 'dvs-sub' }, t('polishHint')),
1503
+ React.createElement('label', { className: 'dvs-field', title: t('wakeWordHint') }, t('wakeWord'),
1504
+ React.createElement('input', {
1505
+ type: 'text', value: String((snap && snap.wakeWord) || ''), disabled: !writable,
1506
+ onChange: (e) => setTop('wakeWord', e.target.value),
1507
+ })),
1508
+ React.createElement('label', { className: 'dvs-field', title: t('streamHint') }, t('stream'),
1509
+ React.createElement('input', {
1510
+ type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.stream), disabled: !writable,
1511
+ onChange: (e) => setIn('dictation', 'stream', e.target.checked),
1512
+ })),
1513
+ numField('dictation', 'streamChunkMs', t('streamChunkMs'), t('streamHint')),
1514
+ React.createElement('label', { className: 'dvs-field', title: t('vadAdaptHint') }, t('vadAdapt'),
1515
+ React.createElement('input', {
1516
+ type: 'range', min: 0, max: 1, step: 0.1,
1517
+ value: Number(draft && draft.dictation && draft.dictation.vadAdapt) || 0, disabled: !writable,
1518
+ onChange: (e) => setIn('dictation', 'vadAdapt', Number(e.target.value)),
1519
+ })),
1373
1520
  ),
1374
1521
  React.createElement('div', { className: 'dvs-block' },
1375
1522
  React.createElement('div', { className: 'dvs-h' }, t('messageTitle')),
@@ -1386,6 +1533,16 @@ window.__ModuleLoader__.load({
1386
1533
  type: 'checkbox', checked: !!(draft && draft.message && draft.message.polish), disabled: !writable,
1387
1534
  onChange: (e) => setIn('message', 'polish', e.target.checked),
1388
1535
  })),
1536
+ React.createElement('label', { className: 'dvs-field', title: t('polishSendHint') }, t('polishSend'),
1537
+ React.createElement('input', {
1538
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.polishSend), disabled: !writable,
1539
+ onChange: (e) => setIn('message', 'polishSend', e.target.checked),
1540
+ })),
1541
+ React.createElement('label', { className: 'dvs-field', title: t('sessionCommandsHint') }, t('sessionCommands'),
1542
+ React.createElement('input', {
1543
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.sessionCommands), disabled: !writable,
1544
+ onChange: (e) => setIn('message', 'sessionCommands', e.target.checked),
1545
+ })),
1389
1546
  ),
1390
1547
  React.createElement('div', { className: 'dvs-block' },
1391
1548
  React.createElement('div', { className: 'dvs-h' }, t('customTitle')),
@@ -1435,6 +1592,21 @@ window.__ModuleLoader__.load({
1435
1592
  value: Array.isArray(draft && draft.vocabulary) ? draft.vocabulary.join('\n') : '',
1436
1593
  onChange: (e) => setTop('vocabulary', e.target.value.split('\n').map((x) => x.trim()).filter(Boolean)),
1437
1594
  })),
1595
+ React.createElement('label', { className: 'dvs-field', title: t('polishBaseUrlHint') }, t('polishBaseUrl'),
1596
+ React.createElement('input', {
1597
+ type: 'text', value: String((snap && snap.polishBaseUrl) || ''), disabled: !writable,
1598
+ onChange: (e) => setTop('polishBaseUrl', e.target.value),
1599
+ })),
1600
+ React.createElement('label', { className: 'dvs-field' }, t('polishModel'),
1601
+ React.createElement('input', {
1602
+ type: 'text', value: String((draft && draft.polishModel) || ''), disabled: !writable,
1603
+ onChange: (e) => setTop('polishModel', e.target.value),
1604
+ })),
1605
+ React.createElement('label', { className: 'dvs-field' }, t('polishKeyEnv'),
1606
+ React.createElement('input', {
1607
+ type: 'text', value: String((draft && draft.polishKeyEnv) || ''), disabled: !writable,
1608
+ onChange: (e) => setTop('polishKeyEnv', e.target.value),
1609
+ })),
1438
1610
  ),
1439
1611
  React.createElement('div', { className: 'dvs-row' },
1440
1612
  React.createElement('button', { type: 'button', className: 'dvs-save', disabled: !writable, onClick: save }, t('save')),
package/lib/index.js CHANGED
@@ -69,6 +69,12 @@ export const Config = z.object({
69
69
  .description('Dictation: wait this many ms after a phrase before appending it, with a cancel window. 0 disables the delay.'),
70
70
  polish: z.boolean().default(false)
71
71
  .description('Polish the transcript through the harness model before inserting: punctuation, paragraphs, filler-word removal.'),
72
+ stream: z.boolean().default(false)
73
+ .description('Continuous dictation: cut phrases by a timer instead of waiting for a long silence, so text flows while you speak.'),
74
+ streamChunkMs: z.number().default(1200)
75
+ .description('Continuous dictation: phrase length in ms of uninterrupted speech before the chunk is sent.'),
76
+ vadAdapt: z.number().min(0).max(1).default(0)
77
+ .description('Adaptive silence threshold: 0 = fixed (current behaviour); >0 shrinks the threshold during dense speech and grows it during pauses.'),
72
78
  }).default({}),
73
79
  message: z.object({
74
80
  chain: z.array(ChainEntry)
@@ -77,6 +83,10 @@ export const Config = z.object({
77
83
  language: z.string().default('ru'),
78
84
  autoSendMs: z.number().default(4000)
79
85
  .description('Cancel window before the recognized text is sent to the agent.'),
86
+ sessionCommands: z.boolean().default(false)
87
+ .description('Voice session commands: a clean "send", "cancel", "stop", "continue" does not become text — it drives the composer/session.'),
88
+ polishSend: z.boolean().default(false)
89
+ .description('Polish the whole composed draft through the model right before sending, not just single phrases.'),
80
90
  }).default({}),
81
91
  hotkey: z.string().default('Control')
82
92
  .description('Hold this key anywhere in the page to record a voice message; release to send, '
@@ -114,6 +124,16 @@ export const Config = z.object({
114
124
  .description('Custom words (names, terms) hinted to providers so they recognize them correctly.'),
115
125
  voiceCommands: z.boolean().default(false)
116
126
  .description('During dictation, spoken edit commands ("new line", "paragraph") become real line breaks instead of words.'),
127
+ wakeWord: z.string().default('')
128
+ .description('Heads-free dictation: a phrase that, when recognized by the browser leg, starts a recording. Empty disables.'),
129
+ bargeIn: z.boolean().default(false)
130
+ .description('Ongoing playback or a long turn is interrupted by detected speech (browser leg).'),
131
+ polishBaseUrl: z.string().default('')
132
+ .description('Offline polish: OpenAI-compatible /chat/completions endpoint (e.g. local Ollama). Empty uses the harness model.'),
133
+ polishModel: z.string().default('')
134
+ .description('Offline polish: model id on polishBaseUrl.'),
135
+ polishKeyEnv: z.string().default('')
136
+ .description('Offline polish: credential name for the api key. Empty means no Authorization header.'),
117
137
  })
118
138
 
119
139
  const MIME_BY_EXT = {
@@ -230,18 +250,40 @@ export function apply(ctx, baseConfig) {
230
250
  return runChain(order, providers)
231
251
  }
232
252
 
233
- // Полировка транскрипта через модель харнесса (#35). Ошибка/таймаут не
234
- // блокирует: возвращаем сырой текст.
253
+ // Полировка транскрипта (#35) с поддержкой локального LLM (#47).
254
+ // Ошибка/таймаут не блокирует: возвращаем сырой текст.
235
255
  async function polishText(text, modeCfg, signal) {
236
- if (!modeCfg || modeCfg.polish !== true) return text
256
+ const enable = modeCfg && (modeCfg.polish === true || modeCfg.polishSend === true)
257
+ if (!enable || !text) return text
258
+ const cfg = live()
259
+ const ask =
260
+ 'Fix the punctuation and spelling of this dictated text and split it into '
261
+ + 'paragraphs where the speaker changes topic. Remove filler words ("um", "uh", '
262
+ + '"ээ", "ну", "как бы"). Keep the original language, wording and meaning. '
263
+ + 'Reply with the polished text only:\n\n' + text
237
264
  try {
265
+ // Локальный OpenAI-совместимый эндпоинт (#47), когда задан.
266
+ if (cfg.polishBaseUrl) {
267
+ const headers = { 'content-type': 'application/json' }
268
+ if (cfg.polishKeyEnv) {
269
+ const key = await resolveKey(cfg.polishKeyEnv)
270
+ if (key) headers.authorization = 'Bearer ' + key
271
+ }
272
+ const res = await fetch(
273
+ String(cfg.polishBaseUrl).replace(/\/+$/, '') + '/chat/completions',
274
+ { method: 'POST', headers, signal, body: JSON.stringify({
275
+ model: cfg.polishModel || 'local-model',
276
+ messages: [{ role: 'user', content: ask }],
277
+ }) })
278
+ if (!res.ok) return text
279
+ const data = await res.json().catch(() => null)
280
+ const pick = data && data.choices && data.choices[0] && data.choices[0].message
281
+ && data.choices[0].message.content
282
+ return (pick && String(pick).trim()) || text
283
+ }
284
+ // Штатная модель харнесса.
238
285
  const llm = ctx.llm
239
286
  if (!llm || typeof llm.stream !== 'function') return text
240
- const ask =
241
- 'Fix the punctuation and spelling of this dictated text and split it into '
242
- + 'paragraphs where the speaker changes topic. Remove filler words ("um", "uh", '
243
- + '"ээ", "ну", "как бы"). Keep the original language, wording and meaning. '
244
- + 'Reply with the polished text only:\n\n' + text
245
287
  let acc = ''
246
288
  for await (const chunk of llm.stream({ messages: [{ role: 'user', content: ask }], signal })) {
247
289
  acc += (chunk && (chunk.text || (chunk.delta && chunk.delta.text))) || ''
@@ -251,6 +293,23 @@ export function apply(ctx, baseConfig) {
251
293
  } catch { return text }
252
294
  }
253
295
 
296
+ // Чистые голосовые команды сессии (#48). Возвращает название команды или null.
297
+ // Распознаются на сервере, чтобы не попадать в текст композера.
298
+ const SESSION_COMMANDS = [
299
+ { re: /^(отправь|отправить|пошли|send)\s*[.!?]*$/i, cmd: 'send' },
300
+ { re: /^(отмени|отмена|cancel|отменить)\s*[.!?]*$/i, cmd: 'cancel' },
301
+ { re: /^(стоп|stop|хватит)\s*[.!?]*$/i, cmd: 'stop' },
302
+ { re: /^(продолжи|continue|продолжай)\s*[.!?]*$/i, cmd: 'continue' },
303
+ ]
304
+ function sessionCommand(text) {
305
+ const t = String(text || '').trim().toLowerCase()
306
+ if (!t) return null
307
+ for (const { re, cmd } of SESSION_COMMANDS) {
308
+ if (re.test(t)) return cmd
309
+ }
310
+ return null
311
+ }
312
+
254
313
  ctx.effect(() => ctx.webServer.register({
255
314
  kind: 'exact',
256
315
  path: '/dsh-voice/status',
@@ -271,10 +330,13 @@ export function apply(ctx, baseConfig) {
271
330
  chain: cfg.dictation.chain, language: cfg.dictation.language,
272
331
  vadSilenceMs: cfg.dictation.vadSilenceMs,
273
332
  sendDelayMs: cfg.dictation.sendDelayMs, polish: cfg.dictation.polish,
333
+ stream: cfg.dictation.stream, streamChunkMs: cfg.dictation.streamChunkMs,
334
+ vadAdapt: cfg.dictation.vadAdapt,
274
335
  },
275
336
  message: {
276
337
  chain: cfg.message.chain, language: cfg.message.language,
277
338
  autoSendMs: cfg.message.autoSendMs, polish: cfg.message.polish,
339
+ sessionCommands: cfg.message.sessionCommands, polishSend: cfg.message.polishSend,
278
340
  },
279
341
  },
280
342
  beep: cfg.beep,
@@ -282,10 +344,36 @@ export function apply(ctx, baseConfig) {
282
344
  micDeviceId: cfg.micDeviceId,
283
345
  historyLimit: cfg.historyLimit,
284
346
  voiceCommands: cfg.voiceCommands,
347
+ wakeWord: String(cfg.wakeWord || ''),
348
+ bargeIn: !!cfg.bargeIn,
349
+ polishBaseUrl: String(cfg.polishBaseUrl || ''),
285
350
  })
286
351
  },
287
352
  }), 'dsh-voice: /status route')
288
353
 
354
+ ctx.effect(() => ctx.webServer.register({
355
+ kind: 'exact',
356
+ path: '/dsh-voice/polish',
357
+ handler: async (req, res) => {
358
+ if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
359
+ const cfg = live()
360
+ let raw
361
+ try { raw = await readBody(req, cfg.maxFileBytes + 1024 * 1024) } catch (e) { writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return }
362
+ let payload = {}
363
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* пусто */ }
364
+ const text = typeof payload.text === 'string' ? payload.text.trim() : ''
365
+ if (!text) { writeJson(res, 400, { ok: false, error: { code: 'empty', message: 'text required' } }); return }
366
+ const controller = new AbortController()
367
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
368
+ try {
369
+ const out = await polishText(text, { polish: true }, controller.signal)
370
+ writeJson(res, 200, { ok: true, text: out })
371
+ } catch (e) {
372
+ writeJson(res, 502, { ok: false, error: { code: 'polish', message: String(e && e.message || e) } })
373
+ } finally { clearTimeout(timer) }
374
+ },
375
+ }), 'dsh-voice: /polish route')
376
+
289
377
  ctx.effect(() => ctx.webServer.register({
290
378
  kind: 'exact',
291
379
  path: '/dsh-voice/transcribe',
@@ -325,6 +413,12 @@ export function apply(ctx, baseConfig) {
325
413
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
326
414
  try {
327
415
  const out = await transcribe(modeCfg, bytes, mime, controller.signal)
416
+ // Голосовые команды сессии (#48): чистые «отправь/отмени/стоп/продолжи»
417
+ // не становятся текстом, а возвращаются командой для браузера.
418
+ if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
419
+ const cmd = sessionCommand(out.text)
420
+ if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
421
+ }
328
422
  const text = await polishText(out.text, modeCfg, controller.signal)
329
423
  writeJson(res, 200, { ok: true, text, provider: out.provider, tookMs: out.tookMs })
330
424
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
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",