@goodandready/dsh-voice 0.8.18 → 0.8.19

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/index.js CHANGED
@@ -1,16 +1,16 @@
1
- // dsh-voice — хост-половина.
1
+ // dsh-voice — host half.
2
2
  //
3
- // Два режима ввода голосом, у каждого своя цепочка провайдеров:
4
- // dictation — браузер режет речь по паузам и шлёт куски, текст дописывается
5
- // в строку ввода;
6
- // message — одна запись целиком, текст уходит агенту после окна отмены.
3
+ // Two voice input modes, each with its own provider fallback chain:
4
+ // dictation — the browser cuts speech on pauses and posts chunks; text is
5
+ // appended to the composer;
6
+ // message — one whole recording; text is sent after the cancel window.
7
7
  //
8
- // Роуты:
8
+ // Routes:
9
9
  // POST /dsh-voice/transcribe {dataBase64, mimeType, mode} -> {ok, text, provider, tookMs}
10
10
  // GET /dsh-voice/status -> {ok, whisperRunning, modes}
11
11
  //
12
- // Ключи провайдеров читаются на хосте через ctx.credentials и в браузер не
13
- // попадают.
12
+ // Provider keys are resolved on the host via ctx.credentials and never reach
13
+ // the browser.
14
14
 
15
15
  import z from '@deepseek-ai/schemastery'
16
16
  import { defineTool } from '@deepseek-ai/dsh-tools'
@@ -42,7 +42,7 @@ const ChainEntry = z.object({
42
42
  .description('Model override. Empty means the provider default.'),
43
43
  })
44
44
 
