@goodandready/dsh-voice 0.8.17 → 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.'),
@@ -155,12 +155,6 @@ export const Config = z.object({
155
155
  .description('SenseVoice / sherpa-onnx model path or model identifier.'),
156
156
  sensevoiceAutostart: z.boolean().default(false)
157
157
  .description('Start the local SenseVoice / sherpa-onnx server process automatically on startup.'),
158
- realtimeStreaming: z.boolean().default(false)
159
- .description('Enable low-latency streaming recognition (OpenAI Realtime API or Sherpa-ONNX).'),
160
- realtimeProvider: z.string().default('openai')
161
- .description('Realtime streaming provider: "openai" (OpenAI Realtime API) or "sherpa-onnx" (local streaming).'),
162
- realtimeModel: z.string().default('gpt-4o-realtime-preview')
163
- .description('Realtime model identifier.'),
164
158
  visualizerStyle: z.union(['liquid-wave', 'dynamic-orb', 'bars', 'off']).default('liquid-wave')
165
159
  .description('Audio visualizer animation style in the recording pill: "liquid-wave", "dynamic-orb", "bars", or "off".'),
166
160
  })
@@ -174,7 +168,7 @@ function writeJson(res, code, body) {
174
168
  try {
175
169
  res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
176
170
  res.end(JSON.stringify(body))
177
- } catch { /* сокет мог закрыться */ }
171
+ } catch { /* socket may already be closed */ }
178
172
  }
179
173
 
