@goodandready/dsh-voice 0.8.4 → 0.8.6

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,25 @@ 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
+ '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
+ 'voiceCommandsLabel': 'Voice edit commands ("new line", "paragraph")',
86
105
  'normalizeTranscriptHint': 'transcribe_audio: spoken numbers to digits, tidy punctuation',
87
106
  'messageTitle': 'Voice message',
88
107
  'messageHint': 'One whole recording, sent to the agent once it is transcribed.',
@@ -162,6 +181,25 @@ window.__ModuleLoader__.load({
162
181
  'speaking': 'Вы говорите…',
163
182
  'silence': 'Пауза…',
164
183
  'normalizeTranscript': 'Нормализация расшифровок',
184
+ 'undo': 'Отменить вставку',
185
+ 'undone': 'Вставка отменена',
186
+ 'nothingToUndo': 'Отменять нечего',
187
+ 'beep': 'Звук старта/стопа',
188
+ 'localOnly': 'Только локальный whisper',
189
+ 'localOnlyHint': 'Обе цепочки — только локальный сервер whisper.cpp: полностью офлайн.',
190
+ 'sendDelay': 'Задержка вставки диктовки (мс)',
191
+ 'sendDelayHint': 'Пауза перед вставкой фразы с окном отмены. 0 — выкл',
192
+ 'mic': 'Микрофон',
193
+ 'micDefault': 'Системной по умолчанию',
194
+ 'vocabulary': 'Свой словарь (одно слово в строке)',
195
+ 'polish': 'Полировка текста моделью',
196
+ 'polishHint': 'Пунктуация и слова-паразиты через модель харнесса перед вставкой',
197
+ 'stream': 'Непрерывная диктовка',
198
+ 'streamHint': 'Резать фразы по таймеру во время речи, а не по длинной паузе',
199
+ 'streamChunkMs': 'Кусок потока (мс)',
200
+ 'vadAdapt': 'Адаптивная тишина',
201
+ 'vadAdaptHint': 'Авто-подстройка порога под темп речи. 0 = фикс. поведение',
202
+ 'voiceCommandsLabel': 'Голосовые команды («с новой строки», «абзац»)',
165
203
  'normalizeTranscriptHint': 'transcribe_audio: числа словами — в цифры, аккуратная пунктуация',
166
204
  'messageTitle': 'Голосовое сообщение',
167
205
  'messageHint': 'Одна запись целиком, после распознавания уходит агенту.',
@@ -219,7 +257,7 @@ window.__ModuleLoader__.load({
219
257
  pending: null, // {text, leftMs} — окно отмены режима message
220
258
  inputActions: null,
221
259
  input: null,
222
- settings: { vadSilenceMs: 700, autoSendMs: 4000 },
260
+ settings: { vadSilenceMs: 700, autoSendMs: 4000, stream: false, streamChunkMs: 1200, vadAdapt: 0 },
223
261
  listeners: new Set(),
224
262
  notify() { this.listeners.forEach((l) => l()) },
225
263
  set(patch) { Object.assign(this, patch); this.notify() },
@@ -292,16 +330,56 @@ window.__ModuleLoader__.load({
292
330
  return s
293
331
  }
294
332
 
333
+ // Голосовые команды редактирования (#37): «с новой строки» -> \n и т.п.
334
+ // Применяются до нормализации, только когда включены в настройках.
335
+ const VOICE_COMMANDS = [
336
+ [/(^|[\s,.!?])с новой строки([\s,.!?]|$)/gi, '$1\n$2'],
337
+ [/(^|[\s,.!?])новая строка([\s,.!?]|$)/gi, '$1\n$2'],
338
+ [/(^|[\s,.!?])абзац([\s,.!?]|$)/gi, '$1\n\n$2'],
339
+ [/(^|\s)тире(\s|$)/gi, '$1—$2'],
340
+ ]
341
+
342
+ function applyVoiceCommands(text) {
343
+ let s = text
344
+ for (const [re, to] of VOICE_COMMANDS) s = s.replace(re, to)
345
+ return s.replace(/[ \t]+/g, ' ').trim()
346
+ }
347
+
348
+ // История вставок для undo (#29-9). Хранится только в браузере.
349
+ const insertHistory = []
350
+
351
+ async function undoLastInsert() {
352
+ const last = insertHistory.pop()
353
+ if (!last) return t('nothingToUndo')
354
+ const actions = voice.inputActions
355
+ if (!actions || typeof actions.setDraft !== 'function') return t('composerUnavailable')
356
+ const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
357
+ if (draft === last.after) actions.setDraft(last.before)
358
+ else {
359
+ // Текст уже менялся руками — вырезаем последнюю вставку как подстроку.
360
+ const i = draft.lastIndexOf(last.added)
361
+ if (i < 0) { insertHistory.push(last); return t('nothingToUndo') }
362
+ actions.setDraft((draft.slice(0, i) + draft.slice(i + last.added.length)).replace(/\s+$/, ''))
363
+ }
364
+ return t('undone')
365
+ }
366
+
295
367
  function appendDraft(text) {
296
368
  const actions = voice.inputActions
297
369
  if (!actions || typeof actions.setDraft !== 'function') {
298
370
  voice.set({ phase: 'error', error: t('composerUnavailable') })
299
371
  return
300
372
  }
301
- const clean = tidyPhrase(text)
373
+ let clean = voice.settings.voiceCommands ? applyVoiceCommands(text) : tidyPhrase(text)
302
374
  if (!clean) return
303
375
  const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
376
+ const before = draft
304
377
  actions.setDraft(draft ? draft + ' ' + clean : clean)
378
+ const limit = Number(voice.settings.historyLimit)
379
+ if (limit > 0) {
380
+ insertHistory.push({ before, added: draft ? ' ' + clean : clean, after: draft ? draft + ' ' + clean : clean })
381
+ while (insertHistory.length > limit) insertHistory.shift()
382
+ }
305
383
  }
306
384
 
307
385
  // Как клавиша называется на человеческом языке.
@@ -335,6 +413,26 @@ window.__ModuleLoader__.load({
335
413
  try {
336
414
  window.dispatchEvent(new CustomEvent('dsh-voice:speaking', { detail: { phase } }))
337
415
  } catch (noEvents) { /* окна нет — значит и слушать некому */ }
416
+ if (voice.settings.beep) playBeep(phase === 'start' ? 880 : 660)
417
+ }
418
+
419
+ // Короткий пик через WebAudio: слышно без взгляда на экран (#29-6).
420
+ function playBeep(freq) {
421
+ try {
422
+ const AC = typeof AudioContext !== 'undefined' ? AudioContext
423
+ : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : null)
424
+ if (!AC) return
425
+ const ac = new AC()
426
+ const osc = ac.createOscillator()
427
+ const gain = ac.createGain()
428
+ osc.frequency.value = freq
429
+ osc.type = 'sine'
430
+ gain.gain.setValueAtTime(0.12, ac.currentTime)
431
+ gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.09)
432
+ osc.connect(gain); gain.connect(ac.destination)
433
+ osc.start(); osc.stop(ac.currentTime + 0.1)
434
+ osc.onended = () => { try { ac.close() } catch (e) { /* уже закрыт */ } }
435
+ } catch (noAudio) { /* без звука — не критично */ }
338
436
  }
339
437
 
340
438
  // ------------------------------------------------- распознавание в браузере
@@ -434,9 +532,9 @@ window.__ModuleLoader__.load({
434
532
  throw new Error(t('micUnavailable'))
435
533
  }
436
534
  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
- })
535
+ const audio = { channelCount: 1, echoCancellation: true, noiseSuppression: true }
536
+ if (voice.settings.micDeviceId) audio.deviceId = { exact: voice.settings.micDeviceId }
537
+ const stream = await navigator.mediaDevices.getUserMedia({ audio })
440
538
  let mimeType = 'audio/webm;codecs=opus'
441
539
  if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = ''
442
540
  const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream)
@@ -447,6 +545,7 @@ window.__ModuleLoader__.load({
447
545
  audioCtx: null, analyser: null,
448
546
  cutting: false, closing: false,
449
547
  silenceMs: 0, hadSpeech: false,
548
+ streamMs: 0,
450
549
  }
451
550
  recorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) rec.chunks.push(e.data) }
452
551
  const AC = typeof AudioContext !== 'undefined' ? AudioContext
@@ -485,6 +584,7 @@ window.__ModuleLoader__.load({
485
584
  rec.chunks = []
486
585
  rec.silenceMs = 0
487
586
  rec.hadSpeech = false
587
+ rec.streamMs = 0
488
588
  if (!rec.closing) {
489
589
  try { rec.recorder.start() } catch (e) { /* поток закрылся */ }
490
590
  }
@@ -492,6 +592,14 @@ window.__ModuleLoader__.load({
492
592
  if (blob.size < 1200) return // слишком короткий кусок — это не речь
493
593
  try {
494
594
  const text = await sendAudio(blob, rec.mime, 'dictation')
595
+ const delay = Number(voice.settings.sendDelayMs) || 0
596
+ if (text && delay > 0 && !voice.holding) {
597
+ // Отложенная вставка с окном отмены (#29-5): текст уже вставлен,
598
+ // окно позволяет его отменить.
599
+ appendDraft(text)
600
+ voice.set({ phase: 'pending', pending: { text, undoOnly: true, leftMs: delay } })
601
+ return
602
+ }
495
603
  if (text) appendDraft(text)
496
604
  } catch (e) {
497
605
  voice.set({ error: String(e && e.message ? e.message : e) })
@@ -737,7 +845,29 @@ window.__ModuleLoader__.load({
737
845
  else if (rec.hadSpeech) rec.silenceMs += tick
738
846
  const speaking = level > 0.06
739
847
  if (voice.speaking !== speaking) { voice.speaking = speaking; voice.notify() }
740
- if (rec.mode === 'dictation' && rec.hadSpeech && rec.silenceMs >= voice.settings.vadSilenceMs) {
848
+ const adapt = Number(voice.settings.vadAdapt) || 0
849
+ let effectiveVad = Number(voice.settings.vadSilenceMs) || 700
850
+ if (adapt > 0 && rec.hadSpeech) {
851
+ // Адаптивный порог (#41): считаем плотность речи за последние
852
+ // ~1s (20 сэмплов). Плотная речь -> порог ниже (точнее режем),
853
+ // паузная -> порог растёт к базе (не режем на вдохе).
854
+ const win = voice.levels.slice(-20)
855
+ const density = win.length ? win.filter((v) => v > 0.06).length / win.length : 0
856
+ const k = adapt * (density - 0.5) * 2
857
+ effectiveVad = Math.max(150, Math.round(Number(voice.settings.vadSilenceMs) * (1 - k)))
858
+ }
859
+ // Непрерывный режим (#40): режем по таймеру непрерывной речи,
860
+ // не дожидаясь длинной паузы.
861
+ const stream = !!voice.settings.stream && rec.mode === 'dictation'
862
+ if (stream && rec.hadSpeech && !rec.cutting) {
863
+ rec.streamMs += tick
864
+ const chunk = Number(voice.settings.streamChunkMs) || 1200
865
+ if (rec.streamMs >= chunk) { cutPhrase(); return }
866
+ } else {
867
+ rec.streamMs = 0
868
+ }
869
+ if (rec.mode === 'dictation' && rec.hadSpeech && rec.silenceMs >= effectiveVad) {
870
+ rec.streamMs = 0
741
871
  cutPhrase()
742
872
  }
743
873
  }, tick)
@@ -779,6 +909,14 @@ window.__ModuleLoader__.load({
779
909
  const dispose = ctx.interval(() => {
780
910
  const p = voice.pending
781
911
  if (!p) return
912
+ if (p.undoOnly) {
913
+ // Режим «только отмена вставки» (#29-5): по истечении окна просто
914
+ // прячем панель; текст либо остался, либо уже откатили руками.
915
+ p.leftMs -= tick
916
+ if (p.leftMs <= 0) voice.set({ phase: 'idle' })
917
+ else voice.notify()
918
+ return
919
+ }
782
920
  p.leftMs -= tick
783
921
  if (p.leftMs <= 0) { submitPending(); return }
784
922
  voice.notify()
@@ -819,6 +957,19 @@ window.__ModuleLoader__.load({
819
957
 
820
958
  if (v.phase === 'pending') {
821
959
  const left = Math.max(0, Math.ceil((voice.pending ? voice.pending.leftMs : 0) / 1000))
960
+ if (voice.pending && voice.pending.undoOnly) {
961
+ // Окно отмены отложенной вставки диктовки (#29-5).
962
+ return React.createElement('div', { className: 'dvo-pill' },
963
+ React.createElement('span', { className: 'dvo-status' }, t('undo'), ': ', left, t('secondsShort')),
964
+ React.createElement('button', {
965
+ type: 'button', className: 'dvo-pbtn', title: t('undo'),
966
+ onClick: async () => {
967
+ const msg = await undoLastInsert()
968
+ voice.set({ phase: 'idle', error: msg === t('undone') ? '' : msg })
969
+ },
970
+ }, xIcon()),
971
+ )
972
+ }
822
973
  return React.createElement('div', { className: 'dvo-pill' },
823
974
  React.createElement('span', { className: 'dvo-status' }, t('sendingIn')),
824
975
  React.createElement('span', { className: 'dvo-count' }, left + t('secondsShort')),
@@ -849,6 +1000,17 @@ window.__ModuleLoader__.load({
849
1000
  dispose = () => {}
850
1001
  const key = data && data.hotkey
851
1002
  if (key) dispose = installHotkey(ctx, key, 'message')
1003
+ // Новые настройки композеру нужны без открытия карточки.
1004
+ Object.assign(voice.settings, {
1005
+ beep: !!(data && data.beep),
1006
+ micDeviceId: String((data && data.micDeviceId) || ''),
1007
+ historyLimit: Number(data && data.historyLimit),
1008
+ voiceCommands: !!(data && data.voiceCommands),
1009
+ sendDelayMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.sendDelayMs) || 0,
1010
+ stream: !!(data && data.modes && data.modes.dictation && data.modes.dictation.stream),
1011
+ streamChunkMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.streamChunkMs) || 1200,
1012
+ vadAdapt: Number(data && data.modes && data.modes.dictation && data.modes.dictation.vadAdapt) || 0,
1013
+ })
852
1014
  })
853
1015
  .catch(() => { /* без подсказки хоста клавиши просто не будет */ })
854
1016
  }
@@ -1094,11 +1256,19 @@ window.__ModuleLoader__.load({
1094
1256
  // Клиент должен знать порог VAD и окно отмены — они живут в тех же настройках.
1095
1257
  React.useEffect(() => {
1096
1258
  if (!ready) return
1097
- voice.settings = {
1259
+ voice.settings = Object.assign({}, voice.settings, {
1098
1260
  vadSilenceMs: Number(value && value.dictation && value.dictation.vadSilenceMs) || 700,
1099
1261
  autoSendMs: Number(value && value.message && value.message.autoSendMs) || 4000,
1100
- }
1101
- }, [ready, value])
1262
+ beep: !!(snap && snap.beep),
1263
+ micDeviceId: String((snap && snap.micDeviceId) || ''),
1264
+ historyLimit: Number(snap && snap.historyLimit),
1265
+ voiceCommands: !!(snap && snap.voiceCommands),
1266
+ sendDelayMs: Number(value && value.dictation && value.dictation.sendDelayMs) || 0,
1267
+ stream: !!(value && value.dictation && value.dictation.stream),
1268
+ streamChunkMs: Number(value && value.dictation && value.dictation.streamChunkMs) || 1200,
1269
+ vadAdapt: Number(value && value.dictation && value.dictation.vadAdapt) || 0,
1270
+ })
1271
+ }, [ready, value, snap])
1102
1272
 
1103
1273
  if (!ready) {
1104
1274
  const waiting = !snap || snap.status === 'loading'
@@ -1136,10 +1306,18 @@ window.__ModuleLoader__.load({
1136
1306
  }
1137
1307
  }
1138
1308
 
1139
- voice.settings = {
1309
+ Object.assign(voice.settings, {
1140
1310
  vadSilenceMs: Number(draft.dictation && draft.dictation.vadSilenceMs) || 700,
1141
1311
  autoSendMs: Number(draft.message && draft.message.autoSendMs) || 4000,
1142
- }
1312
+ beep: !!draft.beep,
1313
+ micDeviceId: String(draft.micDeviceId || ''),
1314
+ historyLimit: Number(draft.historyLimit),
1315
+ voiceCommands: !!draft.voiceCommands,
1316
+ sendDelayMs: Number(draft.dictation && draft.dictation.sendDelayMs) || 0,
1317
+ stream: !!(draft.dictation && draft.dictation.stream),
1318
+ streamChunkMs: Number(draft.dictation && draft.dictation.streamChunkMs) || 1200,
1319
+ vadAdapt: Number(draft.dictation && draft.dictation.vadAdapt) || 0,
1320
+ })
1143
1321
  // Композер держит обработчик клавиши: пусть перечитает настройку,
1144
1322
  // иначе новая клавиша заработает только после перезагрузки страницы.
1145
1323
  try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* некому */ }
@@ -1177,6 +1355,26 @@ window.__ModuleLoader__.load({
1177
1355
  t('hotkeyHint1')
1178
1356
  + t('hotkeyHint2')))
1179
1357
 
1358
+ const micField = () => {
1359
+ const devices = React.useState([])[0]
1360
+ const setDevices = React.useState([])[1]
1361
+ React.useEffect(() => {
1362
+ if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
1363
+ navigator.mediaDevices.enumerateDevices()
1364
+ .then((list) => setDevices(list.filter((d) => d.kind === 'audioinput')))
1365
+ .catch(() => {})
1366
+ }, [])
1367
+ return React.createElement('label', { className: 'dvs-field' }, t('mic'),
1368
+ React.createElement('select', {
1369
+ value: String((snap && snap.micDeviceId) || ''), disabled: !writable,
1370
+ onChange: (e) => setTop('micDeviceId', e.target.value),
1371
+ },
1372
+ React.createElement('option', { value: '' }, t('micDefault')),
1373
+ devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
1374
+ d.label || d.deviceId.slice(0, 12)))),
1375
+ )
1376
+ }
1377
+
1180
1378
  const langField = (mode) => React.createElement('label', { className: 'dvs-field' }, t('language'),
1181
1379
  React.createElement('select', {
1182
1380
  value: modeVal(mode, 'language', 'ru'), disabled: !writable,
@@ -1208,6 +1406,25 @@ window.__ModuleLoader__.load({
1208
1406
  }),
1209
1407
  langField('dictation'),
1210
1408
  numField('dictation', 'vadSilenceMs', t('pauseMs'), t('pauseHint')),
1409
+ numField('dictation', 'sendDelayMs', t('sendDelay'), t('sendDelayHint')),
1410
+ React.createElement('label', { className: 'dvs-field' }, t('polish'),
1411
+ React.createElement('input', {
1412
+ type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.polish), disabled: !writable,
1413
+ onChange: (e) => setIn('dictation', 'polish', e.target.checked),
1414
+ })),
1415
+ React.createElement('div', { className: 'dvs-sub' }, t('polishHint')),
1416
+ React.createElement('label', { className: 'dvs-field', title: t('streamHint') }, t('stream'),
1417
+ React.createElement('input', {
1418
+ type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.stream), disabled: !writable,
1419
+ onChange: (e) => setIn('dictation', 'stream', e.target.checked),
1420
+ })),
1421
+ numField('dictation', 'streamChunkMs', t('streamChunkMs'), t('streamHint')),
1422
+ React.createElement('label', { className: 'dvs-field', title: t('vadAdaptHint') }, t('vadAdapt'),
1423
+ React.createElement('input', {
1424
+ type: 'range', min: 0, max: 1, step: 0.1,
1425
+ value: Number(draft && draft.dictation && draft.dictation.vadAdapt) || 0, disabled: !writable,
1426
+ onChange: (e) => setIn('dictation', 'vadAdapt', Number(e.target.value)),
1427
+ })),
1211
1428
  ),
1212
1429
  React.createElement('div', { className: 'dvs-block' },
1213
1430
  React.createElement('div', { className: 'dvs-h' }, t('messageTitle')),
@@ -1219,6 +1436,11 @@ window.__ModuleLoader__.load({
1219
1436
  }),
1220
1437
  langField('message'),
1221
1438
  numField('message', 'autoSendMs', t('undoMs'), t('undoHint')),
1439
+ React.createElement('label', { className: 'dvs-field' }, t('polish'),
1440
+ React.createElement('input', {
1441
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.polish), disabled: !writable,
1442
+ onChange: (e) => setIn('message', 'polish', e.target.checked),
1443
+ })),
1222
1444
  ),
1223
1445
  React.createElement('div', { className: 'dvs-block' },
1224
1446
  React.createElement('div', { className: 'dvs-h' }, t('customTitle')),
@@ -1246,6 +1468,28 @@ window.__ModuleLoader__.load({
1246
1468
  type: 'checkbox', checked: !!(draft && draft.normalizeTranscript), disabled: !writable,
1247
1469
  onChange: (e) => setTop('normalizeTranscript', e.target.checked),
1248
1470
  })),
1471
+ React.createElement('label', { className: 'dvs-field' }, t('beep'),
1472
+ React.createElement('input', {
1473
+ type: 'checkbox', checked: !!(draft && draft.beep), disabled: !writable,
1474
+ onChange: (e) => setTop('beep', e.target.checked),
1475
+ })),
1476
+ React.createElement('label', { className: 'dvs-field', title: t('localOnlyHint') }, t('localOnly'),
1477
+ React.createElement('input', {
1478
+ type: 'checkbox', checked: !!(draft && draft.localOnly), disabled: !writable,
1479
+ onChange: (e) => setTop('localOnly', e.target.checked),
1480
+ })),
1481
+ React.createElement('label', { className: 'dvs-field' }, t('voiceCommandsLabel'),
1482
+ React.createElement('input', {
1483
+ type: 'checkbox', checked: !!(draft && draft.voiceCommands), disabled: !writable,
1484
+ onChange: (e) => setTop('voiceCommands', e.target.checked),
1485
+ })),
1486
+ micField(),
1487
+ React.createElement('label', { className: 'dvs-field' }, t('vocabulary'),
1488
+ React.createElement('textarea', {
1489
+ rows: 3, disabled: !writable,
1490
+ value: Array.isArray(draft && draft.vocabulary) ? draft.vocabulary.join('\n') : '',
1491
+ onChange: (e) => setTop('vocabulary', e.target.value.split('\n').map((x) => x.trim()).filter(Boolean)),
1492
+ })),
1249
1493
  ),
1250
1494
  React.createElement('div', { className: 'dvs-row' },
1251
1495
  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,16 @@ 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.'),
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.'),
68
78
  }).default({}),
69
79
  message: z.object({
70
80
  chain: z.array(ChainEntry)
@@ -98,6 +108,18 @@ export const Config = z.object({
98
108
  maxFileBytes: z.number().default(25 * 1024 * 1024),
99
109
  normalizeTranscript: z.boolean().default(false)
100
110
  .description('transcribe_audio: convert spoken numbers to digits and tidy punctuation.'),
111
+ beep: z.boolean().default(false)
112
+ .description('Play a short beep when recording starts and stops.'),
113
+ localOnly: z.boolean().default(false)
114
+ .description('Restrict both chains to local-whisper only: fully offline, no cloud providers.'),
115
+ micDeviceId: z.string().default('')
116
+ .description('Microphone device id for recording. Empty means the system default.'),
117
+ historyLimit: z.number().default(20)
118
+ .description('How many recent dictation inserts to keep for undo in the browser. 0 disables history.'),
119
+ vocabulary: z.array(z.string()).default([])
120
+ .description('Custom words (names, terms) hinted to providers so they recognize them correctly.'),
121
+ voiceCommands: z.boolean().default(false)
122
+ .description('During dictation, spoken edit commands ("new line", "paragraph") become real line breaks instead of words.'),
101
123
  })
102
124
 
103
125
  const MIME_BY_EXT = {
@@ -190,24 +212,51 @@ export function apply(ctx, baseConfig) {
190
212
  async function transcribe(modeCfg, bytes, mime, signal) {
191
213
  const cfg = live()
192
214
  const customKeys = (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
193
- .map((c) => String(c && c.key || '').trim())
194
- .filter(Boolean)
215
+ .map((c) => String(c && c.key || '').trim()).filter(Boolean)
216
+ // Режим «только локальный whisper»: цепочка обрезается до него.
217
+ const chain = cfg.localOnly
218
+ ? (modeCfg.chain || []).filter((e) => e.provider === 'local-whisper')
219
+ : (modeCfg.chain || [])
195
220
  const models = {}
196
221
  const order = []
197
- for (const entry of Array.isArray(modeCfg.chain) ? modeCfg.chain : []) {
222
+ for (const entry of chain) {
198
223
  if (!KNOWN_KEYS.includes(entry.provider) && !customKeys.includes(entry.provider)) continue
199
224
  order.push(entry.provider)
200
225
  // Для своего провайдера модель по умолчанию живёт в его описании,
201
226
  // подставит makeProviders — здесь пусто означает «бери оттуда».
202
227
  models[entry.provider] = entry.model || DEFAULT_MODELS[entry.provider] || ''
203
228
  }
229
+ if (cfg.localOnly && order.length === 0) {
230
+ throw new Error('localOnly mode is on, but local-whisper is not in the chain')
231
+ }
204
232
  const providers = makeProviders(
205
233
  { resolveKey, fetchImpl: fetch, cfg, toWav: (b) => toWav16k(b, cfg.ffmpegBin) },
206
- { bytes, mime, lang: modeCfg.language, signal, models },
234
+ { bytes, mime, lang: modeCfg.language, signal, models, vocabulary: cfg.vocabulary },
207
235
  )
208
236
  return runChain(order, providers)
209
237
  }
210
238
 
239
+ // Полировка транскрипта через модель харнесса (#35). Ошибка/таймаут не
240
+ // блокирует: возвращаем сырой текст.
241
+ async function polishText(text, modeCfg, signal) {
242
+ if (!modeCfg || modeCfg.polish !== true) return text
243
+ try {
244
+ const llm = ctx.llm
245
+ if (!llm || typeof llm.stream !== 'function') return text
246
+ const ask =
247
+ 'Fix the punctuation and spelling of this dictated text and split it into '
248
+ + 'paragraphs where the speaker changes topic. Remove filler words ("um", "uh", '
249
+ + '"ээ", "ну", "как бы"). Keep the original language, wording and meaning. '
250
+ + 'Reply with the polished text only:\n\n' + text
251
+ let acc = ''
252
+ for await (const chunk of llm.stream({ messages: [{ role: 'user', content: ask }], signal })) {
253
+ acc += (chunk && (chunk.text || (chunk.delta && chunk.delta.text))) || ''
254
+ }
255
+ const clean = acc.trim()
256
+ return clean || text
257
+ } catch { return text }
258
+ }
259
+
211
260
  ctx.effect(() => ctx.webServer.register({
212
261
  kind: 'exact',
213
262
  path: '/dsh-voice/status',
@@ -224,9 +273,23 @@ export function apply(ctx, baseConfig) {
224
273
  .map((c) => String(c && c.key || '').trim()).filter(Boolean),
225
274
  ),
226
275
  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 },
276
+ dictation: {
277
+ chain: cfg.dictation.chain, language: cfg.dictation.language,
278
+ vadSilenceMs: cfg.dictation.vadSilenceMs,
279
+ sendDelayMs: cfg.dictation.sendDelayMs, polish: cfg.dictation.polish,
280
+ stream: cfg.dictation.stream, streamChunkMs: cfg.dictation.streamChunkMs,
281
+ vadAdapt: cfg.dictation.vadAdapt,
282
+ },
283
+ message: {
284
+ chain: cfg.message.chain, language: cfg.message.language,
285
+ autoSendMs: cfg.message.autoSendMs, polish: cfg.message.polish,
286
+ },
229
287
  },
288
+ beep: cfg.beep,
289
+ localOnly: cfg.localOnly,
290
+ micDeviceId: cfg.micDeviceId,
291
+ historyLimit: cfg.historyLimit,
292
+ voiceCommands: cfg.voiceCommands,
230
293
  })
231
294
  },
232
295
  }), 'dsh-voice: /status route')
@@ -270,7 +333,8 @@ export function apply(ctx, baseConfig) {
270
333
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
271
334
  try {
272
335
  const out = await transcribe(modeCfg, bytes, mime, controller.signal)
273
- writeJson(res, 200, { ok: true, text: out.text, provider: out.provider, tookMs: out.tookMs })
336
+ const text = await polishText(out.text, modeCfg, controller.signal)
337
+ writeJson(res, 200, { ok: true, text, provider: out.provider, tookMs: out.tookMs })
274
338
  } catch (e) {
275
339
  writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
276
340
  } finally {
@@ -319,7 +383,9 @@ export function apply(ctx, baseConfig) {
319
383
  const mime = MIME_BY_EXT[path.extname(filePath).toLowerCase()] || 'audio/wav'
320
384
  const bytes = await readFile(filePath)
321
385
  const modeCfg = { ...cfg.message, language: String(args.language || cfg.message.language) }
322
- const out = await transcribe(modeCfg, bytes, mime, exec.signal)
386
+ let raw = await transcribe(modeCfg, bytes, mime, exec.signal)
387
+ if (raw && raw.text) raw = { ...raw, text: await polishText(raw.text, modeCfg, exec.signal) }
388
+ const out = raw
323
389
  if (cfg.normalizeTranscript && out && out.text) {
324
390
  out.text = normalizePhrase(out.text, {
325
391
  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.6",
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",