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