@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 +228 -10
- package/lib/index.js +81 -10
- package/lib/normalize.js +56 -0
- package/lib/providers.js +14 -3
- package/package.json +1 -1
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
|
-
|
|
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
|
|
420
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: {
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
)
|
package/lib/normalize.js
ADDED
|
@@ -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
|
+
"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",
|