@goodandready/dsh-voice 0.8.3 → 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
@@ -80,6 +80,24 @@ window.__ModuleLoader__.load({
80
80
  'dictationHint': 'Speech is cut at pauses and the text is appended to the input.',
81
81
  'pauseMs': 'Pause that ends a phrase, ms',
82
82
  'pauseHint': 'Lower means more frequent chunks and faster text, but a higher risk of cutting a word',
83
+ 'speaking': 'You are speaking…',
84
+ 'silence': 'Pause…',
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")',
100
+ 'normalizeTranscriptHint': 'transcribe_audio: spoken numbers to digits, tidy punctuation',
83
101
  'messageTitle': 'Voice message',
84
102
  'messageHint': 'One whole recording, sent to the agent once it is transcribed.',
85
103
  'undoMs': 'Undo window, ms',
@@ -155,6 +173,24 @@ window.__ModuleLoader__.load({
155
173
  'dictationHint': 'Речь режется по паузам, текст дописывается в строку ввода.',
156
174
  'pauseMs': 'Пауза до конца фразы, мс',
157
175
  'pauseHint': 'Меньше — чаще куски и быстрее текст, но выше риск обрезать слово',
176
+ 'speaking': 'Вы говорите…',
177
+ 'silence': 'Пауза…',
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': 'Голосовые команды («с новой строки», «абзац»)',
193
+ 'normalizeTranscriptHint': 'transcribe_audio: числа словами — в цифры, аккуратная пунктуация',
158
194
  'messageTitle': 'Голосовое сообщение',
159
195
  'messageHint': 'Одна запись целиком, после распознавания уходит агенту.',
160
196
  'undoMs': 'Окно отмены, мс',
@@ -276,14 +312,64 @@ window.__ModuleLoader__.load({
276
312
  return String(parsed.text || '').trim()
277
313
  }
278
314
 
315
+ function tidyPhrase(text) {
316
+ let s = String(text || '').trim()
317
+ if (!s) return s
318
+ s = s.replace(/\s*,\s*/g, ', ')
319
+ s = s.replace(/(^|[.!?\n]\s+)([a-zа-яё])/gi, (m, lead, ch) => lead + ch.toUpperCase())
320
+ return s
321
+ }
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
+
279
357
  function appendDraft(text) {
280
358
  const actions = voice.inputActions
281
359
  if (!actions || typeof actions.setDraft !== 'function') {
282
360
  voice.set({ phase: 'error', error: t('composerUnavailable') })
283
361
  return
284
362
  }
363
+ let clean = voice.settings.voiceCommands ? applyVoiceCommands(text) : tidyPhrase(text)
364
+ if (!clean) return
285
365
  const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
286
- actions.setDraft(draft ? draft + ' ' + text : text)
366
+ const before = draft
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
+ }
287
373
  }
288
374
 
289
375
  // Как клавиша называется на человеческом языке.
@@ -317,6 +403,26 @@ window.__ModuleLoader__.load({
317
403
  try {
318
404
  window.dispatchEvent(new CustomEvent('dsh-voice:speaking', { detail: { phase } }))
319
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) { /* без звука — не критично */ }
320
426
  }
321
427
 
322
428
  // ------------------------------------------------- распознавание в браузере
@@ -416,9 +522,9 @@ window.__ModuleLoader__.load({
416
522
  throw new Error(t('micUnavailable'))
417
523
  }
418
524
  if (typeof MediaRecorder === 'undefined') throw new Error(t('noRecorder'))
419
- const stream = await navigator.mediaDevices.getUserMedia({
420
- audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
421
- })
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 })
422
528
  let mimeType = 'audio/webm;codecs=opus'
423
529
  if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = ''
424
530
  const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream)
@@ -474,6 +580,14 @@ window.__ModuleLoader__.load({
474
580
  if (blob.size < 1200) return // слишком короткий кусок — это не речь
475
581
  try {
476
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
+ }
477
591
  if (text) appendDraft(text)
478
592
  } catch (e) {
479
593
  voice.set({ error: String(e && e.message ? e.message : e) })
@@ -717,6 +831,8 @@ window.__ModuleLoader__.load({
717
831
  if (voice.levels.length > 150) voice.levels.shift()
718
832
  if (level > 0.06) { rec.hadSpeech = true; rec.silenceMs = 0 }
719
833
  else if (rec.hadSpeech) rec.silenceMs += tick
834
+ const speaking = level > 0.06
835
+ if (voice.speaking !== speaking) { voice.speaking = speaking; voice.notify() }
720
836
  if (rec.mode === 'dictation' && rec.hadSpeech && rec.silenceMs >= voice.settings.vadSilenceMs) {
721
837
  cutPhrase()
722
838
  }
@@ -759,6 +875,14 @@ window.__ModuleLoader__.load({
759
875
  const dispose = ctx.interval(() => {
760
876
  const p = voice.pending
761
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
+ }
762
886
  p.leftMs -= tick
763
887
  if (p.leftMs <= 0) { submitPending(); return }
764
888
  voice.notify()
@@ -770,6 +894,7 @@ window.__ModuleLoader__.load({
770
894
 
771
895
  if (v.phase === 'recording') {
772
896
  const inBrowser = !!voice.browser
897
+ const rec = voice.rec
773
898
  const hint = v.mode === 'dictation' ? t('dictationPill') : t('messagePill')
774
899
  // Живая подпись: что слышно прямо сейчас. Пока браузер не выдал
775
900
  // окончательный кусок, текст черновой и меняется на глазах.
@@ -784,7 +909,9 @@ window.__ModuleLoader__.load({
784
909
  ? caption
785
910
  : (voice.holding
786
911
  ? t('holdHint')
787
- : (inBrowser ? t('listening') : hint))),
912
+ : (inBrowser
913
+ ? t('listening')
914
+ : (rec && rec.hadSpeech ? (v.speaking ? t('speaking') : t('silence')) : hint)))),
788
915
  React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('stop'), onClick: stopCurrent }, stopIcon()),
789
916
  )
790
917
  }
@@ -796,6 +923,19 @@ window.__ModuleLoader__.load({
796
923
 
797
924
  if (v.phase === 'pending') {
798
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
+ }
799
939
  return React.createElement('div', { className: 'dvo-pill' },
800
940
  React.createElement('span', { className: 'dvo-status' }, t('sendingIn')),
801
941
  React.createElement('span', { className: 'dvo-count' }, left + t('secondsShort')),
@@ -826,6 +966,14 @@ window.__ModuleLoader__.load({
826
966
  dispose = () => {}
827
967
  const key = data && data.hotkey
828
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
+ })
829
977
  })
830
978
  .catch(() => { /* без подсказки хоста клавиши просто не будет */ })
831
979
  }
@@ -1071,11 +1219,16 @@ window.__ModuleLoader__.load({
1071
1219
  // Клиент должен знать порог VAD и окно отмены — они живут в тех же настройках.
1072
1220
  React.useEffect(() => {
1073
1221
  if (!ready) return
1074
- voice.settings = {
1222
+ voice.settings = Object.assign({}, voice.settings, {
1075
1223
  vadSilenceMs: Number(value && value.dictation && value.dictation.vadSilenceMs) || 700,
1076
1224
  autoSendMs: Number(value && value.message && value.message.autoSendMs) || 4000,
1077
- }
1078
- }, [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])
1079
1232
 
1080
1233
  if (!ready) {
1081
1234
  const waiting = !snap || snap.status === 'loading'
@@ -1113,10 +1266,15 @@ window.__ModuleLoader__.load({
1113
1266
  }
1114
1267
  }
1115
1268
 
1116
- voice.settings = {
1269
+ Object.assign(voice.settings, {
1117
1270
  vadSilenceMs: Number(draft.dictation && draft.dictation.vadSilenceMs) || 700,
1118
1271
  autoSendMs: Number(draft.message && draft.message.autoSendMs) || 4000,
1119
- }
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
+ })
1120
1278
  // Композер держит обработчик клавиши: пусть перечитает настройку,
1121
1279
  // иначе новая клавиша заработает только после перезагрузки страницы.
1122
1280
  try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* некому */ }
@@ -1154,6 +1312,26 @@ window.__ModuleLoader__.load({
1154
1312
  t('hotkeyHint1')
1155
1313
  + t('hotkeyHint2')))
1156
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
+
1157
1335
  const langField = (mode) => React.createElement('label', { className: 'dvs-field' }, t('language'),
1158
1336
  React.createElement('select', {
1159
1337
  value: modeVal(mode, 'language', 'ru'), disabled: !writable,
@@ -1185,6 +1363,13 @@ window.__ModuleLoader__.load({
1185
1363
  }),
1186
1364
  langField('dictation'),
1187
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')),
1188
1373
  ),
1189
1374
  React.createElement('div', { className: 'dvs-block' },
1190
1375
  React.createElement('div', { className: 'dvs-h' }, t('messageTitle')),
@@ -1196,6 +1381,11 @@ window.__ModuleLoader__.load({
1196
1381
  }),
1197
1382
  langField('message'),
1198
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
+ })),
1199
1389
  ),
1200
1390
  React.createElement('div', { className: 'dvs-block' },
1201
1391
  React.createElement('div', { className: 'dvs-h' }, t('customTitle')),
@@ -1217,6 +1407,34 @@ window.__ModuleLoader__.load({
1217
1407
  type: 'checkbox', checked: !!(draft && draft.autoStart), disabled: !writable,
1218
1408
  onChange: (e) => setTop('autoStart', e.target.checked),
1219
1409
  })),
1410
+ React.createElement('label', { className: 'dvs-field', title: t('normalizeTranscriptHint') },
1411
+ t('normalizeTranscript'),
1412
+ React.createElement('input', {
1413
+ type: 'checkbox', checked: !!(draft && draft.normalizeTranscript), disabled: !writable,
1414
+ onChange: (e) => setTop('normalizeTranscript', e.target.checked),
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
+ })),
1220
1438
  ),
1221
1439
  React.createElement('div', { className: 'dvs-row' },
1222
1440
  React.createElement('button', { type: 'button', className: 'dvs-save', disabled: !writable, onClick: save }, t('save')),
package/lib/index.js CHANGED
@@ -20,9 +20,14 @@ import path from 'node:path'
20
20
  import { runChain } from './chain.js'
21
21
  import { makeProviders, PROVIDER_KEYS, PRESET_KEYS, KNOWN_KEYS, DEFAULT_MODELS, CUSTOM_TEMPLATES } from './providers.js'
22
22
  import { toWav16k } from './wav.js'
23
+ import { normalizePhrase } from './normalize.js'
24
+
25
+ function isAutoLang(lang) {
26
+ return !lang || lang === 'auto' || String(lang).includes(',')
27
+ }
23
28
 
24
29
  export const name = 'dsh-voice'
25
- export const inject = ['tools', 'credentials', 'webServer', 'shell', 'settings']
30
+ export const inject = ['tools', 'credentials', 'webServer', 'shell', 'settings', 'llm']
26
31
 
27
32
  const ChainEntry = z.object({
28
33
  provider: z.string().default('local-whisper')
@@ -60,6 +65,10 @@ export const Config = z.object({
60
65
  language: z.string().default('ru'),
61
66
  vadSilenceMs: z.number().default(700)
62
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.'),
63
72
  }).default({}),
64
73
  message: z.object({
65
74
  chain: z.array(ChainEntry)
@@ -91,6 +100,20 @@ export const Config = z.object({
91
100
  .description('ffmpeg used to convert browser webm/opus into the WAV that whisper.cpp requires.'),
92
101
  timeoutMs: z.number().default(120000),
93
102
  maxFileBytes: z.number().default(25 * 1024 * 1024),
103
+ normalizeTranscript: z.boolean().default(false)
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.'),
94
117
  })
95
118
 
96
119
  const MIME_BY_EXT = {
@@ -164,7 +187,7 @@ export function apply(ctx, baseConfig) {
164
187
  try {
165
188
  const spec = ctx.shell.resolve({
166
189
  command: `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
167
- + ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${cfg.dictation.language}`,
190
+ + ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${isAutoLang(cfg.dictation.language) ? 'auto' : cfg.dictation.language}`,
168
191
  timeoutMs: 0,
169
192
  stdoutMaxBytes: 4 * 1024 * 1024,
170
193
  })
@@ -183,24 +206,51 @@ export function apply(ctx, baseConfig) {
183
206
  async function transcribe(modeCfg, bytes, mime, signal) {
184
207
  const cfg = live()
185
208
  const customKeys = (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
186
- .map((c) => String(c && c.key || '').trim())
187
- .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 || [])
188
214
  const models = {}
189
215
  const order = []
190
- for (const entry of Array.isArray(modeCfg.chain) ? modeCfg.chain : []) {
216
+ for (const entry of chain) {
191
217
  if (!KNOWN_KEYS.includes(entry.provider) && !customKeys.includes(entry.provider)) continue
192
218
  order.push(entry.provider)
193
219
  // Для своего провайдера модель по умолчанию живёт в его описании,
194
220
  // подставит makeProviders — здесь пусто означает «бери оттуда».
195
221
  models[entry.provider] = entry.model || DEFAULT_MODELS[entry.provider] || ''
196
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
+ }
197
226
  const providers = makeProviders(
198
227
  { resolveKey, fetchImpl: fetch, cfg, toWav: (b) => toWav16k(b, cfg.ffmpegBin) },
199
- { bytes, mime, lang: modeCfg.language, signal, models },
228
+ { bytes, mime, lang: modeCfg.language, signal, models, vocabulary: cfg.vocabulary },
200
229
  )
201
230
  return runChain(order, providers)
202
231
  }
203
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
+
204
254
  ctx.effect(() => ctx.webServer.register({
205
255
  kind: 'exact',
206
256
  path: '/dsh-voice/status',
@@ -217,9 +267,21 @@ export function apply(ctx, baseConfig) {
217
267
  .map((c) => String(c && c.key || '').trim()).filter(Boolean),
218
268
  ),
219
269
  modes: {
220
- dictation: { chain: cfg.dictation.chain, language: cfg.dictation.language, vadSilenceMs: cfg.dictation.vadSilenceMs },
221
- 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
+ },
222
279
  },
280
+ beep: cfg.beep,
281
+ localOnly: cfg.localOnly,
282
+ micDeviceId: cfg.micDeviceId,
283
+ historyLimit: cfg.historyLimit,
284
+ voiceCommands: cfg.voiceCommands,
223
285
  })
224
286
  },
225
287
  }), 'dsh-voice: /status route')
@@ -263,7 +325,8 @@ export function apply(ctx, baseConfig) {
263
325
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
264
326
  try {
265
327
  const out = await transcribe(modeCfg, bytes, mime, controller.signal)
266
- 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 })
267
330
  } catch (e) {
268
331
  writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
269
332
  } finally {
@@ -312,7 +375,15 @@ export function apply(ctx, baseConfig) {
312
375
  const mime = MIME_BY_EXT[path.extname(filePath).toLowerCase()] || 'audio/wav'
313
376
  const bytes = await readFile(filePath)
314
377
  const modeCfg = { ...cfg.message, language: String(args.language || cfg.message.language) }
315
- return 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
381
+ if (cfg.normalizeTranscript && out && out.text) {
382
+ out.text = normalizePhrase(out.text, {
383
+ digits: true, capSentences: true, commaSpacing: true, trailingPeriod: true,
384
+ })
385
+ }
386
+ return out
316
387
  },
317
388
  }),
318
389
  )
@@ -0,0 +1,56 @@
1
+ // Постобработка распознанного текста перед вставкой в композер.
2
+ // Без сети и без cordis — чистые функции, чтобы поведение проверить юнитами.
3
+
4
+ export function capitalizeSentences(text) {
5
+ return text.replace(/(^|[.!?\n]\s+)([a-zа-яё])/gi, (m, lead, ch) => lead + ch.toUpperCase())
6
+ }
7
+
8
+ export function fixCommaSpacing(text) {
9
+ return text.replace(/\s*,\s*/g, ', ')
10
+ }
11
+
12
+ export function ensureTrailingPeriod(text) {
13
+ const t = String(text || '').trim()
14
+ if (!t) return t
15
+ if (/[.!?…]$/.test(t)) return t
16
+ return t + '.'
17
+ }
18
+
19
+ const NUM_WORDS = {
20
+ ноль: 0, один: 1, одна: 1, два: 2, две: 2, три: 3, четыре: 4, пять: 5,
21
+ шесть: 6, семь: 7, восемь: 8, девять: 9, десять: 10, одиннадцать: 11,
22
+ двенадцать: 12, тринадцать: 13, четырнадцать: 14, пятнадцать: 15,
23
+ шестнадцать: 16, семнадцать: 17, восемнадцать: 18, девятнадцать: 19,
24
+ двадцать: 20, тридцать: 30, сорок: 40, пятьдесят: 50, шестьдесят: 60,
25
+ семьдесят: 70, восемьдесят: 80, девяносто: 90, сто: 100, двести: 200,
26
+ триста: 300, четыреста: 400, пятьсот: 500, шестьсот: 600, семьсот: 700,
27
+ восемьсот: 800, девятьсот: 900, тысяча: 1000, тысячи: 1000, тысяч: 1000,
28
+ миллион: 1000000, миллиона: 1000000, миллионов: 1000000,
29
+ }
30
+
31
+ const NUM_NAMES = Object.keys(NUM_WORDS).sort((a, b) => b.length - a.length).join('|')
32
+ const NUM_SEQ = new RegExp('(' + NUM_NAMES + ')(?:\\s+(' + NUM_NAMES + '))*', 'gi')
33
+
34
+ export function wordsToDigits(text) {
35
+ return text.replace(NUM_SEQ, (m) => {
36
+ const parts = m.trim().split(/\s+/)
37
+ let total = 0
38
+ let cur = 0
39
+ for (const w of parts) {
40
+ const v = NUM_WORDS[w.toLowerCase()]
41
+ if (v === 1000 || v === 1000000) { total += (cur || 1) * v; cur = 0 }
42
+ else cur += v
43
+ }
44
+ return String(total + cur)
45
+ })
46
+ }
47
+
48
+ export function normalizePhrase(text, opts = {}) {
49
+ let out = String(text || '').trim()
50
+ if (!out) return out
51
+ if (opts.digits) out = wordsToDigits(out)
52
+ if (opts.commaSpacing) out = fixCommaSpacing(out)
53
+ if (opts.capSentences) out = capitalizeSentences(out)
54
+ if (opts.trailingPeriod) out = ensureTrailingPeriod(out)
55
+ return out
56
+ }
package/lib/providers.js CHANGED
@@ -98,16 +98,25 @@ function fileName(mime) {
98
98
  return 'audio.webm'
99
99
  }
100
100
 
101
+ // Автоязык: пусто, 'auto' или список ('ru,en') — провайдер определяет сам,
102
+ // поле language не отправляется. whisper.cpp при этом получает -l auto.
103
+ function isAutoLang(lang) {
104
+ return !lang || lang === 'auto' || String(lang).includes(',')
105
+ }
106
+
101
107
  export function makeProviders(deps, req) {
102
108
  const { resolveKey, fetchImpl, cfg } = deps
103
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
+ : ''
104
113
 
105
114
  async function deepgram() {
106
115
  const key = await resolveKey(cfg.deepgramKeyEnv)
107
116
  if (!key) return { ok: false, provider: 'deepgram', reason: `no ${cfg.deepgramKeyEnv}` }
108
117
  const model = pickModel(models, 'deepgram')
109
118
  const url = `https://api.deepgram.com/v1/listen?model=${encodeURIComponent(model)}`
110
- + `&language=${encodeURIComponent(lang)}&smart_format=true`
119
+ + (!isAutoLang(lang) ? `&language=${encodeURIComponent(lang)}` : '') + '&smart_format=true'
111
120
  const res = await fetchImpl(url, {
112
121
  method: 'POST',
113
122
  headers: { authorization: `Token ${key}`, 'content-type': mime },
@@ -126,7 +135,7 @@ export function makeProviders(deps, req) {
126
135
  const form = new FormData()
127
136
  form.append('file', new Blob([bytes], { type: mime }), fileName(mime))
128
137
  form.append('model', pickModel(models, 'groq'))
129
- form.append('language', lang)
138
+ if (!isAutoLang(lang)) form.append('language', lang)
130
139
  form.append('response_format', 'json')
131
140
  const res = await fetchImpl('https://api.groq.com/openai/v1/audio/transcriptions', {
132
141
  method: 'POST',
@@ -174,7 +183,8 @@ export function makeProviders(deps, req) {
174
183
  }
175
184
  const form = new FormData()
176
185
  form.append('file', new Blob([sendBytes], { type: sendMime }), fileName(sendMime))
177
- form.append('language', lang)
186
+ if (!isAutoLang(lang)) form.append('language', lang)
187
+ if (vocab) form.append('prompt', vocab)
178
188
  form.append('response_format', 'json')
179
189
  const res = await fetchImpl(cfg.whisperUrl, { method: 'POST', body: form, signal })
180
190
  // whisper.cpp отвечает 400 с JSON-телом на внутренних сбоях (например, не
@@ -228,6 +238,7 @@ export function makeProviders(deps, req) {
228
238
  format = 'wav'
229
239
  }
230
240
  const ask = (spec.prompt || CHAT_AUDIO_PROMPT)
241
+ + (vocab ? ` Vocabulary hints (spell these correctly): ${vocab}.` : '')
231
242
  + (lang && lang !== 'auto' ? ` The audio language is ${lang}.` : '')
232
243
  const res = await fetchImpl(`${base}/chat/completions`, {
233
244
  method: 'POST',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.8.3",
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",