@goodandready/dsh-voice 0.8.3 → 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 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 + ' ' + text : text)
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 ? t('listening') : hint))),
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
- return transcribe(modeCfg, bytes, mime, exec.signal)
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
  )
@@ -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",
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",