45
- // Свой провайдер: всё, что нужно, чтобы сходить в чужой OpenAI-совместимый API.
45
+ // Custom provider: everything needed to call any OpenAI-compatible API.
46
46
  const CustomProvider = z.object({
47
47
  key: z.string().default('')
48
48
  .description('Name used in the chains above. Must differ from the built-in keys.'),
@@ -168,7 +168,7 @@ function writeJson(res, code, body) {
168
168
  try {
169
169
  res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
170
170
  res.end(JSON.stringify(body))
171
- } catch { /* сокет мог закрыться */ }
171
+ } catch { /* socket may already be closed */ }
172
172
  }
173
173
 
174
174
  function readBody(req, maxBytes) {
@@ -188,11 +188,10 @@ function readBody(req, maxBytes) {
188
188
  export function apply(ctx, baseConfig) {
189
189
  let child = null
190
190
 
191
- // Карточка настроек правит namespace с именем плагина. Пока хост его не
192
- // объявил через settings.register, снимок приходит пустым и нередактируемым:
193
- // поля серые, цепочки пустые, сохранять некуда. Чтение через live() заодно
194
- // означает, что правка применяется к следующему запросу, а не после
195
- // перезапуска процесса.
191
+ // The settings card edits a namespace named after the plugin. Until the
192
+ // host registers it via settings.register the snapshot is empty and
193
+ // read-only. Reading through live() means edits apply to the next request
194
+ // instead of requiring a process restart.
196
195
  let getConfig = () => baseConfig
197
196
  const live = () => Config(structuredClone(getConfig() ?? {})) ?? baseConfig
198
197
 
@@ -206,7 +205,7 @@ export function apply(ctx, baseConfig) {
206
205
  try {
207
206
  const resolved = await ctx.credentials.resolve(credentialRef(ref))
208
207
  if (resolved && resolved.value) return resolved.value
209
- } catch { /* падаем в окружение */ }
208
+ } catch { /* fall through to environment */ }
210
209
  return process.env[ref] || ''
211
210
  }
212
211
 
@@ -223,8 +222,8 @@ export function apply(ctx, baseConfig) {
223
222
  async function startWhisper() {
224
223
  const cfg = live()
225
224
  if (!cfg.autoStart) return false
226
- // Без пути к модели запускать нечего: пакет не знает, где она лежит у
227
- // конкретного пользователя, и молча стартовать чужой бинарь не должен.
225
+ // Without a model path there is nothing to start: the package cannot know
226
+ // where a user's model lives and must not launch a random binary.
228
227
  if (!cfg.whisperModel) return false
229
228
  if (await whisperAlive()) return true
230
229
  try {
@@ -245,7 +244,7 @@ export function apply(ctx, baseConfig) {
245
244
 
246
245
  startWhisper().catch(() => {})
247
246
 
248
- // SenseVoice-ONNX / Sherpa-ONNX: автозапуск и проверка здоровья.
247
+ // SenseVoice-ONNX / Sherpa-ONNX: autostart and health check.
249
248
  let sensevoiceChild = null
250
249
 
251
250
  async function sensevoiceAlive() {
@@ -257,7 +256,7 @@ export function apply(ctx, baseConfig) {
257
256
  const t = setTimeout(() => controller.abort(), 2000)
258
257
  const res = await fetch(base + '/', { signal: controller.signal })
259
258
  clearTimeout(t)
260
- return res.ok || res.status === 404 // sherpa-onnx отвечает 404 на /, но живой
259
+ return res.ok || res.status === 404 // sherpa-onnx answers 404 on / but is alive
261
260
  } catch { return false }
262
261
  }
263
262
 
@@ -286,15 +285,15 @@ export function apply(ctx, baseConfig) {
286
285
 
287
286
  startSensevoice().catch(() => {})
288
287
 
289
- // Статистика здоровья и задержек провайдеров (Latency & Health Dashboard).
288
+ // Provider health and latency stats (Latency & Health Dashboard).
290
289
  const statsTracker = createStatsTracker()
291
290
 
292
- // Общий путь распознавания: собрать провайдеров по цепочке режима и пройти её.
291
+ // Shared recognition path: build providers from the mode chain and run it.
293
292
  async function transcribe(modeCfg, bytes, mime, signal, contextWords) {
294
293
  const cfg = live()
295
294
  const customKeys = (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
296
295
  .map((c) => String(c && c.key || '').trim()).filter(Boolean)
297
- // Режим «только локальный whisper»: цепочка обрезается до него.
296
+ // localOnly mode: restrict the chain to local-whisper.
298
297
  const chain = cfg.localOnly
299
298
  ? (modeCfg.chain || []).filter((e) => e.provider === 'local-whisper')
300
299
  : (modeCfg.chain || [])
@@ -303,8 +302,8 @@ export function apply(ctx, baseConfig) {
303
302
  for (const entry of chain) {
304
303
  if (!KNOWN_KEYS.includes(entry.provider) && !customKeys.includes(entry.provider)) continue
305
304
  order.push(entry.provider)
306
- // Для своего провайдера модель по умолчанию живёт в его описании,
307
- // подставит makeProvidersздесь пусто означает «бери оттуда».
305
+ // Custom provider default models live in their spec; makeProviders fills
306
+ // them inempty here means "use the provider default".
308
307
  models[entry.provider] = entry.model || DEFAULT_MODELS[entry.provider] || ''
309
308
  }
310
309
  if (cfg.localOnly && order.length === 0) {
@@ -322,8 +321,8 @@ export function apply(ctx, baseConfig) {
322
321
  return runChain(order, providers, statsTracker.record)
323
322
  }
324
323
 
325
- // Полировка транскрипта (#35) с поддержкой локального LLM (#47).
326
- // Ошибка/таймаут не блокирует: возвращаем сырой текст.
324
+ // Transcript polish (#35) with local LLM support (#47).
325
+ // Errors/timeouts do not block: return the raw text.
327
326
  async function polishText(text, modeCfg, signal) {
328
327
  const enable = modeCfg && (modeCfg.polish === true || modeCfg.polishSend === true)
329
328
  if (!enable || !text) return text
@@ -334,7 +333,7 @@ export function apply(ctx, baseConfig) {
334
333
  + '"ээ", "ну", "как бы"). Keep the original language, wording and meaning. '
335
334
  + 'Reply with the polished text only:\n\n' + text
336
335
  try {
337
- // Локальный OpenAI-совместимый эндпоинт (#47), когда задан.
336
+ // Local OpenAI-compatible endpoint (#47) when configured.
338
337
  if (cfg.polishBaseUrl) {
339
338
  const headers = { 'content-type': 'application/json' }
340
339
  if (cfg.polishKeyEnv) {
@@ -353,11 +352,11 @@ export function apply(ctx, baseConfig) {
353
352
  && data.choices[0].message.content
354
353
  return (pick && String(pick).trim()) || text
355
354
  }
356
- // Штатная модель харнесса (DSH 0.1.2-alpha.1 API).
355
+ // Harness model (DSH 0.1.2-alpha.1 API).
357
356
  const llm = ctx.llm
358
357
  if (!llm || typeof llm.stream !== 'function') return text
359
- // provider/model обязательны в GenerateOptions; берём дефолт из
360
- // agentDefaultModel, если не заданы явно.
358
+ // provider/model are required in GenerateOptions; fall back to
359
+ // agentDefaultModel when not set explicitly.
361
360
  const sel = (ctx.get && ctx.get('agentDefaultModel') && ctx.get('agentDefaultModel').currentSelection)
362
361
  ? ctx.get('agentDefaultModel').currentSelection()
363
362
  : null
@@ -377,8 +376,8 @@ export function apply(ctx, baseConfig) {
377
376
  } catch { return text }
378
377
  }
379
378
 
380
- // Чистые голосовые команды сессии (#48). Возвращает название команды или null.
381
- // Распознаются на сервере, чтобы не попадать в текст композера.
379
+ // Clean session voice commands (#48). Returns the command name or null.
380
+ // Matched on the host so they never enter the composer text.
382
381
  const SESSION_COMMANDS = [
383
382
  { re: /^(отправь|отправить|пошли|send)\s*[.!?]*$/i, cmd: 'send' },
384
383
  { re: /^(отмени|отмена|cancel|отменить)\s*[.!?]*$/i, cmd: 'cancel' },
@@ -403,7 +402,7 @@ export function apply(ctx, baseConfig) {
403
402
  writeJson(res, 200, {
404
403
  ok: true,
405
404
  whisperRunning: await whisperAlive(),
406
- // Клавиша нужна браузерной половине: она вешает обработчик удержания.
405
+ // The hotkey is needed by the browser half: it installs the hold handler.
407
406
  hotkey: cfg.hotkey,
408
407
  providers: KNOWN_KEYS.concat(
409
408
  (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
@@ -451,7 +450,7 @@ export function apply(ctx, baseConfig) {
451
450
  let raw
452
451
  try { raw = await readBody(req, cfg.maxFileBytes + 1024 * 1024) } catch (e) { writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return }
453
452
  let payload = {}
454
- try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* пусто */ }
453
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
455
454
  const text = typeof payload.text === 'string' ? payload.text.trim() : ''
456
455
  if (!text) { writeJson(res, 400, { ok: false, error: { code: 'empty', message: 'text required' } }); return }
457
456
  const controller = new AbortController()
@@ -494,12 +493,12 @@ export function apply(ctx, baseConfig) {
494
493
  writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio is ${bytes.length} bytes, max ${cfg.maxFileBytes}` } }); return
495
494
  }
496
495
 
497
- // Локальный whisper в цепочкеподнимаем сервер заранее, иначе первый
498
- // же чанк уйдёт в отказ, пока сервер стартует.
496
+ // Local whisper in the chain start the server up front, otherwise the
497
+ // first chunk fails while the server is still booting.
499
498
  if ((modeCfg.chain || []).some((e) => e.provider === 'local-whisper') && !(await whisperAlive())) {
500
499
  await startWhisper()
501
500
  }
502
- // Аналогично для SenseVoice-ONNX / Sherpa-ONNX.
501
+ // Same for SenseVoice-ONNX / Sherpa-ONNX.
503
502
  if ((modeCfg.chain || []).some((e) => e.provider === 'sensevoice') && !(await sensevoiceAlive())) {
504
503
  await startSensevoice()
505
504
  }
@@ -508,8 +507,8 @@ export function apply(ctx, baseConfig) {
508
507
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
509
508
  try {
510
509
  const out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
511
- // Голосовые команды сессии (#48): чистые «отправь/отмени/стоп/продолжи»
512
- // не становятся текстом, а возвращаются командой для браузера.
510
+ // Session voice commands (#48): clean "send/cancel/stop/continue"
511
+ // become a command for the browser instead of composer text.
513
512
  if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
514
513
  const cmd = sessionCommand(out.text)
515
514
  if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
@@ -578,7 +577,7 @@ export function apply(ctx, baseConfig) {
578
577
  )
579
578
 
580
579
  ctx.effect(() => () => {
581
- if (child) { try { child.kill && child.kill() } catch { /* уже мёртв */ } }
582
- if (sensevoiceChild) { try { sensevoiceChild.kill && sensevoiceChild.kill() } catch { /* уже мёртв */ } }
580
+ if (child) { try { child.kill && child.kill() } catch { /* already dead */ } }
581
+ if (sensevoiceChild) { try { sensevoiceChild.kill && sensevoiceChild.kill() } catch { /* already dead */ } }
583
582
  }, 'dsh-voice: stop child processes')
584
583
  }
package/lib/normalize.js CHANGED
@@ -1,5 +1,5 @@
1
- // Постобработка распознанного текста перед вставкой в композер.
2
- // Без сети и без cordis — чистые функции, чтобы поведение проверить юнитами.
1
+ // Transcript post-processing before inserting into the composer.
2
+ // No network and no cordis — pure functions for unit tests.
3
3
 
4
4
  export function capitalizeSentences(text) {
5
5
  return text.replace(/(^|[.!?\n]\s+)([a-zа-яё])/gi, (m, lead, ch) => lead + ch.toUpperCase())
package/lib/providers.js CHANGED
@@ -1,27 +1,24 @@
1
- // Провайдеры распознавания речи: четыре встроенных плюс любые свои, объявленные
2
- // в настройках. Чистые функции: сеть приходит параметром (fetchImpl), ключи —
3
- // через resolveKey, поэтому всё проверяется без реальных запросов.
1
+ // Speech recognition providers: built-ins plus custom ones from settings.
2
+ // Pure functions: network arrives as fetchImpl, keys via resolveKey, so
3
+ // everything is testable without real requests.
4
4
 
5
- // 'browser' стоит в этом списке, но работает не здесь: речь распознаёт сам
6
- // браузер, до хоста звук не доходит. Ключ нужен, чтобы такую цепочку принимал
7
- // и валидатор настроек, и перебор ниже иначе цепочка ['browser', 'groq'] на
8
- // хосте оборвалась бы на первом же шаге вместо перехода к groq.
5
+ // 'browser' is listed for chain validation only: recognition runs in the
6
+ // page and audio never reaches the host. Without this key a ['browser','groq']
7
+ // chain would break on the host instead of falling through to groq.
9
8
  export const PROVIDER_KEYS = ['browser', 'deepgram', 'groq', 'hf', 'local-whisper', 'sensevoice']
10
9
 
11
- // Свой провайдер описывается одним из двух шаблонов, потому что
12
- // OpenAI-совместимые API разошлись: у OpenRouter, например, нет
13
- // /audio/transcriptions вовсе, и распознавание там идёт через чат.
10
+ // Custom providers use one of two templates because OpenAI-compatible APIs
11
+ // diverged: OpenRouter has no /audio/transcriptions and recognizes via chat.
14
12
  export const CUSTOM_TEMPLATES = ['openai-transcriptions', 'openai-chat-audio']
15
13
 
16
- // Готовые провайдеры: то же самое, что свой провайдер, только адрес, модель и
17
- // имя ключа уже проставлены. Нужен лишь ключ.
14
+ // Presets: the same as a custom provider with address, model and key name
15
+ // already filled in. Only the key itself is required.
18
16
  //
19
- // Каждый адрес проверен запросом без ключа: все отвечают 401 «дайте ключ», то
20
- // есть путь существует. Модель разумная отправная точка, её можно заменить
21
- // в строке цепочки, не трогая остальное.
17
+ // Each URL answers 401 without a key, so the path exists. The model is a
18
+ // reasonable starting point and can be overridden per chain row.
22
19
  //
23
- // Своим провайдером с тем же именем можно перекрыть любую заготовку целиком:
24
- // это не встроенный движок, а всего лишь заранее заполненная анкета.
20
+ // A custom provider with the same name fully replaces a preset: presets are
21
+ // prefilled forms, not special engines.
25
22
  export const PRESET_PROVIDERS = {
26
23
  openai: {
27
24
  template: 'openai-transcriptions',
@@ -53,7 +50,7 @@ export const PRESET_PROVIDERS = {
53
50
  model: 'voxtral-mini-latest',
54
51
  keyEnv: 'MISTRAL_API_KEY',
55
52
  },
56
- // У OpenRouter нет /audio/transcriptions вовсетолько через чат.
53
+ // OpenRouter has no /audio/transcriptions at all chat only.
57
54
  openrouter: {
58
55
  template: 'openai-chat-audio',
59
56
  baseURL: 'https://openrouter.ai/api/v1',
@@ -64,15 +61,15 @@ export const PRESET_PROVIDERS = {
64
61
 
65
62
  export const PRESET_KEYS = Object.keys(PRESET_PROVIDERS)
66
63
 
67
- /** Все имена, которые можно ставить в цепочку без объявления своего провайдера. */
64
+ /** All names usable in a chain without declaring a custom provider. */
68
65
  export const KNOWN_KEYS = PROVIDER_KEYS.concat(PRESET_KEYS)
69
66
 
70
67
  const CHAT_AUDIO_PROMPT =
71
68
  'Transcribe the audio verbatim. Reply with the transcript text only, '
72
69
  + 'without comments, quotes or formatting.'
73
70
 
74
- // Форматы, которые чат-шаблон принимает в input_audio. Всё остальное —
75
- // включая webm/opus, который пишет браузер, сначала перегоняем в WAV.
71
+ // Formats the chat template accepts in input_audio. Everything else
72
+ // (including browser webm/opus) is converted to WAV first.
76
73
  function chatAudioFormat(mime) {
77
74
  if (mime.includes('wav')) return 'wav'
78
75
  if (mime.includes('mpeg') || mime.includes('mp3')) return 'mp3'
@@ -99,8 +96,8 @@ function fileName(mime) {
99
96
  return 'audio.webm'
100
97
  }
101
98
 
102
- // Автоязык: пусто, 'auto' или список ('ru,en') провайдер определяет сам,
103
- // поле language не отправляется. whisper.cpp при этом получает -l auto.
99
+ // Auto language: empty, 'auto', or a list ('ru,en') means the provider
100
+ // detects language; the language field is omitted. whisper.cpp gets -l auto.
104
101
  function isAutoLang(lang) {
105
102
  return !lang || lang === 'auto' || String(lang).includes(',')
106
103
  }
@@ -167,8 +164,8 @@ export function makeProviders(deps, req) {
167
164
  return { ok: text.length > 0, provider: 'hf', text, reason: text ? '' : 'empty transcript' }
168
165
  }
169
166
 
170
- // whisper.cpp принимает только WAV — на webm/opus из браузера его сервер
171
- // отвечает "Invalid request". Перегоняем, если формат не WAV.
167
+ // whisper.cpp accepts WAV only browser webm/opus gets "Invalid request".
168
+ // Convert when the format is not WAV.
172
169
  async function localWhisper() {
173
170
  let sendBytes = bytes
174
171
  let sendMime = mime
@@ -195,11 +192,11 @@ export function makeProviders(deps, req) {
195
192
  if (vocab) form.append('prompt', vocab)
196
193
  form.append('response_format', 'json')
197
194
  const res = await fetchImpl(cfg.whisperUrl, { method: 'POST', body: form, signal })
198
- // whisper.cpp отвечает 400 с JSON-телом на внутренних сбоях (например, не
199
- // смог декодировать аудио) — читаем причину, а не бросаем исключение.
195
+ // whisper.cpp returns 400 with a JSON body on internal failures (e.g. decode
196
+ // errors) — read the reason instead of throwing.
200
197
  if (!res.ok) {
201
198
  let detail = `HTTP ${res.status}`
202
- try { const e = await res.json(); if (e?.error) detail = e.error } catch { /* тело не json */ }
199
+ try { const e = await res.json(); if (e?.error) detail = e.error } catch { /* body is not json */ }
203
200
  return { ok: false, provider: 'local-whisper', reason: `local whisper: ${detail}` }
204
201
  }
205
202
  const data = await res.json()
@@ -207,8 +204,8 @@ export function makeProviders(deps, req) {
207
204
  return { ok: text.length > 0, provider: 'local-whisper', text, reason: text ? '' : 'empty transcript' }
208
205
  }
209
206
 
210
- // SenseVoice-ONNX / Sherpa-ONNX: сверхбыстрое локальное распознавание (~50-100ms).
211
- // Поддерживает эндпоинты sherpa-onnx (/api/v1/asr) и OpenAI-совместимые (/audio/transcriptions).
207
+ // SenseVoice-ONNX / Sherpa-ONNX: ultra-fast local recognition (~50-100ms).
208
+ // Supports sherpa-onnx (/api/v1/asr) and OpenAI-compatible endpoints.
212
209
  async function sensevoice() {
213
210
  const url = String(cfg.sensevoiceUrl || 'http://127.0.0.1:6006/api/v1/asr').trim()
214
211
  let sendBytes = bytes
@@ -244,7 +241,7 @@ export function makeProviders(deps, req) {
244
241
 
245
242
  if (!res.ok) {
246
243
  let detail = `HTTP ${res.status}`
247
- try { const e = await res.json(); if (e?.error) detail = e.error } catch { /* тело не json */ }
244
+ try { const e = await res.json(); if (e?.error) detail = e.error } catch { /* body is not json */ }
248
245
  return { ok: false, provider: 'sensevoice', reason: `sensevoice: ${detail}` }
249
246
  }
250
247
 
@@ -256,7 +253,7 @@ export function makeProviders(deps, req) {
256
253
  }
257
254
 
258
255
  let text = String(data?.text || data?.transcript || '').trim()
259
- // Очищаем служебные теги эмоций и звуковых событий SenseVoice (<|NEUTRAL|>, <|HAPPY|>, <|Speech|> и др.)
256
+ // Strip SenseVoice emotion/event tags (<|NEUTRAL|>, <|HAPPY|>, <|Speech|>, ...).
260
257
  text = text.replace(/<\|[^|>]+\|>/g, '').trim()
261
258
  return {
262
259
  ok: text.length > 0,
@@ -266,8 +263,8 @@ export function makeProviders(deps, req) {
266
263
  }
267
264
  }
268
265
 
269
- // Свой провайдер. Ключ в цепочке его имя, поэтому в остальном коде он
270
- // ничем не отличается от встроенного.
266
+ // Custom provider. The chain key is its name, so the rest of the code
267
+ // treats it like a built-in.
271
268
  function customProvider(spec) {
272
269
  const label = spec.key
273
270
  const base = String(spec.baseURL || '').replace(/\/+$/, '')
@@ -338,27 +335,27 @@ export function makeProviders(deps, req) {
338
335
  ? await viaChatAudio(headers)
339
336
  : await viaTranscriptions(headers)
340
337
  } catch (e) {
341
- // Отказ одного провайдера не должен ронять цепочкуона сама решит,
342
- // идти дальше или сдаться.
338
+ // One provider failure must not abort the chain the chain decides
339
+ // whether to continue or give up.
343
340
  return { ok: false, provider: label, reason: `${label}: ${String(e && e.message || e)}` }
344
341
  }
345
342
  return { ok: text.length > 0, provider: label, text, reason: text ? '' : 'empty transcript' }
346
343
  }
347
344
  }
348
345
 
349
- // Если звук всё-таки доехал до хоста с 'browser' в цепочке, значит
350
- // браузерная нога не сработала: отказываем понятно и идём к следующему.
346
+ // If audio still reached the host with 'browser' in the chain, the browser
347
+ // leg failed: fail clearly and move on.
351
348
  async function browser() {
352
349
  return {
353
350
  ok: false,
354
351
  provider: 'browser',
355
- reason: 'browser: распознавание идёт в браузере, на хосте его нет',
352
+ reason: 'browser: recognition runs in the page, the host has no browser engine',
356
353
  }
357
354
  }
358
355
 
359
356
  const out = { browser, deepgram, groq, hf, 'local-whisper': localWhisper, sensevoice }
360
357
 
361
- // Заготовки: те же свои провайдеры, только анкета заполнена заранее.
358
+ // Presets: custom providers with the form pre-filled.
362
359
  for (const key of PRESET_KEYS) {
363
360
  if (out[key]) continue
364
361
  out[key] = customProvider({ ...PRESET_PROVIDERS[key], key })
@@ -366,9 +363,8 @@ export function makeProviders(deps, req) {
366
363
 
367
364
  for (const spec of Array.isArray(cfg.customProviders) ? cfg.customProviders : []) {
368
365
  const key = String(spec && spec.key || '').trim()
369
- // Встроенные движки не перекрываем: опечатка в имени тихо подменила бы
370
- // рабочего провайдера в чужой цепочке. А заготовку перекрыть можно она
371
- // для того и заготовка, чтобы её правили.
366
+ // Do not override built-in engines: a typo would silently replace a working
367
+ // provider in a chain. Presets may be overriddenthat is their purpose.
372
368
  if (!key || PROVIDER_KEYS.includes(key)) continue
373
369
  out[key] = customProvider({ ...spec, key })
374
370
  }
package/lib/stats.js CHANGED
@@ -1,5 +1,5 @@
1
- // dsh-voice — чистые утилиты метрик провайдеров и объединения контекстного словаря.
2
- // Без cordis и без сетитестируются юнит-тестами.
1
+ // dsh-voice — pure helpers for provider metrics and context vocabulary.
2
+ // No cordis and no networkunit-tested.
3
3
 
4
4
  export function createStatsTracker() {
5
5
  const stats = new Map()
package/lib/wav.js CHANGED
@@ -1,15 +1,15 @@
1
- // Перегон произвольного аудио в WAV 16 кГц моно.
1
+ // Convert arbitrary audio to 16 kHz mono WAV.
2
2
  //
3
- // Локальный whisper.cpp принимает только WAV: на webm/opus, который пишет
4
- // браузер, его сервер отвечает "Invalid request". API-провайдеры webm едят
5
- // как есть, поэтому конвертация нужна ровно для локальной ноги цепочки.
3
+ // Local whisper.cpp accepts WAV only: browser webm/opus gets "Invalid
4
+ // request". Cloud providers accept webm as-is, so conversion is only needed
5
+ // for the local leg of the chain.
6
6
 
7
7
  import { spawn } from 'node:child_process'
8
8
 
9
9
  /**
10
- * @param bytes {Buffer|Uint8Array} исходное аудио в любом контейнере
11
- * @param ffmpegBin {string} путь к ffmpeg
12
- * @returns {Promise<Buffer>} WAV 16 кГц моно
10
+ * @param bytes {Buffer|Uint8Array} source audio in any container
11
+ * @param ffmpegBin {string} ffmpeg binary path
12
+ * @returns {Promise<Buffer>} 16 kHz mono WAV
13
13
  */
14
14
  export function toWav16k(bytes, ffmpegBin = 'ffmpeg') {
15
15
  return new Promise((resolve, reject) => {
@@ -33,7 +33,7 @@ export function toWav16k(bytes, ffmpegBin = 'ffmpeg') {
33
33
  if (wav.length < 64) { reject(new Error('ffmpeg produced empty output')); return }
34
34
  resolve(wav)
35
35
  })
36
- proc.stdin.on('error', () => { /* ffmpeg закрыл вход раньшекод возврата всё расскажет */ })
36
+ proc.stdin.on('error', () => { /* ffmpeg closed stdin earlythe exit code tells the story */ })
37
37
  proc.stdin.end(Buffer.from(bytes))
38
38
  })
39
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.8.18",
3
+ "version": "0.8.19",
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",
@@ -39,7 +39,9 @@
39
39
  "url": "https://github.com/GooDAnDReaDY/dsh-voice/issues"
40
40
  },
41
41
  "scripts": {
42
- "test": "node --test test/*.test.mjs"
42
+ "test": "node --test test/*.test.mjs",
43
+ "build:client": "node scripts/build-client.mjs",
44
+ "pretest": "node scripts/build-client.mjs"
43
45
  },
44
46
  "dsh": {
45
47
  "bundle": {