@goodandready/dsh-voice 0.8.4 → 0.8.5

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/lib/client.js CHANGED
@@ -83,6 +83,20 @@ window.__ModuleLoader__.load({
83
83
  'speaking': 'You are speaking…',
84
84
  'silence': 'Pause…',
85
85
  'normalizeTranscript': 'Normalize file transcripts',
86
+ 'undo': 'Undo last insert',
87
+ 'undone': 'Insert undone',
88
+ 'nothingToUndo': 'Nothing to undo',
89
+ 'beep': 'Beep on start/stop',
90
+ 'localOnly': 'Local whisper only',
91
+ 'localOnlyHint': 'Restrict both chains to the local whisper.cpp server: fully offline.',
92
+ 'sendDelay': 'Dictation send delay (ms)',
93
+ 'sendDelayHint': 'Wait before appending a dictated phrase, with a cancel window. 0 = off',
94
+ 'mic': 'Microphone',
95
+ 'micDefault': 'System default',
96
+ 'vocabulary': 'Custom vocabulary (one word per line)',
97
+ 'polish': 'Polish transcript with model',
98
+ 'polishHint': 'Fix punctuation and fillers via the harness model before inserting',
99
+ 'voiceCommandsLabel': 'Voice edit commands ("new line", "paragraph")',
86
100
  'normalizeTranscriptHint': 'transcribe_audio: spoken numbers to digits, tidy punctuation',
87
101
  'messageTitle': 'Voice message',
88
102
  'messageHint': 'One whole recording, sent to the agent once it is transcribed.',
@@ -162,6 +176,20 @@ window.__ModuleLoader__.load({
162
176
  'speaking': 'Вы говорите…',
163
177
  'silence': 'Пауза…',
164
178
  'normalizeTranscript': 'Нормализация расшифровок',
179
+ 'undo': 'Отменить вставку',
180
+ 'undone': 'Вставка отменена',
181
+ 'nothingToUndo': 'Отменять нечего',
182
+ 'beep': 'Звук старта/стопа',
183
+ 'localOnly': 'Только локальный whisper',
184
+ 'localOnlyHint': 'Обе цепочки — только локальный сервер whisper.cpp: полностью офлайн.',
185
+ 'sendDelay': 'Задержка вставки диктовки (мс)',
186
+ 'sendDelayHint': 'Пауза перед вставкой фразы с окном отмены. 0 — выкл',
187
+ 'mic': 'Микрофон',
188
+ 'micDefault': 'Системной по умолчанию',
189
+ 'vocabulary': 'Свой словарь (одно слово в строке)',
190
+ 'polish': 'Полировка текста моделью',
191
+ 'polishHint': 'Пунктуация и слова-паразиты через модель харнесса перед вставкой',
192
+ 'voiceCommandsLabel': 'Голосовые команды («с новой строки», «абзац»)',
165
193
  'normalizeTranscriptHint': 'transcribe_audio: числа словами — в цифры, аккуратная пунктуация',
166
194
  'messageTitle': 'Голосовое сообщение',
167
195
  'messageHint': 'Одна запись целиком, после распознавания уходит агенту.',
@@ -292,16 +320,56 @@ window.__ModuleLoader__.load({
292
320
  return s
293
321
  }
294
322
 
323
+ // Голосовые команды редактирования (#37): «с новой строки» -> \n и т.п.
324
+ // Применяются до нормализации, только когда включены в настройках.
325
+ const VOICE_COMMANDS = [
326
+ [/(^|[\s,.!?])с новой строки([\s,.!?]|$)/gi, '$1\n$2'],
327
+ [/(^|[\s,.!?])новая строка([\s,.!?]|$)/gi, '$1\n$2'],
328
+ [/(^|[\s,.!?])абзац([\s,.!?]|$)/gi, '$1\n\n$2'],
329
+ [/(^|\s)тире(\s|$)/gi, '$1—$2'],
330
+ ]
331
+
332
+ function applyVoiceCommands(text) {
333
+ let s = text
334
+ for (const [re, to] of VOICE_COMMANDS) s = s.replace(re, to)
335
+ return s.replace(/[ \t]+/g, ' ').trim()
336
+ }
337
+
338
+ // История вставок для undo (#29-9). Хранится только в браузере.
339
+ const insertHistory = []
340
+
341
+ async function undoLastInsert() {
342
+ const last = insertHistory.pop()
343
+ if (!last) return t('nothingToUndo')
344
+ const actions = voice.inputActions
345
+ if (!actions || typeof actions.setDraft !== 'function') return t('composerUnavailable')
346
+ const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
347
+ if (draft === last.after) actions.setDraft(last.before)
348
+ else {
349
+ // Текст уже менялся руками — вырезаем последнюю вставку как подстроку.
350
+ const i = draft.lastIndexOf(last.added)
351
+ if (i < 0) { insertHistory.push(last); return t('nothingToUndo') }
352
+ actions.setDraft((draft.slice(0, i) + draft.slice(i + last.added.length)).replace(/\s+$/, ''))
353
+ }
354
+ return t('undone')
355
+ }
356
+
295
357
  function appendDraft(text) {
296
358
  const actions = voice.inputActions
297
359
  if (!actions || typeof actions.setDraft !== 'function') {
298
360
  voice.set({ phase: 'error', error: t('composerUnavailable') })
299
361
  return
300
362
  }
301
- const clean = tidyPhrase(text)
363
+ let clean = voice.settings.voiceCommands ? applyVoiceCommands(text) : tidyPhrase(text)
302
364
  if (!clean) return
303
365
  const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
366
+ const before = draft
304
367
  actions.setDraft(draft ? draft + ' ' + clean : clean)
368
+ const limit = Number(voice.settings.historyLimit)
369
+ if (limit > 0) {
370
+ insertHistory.push({ before, added: draft ? ' ' + clean : clean, after: draft ? draft + ' ' + clean : clean })
371
+ while (insertHistory.length > limit) insertHistory.shift()
372
+ }
305
373
  }
306
374
 
307
375
  // Как клавиша называется на человеческом языке.
@@ -335,6 +403,26 @@ window.__ModuleLoader__.load({
335
403
  try {
336
404
  window.dispatchEvent(new CustomEvent('dsh-voice:speaking', { detail: { phase } }))
337
405
  } catch (noEvents) { /* окна нет — значит и слушать некому */ }
406
+ if (voice.settings.beep) playBeep(phase === 'start' ? 880 : 660)
407
+ }
408
+
409
+ // Короткий пик через WebAudio: слышно без взгляда на экран (#29-6).
410
+ function playBeep(freq) {
411
+ try {
412
+ const AC = typeof AudioContext !== 'undefined' ? AudioContext
413
+ : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
414
+ if (!AC) return
415
+ const ac = new AC()
416
+ const osc = ac.createOscillator()
417
+ const gain = ac.createGain()
418
+ osc.frequency.value = freq
419
+ osc.type = 'sine'
420
+ gain.gain.setValueAtTime(0.12, ac.currentTime)
421
+ gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.09)
422
+ osc.connect(gain); gain.connect(ac.destination)
423
+ osc.start(); osc.stop(ac.currentTime + 0.1)
424
+ osc.onended = () => { try { ac.close() } catch (e) { /* уже закрыт */ } }
425
+ } catch (noAudio) { /* без звука — не критично */ }
338
426
  }
339
427
 
340
428
  // ------------------------------------------------- распознавание в браузере
@@ -434,9 +522,9 @@ window.__ModuleLoader__.load({
434
522
  throw new Error(t('micUnavailable'))
435
523
  }
436
524
  if (typeof MediaRecorder === 'undefined') throw new Error(t('noRecorder'))
437
- const stream = await navigator.mediaDevices.getUserMedia({
438
- audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
439
- })
525
+ const audio = { channelCount: 1, echoCancellation: true, noiseSuppression: true }
526
+ if (voice.settings.micDeviceId) audio.deviceId = { exact: voice.settings.micDeviceId }
527
+ const stream = await navigator.mediaDevices.getUserMedia({ audio })
440
528
  let mimeType = 'audio/webm;codecs=opus'
441
529
  if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = ''
442
530
  const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream)
@@ -492,6 +580,14 @@ window.__ModuleLoader__.load({
492
580
  if (blob.size < 1200) return // слишком короткий кусок — это не речь
493
581
  try {
494
582
  const text = await sendAudio(blob, rec.mime, 'dictation')
583
+ const delay = Number(voice.settings.sendDelayMs) || 0
584
+ if (text && delay > 0 && !voice.holding) {
585
+ // Отложенная вставка с окном отмены (#29-5): текст уже вставлен,
586
+ // окно позволяет его отменить.
587
+ appendDraft(text)
588
+ voice.set({ phase: 'pending', pending: { text, undoOnly: true, leftMs: delay } })
589
+ return
590
+ }
495
591
  if (text) appendDraft(text)
496
592
  } catch (e) {
497
593
  voice.set({ error: String(e && e.message ? e.message : e) })
@@ -779,6 +875,14 @@ window.__ModuleLoader__.load({
779
875
  const dispose = ctx.interval(() => {
780
876
  const p = voice.pending
781
877
  if (!p) return
878
+ if (p.undoOnly) {
879
+ // Режим «только отмена вставки» (#29-5): по истечении окна просто
880
+ // прячем панель; текст либо остался, либо уже откатили руками.
881
+ p.leftMs -= tick
882
+ if (p.leftMs <= 0) voice.set({ phase: 'idle' })
883
+ else voice.notify()
884
+ return
885
+ }
782
886
  p.leftMs -= tick
783
887
  if (p.leftMs <= 0) { submitPending(); return }
784
888
  voice.notify()
@@ -819,6 +923,19 @@ window.__ModuleLoader__.load({
819
923
 
820
924
  if (v.phase === 'pending') {
821
925
  const left = Math.max(0, Math.ceil((voice.pending ? voice.pending.leftMs : 0) / 1000))
926
+ if (voice.pending && voice.pending.undoOnly) {
927
+ // Окно отмены отложенной вставки диктовки (#29-5).
928
+ return React.createElement('div', { className: 'dvo-pill' },
929
+ React.createElement('span', { className: 'dvo-status' }, t('undo'), ': ', left, t('secondsShort')),
930
+ React.createElement('button', {
931
+ type: 'button', className: 'dvo-pbtn', title: t('undo'),
932
+ onClick: async () => {
933
+ const msg = await undoLastInsert()
934
+ voice.set({ phase: 'idle', error: msg === t('undone') ? '' : msg })
935
+ },
936
+ }, xIcon()),
937
+ )
938
+ }
822
939
  return React.createElement('div', { className: 'dvo-pill' },
823
940
  React.createElement('span', { className: 'dvo-status' }, t('sendingIn')),
824
941
  React.createElement('span', { className: 'dvo-count' }, left + t('secondsShort')),
@@ -849,6 +966,14 @@ window.__ModuleLoader__.load({
849
966
  dispose = () => {}
850
967
  const key = data && data.hotkey
851
968
  if (key) dispose = installHotkey(ctx, key, 'message')
969
+ // Новые настройки композеру нужны без открытия карточки.
970
+ Object.assign(voice.settings, {
971
+ beep: !!(data && data.beep),
972
+ micDeviceId: String((data && data.micDeviceId) || ''),
973
+ historyLimit: Number(data && data.historyLimit),
974
+ voiceCommands: !!(data && data.voiceCommands),
975
+ sendDelayMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.sendDelayMs) || 0,
976
+ })
852
977
  })
853
978
  .catch(() => { /* без подсказки хоста клавиши просто не будет */ })
854
979
  }
@@ -1094,11 +1219,16 @@ window.__ModuleLoader__.load({
1094
1219
  // Клиент должен знать порог VAD и окно отмены — они живут в тех же настройках.
1095
1220
  React.useEffect(() => {
1096
1221
  if (!ready) return
1097
- voice.settings = {
1222
+ voice.settings = Object.assign({}, voice.settings, {
1098
1223
  vadSilenceMs: Number(value && value.dictation && value.dictation.vadSilenceMs) || 700,
1099
1224
  autoSendMs: Number(value && value.message && value.message.autoSendMs) || 4000,
1100
- }
1101
- }, [ready, value])
1225
+ beep: !!(snap && snap.beep),
1226
+ micDeviceId: String((snap && snap.micDeviceId) || ''),
1227
+ historyLimit: Number(snap && snap.historyLimit),
1228
+ voiceCommands: !!(snap && snap.voiceCommands),
1229
+ sendDelayMs: Number(value && value.dictation && value.dictation.sendDelayMs) || 0,
1230
+ })
1231
+ }, [ready, value, snap])
1102
1232
 
1103
1233
  if (!ready) {
1104
1234
  const waiting = !snap || snap.status === 'loading'
@@ -1136,10 +1266,15 @@ window.__ModuleLoader__.load({
1136
1266
  }
1137
1267
  }
1138
1268
 
1139
- voice.settings = {
1269
+ Object.assign(voice.settings, {
1140
1270
  vadSilenceMs: Number(draft.dictation && draft.dictation.vadSilenceMs) || 700,
1141
1271
  autoSendMs: Number(draft.message && draft.message.autoSendMs) || 4000,
1142
- }
1272
+ beep: !!draft.beep,
1273
+ micDeviceId: String(draft.micDeviceId || ''),
1274
+ historyLimit: Number(draft.historyLimit),
1275
+ voiceCommands: !!draft.voiceCommands,
1276
+ sendDelayMs: Number(draft.dictation && draft.dictation.sendDelayMs) || 0,
1277
+ })
1143
1278
  // Композер держит обработчик клавиши: пусть перечитает настройку,
1144
1279
  // иначе новая клавиша заработает только после перезагрузки страницы.
1145
1280
  try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* некому */ }
@@ -1177,6 +1312,26 @@ window.__ModuleLoader__.load({
1177
1312
  t('hotkeyHint1')
1178
1313
  + t('hotkeyHint2')))
1179
1314
 
1315
+ const micField = () => {
1316
+ const devices = React.useState([])[0]
1317
+ const setDevices = React.useState([])[1]
1318
+ React.useEffect(() => {
1319
+ if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
1320
+ navigator.mediaDevices.enumerateDevices()
1321
+ .then((list) => setDevices(list.filter((d) => d.kind === 'audioinput')))
1322
+ .catch(() => {})
1323
+ }, [])
1324
+ return React.createElement('label', { className: 'dvs-field' }, t('mic'),
1325
+ React.createElement('select', {
1326
+ value: String((snap && snap.micDeviceId) || ''), disabled: !writable,
1327
+ onChange: (e) => setTop('micDeviceId', e.target.value),
1328
+ },
1329
+ React.createElement('option', { value: '' }, t('micDefault')),
1330
+ devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
1331
+ d.label || d.deviceId.slice(0, 12)))),
1332
+ )
1333
+ }
1334
+
1180
1335
  const langField = (mode) => React.createElement('label', { className: 'dvs-field' }, t('language'),
1181
1336
  React.createElement('select', {
1182
1337
  value: modeVal(mode, 'language', 'ru'), disabled: !writable,
@@ -1208,6 +1363,13 @@ window.__ModuleLoader__.load({
1208
1363
  }),
1209
1364
  langField('dictation'),
1210
1365
  numField('dictation', 'vadSilenceMs', t('pauseMs'), t('pauseHint')),
1366
+ numField('dictation', 'sendDelayMs', t('sendDelay'), t('sendDelayHint')),
1367
+ React.createElement('label', { className: 'dvs-field' }, t('polish'),
1368
+ React.createElement('input', {
1369
+ type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.polish), disabled: !writable,
1370
+ onChange: (e) => setIn('dictation', 'polish', e.target.checked),
1371
+ })),
1372
+ React.createElement('div', { className: 'dvs-sub' }, t('polishHint')),
1211
1373
  ),
1212
1374
  React.createElement('div', { className: 'dvs-block' },
1213
1375
  React.createElement('div', { className: 'dvs-h' }, t('messageTitle')),
@@ -1219,6 +1381,11 @@ window.__ModuleLoader__.load({
1219
1381
  }),
1220
1382
  langField('message'),
1221
1383
  numField('message', 'autoSendMs', t('undoMs'), t('undoHint')),
1384
+ React.createElement('label', { className: 'dvs-field' }, t('polish'),
1385
+ React.createElement('input', {
1386
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.polish), disabled: !writable,
1387
+ onChange: (e) => setIn('message', 'polish', e.target.checked),
1388
+ })),
1222
1389
  ),
1223
1390
  React.createElement('div', { className: 'dvs-block' },
1224
1391
  React.createElement('div', { className: 'dvs-h' }, t('customTitle')),
@@ -1246,6 +1413,28 @@ window.__ModuleLoader__.load({
1246
1413
  type: 'checkbox', checked: !!(draft && draft.normalizeTranscript), disabled: !writable,
1247
1414
  onChange: (e) => setTop('normalizeTranscript', e.target.checked),
1248
1415
  })),
1416
+ React.createElement('label', { className: 'dvs-field' }, t('beep'),
1417
+ React.createElement('input', {
1418
+ type: 'checkbox', checked: !!(draft && draft.beep), disabled: !writable,
1419
+ onChange: (e) => setTop('beep', e.target.checked),
1420
+ })),
1421
+ React.createElement('label', { className: 'dvs-field', title: t('localOnlyHint') }, t('localOnly'),
1422
+ React.createElement('input', {
1423
+ type: 'checkbox', checked: !!(draft && draft.localOnly), disabled: !writable,
1424
+ onChange: (e) => setTop('localOnly', e.target.checked),
1425
+ })),
1426
+ React.createElement('label', { className: 'dvs-field' }, t('voiceCommandsLabel'),
1427
+ React.createElement('input', {
1428
+ type: 'checkbox', checked: !!(draft && draft.voiceCommands), disabled: !writable,
1429
+ onChange: (e) => setTop('voiceCommands', e.target.checked),
1430
+ })),
1431
+ micField(),
1432
+ React.createElement('label', { className: 'dvs-field' }, t('vocabulary'),
1433
+ React.createElement('textarea', {
1434
+ rows: 3, disabled: !writable,
1435
+ value: Array.isArray(draft && draft.vocabulary) ? draft.vocabulary.join('\n') : '',
1436
+ onChange: (e) => setTop('vocabulary', e.target.value.split('\n').map((x) => x.trim()).filter(Boolean)),
1437
+ })),
1249
1438
  ),
1250
1439
  React.createElement('div', { className: 'dvs-row' },
1251
1440
  React.createElement('button', { type: 'button', className: 'dvs-save', disabled: !writable, onClick: save }, t('save')),
package/lib/index.js CHANGED
@@ -27,7 +27,7 @@ function isAutoLang(lang) {
27
27
  }
28
28
 
29
29
  export const name = 'dsh-voice'
30
- export const inject = ['tools', 'credentials', 'webServer', 'shell', 'settings']
30
+ export const inject = ['tools', 'credentials', 'webServer', 'shell', 'settings', 'llm']
31
31
 
32
32
  const ChainEntry = z.object({
33
33
  provider: z.string().default('local-whisper')
@@ -65,6 +65,10 @@ export const Config = z.object({
65
65
  language: z.string().default('ru'),
66
66
  vadSilenceMs: z.number().default(700)
67
67
  .description('Silence longer than this ends a phrase and sends the chunk.'),
68
+ sendDelayMs: z.number().default(0)
69
+ .description('Dictation: wait this many ms after a phrase before appending it, with a cancel window. 0 disables the delay.'),
70
+ polish: z.boolean().default(false)
71
+ .description('Polish the transcript through the harness model before inserting: punctuation, paragraphs, filler-word removal.'),
68
72
  }).default({}),
69
73
  message: z.object({
70
74
  chain: z.array(ChainEntry)
@@ -98,6 +102,18 @@ export const Config = z.object({
98
102
  maxFileBytes: z.number().default(25 * 1024 * 1024),
99
103
  normalizeTranscript: z.boolean().default(false)
100
104
  .description('transcribe_audio: convert spoken numbers to digits and tidy punctuation.'),
105
+ beep: z.boolean().default(false)
106
+ .description('Play a short beep when recording starts and stops.'),
107
+ localOnly: z.boolean().default(false)
108
+ .description('Restrict both chains to local-whisper only: fully offline, no cloud providers.'),
109
+ micDeviceId: z.string().default('')
110
+ .description('Microphone device id for recording. Empty means the system default.'),
111
+ historyLimit: z.number().default(20)
112
+ .description('How many recent dictation inserts to keep for undo in the browser. 0 disables history.'),
113
+ vocabulary: z.array(z.string()).default([])
114
+ .description('Custom words (names, terms) hinted to providers so they recognize them correctly.'),
115
+ voiceCommands: z.boolean().default(false)
116
+ .description('During dictation, spoken edit commands ("new line", "paragraph") become real line breaks instead of words.'),
101
117
  })
102
118
 
103
119
  const MIME_BY_EXT = {
@@ -190,24 +206,51 @@ export function apply(ctx, baseConfig) {
190
206
  async function transcribe(modeCfg, bytes, mime, signal) {
191
207
  const cfg = live()
192
208
  const customKeys = (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
193
- .map((c) => String(c && c.key || '').trim())
194
- .filter(Boolean)
209
+ .map((c) => String(c && c.key || '').trim()).filter(Boolean)
210
+ // Режим «только локальный whisper»: цепочка обрезается до него.
211
+ const chain = cfg.localOnly
212
+ ? (modeCfg.chain || []).filter((e) => e.provider === 'local-whisper')
213
+ : (modeCfg.chain || [])
195
214
  const models = {}
196
215
  const order = []
197
- for (const entry of Array.isArray(modeCfg.chain) ? modeCfg.chain : []) {
216
+ for (const entry of chain) {
198
217
  if (!KNOWN_KEYS.includes(entry.provider) && !customKeys.includes(entry.provider)) continue
199
218
  order.push(entry.provider)
200
219
  // Для своего провайдера модель по умолчанию живёт в его описании,
201
220
  // подставит makeProviders — здесь пусто означает «бери оттуда».
202
221
  models[entry.provider] = entry.model || DEFAULT_MODELS[entry.provider] || ''
203
222
  }
223
+ if (cfg.localOnly && order.length === 0) {
224
+ throw new Error('localOnly mode is on, but local-whisper is not in the chain')
225
+ }
204
226
  const providers = makeProviders(
205
227
  { resolveKey, fetchImpl: fetch, cfg, toWav: (b) => toWav16k(b, cfg.ffmpegBin) },
206
- { bytes, mime, lang: modeCfg.language, signal, models },
228
+ { bytes, mime, lang: modeCfg.language, signal, models, vocabulary: cfg.vocabulary },
207
229
  )
208
230
  return runChain(order, providers)
209
231
  }
210
232
 
233
+ // Полировка транскрипта через модель харнесса (#35). Ошибка/таймаут не
234
+ // блокирует: возвращаем сырой текст.
235
+ async function polishText(text, modeCfg, signal) {
236
+ if (!modeCfg || modeCfg.polish !== true) return text
237
+ try {
238
+ const llm = ctx.llm
239
+ 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
+ let acc = ''
246
+ for await (const chunk of llm.stream({ messages: [{ role: 'user', content: ask }], signal })) {
247
+ acc += (chunk && (chunk.text || (chunk.delta && chunk.delta.text))) || ''
248
+ }
249
+ const clean = acc.trim()
250
+ return clean || text
251
+ } catch { return text }
252
+ }
253
+
211
254
  ctx.effect(() => ctx.webServer.register({
212
255
  kind: 'exact',
213
256
  path: '/dsh-voice/status',
@@ -224,9 +267,21 @@ export function apply(ctx, baseConfig) {
224
267
  .map((c) => String(c && c.key || '').trim()).filter(Boolean),
225
268
  ),
226
269
  modes: {
227
- dictation: { chain: cfg.dictation.chain, language: cfg.dictation.language, vadSilenceMs: cfg.dictation.vadSilenceMs },
228
- message: { chain: cfg.message.chain, language: cfg.message.language, autoSendMs: cfg.message.autoSendMs },
270
+ dictation: {
271
+ chain: cfg.dictation.chain, language: cfg.dictation.language,
272
+ vadSilenceMs: cfg.dictation.vadSilenceMs,
273
+ sendDelayMs: cfg.dictation.sendDelayMs, polish: cfg.dictation.polish,
274
+ },
275
+ message: {
276
+ chain: cfg.message.chain, language: cfg.message.language,
277
+ autoSendMs: cfg.message.autoSendMs, polish: cfg.message.polish,
278
+ },
229
279
  },
280
+ beep: cfg.beep,
281
+ localOnly: cfg.localOnly,
282
+ micDeviceId: cfg.micDeviceId,
283
+ historyLimit: cfg.historyLimit,
284
+ voiceCommands: cfg.voiceCommands,
230
285
  })
231
286
  },
232
287
  }), 'dsh-voice: /status route')
@@ -270,7 +325,8 @@ export function apply(ctx, baseConfig) {
270
325
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
271
326
  try {
272
327
  const out = await transcribe(modeCfg, bytes, mime, controller.signal)
273
- writeJson(res, 200, { ok: true, text: out.text, provider: out.provider, tookMs: out.tookMs })
328
+ const text = await polishText(out.text, modeCfg, controller.signal)
329
+ writeJson(res, 200, { ok: true, text, provider: out.provider, tookMs: out.tookMs })
274
330
  } catch (e) {
275
331
  writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
276
332
  } finally {
@@ -319,7 +375,9 @@ export function apply(ctx, baseConfig) {
319
375
  const mime = MIME_BY_EXT[path.extname(filePath).toLowerCase()] || 'audio/wav'
320
376
  const bytes = await readFile(filePath)
321
377
  const modeCfg = { ...cfg.message, language: String(args.language || cfg.message.language) }
322
- const out = await transcribe(modeCfg, bytes, mime, exec.signal)
378
+ let raw = await transcribe(modeCfg, bytes, mime, exec.signal)
379
+ if (raw && raw.text) raw = { ...raw, text: await polishText(raw.text, modeCfg, exec.signal) }
380
+ const out = raw
323
381
  if (cfg.normalizeTranscript && out && out.text) {
324
382
  out.text = normalizePhrase(out.text, {
325
383
  digits: true, capSentences: true, commaSpacing: true, trailingPeriod: true,
package/lib/providers.js CHANGED
@@ -107,6 +107,9 @@ function isAutoLang(lang) {
107
107
  export function makeProviders(deps, req) {
108
108
  const { resolveKey, fetchImpl, cfg } = deps
109
109
  const { bytes, mime, lang, signal, models } = req
110
+ const vocab = Array.isArray(req.vocabulary)
111
+ ? req.vocabulary.map((w) => String(w || '').trim()).filter(Boolean).join(', ')
112
+ : ''
110
113
 
111
114
  async function deepgram() {
112
115
  const key = await resolveKey(cfg.deepgramKeyEnv)
@@ -181,6 +184,7 @@ export function makeProviders(deps, req) {
181
184
  const form = new FormData()
182
185
  form.append('file', new Blob([sendBytes], { type: sendMime }), fileName(sendMime))
183
186
  if (!isAutoLang(lang)) form.append('language', lang)
187
+ if (vocab) form.append('prompt', vocab)
184
188
  form.append('response_format', 'json')
185
189
  const res = await fetchImpl(cfg.whisperUrl, { method: 'POST', body: form, signal })
186
190
  // whisper.cpp отвечает 400 с JSON-телом на внутренних сбоях (например, не
@@ -234,6 +238,7 @@ export function makeProviders(deps, req) {
234
238
  format = 'wav'
235
239
  }
236
240
  const ask = (spec.prompt || CHAT_AUDIO_PROMPT)
241
+ + (vocab ? ` Vocabulary hints (spell these correctly): ${vocab}.` : '')
237
242
  + (lang && lang !== 'auto' ? ` The audio language is ${lang}.` : '')
238
243
  const res = await fetchImpl(`${base}/chat/completions`, {
239
244
  method: 'POST',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
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",