180
174
  function readBody(req, maxBytes) {
@@ -194,11 +188,10 @@ function readBody(req, maxBytes) {
194
188
  export function apply(ctx, baseConfig) {
195
189
  let child = null
196
190
 
197
- // Карточка настроек правит namespace с именем плагина. Пока хост его не
198
- // объявил через settings.register, снимок приходит пустым и нередактируемым:
199
- // поля серые, цепочки пустые, сохранять некуда. Чтение через live() заодно
200
- // означает, что правка применяется к следующему запросу, а не после
201
- // перезапуска процесса.
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.
202
195
  let getConfig = () => baseConfig
203
196
  const live = () => Config(structuredClone(getConfig() ?? {})) ?? baseConfig
204
197
 
@@ -212,7 +205,7 @@ export function apply(ctx, baseConfig) {
212
205
  try {
213
206
  const resolved = await ctx.credentials.resolve(credentialRef(ref))
214
207
  if (resolved && resolved.value) return resolved.value
215
- } catch { /* падаем в окружение */ }
208
+ } catch { /* fall through to environment */ }
216
209
  return process.env[ref] || ''
217
210
  }
218
211
 
@@ -229,8 +222,8 @@ export function apply(ctx, baseConfig) {
229
222
  async function startWhisper() {
230
223
  const cfg = live()
231
224
  if (!cfg.autoStart) return false
232
- // Без пути к модели запускать нечего: пакет не знает, где она лежит у
233
- // конкретного пользователя, и молча стартовать чужой бинарь не должен.
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.
234
227
  if (!cfg.whisperModel) return false
235
228
  if (await whisperAlive()) return true
236
229
  try {
@@ -251,7 +244,7 @@ export function apply(ctx, baseConfig) {
251
244
 
252
245
  startWhisper().catch(() => {})
253
246
 
254
- // SenseVoice-ONNX / Sherpa-ONNX: автозапуск и проверка здоровья.
247
+ // SenseVoice-ONNX / Sherpa-ONNX: autostart and health check.
255
248
  let sensevoiceChild = null
256
249
 
257
250
  async function sensevoiceAlive() {
@@ -263,7 +256,7 @@ export function apply(ctx, baseConfig) {
263
256
  const t = setTimeout(() => controller.abort(), 2000)
264
257
  const res = await fetch(base + '/', { signal: controller.signal })
265
258
  clearTimeout(t)
266
- return res.ok || res.status === 404 // sherpa-onnx отвечает 404 на /, но живой
259
+ return res.ok || res.status === 404 // sherpa-onnx answers 404 on / but is alive
267
260
  } catch { return false }
268
261
  }
269
262
 
@@ -292,15 +285,15 @@ export function apply(ctx, baseConfig) {
292
285
 
293
286
  startSensevoice().catch(() => {})
294
287
 
295
- // Статистика здоровья и задержек провайдеров (Latency & Health Dashboard).
288
+ // Provider health and latency stats (Latency & Health Dashboard).
296
289
  const statsTracker = createStatsTracker()
297
290
 
298
- // Общий путь распознавания: собрать провайдеров по цепочке режима и пройти её.
291
+ // Shared recognition path: build providers from the mode chain and run it.
299
292
  async function transcribe(modeCfg, bytes, mime, signal, contextWords) {
300
293
  const cfg = live()
301
294
  const customKeys = (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
302
295
  .map((c) => String(c && c.key || '').trim()).filter(Boolean)
303
- // Режим «только локальный whisper»: цепочка обрезается до него.
296
+ // localOnly mode: restrict the chain to local-whisper.
304
297
  const chain = cfg.localOnly
305
298
  ? (modeCfg.chain || []).filter((e) => e.provider === 'local-whisper')
306
299
  : (modeCfg.chain || [])
@@ -309,8 +302,8 @@ export function apply(ctx, baseConfig) {
309
302
  for (const entry of chain) {
310
303
  if (!KNOWN_KEYS.includes(entry.provider) && !customKeys.includes(entry.provider)) continue
311
304
  order.push(entry.provider)
312
- // Для своего провайдера модель по умолчанию живёт в его описании,
313
- // подставит makeProvidersздесь пусто означает «бери оттуда».
305
+ // Custom provider default models live in their spec; makeProviders fills
306
+ // them inempty here means "use the provider default".
314
307
  models[entry.provider] = entry.model || DEFAULT_MODELS[entry.provider] || ''
315
308
  }
316
309
  if (cfg.localOnly && order.length === 0) {
@@ -328,8 +321,8 @@ export function apply(ctx, baseConfig) {
328
321
  return runChain(order, providers, statsTracker.record)
329
322
  }
330
323
 
331
- // Полировка транскрипта (#35) с поддержкой локального LLM (#47).
332
- // Ошибка/таймаут не блокирует: возвращаем сырой текст.
324
+ // Transcript polish (#35) with local LLM support (#47).
325
+ // Errors/timeouts do not block: return the raw text.
333
326
  async function polishText(text, modeCfg, signal) {
334
327
  const enable = modeCfg && (modeCfg.polish === true || modeCfg.polishSend === true)
335
328
  if (!enable || !text) return text
@@ -340,7 +333,7 @@ export function apply(ctx, baseConfig) {
340
333
  + '"ээ", "ну", "как бы"). Keep the original language, wording and meaning. '
341
334
  + 'Reply with the polished text only:\n\n' + text
342
335
  try {
343
- // Локальный OpenAI-совместимый эндпоинт (#47), когда задан.
336
+ // Local OpenAI-compatible endpoint (#47) when configured.
344
337
  if (cfg.polishBaseUrl) {
345
338
  const headers = { 'content-type': 'application/json' }
346
339
  if (cfg.polishKeyEnv) {
@@ -359,11 +352,11 @@ export function apply(ctx, baseConfig) {
359
352
  && data.choices[0].message.content
360
353
  return (pick && String(pick).trim()) || text
361
354
  }
362
- // Штатная модель харнесса (DSH 0.1.2-alpha.1 API).
355
+ // Harness model (DSH 0.1.2-alpha.1 API).
363
356
  const llm = ctx.llm
364
357
  if (!llm || typeof llm.stream !== 'function') return text
365
- // provider/model обязательны в GenerateOptions; берём дефолт из
366
- // agentDefaultModel, если не заданы явно.
358
+ // provider/model are required in GenerateOptions; fall back to
359
+ // agentDefaultModel when not set explicitly.
367
360
  const sel = (ctx.get && ctx.get('agentDefaultModel') && ctx.get('agentDefaultModel').currentSelection)
368
361
  ? ctx.get('agentDefaultModel').currentSelection()
369
362
  : null
@@ -383,8 +376,8 @@ export function apply(ctx, baseConfig) {
383
376
  } catch { return text }
384
377
  }
385
378
 
386
- // Чистые голосовые команды сессии (#48). Возвращает название команды или null.
387
- // Распознаются на сервере, чтобы не попадать в текст композера.
379
+ // Clean session voice commands (#48). Returns the command name or null.
380
+ // Matched on the host so they never enter the composer text.
388
381
  const SESSION_COMMANDS = [
389
382
  { re: /^(отправь|отправить|пошли|send)\s*[.!?]*$/i, cmd: 'send' },
390
383
  { re: /^(отмени|отмена|cancel|отменить)\s*[.!?]*$/i, cmd: 'cancel' },
@@ -409,7 +402,7 @@ export function apply(ctx, baseConfig) {
409
402
  writeJson(res, 200, {
410
403
  ok: true,
411
404
  whisperRunning: await whisperAlive(),
412
- // Клавиша нужна браузерной половине: она вешает обработчик удержания.
405
+ // The hotkey is needed by the browser half: it installs the hold handler.
413
406
  hotkey: cfg.hotkey,
414
407
  providers: KNOWN_KEYS.concat(
415
408
  (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
@@ -442,9 +435,6 @@ export function apply(ctx, baseConfig) {
442
435
  sensevoiceUrl: cfg.sensevoiceUrl,
443
436
  sensevoiceAutostart: !!cfg.sensevoiceAutostart,
444
437
  deepgramBaseUrl: cfg.deepgramBaseUrl || 'https://api.deepgram.com',
445
- realtimeStreaming: !!cfg.realtimeStreaming,
446
- realtimeProvider: cfg.realtimeProvider || 'openai',
447
- realtimeModel: cfg.realtimeModel || 'gpt-4o-realtime-preview',
448
438
  visualizerStyle: cfg.visualizerStyle || 'liquid-wave',
449
439
  providerStats: statsTracker.get(),
450
440
  })
@@ -460,7 +450,7 @@ export function apply(ctx, baseConfig) {
460
450
  let raw
461
451
  try { raw = await readBody(req, cfg.maxFileBytes + 1024 * 1024) } catch (e) { writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return }
462
452
  let payload = {}
463
- try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* пусто */ }
453
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
464
454
  const text = typeof payload.text === 'string' ? payload.text.trim() : ''
465
455
  if (!text) { writeJson(res, 400, { ok: false, error: { code: 'empty', message: 'text required' } }); return }
466
456
  const controller = new AbortController()
@@ -503,12 +493,12 @@ export function apply(ctx, baseConfig) {
503
493
  writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio is ${bytes.length} bytes, max ${cfg.maxFileBytes}` } }); return
504
494
  }
505
495
 
506
- // Локальный whisper в цепочкеподнимаем сервер заранее, иначе первый
507
- // же чанк уйдёт в отказ, пока сервер стартует.
496
+ // Local whisper in the chain start the server up front, otherwise the
497
+ // first chunk fails while the server is still booting.
508
498
  if ((modeCfg.chain || []).some((e) => e.provider === 'local-whisper') && !(await whisperAlive())) {
509
499
  await startWhisper()
510
500
  }
511
- // Аналогично для SenseVoice-ONNX / Sherpa-ONNX.
501
+ // Same for SenseVoice-ONNX / Sherpa-ONNX.
512
502
  if ((modeCfg.chain || []).some((e) => e.provider === 'sensevoice') && !(await sensevoiceAlive())) {
513
503
  await startSensevoice()
514
504
  }
@@ -517,8 +507,8 @@ export function apply(ctx, baseConfig) {
517
507
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
518
508
  try {
519
509
  const out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
520
- // Голосовые команды сессии (#48): чистые «отправь/отмени/стоп/продолжи»
521
- // не становятся текстом, а возвращаются командой для браузера.
510
+ // Session voice commands (#48): clean "send/cancel/stop/continue"
511
+ // become a command for the browser instead of composer text.
522
512
  if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
523
513
  const cmd = sessionCommand(out.text)
524
514
  if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
@@ -586,226 +576,8 @@ export function apply(ctx, baseConfig) {
586
576
  }),
587
577
  )
588
578
 
589
- // ─── Realtime WebSocket Bridge ───────────────────────────────────────
590
- // Клиент открывает WS на /dsh-voice/realtime, шлёт бинарные аудио-чанки.
591
- // Хост открывает WS к провайдеру (OpenAI Realtime API или совместимому),
592
- // пересылает аудио и получает текстовые дельты обратно клиенту.
593
- // API-ключи никогда не покидают хост.
594
- const realtimeSessions = new Set()
595
-
596
- ctx.effect(() => ctx.webServer.register({
597
- kind: 'exact',
598
- path: '/dsh-voice/realtime',
599
- handler: async (req, res) => {
600
- const cfg = live()
601
- if (!cfg.realtimeStreaming) {
602
- writeJson(res, 403, { ok: false, error: { code: 'disabled', message: 'realtime streaming is disabled' } })
603
- return
604
- }
605
- // Только WebSocket upgrade
606
- if (!req.headers.upgrade || req.headers.upgrade.toLowerCase() !== 'websocket') {
607
- writeJson(res, 426, { ok: false, error: { code: 'upgrade', message: 'WebSocket upgrade required' } })
608
- return
609
- }
610
-
611
- // Получаем API-ключ для upstream-провайдера
612
- const provider = cfg.realtimeProvider || 'openai'
613
- let apiKey = ''
614
- if (provider === 'openai') {
615
- apiKey = await resolveKey('OPENAI_API_KEY')
616
- }
617
-
618
- // Определяем upstream WebSocket URL
619
- let upstreamUrl
620
- const model = cfg.realtimeModel || 'gpt-4o-realtime-preview'
621
- if (provider === 'openai') {
622
- upstreamUrl = `wss://api.openai.com/v1/realtime?model=${encodeURIComponent(model)}`
623
- } else if (provider === 'sherpa-onnx') {
624
- // Локальный sherpa-onnx WebSocket endpoint
625
- const base = String(cfg.sensevoiceUrl || '').replace(/^http/, 'ws').replace(/\/api\/.*$/, '').replace(/\/v1\/.*$/, '')
626
- upstreamUrl = base + '/ws/transcribe'
627
- } else {
628
- writeJson(res, 400, { ok: false, error: { code: 'provider', message: `unknown realtime provider: ${provider}` } })
629
- return
630
- }
631
-
632
- // Upgrade клиентский запрос в WebSocket через ручной handshake
633
- // Node.js http.Server не имеет встроенного WS — используем raw socket
634
- const crypto = await import('node:crypto')
635
- const wsKey = req.headers['sec-websocket-key']
636
- if (!wsKey) {
637
- res.writeHead(400); res.end('Missing Sec-WebSocket-Key'); return
638
- }
639
- const accept = crypto.createHash('sha1')
640
- .update(wsKey + '258EAFA5-E914-47DA-95CA-5AB5ADF35F20')
641
- .digest('base64')
642
-
643
- res.writeHead(101, {
644
- Upgrade: 'websocket',
645
- Connection: 'Upgrade',
646
- 'Sec-WebSocket-Accept': accept,
647
- })
648
-
649
- const clientSocket = req.socket
650
-
651
- // Простой WS frame parser/writer для бинарных и текстовых сообщений
652
- function writeWsFrame(socket, data, opcode = 0x01) {
653
- const payload = typeof data === 'string' ? Buffer.from(data, 'utf8') : data
654
- const len = payload.length
655
- let header
656
- if (len < 126) {
657
- header = Buffer.alloc(2)
658
- header[0] = 0x80 | opcode
659
- header[1] = len
660
- } else if (len < 65536) {
661
- header = Buffer.alloc(4)
662
- header[0] = 0x80 | opcode
663
- header[1] = 126
664
- header.writeUInt16BE(len, 2)
665
- } else {
666
- header = Buffer.alloc(10)
667
- header[0] = 0x80 | opcode
668
- header[1] = 127
669
- header.writeBigUInt64BE(BigInt(len), 2)
670
- }
671
- socket.write(Buffer.concat([header, payload]))
672
- }
673
-
674
- // Открываем upstream WebSocket через глобальный WebSocket (Node 22+)
675
- let upstream = null
676
- try {
677
- const headers = {}
678
- if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
679
- if (provider === 'openai') headers['OpenAI-Beta'] = 'realtime=v1'
680
- upstream = new WebSocket(upstreamUrl, { headers })
681
- } catch (e) {
682
- writeWsFrame(clientSocket, JSON.stringify({ type: 'error', message: String(e && e.message || e) }))
683
- clientSocket.end()
684
- return
685
- }
686
-
687
- const sessionId = crypto.randomUUID()
688
- realtimeSessions.add(sessionId)
689
-
690
- upstream.addEventListener('open', () => {
691
- writeWsFrame(clientSocket, JSON.stringify({ type: 'session.created', sessionId }))
692
- // Для OpenAI Realtime — отправить session.update с конфигурацией
693
- if (provider === 'openai') {
694
- upstream.send(JSON.stringify({
695
- type: 'session.update',
696
- session: {
697
- modalities: ['text'],
698
- input_audio_format: 'pcm16',
699
- input_audio_transcription: { model: 'whisper-1' },
700
- turn_detection: { type: 'server_vad' },
701
- },
702
- }))
703
- }
704
- })
705
-
706
- upstream.addEventListener('message', (event) => {
707
- // Пересылаем текстовые сообщения от upstream к клиенту
708
- const msg = typeof event.data === 'string' ? event.data : event.data.toString()
709
- try {
710
- const parsed = JSON.parse(msg)
711
- // Фильтруем и пересылаем только полезные события
712
- if (parsed.type === 'conversation.item.input_audio_transcription.completed'
713
- || parsed.type === 'response.audio_transcript.delta'
714
- || parsed.type === 'response.audio_transcript.done'
715
- || parsed.type === 'input_audio_buffer.speech_started'
716
- || parsed.type === 'input_audio_buffer.speech_stopped'
717
- || parsed.type === 'error') {
718
- writeWsFrame(clientSocket, msg)
719
- }
720
- } catch {
721
- // Не JSON — пересылаем как есть
722
- writeWsFrame(clientSocket, msg)
723
- }
724
- })
725
-
726
- upstream.addEventListener('error', () => {
727
- writeWsFrame(clientSocket, JSON.stringify({ type: 'error', message: 'upstream connection error' }))
728
- })
729
-
730
- upstream.addEventListener('close', () => {
731
- writeWsFrame(clientSocket, JSON.stringify({ type: 'session.closed' }))
732
- try { clientSocket.end() } catch { /* socket уже закрыт */ }
733
- realtimeSessions.delete(sessionId)
734
- })
735
-
736
- // Парсим входящие WS фреймы от клиента
737
- let frameBuf = Buffer.alloc(0)
738
- clientSocket.on('data', (chunk) => {
739
- frameBuf = Buffer.concat([frameBuf, chunk])
740
- while (frameBuf.length >= 2) {
741
- const opcode = frameBuf[0] & 0x0f
742
- const masked = !!(frameBuf[1] & 0x80)
743
- let payloadLen = frameBuf[1] & 0x7f
744
- let offset = 2
745
- if (payloadLen === 126) {
746
- if (frameBuf.length < 4) return
747
- payloadLen = frameBuf.readUInt16BE(2)
748
- offset = 4
749
- } else if (payloadLen === 127) {
750
- if (frameBuf.length < 10) return
751
- payloadLen = Number(frameBuf.readBigUInt64BE(2))
752
- offset = 10
753
- }
754
- if (masked) offset += 4
755
- if (frameBuf.length < offset + payloadLen) return
756
-
757
- let payload = frameBuf.subarray(offset, offset + payloadLen)
758
- if (masked) {
759
- const mask = frameBuf.subarray(offset - 4, offset)
760
- payload = Buffer.from(payload)
761
- for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3]
762
- }
763
- frameBuf = frameBuf.subarray(offset + payloadLen)
764
-
765
- if (opcode === 0x08) {
766
- // Close frame
767
- upstream.close()
768
- try { clientSocket.end() } catch { /* ok */ }
769
- realtimeSessions.delete(sessionId)
770
- return
771
- }
772
- if (opcode === 0x09) {
773
- // Ping → Pong
774
- writeWsFrame(clientSocket, payload, 0x0a)
775
- continue
776
- }
777
- if (opcode === 0x01) {
778
- // Текст: JSON команда от клиента → upstream
779
- try { upstream.send(payload.toString('utf8')) } catch { /* upstream закрыт */ }
780
- } else if (opcode === 0x02) {
781
- // Бинарные данные: аудио-чанк → upstream как input_audio_buffer.append
782
- if (provider === 'openai') {
783
- upstream.send(JSON.stringify({
784
- type: 'input_audio_buffer.append',
785
- audio: payload.toString('base64'),
786
- }))
787
- } else {
788
- // Для sherpa-onnx — отправляем бинарные данные напрямую
789
- try { upstream.send(payload) } catch { /* upstream закрыт */ }
790
- }
791
- }
792
- }
793
- })
794
-
795
- clientSocket.on('close', () => {
796
- try { upstream.close() } catch { /* ok */ }
797
- realtimeSessions.delete(sessionId)
798
- })
799
- clientSocket.on('error', () => {
800
- try { upstream.close() } catch { /* ok */ }
801
- realtimeSessions.delete(sessionId)
802
- })
803
- },
804
- }), 'dsh-voice: /realtime route')
805
-
806
579
  ctx.effect(() => () => {
807
- if (child) { try { child.kill && child.kill() } catch { /* уже мёртв */ } }
808
- if (sensevoiceChild) { try { sensevoiceChild.kill && sensevoiceChild.kill() } catch { /* уже мёртв */ } }
809
- for (const sid of realtimeSessions) realtimeSessions.delete(sid)
580
+ if (child) { try { child.kill && child.kill() } catch { /* already dead */ } }
581
+ if (sensevoiceChild) { try { sensevoiceChild.kill && sensevoiceChild.kill() } catch { /* already dead */ } }
810
582
  }, 'dsh-voice: stop child processes')
811
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())
@@ -29,7 +29,7 @@ const NUM_WORDS = {
29
29
  }
30
30
 
31
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')
32
+ const NUM_SEQ = new RegExp('(?<![а-яёa-z0-9])(?:' + NUM_NAMES + ')(?:\\s+(?:' + NUM_NAMES + '))*(?![а-яёa-z0-9])', 'gi')
33
33
 
34
34
  export function wordsToDigits(text) {
35
35
  return text.replace(NUM_SEQ, (m) => {