@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 +198 -9
- package/lib/index.js +67 -9
- package/lib/providers.js +5 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: {
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|