@goodandready/dsh-voice 0.8.5 → 0.8.7
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/README.md +6 -1
- package/lib/client.js +179 -7
- package/lib/index.js +102 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -149,7 +149,12 @@ The card has four blocks:
|
|
|
149
149
|
- **Your own providers** — an OpenAI-compatible API per card: name, template,
|
|
150
150
|
base URL, model, credential name. The name becomes selectable in both chains
|
|
151
151
|
as soon as it is filled in.
|
|
152
|
-
- **General** — local whisper endpoint, binary, model, autostart
|
|
152
|
+
- **General** — local whisper endpoint, binary, model, autostart, beep, localOnly,
|
|
153
|
+
microphone, custom vocabulary, offline polish endpoint (`polishBaseUrl`/`polishModel`/`polishKeyEnv`).
|
|
154
|
+
- **Voice message** also has `polishSend` (polish the whole draft before sending) and
|
|
155
|
+
`sessionCommands` ("send", "cancel", "stop", "continue" act on the session instead of text).
|
|
156
|
+
- **Dictation** also has a wake word: browser recognition starts recording when speech
|
|
157
|
+
begins with that phrase (empty disables it).
|
|
153
158
|
|
|
154
159
|
Speed matters for dictation and accuracy for messages, which is why the chains
|
|
155
160
|
are separate: a sensible pair is Deepgram → Groq → local for dictation and
|
package/lib/client.js
CHANGED
|
@@ -96,6 +96,22 @@ window.__ModuleLoader__.load({
|
|
|
96
96
|
'vocabulary': 'Custom vocabulary (one word per line)',
|
|
97
97
|
'polish': 'Polish transcript with model',
|
|
98
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
|
+
'wakeWord': 'Wake word',
|
|
105
|
+
'wakeWordHint': 'Browser recognition starts recording when speech begins with this phrase. Empty = off',
|
|
106
|
+
'bargeIn': 'Barge-in',
|
|
107
|
+
'polishSend': 'Polish whole draft before sending',
|
|
108
|
+
'polishSendHint': 'Run the composed draft through the model right before sending',
|
|
109
|
+
'sessionCommands': 'Voice session commands',
|
|
110
|
+
'sessionCommandsHint': '"send", "cancel", "stop", "continue" act on the session instead of becoming text',
|
|
111
|
+
'polishBaseUrl': 'Offline polish endpoint',
|
|
112
|
+
'polishBaseUrlHint': 'OpenAI-compatible /chat/completions base URL, e.g. a local Ollama. Empty = harness model',
|
|
113
|
+
'polishModel': 'Offline polish model',
|
|
114
|
+
'polishKeyEnv': 'Offline polish key credential',
|
|
99
115
|
'voiceCommandsLabel': 'Voice edit commands ("new line", "paragraph")',
|
|
100
116
|
'normalizeTranscriptHint': 'transcribe_audio: spoken numbers to digits, tidy punctuation',
|
|
101
117
|
'messageTitle': 'Voice message',
|
|
@@ -189,6 +205,22 @@ window.__ModuleLoader__.load({
|
|
|
189
205
|
'vocabulary': 'Свой словарь (одно слово в строке)',
|
|
190
206
|
'polish': 'Полировка текста моделью',
|
|
191
207
|
'polishHint': 'Пунктуация и слова-паразиты через модель харнесса перед вставкой',
|
|
208
|
+
'stream': 'Непрерывная диктовка',
|
|
209
|
+
'streamHint': 'Резать фразы по таймеру во время речи, а не по длинной паузе',
|
|
210
|
+
'streamChunkMs': 'Кусок потока (мс)',
|
|
211
|
+
'vadAdapt': 'Адаптивная тишина',
|
|
212
|
+
'vadAdaptHint': 'Авто-подстройка порога под темп речи. 0 = фикс. поведение',
|
|
213
|
+
'wakeWord': 'Слово-активатор',
|
|
214
|
+
'wakeWordHint': 'Распознавание в браузере начинает запись, если речь начинается с этой фразы. Пусто = выкл',
|
|
215
|
+
'bargeIn': 'Перебивание',
|
|
216
|
+
'polishSend': 'Полировка всего текста перед отправкой',
|
|
217
|
+
'polishSendHint': 'Прогнать весь текст через модель перед отправкой агенту',
|
|
218
|
+
'sessionCommands': 'Голосовые команды сессии',
|
|
219
|
+
'sessionCommandsHint': '«отправь», «отмени», «стоп», «продолжи» — действия сессии, а не текст',
|
|
220
|
+
'polishBaseUrl': 'Локальный ендпоинт полировки',
|
|
221
|
+
'polishBaseUrlHint': 'OpenAI-совместимый /chat/completions базовый URL, напр. локальный Ollama. Пусто = модель харнесса',
|
|
222
|
+
'polishModel': 'Модель офлайн-полировки',
|
|
223
|
+
'polishKeyEnv': 'Ключ офлайн-полировки',
|
|
192
224
|
'voiceCommandsLabel': 'Голосовые команды («с новой строки», «абзац»)',
|
|
193
225
|
'normalizeTranscriptHint': 'transcribe_audio: числа словами — в цифры, аккуратная пунктуация',
|
|
194
226
|
'messageTitle': 'Голосовое сообщение',
|
|
@@ -247,7 +279,7 @@ window.__ModuleLoader__.load({
|
|
|
247
279
|
pending: null, // {text, leftMs} — окно отмены режима message
|
|
248
280
|
inputActions: null,
|
|
249
281
|
input: null,
|
|
250
|
-
settings: { vadSilenceMs: 700, autoSendMs: 4000 },
|
|
282
|
+
settings: { vadSilenceMs: 700, autoSendMs: 4000, stream: false, streamChunkMs: 1200, vadAdapt: 0 },
|
|
251
283
|
listeners: new Set(),
|
|
252
284
|
notify() { this.listeners.forEach((l) => l()) },
|
|
253
285
|
set(patch) { Object.assign(this, patch); this.notify() },
|
|
@@ -309,7 +341,8 @@ window.__ModuleLoader__.load({
|
|
|
309
341
|
if (!res.ok || !parsed || !parsed.ok) {
|
|
310
342
|
throw new Error((parsed && parsed.error && parsed.error.message) || `HTTP ${res.status}`)
|
|
311
343
|
}
|
|
312
|
-
|
|
344
|
+
if (parsed.command) return { command: parsed.command, text: '' }
|
|
345
|
+
return { text: String(parsed.text || '').trim(), command: null }
|
|
313
346
|
}
|
|
314
347
|
|
|
315
348
|
function tidyPhrase(text) {
|
|
@@ -535,6 +568,7 @@ window.__ModuleLoader__.load({
|
|
|
535
568
|
audioCtx: null, analyser: null,
|
|
536
569
|
cutting: false, closing: false,
|
|
537
570
|
silenceMs: 0, hadSpeech: false,
|
|
571
|
+
streamMs: 0,
|
|
538
572
|
}
|
|
539
573
|
recorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) rec.chunks.push(e.data) }
|
|
540
574
|
const AC = typeof AudioContext !== 'undefined' ? AudioContext
|
|
@@ -573,6 +607,7 @@ window.__ModuleLoader__.load({
|
|
|
573
607
|
rec.chunks = []
|
|
574
608
|
rec.silenceMs = 0
|
|
575
609
|
rec.hadSpeech = false
|
|
610
|
+
rec.streamMs = 0
|
|
576
611
|
if (!rec.closing) {
|
|
577
612
|
try { rec.recorder.start() } catch (e) { /* поток закрылся */ }
|
|
578
613
|
}
|
|
@@ -604,7 +639,22 @@ window.__ModuleLoader__.load({
|
|
|
604
639
|
voice.browser = startBrowserRecognition({
|
|
605
640
|
lang: language,
|
|
606
641
|
continuous: true,
|
|
607
|
-
onInterim: (text) => {
|
|
642
|
+
onInterim: (text) => {
|
|
643
|
+
voice.caption = text; voice.notify()
|
|
644
|
+
// Wake-word (#45): если interim начинается с ключевой фразы —
|
|
645
|
+
// обрываем прослушивание browser и уходим в обычную запись.
|
|
646
|
+
const ww = String(voice.settings.wakeWord || '').trim().toLowerCase()
|
|
647
|
+
if (ww && mode === 'dictation' && !voice.rec) {
|
|
648
|
+
const t = String(text || '').trim().toLowerCase()
|
|
649
|
+
if (t.startsWith(ww)) {
|
|
650
|
+
voice.browser = null
|
|
651
|
+
voice.caption = ''
|
|
652
|
+
openMic(mode)
|
|
653
|
+
.then((rec) => { voice.rec = rec; voice.notify() })
|
|
654
|
+
.catch((err) => voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null }))
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
},
|
|
608
658
|
onFinal: (text) => {
|
|
609
659
|
finals.push(text)
|
|
610
660
|
voice.caption = ''
|
|
@@ -761,7 +811,9 @@ window.__ModuleLoader__.load({
|
|
|
761
811
|
if (blob.size < 1200) { voice.set({ phase: 'idle' }); return }
|
|
762
812
|
voice.set({ phase: 'processing' })
|
|
763
813
|
try {
|
|
764
|
-
const
|
|
814
|
+
const out = await sendAudio(blob, rec.mime, mode)
|
|
815
|
+
if (out.command) { runSessionCommand(out.command); return }
|
|
816
|
+
const text = out.text || ''
|
|
765
817
|
if (!text) { voice.set({ phase: 'error', error: t('nothingHeard') }); return }
|
|
766
818
|
appendDraft(text)
|
|
767
819
|
if (mode === 'message') {
|
|
@@ -779,8 +831,46 @@ window.__ModuleLoader__.load({
|
|
|
779
831
|
voice.pending = null
|
|
780
832
|
voice.set({ phase: 'idle' })
|
|
781
833
|
const actions = voice.inputActions
|
|
782
|
-
if (actions
|
|
783
|
-
|
|
834
|
+
if (!actions || typeof actions.submit !== 'function') return
|
|
835
|
+
// Полировка всего draft перед отправкой (#46). Ошибка не блокирует.
|
|
836
|
+
if (voice.settings.polishSend) {
|
|
837
|
+
const run = async () => {
|
|
838
|
+
try {
|
|
839
|
+
const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
|
|
840
|
+
if (draft.trim()) {
|
|
841
|
+
const res = await fetch('/dsh-voice/polish', {
|
|
842
|
+
method: 'POST',
|
|
843
|
+
headers: { 'content-type': 'application/json' },
|
|
844
|
+
body: JSON.stringify({ text: draft }),
|
|
845
|
+
})
|
|
846
|
+
const parsed = await res.json().catch(() => null)
|
|
847
|
+
if (parsed && parsed.ok && typeof parsed.text === 'string' && parsed.text.trim()) {
|
|
848
|
+
actions.setDraft(parsed.text.trim())
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
} catch (e) { /* полировка best-effort */ }
|
|
852
|
+
}
|
|
853
|
+
run().finally(() => setTimeout(() => { try { actions.submit() } catch (e) { /* занят */ } }, 0))
|
|
854
|
+
return
|
|
855
|
+
}
|
|
856
|
+
setTimeout(() => { try { actions.submit() } catch (e) { /* композер занят */ } }, 0)
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Голосовые команды сессии (#48): чистые «отправь/отмени/стоп/продолжи».
|
|
860
|
+
function runSessionCommand(cmd) {
|
|
861
|
+
const actions = voice.inputActions
|
|
862
|
+
voice.set({ phase: 'idle', pending: null })
|
|
863
|
+
switch (cmd) {
|
|
864
|
+
case 'send':
|
|
865
|
+
case 'continue':
|
|
866
|
+
if (actions && typeof actions.submit === 'function') actions.submit()
|
|
867
|
+
break
|
|
868
|
+
case 'cancel':
|
|
869
|
+
case 'stop':
|
|
870
|
+
// Сбрасываем ожидание/запись; текст draft намеренно не трогаем.
|
|
871
|
+
break
|
|
872
|
+
default:
|
|
873
|
+
break
|
|
784
874
|
}
|
|
785
875
|
}
|
|
786
876
|
|
|
@@ -833,7 +923,29 @@ window.__ModuleLoader__.load({
|
|
|
833
923
|
else if (rec.hadSpeech) rec.silenceMs += tick
|
|
834
924
|
const speaking = level > 0.06
|
|
835
925
|
if (voice.speaking !== speaking) { voice.speaking = speaking; voice.notify() }
|
|
836
|
-
|
|
926
|
+
const adapt = Number(voice.settings.vadAdapt) || 0
|
|
927
|
+
let effectiveVad = Number(voice.settings.vadSilenceMs) || 700
|
|
928
|
+
if (adapt > 0 && rec.hadSpeech) {
|
|
929
|
+
// Адаптивный порог (#41): считаем плотность речи за последние
|
|
930
|
+
// ~1s (20 сэмплов). Плотная речь -> порог ниже (точнее режем),
|
|
931
|
+
// паузная -> порог растёт к базе (не режем на вдохе).
|
|
932
|
+
const win = voice.levels.slice(-20)
|
|
933
|
+
const density = win.length ? win.filter((v) => v > 0.06).length / win.length : 0
|
|
934
|
+
const k = adapt * (density - 0.5) * 2
|
|
935
|
+
effectiveVad = Math.max(150, Math.round(Number(voice.settings.vadSilenceMs) * (1 - k)))
|
|
936
|
+
}
|
|
937
|
+
// Непрерывный режим (#40): режем по таймеру непрерывной речи,
|
|
938
|
+
// не дожидаясь длинной паузы.
|
|
939
|
+
const stream = !!voice.settings.stream && rec.mode === 'dictation'
|
|
940
|
+
if (stream && rec.hadSpeech && !rec.cutting) {
|
|
941
|
+
rec.streamMs += tick
|
|
942
|
+
const chunk = Number(voice.settings.streamChunkMs) || 1200
|
|
943
|
+
if (rec.streamMs >= chunk) { cutPhrase(); return }
|
|
944
|
+
} else {
|
|
945
|
+
rec.streamMs = 0
|
|
946
|
+
}
|
|
947
|
+
if (rec.mode === 'dictation' && rec.hadSpeech && rec.silenceMs >= effectiveVad) {
|
|
948
|
+
rec.streamMs = 0
|
|
837
949
|
cutPhrase()
|
|
838
950
|
}
|
|
839
951
|
}, tick)
|
|
@@ -973,6 +1085,13 @@ window.__ModuleLoader__.load({
|
|
|
973
1085
|
historyLimit: Number(data && data.historyLimit),
|
|
974
1086
|
voiceCommands: !!(data && data.voiceCommands),
|
|
975
1087
|
sendDelayMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.sendDelayMs) || 0,
|
|
1088
|
+
stream: !!(data && data.modes && data.modes.dictation && data.modes.dictation.stream),
|
|
1089
|
+
streamChunkMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.streamChunkMs) || 1200,
|
|
1090
|
+
vadAdapt: Number(data && data.modes && data.modes.dictation && data.modes.dictation.vadAdapt) || 0,
|
|
1091
|
+
wakeWord: String((data && data.wakeWord) || ''),
|
|
1092
|
+
bargeIn: !!(data && data.bargeIn),
|
|
1093
|
+
polishSend: !!(data && data.modes && data.modes.message && data.modes.message.polishSend),
|
|
1094
|
+
sessionCommands: !!(data && data.modes && data.modes.message && data.modes.message.sessionCommands),
|
|
976
1095
|
})
|
|
977
1096
|
})
|
|
978
1097
|
.catch(() => { /* без подсказки хоста клавиши просто не будет */ })
|
|
@@ -1227,6 +1346,14 @@ window.__ModuleLoader__.load({
|
|
|
1227
1346
|
historyLimit: Number(snap && snap.historyLimit),
|
|
1228
1347
|
voiceCommands: !!(snap && snap.voiceCommands),
|
|
1229
1348
|
sendDelayMs: Number(value && value.dictation && value.dictation.sendDelayMs) || 0,
|
|
1349
|
+
stream: !!(value && value.dictation && value.dictation.stream),
|
|
1350
|
+
streamChunkMs: Number(value && value.dictation && value.dictation.streamChunkMs) || 1200,
|
|
1351
|
+
vadAdapt: Number(value && value.dictation && value.dictation.vadAdapt) || 0,
|
|
1352
|
+
wakeWord: String((snap && snap.wakeWord) || ''),
|
|
1353
|
+
bargeIn: !!(snap && snap.bargeIn),
|
|
1354
|
+
polishSend: !!(value && value.message && value.message.polishSend),
|
|
1355
|
+
sessionCommands: !!(value && value.message && value.message.sessionCommands),
|
|
1356
|
+
polishBaseUrl: String((snap && snap.polishBaseUrl) || ''),
|
|
1230
1357
|
})
|
|
1231
1358
|
}, [ready, value, snap])
|
|
1232
1359
|
|
|
@@ -1274,6 +1401,9 @@ window.__ModuleLoader__.load({
|
|
|
1274
1401
|
historyLimit: Number(draft.historyLimit),
|
|
1275
1402
|
voiceCommands: !!draft.voiceCommands,
|
|
1276
1403
|
sendDelayMs: Number(draft.dictation && draft.dictation.sendDelayMs) || 0,
|
|
1404
|
+
stream: !!(draft.dictation && draft.dictation.stream),
|
|
1405
|
+
streamChunkMs: Number(draft.dictation && draft.dictation.streamChunkMs) || 1200,
|
|
1406
|
+
vadAdapt: Number(draft.dictation && draft.dictation.vadAdapt) || 0,
|
|
1277
1407
|
})
|
|
1278
1408
|
// Композер держит обработчик клавиши: пусть перечитает настройку,
|
|
1279
1409
|
// иначе новая клавиша заработает только после перезагрузки страницы.
|
|
@@ -1370,6 +1500,23 @@ window.__ModuleLoader__.load({
|
|
|
1370
1500
|
onChange: (e) => setIn('dictation', 'polish', e.target.checked),
|
|
1371
1501
|
})),
|
|
1372
1502
|
React.createElement('div', { className: 'dvs-sub' }, t('polishHint')),
|
|
1503
|
+
React.createElement('label', { className: 'dvs-field', title: t('wakeWordHint') }, t('wakeWord'),
|
|
1504
|
+
React.createElement('input', {
|
|
1505
|
+
type: 'text', value: String((snap && snap.wakeWord) || ''), disabled: !writable,
|
|
1506
|
+
onChange: (e) => setTop('wakeWord', e.target.value),
|
|
1507
|
+
})),
|
|
1508
|
+
React.createElement('label', { className: 'dvs-field', title: t('streamHint') }, t('stream'),
|
|
1509
|
+
React.createElement('input', {
|
|
1510
|
+
type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.stream), disabled: !writable,
|
|
1511
|
+
onChange: (e) => setIn('dictation', 'stream', e.target.checked),
|
|
1512
|
+
})),
|
|
1513
|
+
numField('dictation', 'streamChunkMs', t('streamChunkMs'), t('streamHint')),
|
|
1514
|
+
React.createElement('label', { className: 'dvs-field', title: t('vadAdaptHint') }, t('vadAdapt'),
|
|
1515
|
+
React.createElement('input', {
|
|
1516
|
+
type: 'range', min: 0, max: 1, step: 0.1,
|
|
1517
|
+
value: Number(draft && draft.dictation && draft.dictation.vadAdapt) || 0, disabled: !writable,
|
|
1518
|
+
onChange: (e) => setIn('dictation', 'vadAdapt', Number(e.target.value)),
|
|
1519
|
+
})),
|
|
1373
1520
|
),
|
|
1374
1521
|
React.createElement('div', { className: 'dvs-block' },
|
|
1375
1522
|
React.createElement('div', { className: 'dvs-h' }, t('messageTitle')),
|
|
@@ -1386,6 +1533,16 @@ window.__ModuleLoader__.load({
|
|
|
1386
1533
|
type: 'checkbox', checked: !!(draft && draft.message && draft.message.polish), disabled: !writable,
|
|
1387
1534
|
onChange: (e) => setIn('message', 'polish', e.target.checked),
|
|
1388
1535
|
})),
|
|
1536
|
+
React.createElement('label', { className: 'dvs-field', title: t('polishSendHint') }, t('polishSend'),
|
|
1537
|
+
React.createElement('input', {
|
|
1538
|
+
type: 'checkbox', checked: !!(draft && draft.message && draft.message.polishSend), disabled: !writable,
|
|
1539
|
+
onChange: (e) => setIn('message', 'polishSend', e.target.checked),
|
|
1540
|
+
})),
|
|
1541
|
+
React.createElement('label', { className: 'dvs-field', title: t('sessionCommandsHint') }, t('sessionCommands'),
|
|
1542
|
+
React.createElement('input', {
|
|
1543
|
+
type: 'checkbox', checked: !!(draft && draft.message && draft.message.sessionCommands), disabled: !writable,
|
|
1544
|
+
onChange: (e) => setIn('message', 'sessionCommands', e.target.checked),
|
|
1545
|
+
})),
|
|
1389
1546
|
),
|
|
1390
1547
|
React.createElement('div', { className: 'dvs-block' },
|
|
1391
1548
|
React.createElement('div', { className: 'dvs-h' }, t('customTitle')),
|
|
@@ -1435,6 +1592,21 @@ window.__ModuleLoader__.load({
|
|
|
1435
1592
|
value: Array.isArray(draft && draft.vocabulary) ? draft.vocabulary.join('\n') : '',
|
|
1436
1593
|
onChange: (e) => setTop('vocabulary', e.target.value.split('\n').map((x) => x.trim()).filter(Boolean)),
|
|
1437
1594
|
})),
|
|
1595
|
+
React.createElement('label', { className: 'dvs-field', title: t('polishBaseUrlHint') }, t('polishBaseUrl'),
|
|
1596
|
+
React.createElement('input', {
|
|
1597
|
+
type: 'text', value: String((snap && snap.polishBaseUrl) || ''), disabled: !writable,
|
|
1598
|
+
onChange: (e) => setTop('polishBaseUrl', e.target.value),
|
|
1599
|
+
})),
|
|
1600
|
+
React.createElement('label', { className: 'dvs-field' }, t('polishModel'),
|
|
1601
|
+
React.createElement('input', {
|
|
1602
|
+
type: 'text', value: String((draft && draft.polishModel) || ''), disabled: !writable,
|
|
1603
|
+
onChange: (e) => setTop('polishModel', e.target.value),
|
|
1604
|
+
})),
|
|
1605
|
+
React.createElement('label', { className: 'dvs-field' }, t('polishKeyEnv'),
|
|
1606
|
+
React.createElement('input', {
|
|
1607
|
+
type: 'text', value: String((draft && draft.polishKeyEnv) || ''), disabled: !writable,
|
|
1608
|
+
onChange: (e) => setTop('polishKeyEnv', e.target.value),
|
|
1609
|
+
})),
|
|
1438
1610
|
),
|
|
1439
1611
|
React.createElement('div', { className: 'dvs-row' },
|
|
1440
1612
|
React.createElement('button', { type: 'button', className: 'dvs-save', disabled: !writable, onClick: save }, t('save')),
|
package/lib/index.js
CHANGED
|
@@ -69,6 +69,12 @@ export const Config = z.object({
|
|
|
69
69
|
.description('Dictation: wait this many ms after a phrase before appending it, with a cancel window. 0 disables the delay.'),
|
|
70
70
|
polish: z.boolean().default(false)
|
|
71
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.'),
|
|
72
78
|
}).default({}),
|
|
73
79
|
message: z.object({
|
|
74
80
|
chain: z.array(ChainEntry)
|
|
@@ -77,6 +83,10 @@ export const Config = z.object({
|
|
|
77
83
|
language: z.string().default('ru'),
|
|
78
84
|
autoSendMs: z.number().default(4000)
|
|
79
85
|
.description('Cancel window before the recognized text is sent to the agent.'),
|
|
86
|
+
sessionCommands: z.boolean().default(false)
|
|
87
|
+
.description('Voice session commands: a clean "send", "cancel", "stop", "continue" does not become text — it drives the composer/session.'),
|
|
88
|
+
polishSend: z.boolean().default(false)
|
|
89
|
+
.description('Polish the whole composed draft through the model right before sending, not just single phrases.'),
|
|
80
90
|
}).default({}),
|
|
81
91
|
hotkey: z.string().default('Control')
|
|
82
92
|
.description('Hold this key anywhere in the page to record a voice message; release to send, '
|
|
@@ -114,6 +124,16 @@ export const Config = z.object({
|
|
|
114
124
|
.description('Custom words (names, terms) hinted to providers so they recognize them correctly.'),
|
|
115
125
|
voiceCommands: z.boolean().default(false)
|
|
116
126
|
.description('During dictation, spoken edit commands ("new line", "paragraph") become real line breaks instead of words.'),
|
|
127
|
+
wakeWord: z.string().default('')
|
|
128
|
+
.description('Heads-free dictation: a phrase that, when recognized by the browser leg, starts a recording. Empty disables.'),
|
|
129
|
+
bargeIn: z.boolean().default(false)
|
|
130
|
+
.description('Ongoing playback or a long turn is interrupted by detected speech (browser leg).'),
|
|
131
|
+
polishBaseUrl: z.string().default('')
|
|
132
|
+
.description('Offline polish: OpenAI-compatible /chat/completions endpoint (e.g. local Ollama). Empty uses the harness model.'),
|
|
133
|
+
polishModel: z.string().default('')
|
|
134
|
+
.description('Offline polish: model id on polishBaseUrl.'),
|
|
135
|
+
polishKeyEnv: z.string().default('')
|
|
136
|
+
.description('Offline polish: credential name for the api key. Empty means no Authorization header.'),
|
|
117
137
|
})
|
|
118
138
|
|
|
119
139
|
const MIME_BY_EXT = {
|
|
@@ -230,18 +250,40 @@ export function apply(ctx, baseConfig) {
|
|
|
230
250
|
return runChain(order, providers)
|
|
231
251
|
}
|
|
232
252
|
|
|
233
|
-
// Полировка транскрипта
|
|
234
|
-
// блокирует: возвращаем сырой текст.
|
|
253
|
+
// Полировка транскрипта (#35) с поддержкой локального LLM (#47).
|
|
254
|
+
// Ошибка/таймаут не блокирует: возвращаем сырой текст.
|
|
235
255
|
async function polishText(text, modeCfg, signal) {
|
|
236
|
-
|
|
256
|
+
const enable = modeCfg && (modeCfg.polish === true || modeCfg.polishSend === true)
|
|
257
|
+
if (!enable || !text) return text
|
|
258
|
+
const cfg = live()
|
|
259
|
+
const ask =
|
|
260
|
+
'Fix the punctuation and spelling of this dictated text and split it into '
|
|
261
|
+
+ 'paragraphs where the speaker changes topic. Remove filler words ("um", "uh", '
|
|
262
|
+
+ '"ээ", "ну", "как бы"). Keep the original language, wording and meaning. '
|
|
263
|
+
+ 'Reply with the polished text only:\n\n' + text
|
|
237
264
|
try {
|
|
265
|
+
// Локальный OpenAI-совместимый эндпоинт (#47), когда задан.
|
|
266
|
+
if (cfg.polishBaseUrl) {
|
|
267
|
+
const headers = { 'content-type': 'application/json' }
|
|
268
|
+
if (cfg.polishKeyEnv) {
|
|
269
|
+
const key = await resolveKey(cfg.polishKeyEnv)
|
|
270
|
+
if (key) headers.authorization = 'Bearer ' + key
|
|
271
|
+
}
|
|
272
|
+
const res = await fetch(
|
|
273
|
+
String(cfg.polishBaseUrl).replace(/\/+$/, '') + '/chat/completions',
|
|
274
|
+
{ method: 'POST', headers, signal, body: JSON.stringify({
|
|
275
|
+
model: cfg.polishModel || 'local-model',
|
|
276
|
+
messages: [{ role: 'user', content: ask }],
|
|
277
|
+
}) })
|
|
278
|
+
if (!res.ok) return text
|
|
279
|
+
const data = await res.json().catch(() => null)
|
|
280
|
+
const pick = data && data.choices && data.choices[0] && data.choices[0].message
|
|
281
|
+
&& data.choices[0].message.content
|
|
282
|
+
return (pick && String(pick).trim()) || text
|
|
283
|
+
}
|
|
284
|
+
// Штатная модель харнесса.
|
|
238
285
|
const llm = ctx.llm
|
|
239
286
|
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
287
|
let acc = ''
|
|
246
288
|
for await (const chunk of llm.stream({ messages: [{ role: 'user', content: ask }], signal })) {
|
|
247
289
|
acc += (chunk && (chunk.text || (chunk.delta && chunk.delta.text))) || ''
|
|
@@ -251,6 +293,23 @@ export function apply(ctx, baseConfig) {
|
|
|
251
293
|
} catch { return text }
|
|
252
294
|
}
|
|
253
295
|
|
|
296
|
+
// Чистые голосовые команды сессии (#48). Возвращает название команды или null.
|
|
297
|
+
// Распознаются на сервере, чтобы не попадать в текст композера.
|
|
298
|
+
const SESSION_COMMANDS = [
|
|
299
|
+
{ re: /^(отправь|отправить|пошли|send)\s*[.!?]*$/i, cmd: 'send' },
|
|
300
|
+
{ re: /^(отмени|отмена|cancel|отменить)\s*[.!?]*$/i, cmd: 'cancel' },
|
|
301
|
+
{ re: /^(стоп|stop|хватит)\s*[.!?]*$/i, cmd: 'stop' },
|
|
302
|
+
{ re: /^(продолжи|continue|продолжай)\s*[.!?]*$/i, cmd: 'continue' },
|
|
303
|
+
]
|
|
304
|
+
function sessionCommand(text) {
|
|
305
|
+
const t = String(text || '').trim().toLowerCase()
|
|
306
|
+
if (!t) return null
|
|
307
|
+
for (const { re, cmd } of SESSION_COMMANDS) {
|
|
308
|
+
if (re.test(t)) return cmd
|
|
309
|
+
}
|
|
310
|
+
return null
|
|
311
|
+
}
|
|
312
|
+
|
|
254
313
|
ctx.effect(() => ctx.webServer.register({
|
|
255
314
|
kind: 'exact',
|
|
256
315
|
path: '/dsh-voice/status',
|
|
@@ -271,10 +330,13 @@ export function apply(ctx, baseConfig) {
|
|
|
271
330
|
chain: cfg.dictation.chain, language: cfg.dictation.language,
|
|
272
331
|
vadSilenceMs: cfg.dictation.vadSilenceMs,
|
|
273
332
|
sendDelayMs: cfg.dictation.sendDelayMs, polish: cfg.dictation.polish,
|
|
333
|
+
stream: cfg.dictation.stream, streamChunkMs: cfg.dictation.streamChunkMs,
|
|
334
|
+
vadAdapt: cfg.dictation.vadAdapt,
|
|
274
335
|
},
|
|
275
336
|
message: {
|
|
276
337
|
chain: cfg.message.chain, language: cfg.message.language,
|
|
277
338
|
autoSendMs: cfg.message.autoSendMs, polish: cfg.message.polish,
|
|
339
|
+
sessionCommands: cfg.message.sessionCommands, polishSend: cfg.message.polishSend,
|
|
278
340
|
},
|
|
279
341
|
},
|
|
280
342
|
beep: cfg.beep,
|
|
@@ -282,10 +344,36 @@ export function apply(ctx, baseConfig) {
|
|
|
282
344
|
micDeviceId: cfg.micDeviceId,
|
|
283
345
|
historyLimit: cfg.historyLimit,
|
|
284
346
|
voiceCommands: cfg.voiceCommands,
|
|
347
|
+
wakeWord: String(cfg.wakeWord || ''),
|
|
348
|
+
bargeIn: !!cfg.bargeIn,
|
|
349
|
+
polishBaseUrl: String(cfg.polishBaseUrl || ''),
|
|
285
350
|
})
|
|
286
351
|
},
|
|
287
352
|
}), 'dsh-voice: /status route')
|
|
288
353
|
|
|
354
|
+
ctx.effect(() => ctx.webServer.register({
|
|
355
|
+
kind: 'exact',
|
|
356
|
+
path: '/dsh-voice/polish',
|
|
357
|
+
handler: async (req, res) => {
|
|
358
|
+
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
359
|
+
const cfg = live()
|
|
360
|
+
let raw
|
|
361
|
+
try { raw = await readBody(req, cfg.maxFileBytes + 1024 * 1024) } catch (e) { writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return }
|
|
362
|
+
let payload = {}
|
|
363
|
+
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* пусто */ }
|
|
364
|
+
const text = typeof payload.text === 'string' ? payload.text.trim() : ''
|
|
365
|
+
if (!text) { writeJson(res, 400, { ok: false, error: { code: 'empty', message: 'text required' } }); return }
|
|
366
|
+
const controller = new AbortController()
|
|
367
|
+
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
368
|
+
try {
|
|
369
|
+
const out = await polishText(text, { polish: true }, controller.signal)
|
|
370
|
+
writeJson(res, 200, { ok: true, text: out })
|
|
371
|
+
} catch (e) {
|
|
372
|
+
writeJson(res, 502, { ok: false, error: { code: 'polish', message: String(e && e.message || e) } })
|
|
373
|
+
} finally { clearTimeout(timer) }
|
|
374
|
+
},
|
|
375
|
+
}), 'dsh-voice: /polish route')
|
|
376
|
+
|
|
289
377
|
ctx.effect(() => ctx.webServer.register({
|
|
290
378
|
kind: 'exact',
|
|
291
379
|
path: '/dsh-voice/transcribe',
|
|
@@ -325,6 +413,12 @@ export function apply(ctx, baseConfig) {
|
|
|
325
413
|
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
326
414
|
try {
|
|
327
415
|
const out = await transcribe(modeCfg, bytes, mime, controller.signal)
|
|
416
|
+
// Голосовые команды сессии (#48): чистые «отправь/отмени/стоп/продолжи»
|
|
417
|
+
// не становятся текстом, а возвращаются командой для браузера.
|
|
418
|
+
if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
|
|
419
|
+
const cmd = sessionCommand(out.text)
|
|
420
|
+
if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
|
|
421
|
+
}
|
|
328
422
|
const text = await polishText(out.text, modeCfg, controller.signal)
|
|
329
423
|
writeJson(res, 200, { ok: true, text, provider: out.provider, tookMs: out.tookMs })
|
|
330
424
|
} catch (e) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.7",
|
|
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",
|