@goodandready/dsh-voice 0.8.2 → 0.8.4
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 +31 -2
- package/lib/index.js +15 -2
- package/lib/normalize.js +56 -0
- package/lib/providers.js +9 -3
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -80,6 +80,10 @@ 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
|
+
'normalizeTranscriptHint': 'transcribe_audio: spoken numbers to digits, tidy punctuation',
|
|
83
87
|
'messageTitle': 'Voice message',
|
|
84
88
|
'messageHint': 'One whole recording, sent to the agent once it is transcribed.',
|
|
85
89
|
'undoMs': 'Undo window, ms',
|
|
@@ -155,6 +159,10 @@ window.__ModuleLoader__.load({
|
|
|
155
159
|
'dictationHint': 'Речь режется по паузам, текст дописывается в строку ввода.',
|
|
156
160
|
'pauseMs': 'Пауза до конца фразы, мс',
|
|
157
161
|
'pauseHint': 'Меньше — чаще куски и быстрее текст, но выше риск обрезать слово',
|
|
162
|
+
'speaking': 'Вы говорите…',
|
|
163
|
+
'silence': 'Пауза…',
|
|
164
|
+
'normalizeTranscript': 'Нормализация расшифровок',
|
|
165
|
+
'normalizeTranscriptHint': 'transcribe_audio: числа словами — в цифры, аккуратная пунктуация',
|
|
158
166
|
'messageTitle': 'Голосовое сообщение',
|
|
159
167
|
'messageHint': 'Одна запись целиком, после распознавания уходит агенту.',
|
|
160
168
|
'undoMs': 'Окно отмены, мс',
|
|
@@ -276,14 +284,24 @@ window.__ModuleLoader__.load({
|
|
|
276
284
|
return String(parsed.text || '').trim()
|
|
277
285
|
}
|
|
278
286
|
|
|
287
|
+
function tidyPhrase(text) {
|
|
288
|
+
let s = String(text || '').trim()
|
|
289
|
+
if (!s) return s
|
|
290
|
+
s = s.replace(/\s*,\s*/g, ', ')
|
|
291
|
+
s = s.replace(/(^|[.!?\n]\s+)([a-zа-яё])/gi, (m, lead, ch) => lead + ch.toUpperCase())
|
|
292
|
+
return s
|
|
293
|
+
}
|
|
294
|
+
|
|
279
295
|
function appendDraft(text) {
|
|
280
296
|
const actions = voice.inputActions
|
|
281
297
|
if (!actions || typeof actions.setDraft !== 'function') {
|
|
282
298
|
voice.set({ phase: 'error', error: t('composerUnavailable') })
|
|
283
299
|
return
|
|
284
300
|
}
|
|
301
|
+
const clean = tidyPhrase(text)
|
|
302
|
+
if (!clean) return
|
|
285
303
|
const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
|
|
286
|
-
actions.setDraft(draft ? draft + ' ' +
|
|
304
|
+
actions.setDraft(draft ? draft + ' ' + clean : clean)
|
|
287
305
|
}
|
|
288
306
|
|
|
289
307
|
// Как клавиша называется на человеческом языке.
|
|
@@ -717,6 +735,8 @@ window.__ModuleLoader__.load({
|
|
|
717
735
|
if (voice.levels.length > 150) voice.levels.shift()
|
|
718
736
|
if (level > 0.06) { rec.hadSpeech = true; rec.silenceMs = 0 }
|
|
719
737
|
else if (rec.hadSpeech) rec.silenceMs += tick
|
|
738
|
+
const speaking = level > 0.06
|
|
739
|
+
if (voice.speaking !== speaking) { voice.speaking = speaking; voice.notify() }
|
|
720
740
|
if (rec.mode === 'dictation' && rec.hadSpeech && rec.silenceMs >= voice.settings.vadSilenceMs) {
|
|
721
741
|
cutPhrase()
|
|
722
742
|
}
|
|
@@ -770,6 +790,7 @@ window.__ModuleLoader__.load({
|
|
|
770
790
|
|
|
771
791
|
if (v.phase === 'recording') {
|
|
772
792
|
const inBrowser = !!voice.browser
|
|
793
|
+
const rec = voice.rec
|
|
773
794
|
const hint = v.mode === 'dictation' ? t('dictationPill') : t('messagePill')
|
|
774
795
|
// Живая подпись: что слышно прямо сейчас. Пока браузер не выдал
|
|
775
796
|
// окончательный кусок, текст черновой и меняется на глазах.
|
|
@@ -784,7 +805,9 @@ window.__ModuleLoader__.load({
|
|
|
784
805
|
? caption
|
|
785
806
|
: (voice.holding
|
|
786
807
|
? t('holdHint')
|
|
787
|
-
: (inBrowser
|
|
808
|
+
: (inBrowser
|
|
809
|
+
? t('listening')
|
|
810
|
+
: (rec && rec.hadSpeech ? (v.speaking ? t('speaking') : t('silence')) : hint)))),
|
|
788
811
|
React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('stop'), onClick: stopCurrent }, stopIcon()),
|
|
789
812
|
)
|
|
790
813
|
}
|
|
@@ -1217,6 +1240,12 @@ window.__ModuleLoader__.load({
|
|
|
1217
1240
|
type: 'checkbox', checked: !!(draft && draft.autoStart), disabled: !writable,
|
|
1218
1241
|
onChange: (e) => setTop('autoStart', e.target.checked),
|
|
1219
1242
|
})),
|
|
1243
|
+
React.createElement('label', { className: 'dvs-field', title: t('normalizeTranscriptHint') },
|
|
1244
|
+
t('normalizeTranscript'),
|
|
1245
|
+
React.createElement('input', {
|
|
1246
|
+
type: 'checkbox', checked: !!(draft && draft.normalizeTranscript), disabled: !writable,
|
|
1247
|
+
onChange: (e) => setTop('normalizeTranscript', e.target.checked),
|
|
1248
|
+
})),
|
|
1220
1249
|
),
|
|
1221
1250
|
React.createElement('div', { className: 'dvs-row' },
|
|
1222
1251
|
React.createElement('button', { type: 'button', className: 'dvs-save', disabled: !writable, onClick: save }, t('save')),
|
package/lib/index.js
CHANGED
|
@@ -20,6 +20,11 @@ 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
30
|
export const inject = ['tools', 'credentials', 'webServer', 'shell', 'settings']
|
|
@@ -91,6 +96,8 @@ export const Config = z.object({
|
|
|
91
96
|
.description('ffmpeg used to convert browser webm/opus into the WAV that whisper.cpp requires.'),
|
|
92
97
|
timeoutMs: z.number().default(120000),
|
|
93
98
|
maxFileBytes: z.number().default(25 * 1024 * 1024),
|
|
99
|
+
normalizeTranscript: z.boolean().default(false)
|
|
100
|
+
.description('transcribe_audio: convert spoken numbers to digits and tidy punctuation.'),
|
|
94
101
|
})
|
|
95
102
|
|
|
96
103
|
const MIME_BY_EXT = {
|
|
@@ -164,7 +171,7 @@ export function apply(ctx, baseConfig) {
|
|
|
164
171
|
try {
|
|
165
172
|
const spec = ctx.shell.resolve({
|
|
166
173
|
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}`,
|
|
174
|
+
+ ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${isAutoLang(cfg.dictation.language) ? 'auto' : cfg.dictation.language}`,
|
|
168
175
|
timeoutMs: 0,
|
|
169
176
|
stdoutMaxBytes: 4 * 1024 * 1024,
|
|
170
177
|
})
|
|
@@ -312,7 +319,13 @@ export function apply(ctx, baseConfig) {
|
|
|
312
319
|
const mime = MIME_BY_EXT[path.extname(filePath).toLowerCase()] || 'audio/wav'
|
|
313
320
|
const bytes = await readFile(filePath)
|
|
314
321
|
const modeCfg = { ...cfg.message, language: String(args.language || cfg.message.language) }
|
|
315
|
-
|
|
322
|
+
const out = await transcribe(modeCfg, bytes, mime, exec.signal)
|
|
323
|
+
if (cfg.normalizeTranscript && out && out.text) {
|
|
324
|
+
out.text = normalizePhrase(out.text, {
|
|
325
|
+
digits: true, capSentences: true, commaSpacing: true, trailingPeriod: true,
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
return out
|
|
316
329
|
},
|
|
317
330
|
}),
|
|
318
331
|
)
|
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,6 +98,12 @@ 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
|
|
@@ -107,7 +113,7 @@ export function makeProviders(deps, req) {
|
|
|
107
113
|
if (!key) return { ok: false, provider: 'deepgram', reason: `no ${cfg.deepgramKeyEnv}` }
|
|
108
114
|
const model = pickModel(models, 'deepgram')
|
|
109
115
|
const url = `https://api.deepgram.com/v1/listen?model=${encodeURIComponent(model)}`
|
|
110
|
-
+ `&language=${encodeURIComponent(lang)}&smart_format=true
|
|
116
|
+
+ (!isAutoLang(lang) ? `&language=${encodeURIComponent(lang)}` : '') + '&smart_format=true'
|
|
111
117
|
const res = await fetchImpl(url, {
|
|
112
118
|
method: 'POST',
|
|
113
119
|
headers: { authorization: `Token ${key}`, 'content-type': mime },
|
|
@@ -126,7 +132,7 @@ export function makeProviders(deps, req) {
|
|
|
126
132
|
const form = new FormData()
|
|
127
133
|
form.append('file', new Blob([bytes], { type: mime }), fileName(mime))
|
|
128
134
|
form.append('model', pickModel(models, 'groq'))
|
|
129
|
-
form.append('language', lang)
|
|
135
|
+
if (!isAutoLang(lang)) form.append('language', lang)
|
|
130
136
|
form.append('response_format', 'json')
|
|
131
137
|
const res = await fetchImpl('https://api.groq.com/openai/v1/audio/transcriptions', {
|
|
132
138
|
method: 'POST',
|
|
@@ -174,7 +180,7 @@ export function makeProviders(deps, req) {
|
|
|
174
180
|
}
|
|
175
181
|
const form = new FormData()
|
|
176
182
|
form.append('file', new Blob([sendBytes], { type: sendMime }), fileName(sendMime))
|
|
177
|
-
form.append('language', lang)
|
|
183
|
+
if (!isAutoLang(lang)) form.append('language', lang)
|
|
178
184
|
form.append('response_format', 'json')
|
|
179
185
|
const res = await fetchImpl(cfg.whisperUrl, { method: 'POST', body: form, signal })
|
|
180
186
|
// whisper.cpp отвечает 400 с JSON-телом на внутренних сбоях (например, не
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.4",
|
|
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",
|