@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/client.js CHANGED
@@ -1,13 +1,13 @@
1
- // dsh-voice — клиентская половина (браузер).
1
+ // dsh-voice — client half (browser).
2
2
  //
3
- // Две кнопки в conversation.input.right:
4
- // микрофон — диктовка: речь режется по паузам, каждый кусок распознаётся и
5
- // дописывается в строку ввода; отправка остаётся за пользователем;
6
- // волна — голосовое: одна запись целиком, после распознавания текст
7
- // уходит агенту по истечении окна отмены.
3
+ // Two buttons in conversation.input.right:
4
+ // mic — dictation: speech is cut on pauses, each chunk is recognized and
5
+ // appended to the composer; send stays with the user;
6
+ // wave — voice message: one whole recording; after recognition the text is
7
+ // sent to the agent when the cancel window expires.
8
8
  //
9
- // Полоска записи живёт в conversation.input.dock, страница настроек — в
10
- // settings.section.
9
+ // The recording pill lives in conversation.input.dock; settings are a card
10
+ // in settings.plugin.item.
11
11
 
12
12
  window.__ModuleLoader__.load({
13
13
  id: '@goodandready/dsh-voice',
@@ -19,9 +19,9 @@ window.__ModuleLoader__.load({
19
19
 
20
20
  const NS = 'dsh-voice'
21
21
 
22
- // Строки интерфейса живут в реестре локалей: так их переводит отдельный
23
- // пакет, не трогая код этого плагина. Английский — язык по умолчанию,
24
- // на него же приходится откат, если перевода нет.
22
+ // UI strings live in the locale registry so a separate package can
23
+ // translate them without touching this plugin. English is the source
24
+ // language, the default, and the fallback.
25
25
  const en = {
26
26
  'saveFailed': 'Some fields were not saved —',
27
27
  'cardHint': 'Dictation and voice messages: providers, chains, local whisper',
@@ -128,6 +128,7 @@ window.__ModuleLoader__.load({
128
128
  'whisperBin': 'Local whisper: binary',
129
129
  'whisperBinHint': 'used when autostart is on',
130
130
  'whisperModel': 'Local whisper: model',
131
+ 'whisperModelHint': 'Absolute path to the ggml model file',
131
132
  'whisperAutostart': 'Autostart the local whisper',
132
133
  'save': 'Save',
133
134
  'saved': 'Saved ✓',
@@ -154,14 +155,8 @@ window.__ModuleLoader__.load({
154
155
  'sensevoiceBin': 'SenseVoice: binary',
155
156
  'sensevoiceBinHint': 'used when autostart is on',
156
157
  'sensevoiceModel': 'SenseVoice: model path',
158
+ 'sensevoiceModelHint': 'Absolute path or identifier of the SenseVoice / sherpa-onnx model',
157
159
  'sensevoiceAutostart': 'Autostart the SenseVoice server',
158
- 'realtimeStreaming': 'Realtime audio streaming',
159
- 'realtimeStreamingHint': 'Low-latency streaming via WebSocket (OpenAI Realtime API / sherpa-onnx)',
160
- 'realtimeProvider': 'Realtime provider',
161
- 'realtimeModel': 'Realtime model',
162
- 'realtimeActive': 'Streaming live…',
163
- 'realtimeConnect': 'Start streaming',
164
- 'realtimeDisconnect': 'Stop streaming',
165
160
  'visualizerStyle': 'Audio visualizer style',
166
161
  'visualizerStyleHint': 'Waveform animation inside the recording pill',
167
162
  'visLiquidWave': 'Liquid Wave',
@@ -169,156 +164,9 @@ window.__ModuleLoader__.load({
169
164
  'visBars': 'Classic Bars',
170
165
  'visOff': 'Off',
171
166
  }
172
- const ru = {
173
- 'saveFailed': 'Часть полей не сохранилась —',
174
- 'cardHint': 'Диктовка и голосовые сообщения: провайдеры, цепочки, локальный whisper',
175
- 'expand': 'Развернуть',
176
- 'collapse': 'Свернуть',
177
- 'composerUnavailable': 'Композер недоступен',
178
- 'keySpace': 'Пробел',
179
- 'keyUnset': 'не задана',
180
- 'recognitionError': 'ошибка распознавания',
181
- 'micUnavailable': 'Микрофон недоступен: нужен HTTPS или localhost',
182
- 'noRecorder': 'MediaRecorder не поддерживается браузером',
183
- 'browserFailed': 'Браузер не распознал: ',
184
- 'nothingHeard': 'Речь не распознана',
185
- 'dictationBtn': 'Голосовой набор',
186
- 'messageBtn': 'Голосовое сообщение — нажать или удерживать',
187
- 'dictationPill': 'Диктовка — текст дописывается в строку',
188
- 'messagePill': 'Запись голосового',
189
- 'cancel': 'Отмена',
190
- 'holdHint': 'Держите — отпустите, чтобы отправить',
191
- 'listening': 'Слушаю в браузере…',
192
- 'stop': 'Стоп',
193
- 'transcribing': 'Распознаю…',
194
- 'sendingIn': 'Отправляю агенту через',
195
- 'secondsShort': ' с',
196
- 'keepPending': 'Отменить отправку',
197
- 'hide': 'Скрыть',
198
- 'title': 'Голос',
199
- 'recordSlot': 'Запись голоса',
200
- 'browserHint': 'распознаёт сам браузер, ключ не нужен',
201
- 'openaiHint': 'whisper-1 · ключ OPENAI_API_KEY',
202
- 'siliconflowHint': 'FunAudioLLM/SenseVoiceSmall · ключ SILICONFLOW_API_KEY',
203
- 'deepinfraHint': 'openai/whisper-large-v3-turbo · ключ DEEPINFRA_API_KEY',
204
- 'fireworksHint': 'whisper-v3-turbo · ключ FIREWORKS_API_KEY',
205
- 'mistralHint': 'voxtral-mini-latest · ключ MISTRAL_API_KEY',
206
- 'openrouterHint': 'google/gemini-2.5-flash · ключ OPENROUTER_API_KEY',
207
- 'localHint': 'задаётся при запуске сервера',
208
- 'up': 'Выше',
209
- 'down': 'Ниже',
210
- 'remove': 'Убрать',
211
- 'addProvider': 'Добавить провайдера',
212
- 'chainHint': 'Порядок сверху вниз — порядок попыток',
213
- 'customName': 'имя для цепочки',
214
- 'customModel': 'модель',
215
- 'customKeyName': 'имя ключа',
216
- 'customModelHint': 'указание модели (пусто — встроенное)',
217
- 'addCustom': 'Добавить своего провайдера',
218
- 'loadingSettings': 'Загрузка настроек…',
219
- 'notReady1': 'Харнесс ещё не объявил настройки плагина. Если он только что перезапустился, ',
220
- 'notReady2': 'раздел появится сам через несколько секунд.',
221
- 'hotkey': 'Клавиша для голосового сообщения',
222
- 'pressKey': 'Нажмите клавишу…',
223
- 'clearKey': 'Убрать клавишу',
224
- 'hotkeyHint1': 'Держите её — идёт запись, отпустите — уйдёт агенту, Esc — отмена. ',
225
- 'hotkeyHint2': 'Можно любую: буква, F-клавиша или модификатор.',
226
- 'language': 'Язык',
227
- 'dictationHint': 'Речь режется по паузам, текст дописывается в строку ввода.',
228
- 'pauseMs': 'Пауза до конца фразы, мс',
229
- 'pauseHint': 'Меньше — чаще куски и быстрее текст, но выше риск обрезать слово',
230
- 'speaking': 'Вы говорите…',
231
- 'silence': 'Пауза…',
232
- 'normalizeTranscript': 'Нормализация расшифровок',
233
- 'undo': 'Отменить вставку',
234
- 'undone': 'Вставка отменена',
235
- 'nothingToUndo': 'Отменять нечего',
236
- 'beep': 'Звук старта/стопа',
237
- 'localOnly': 'Только локальный whisper',
238
- 'localOnlyHint': 'Обе цепочки — только локальный сервер whisper.cpp: полностью офлайн.',
239
- 'sendDelay': 'Задержка вставки диктовки (мс)',
240
- 'sendDelayHint': 'Пауза перед вставкой фразы с окном отмены. 0 — выкл',
241
- 'mic': 'Микрофон',
242
- 'micDefault': 'Системной по умолчанию',
243
- 'vocabulary': 'Свой словарь (одно слово в строке)',
244
- 'polish': 'Полировка текста моделью',
245
- 'polishHint': 'Пунктуация и слова-паразиты через модель харнесса перед вставкой',
246
- 'stream': 'Непрерывная диктовка',
247
- 'streamHint': 'Резать фразы по таймеру во время речи, а не по длинной паузе',
248
- 'streamChunkMs': 'Кусок потока (мс)',
249
- 'vadAdapt': 'Адаптивная тишина',
250
- 'vadAdaptHint': 'Авто-подстройка порога под темп речи. 0 = фикс. поведение',
251
- 'wakeWord': 'Слово-активатор',
252
- 'wakeWordHint': 'Распознавание в браузере начинает запись, если речь начинается с этой фразы. Пусто = выкл',
253
- 'bargeIn': 'Перебивание',
254
- 'polishSend': 'Полировка всего текста перед отправкой',
255
- 'polishSendHint': 'Прогнать весь текст через модель перед отправкой агенту',
256
- 'sessionCommands': 'Голосовые команды сессии',
257
- 'sessionCommandsHint': '«отправь», «отмени», «стоп», «продолжи» — действия сессии, а не текст',
258
- 'polishBaseUrl': 'Локальный ендпоинт полировки',
259
- 'polishBaseUrlHint': 'OpenAI-совместимый /chat/completions базовый URL, напр. локальный Ollama. Пусто = модель харнесса',
260
- 'polishModel': 'Модель офлайн-полировки',
261
- 'polishKeyEnv': 'Ключ офлайн-полировки',
262
- 'voiceCommandsLabel': 'Голосовые команды («с новой строки», «абзац»)',
263
- 'normalizeTranscriptHint': 'transcribe_audio: числа словами — в цифры, аккуратная пунктуация',
264
- 'messageTitle': 'Голосовое сообщение',
265
- 'messageHint': 'Одна запись целиком, после распознавания уходит агенту.',
266
- 'undoMs': 'Окно отмены, мс',
267
- 'undoHint': 'Сколько времени можно отменить автоматическую отправку',
268
- 'customTitle': 'Свои провайдеры',
269
- 'customHint': 'Любой OpenAI-совместимый API. Имя становится доступным в цепочках выше.',
270
- 'general': 'Общее',
271
- 'whisperEndpoint': 'Локальный whisper: endpoint',
272
- 'whisperEndpointHint': 'POST /inference сервера whisper.cpp',
273
- 'deepgramEndpoint': 'Deepgram: базовый URL',
274
- 'deepgramEndpointHint': 'Базовый адрес Deepgram или self-hosted развёртывания (по умолчанию https://api.deepgram.com)',
275
- 'whisperBin': 'Локальный whisper: бинарь',
276
- 'whisperBinHint': 'используется при автозапуске',
277
- 'whisperModel': 'Локальный whisper: модель',
278
- 'whisperAutostart': 'Автозапуск локального whisper',
279
- 'save': 'Сохранить',
280
- 'saved': 'Сохранено ✓',
281
- 'openrouterWarning': 'У OpenRouter нет /audio/transcriptions \u2014 там нужен шаблон openai-chat-audio',
282
- 'noiseSuppression': 'Аппаратное шумоподавление',
283
- 'noiseSuppressionHint': 'Включить шумоподавление, эхоподавление и АРУ микрофона в браузере',
284
- 'contextGlossary': 'Контекстный словарь терминов',
285
- 'contextGlossaryHint': 'Авто-извлечение кода и терминов из композера для повышения точности STT',
286
- 'providerDashboard': 'Задержка и здоровье провайдеров',
287
- 'avgLatency': 'Ср. задержка',
288
- 'successRate': 'Успешность',
289
- 'fast': 'Быстро',
290
- 'normal': 'Норма',
291
- 'slow': 'Медленно',
292
- 'error': 'Сбой',
293
- 'idle': 'Нет вызовов',
294
- 'play': 'Слушать',
295
- 'pause': 'Пауза',
296
- 'listenBack': 'Прослушать запись',
297
- 'lastRecording': 'Последняя запись',
298
- 'sensevoiceHint': 'SenseVoice-ONNX / Sherpa-ONNX · сверхбыстрый локальный STT (~50мс)',
299
- 'sensevoiceEndpoint': 'SenseVoice: endpoint',
300
- 'sensevoiceEndpointHint': 'POST endpoint сервера sherpa-onnx или совместимого',
301
- 'sensevoiceBin': 'SenseVoice: бинарь',
302
- 'sensevoiceBinHint': 'используется при автозапуске',
303
- 'sensevoiceModel': 'SenseVoice: путь к модели',
304
- 'sensevoiceAutostart': 'Автозапуск сервера SenseVoice',
305
- 'realtimeStreaming': 'Потоковое аудио в реальном времени',
306
- 'realtimeStreamingHint': 'Низколатентный стриминг через WebSocket (OpenAI Realtime API / sherpa-onnx)',
307
- 'realtimeProvider': 'Провайдер реалтайма',
308
- 'realtimeModel': 'Модель реалтайма',
309
- 'realtimeActive': 'Стриминг идёт…',
310
- 'realtimeConnect': 'Начать стриминг',
311
- 'realtimeDisconnect': 'Остановить стриминг',
312
- 'visualizerStyle': 'Стиль визуализатора звука',
313
- 'visualizerStyleHint': 'Анимация волны внутри полоски записи',
314
- 'visLiquidWave': 'Жидкая волна (Liquid Wave)',
315
- 'visDynamicOrb': 'Динамическая сфера (Dynamic Orb)',
316
- 'visBars': 'Классические столбики',
317
- 'visOff': 'Выключен',
318
- }
319
167
 
320
- // Строки нужны и вне компонентов — в обработчиках записи, в подписях
321
- // слотов. Поэтому переводчик модульный, а не только через props.
168
+ // Strings are also needed outside components — in recording handlers and
169
+ // slot labels — so the translator is module-level, not only via props.
322
170
  let moduleT = (key) => key
323
171
  const t = (key) => moduleT(key)
324
172
 
@@ -353,10 +201,10 @@ window.__ModuleLoader__.load({
353
201
  '.dvo-badge-err{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-error-primary);border:1px solid var(--dsw-alias-state-error-primary)}' +
354
202
  '.dvo-badge-idle{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary)}'
355
203
  const cssId = 'dsh-voice/client.module.css'
356
- if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="' + cssId + '"]')) {
204
+ if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + cssId + '"]')) {
357
205
  const tag = document.createElement('style')
358
206
  tag.textContent = CSS
359
- tag.setAttribute('data-plugin', 'dsh-voice')
207
+ tag.setAttribute('data-dsh-plugin', 'dsh-voice')
360
208
  tag.dataset.pluginCss = cssId
361
209
  document.head.appendChild(tag)
362
210
  }
@@ -368,8 +216,8 @@ window.__ModuleLoader__.load({
368
216
  error: '',
369
217
  rec: null,
370
218
  levels: [],
371
- pending: null, // {text, leftMs} — окно отмены режима message
372
- lastNote: null, // {blob, url, mime, text} — последняя запись
219
+ pending: null, // {text, leftMs} — message-mode cancel window
220
+ lastNote: null, // {blob, url, mime, text} — last recording
373
221
  showPlayer: false,
374
222
  inputActions: null,
375
223
  input: null,
@@ -458,7 +306,7 @@ window.__ModuleLoader__.load({
458
306
  body: JSON.stringify(payload),
459
307
  })
460
308
  let parsed = null
461
- try { parsed = await res.json() } catch (e) { /* не json */ }
309
+ try { parsed = await res.json() } catch (e) { /* not json */ }
462
310
  if (!res.ok || !parsed || !parsed.ok) {
463
311
  throw new Error((parsed && parsed.error && parsed.error.message) || `HTTP ${res.status}`)
464
312
  }
@@ -474,13 +322,16 @@ window.__ModuleLoader__.load({
474
322
  return s
475
323
  }
476
324
 
477
- // Голосовые команды редактирования (#37): «с новой строки» -> \n и т.п.
478
- // Применяются до нормализации, только когда включены в настройках.
325
+ // Spoken edit commands (#37): "new line" / "с новой строки" -> \n and so on.
326
+ // Applied before normalization, only when enabled in settings.
327
+ // Russian phrases match RU STT output; English covers EN dictation.
479
328
  const VOICE_COMMANDS = [
480
329
  [/(^|[\s,.!?])с новой строки([\s,.!?]|$)/gi, '$1\n$2'],
481
330
  [/(^|[\s,.!?])новая строка([\s,.!?]|$)/gi, '$1\n$2'],
482
331
  [/(^|[\s,.!?])абзац([\s,.!?]|$)/gi, '$1\n\n$2'],
483
332
  [/(^|\s)тире(\s|$)/gi, '$1—$2'],
333
+ [/(^|[\s,.!?])new line([\s,.!?]|$)/gi, '$1\n$2'],
334
+ [/(^|[\s,.!?])paragraph([\s,.!?]|$)/gi, '$1\n\n$2'],
484
335
  ]
485
336
 
486
337
  function applyVoiceCommands(text) {
@@ -489,7 +340,7 @@ window.__ModuleLoader__.load({
489
340
  return s.replace(/[ \t]+/g, ' ').trim()
490
341
  }
491
342
 
492
- // История вставок для undo (#29-9). Хранится только в браузере.
343
+ // Insert history for undo (#29-9). Browser-only storage.
493
344
  const insertHistory = []
494
345
 
495
346
  async function undoLastInsert() {
@@ -500,7 +351,7 @@ window.__ModuleLoader__.load({
500
351
  const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
501
352
  if (draft === last.after) actions.setDraft(last.before)
502
353
  else {
503
- // Текст уже менялся руками — вырезаем последнюю вставку как подстроку.
354
+ // Draft was edited manually — cut the last insert as a substring.
504
355
  const i = draft.lastIndexOf(last.added)
505
356
  if (i < 0) { insertHistory.push(last); return t('nothingToUndo') }
506
357
  actions.setDraft((draft.slice(0, i) + draft.slice(i + last.added.length)).replace(/\s+$/, ''))
@@ -526,41 +377,41 @@ window.__ModuleLoader__.load({
526
377
  }
527
378
  }
528
379
 
529
- // Как клавиша называется на человеческом языке.
380
+ // Human-readable key name.
530
381
  const KEY_LABELS = {
531
382
  Control: 'Ctrl', Alt: 'Alt', Shift: 'Shift', Meta: 'Win',
532
- Space: t('keySpace'), Escape: 'Esc',
383
+ Escape: 'Esc',
533
384
  }
534
385
 
535
386
  function keyLabel(name) {
536
387
  if (!name) return t('keyUnset')
388
+ if (name === 'Space') return t('keySpace')
537
389
  if (KEY_LABELS[name]) return KEY_LABELS[name]
538
- // Коды вида KeyR и Digit5 показываем без служебной приставки.
390
+ // Drop the Key/Digit prefix from codes like KeyR and Digit5.
539
391
  return String(name).replace(/^Key/, '').replace(/^Digit/, '')
540
392
  }
541
393
 
542
- // Что записать в настройку по нажатию. Чистый модификатор запоминаем по
543
- // имени: код у левого и правого разный, а человек нажимает «какой-нибудь».
394
+ // What to store on key press. Pure modifiers are remembered by name:
395
+ // left and right have different codes but the user means "either".
544
396
  function keyFromEvent(event) {
545
397
  if (['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)) return event.key
546
398
  if (event.code) return event.code
547
399
  return event.key || ''
548
400
  }
549
401
 
550
- // Объявление о том, что человек заговорил.
402
+ // Announce that the user started speaking.
551
403
  //
552
- // Нужно, чтобы озвучка немедленно замолчала: слушать ответ и говорить
553
- // одновременно невозможно, а перекрикивать собственный плагин — глупо.
554
- // Связи между плагинами нет: голос кричит в окно, кто хочет — слышит.
555
- // Поэтому оба плагина работают и поодиночке.
404
+ // Playback should mute immediately: listening and talking at once is
405
+ // impossible. There is no plugin-to-plugin API — this broadcasts a window
406
+ // event that anyone may hear, so either side works alone.
556
407
  function announceVoice(phase) {
557
408
  try {
558
409
  window.dispatchEvent(new CustomEvent('dsh-voice:speaking', { detail: { phase } }))
559
- } catch (noEvents) { /* окна нет — значит и слушать некому */ }
410
+ } catch (noEvents) { /* no window — nobody to hear it */ }
560
411
  if (voice.settings.beep) playBeep(phase === 'start' ? 880 : 660)
561
412
  }
562
413
 
563
- // Короткий пик через WebAudio: слышно без взгляда на экран (#29-6).
414
+ // Short WebAudio beep so start/stop is audible without looking (#29-6).
564
415
  function playBeep(freq) {
565
416
  try {
566
417
  const AC = typeof AudioContext !== 'undefined' ? AudioContext
@@ -575,18 +426,18 @@ window.__ModuleLoader__.load({
575
426
  gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.09)
576
427
  osc.connect(gain); gain.connect(ac.destination)
577
428
  osc.start(); osc.stop(ac.currentTime + 0.1)
578
- osc.onended = () => { try { ac.close() } catch (e) { /* уже закрыт */ } }
579
- } catch (noAudio) { /* без звука — не критично */ }
429
+ osc.onended = () => { try { ac.close() } catch (e) { /* already closed */ } }
430
+ } catch (noAudio) { /* no audio — not critical */ }
580
431
  }
581
432
 
582
- // ------------------------------------------------- распознавание в браузере
433
+ // ------------------------------------------------- browser recognition
583
434
  //
584
- // Отдельная нога, не похожая на все остальные: речь распознаёт сам браузер,
585
- // на хост ничего не уходит, ключи не нужны, а текст появляется по словам
586
- // прямо во время речи.
435
+ // A separate leg unlike the others: the browser recognizes speech itself,
436
+ // nothing is uploaded to the host, no keys are needed, and text appears
437
+ // word by word while you speak.
587
438
  //
588
- // Плата за это: в Chrome звук уходит на серверы Google. Поэтому провайдер
589
- // никогда не включается сам — только если его прямо поставили в цепочку.
439
+ // Cost: in Chrome audio goes to Google servers. This provider is never
440
+ // enabled automatically — only when placed explicitly in a chain.
590
441
  function speechRecognitionCtor() {
591
442
  if (typeof window === 'undefined') return null
592
443
  return window.SpeechRecognition || window.webkitSpeechRecognition || null
@@ -620,29 +471,29 @@ window.__ModuleLoader__.load({
620
471
  options.onInterim(interim)
621
472
  }
622
473
  recognition.onerror = (event) => {
623
- // no-speech и aborted — обычная жизнь, а не поломка.
474
+ // no-speech and aborted are normal life, not failures.
624
475
  const code = event && event.error
625
476
  if (code === 'no-speech' || code === 'aborted') return
626
477
  options.onError(code || t('recognitionError'))
627
478
  }
628
- // Браузер обрывает распознавание сам: на паузах, по таймауту. Пока нас не
629
- // остановили — поднимаем заново, иначе диктовка молча умрёт на первой паузе.
479
+ // The browser ends recognition on its own (pauses, timeout). Restart
480
+ // until we stop it, otherwise dictation dies silently on the first pause.
630
481
  recognition.onend = () => {
631
482
  if (stopped) return
632
- try { recognition.start() } catch (alreadyRunning) { /* уже поднято */ }
483
+ try { recognition.start() } catch (alreadyRunning) { /* already running */ }
633
484
  }
634
485
 
635
486
  try { recognition.start() } catch (cannotStart) {
636
487
  options.onError(String(cannotStart && cannotStart.message || cannotStart))
637
488
  }
638
489
  return {
639
- stop() { stopped = true; try { recognition.stop() } catch (already) { /* уже стоит */ } },
640
- abort() { stopped = true; try { recognition.abort() } catch (already) { /* уже стоит */ } },
490
+ stop() { stopped = true; try { recognition.stop() } catch (already) { /* already stopped */ } },
491
+ abort() { stopped = true; try { recognition.abort() } catch (already) { /* already stopped */ } },
641
492
  }
642
493
  }
643
494
 
644
- // Какие провайдеры стоят в цепочке режима — узнаём у хоста один раз.
645
- // Нужно только чтобы понять, идти в браузер или писать файл.
495
+ // Fetch the mode chain from the host once. Only needed to decide whether
496
+ // to use browser recognition or record a file.
646
497
  let chainsPromise = null
647
498
  function modeChain(mode) {
648
499
  if (!chainsPromise) {
@@ -663,8 +514,8 @@ window.__ModuleLoader__.load({
663
514
  // ------------------------------------------------------------ recording
664
515
  function teardown(rec) {
665
516
  if (!rec) return
666
- try { rec.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* уже остановлен */ }
667
- if (rec.audioCtx) { try { rec.audioCtx.close() } catch (e) { /* уже закрыт */ } }
517
+ try { rec.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
518
+ if (rec.audioCtx) { try { rec.audioCtx.close() } catch (e) { /* already closed */ } }
668
519
  }
669
520
 
670
521
  function waitStop(recorder) {
@@ -706,7 +557,7 @@ window.__ModuleLoader__.load({
706
557
  rec.analyser.fftSize = 128
707
558
  src.connect(rec.analyser)
708
559
  }
709
- // Без timeslice: только тогда каждый stop() даёт самостоятельный webm-файл.
560
+ // No timeslice: only then each stop() yields a standalone webm file.
710
561
  recorder.start()
711
562
  return rec
712
563
  }
@@ -720,14 +571,14 @@ window.__ModuleLoader__.load({
720
571
  return Math.min(1, (sum / data.length / 255) * 2.2)
721
572
  }
722
573
 
723
- // Режет текущую фразу: останавливает рекордер, отправляет готовый файл и
724
- // немедленно начинает новую запись тем же рекордером.
574
+ // Cut the current phrase: stop the recorder, send the finished file and
575
+ // immediately start a new recording with the same recorder.
725
576
  function cutPhrase() {
726
577
  const rec = voice.rec
727
578
  if (!rec || rec.cutting || rec.closing) return
728
579
  rec.cutting = true
729
580
  const stopped = waitStop(rec.recorder)
730
- try { rec.recorder.stop() } catch (e) { /* уже остановлен */ }
581
+ try { rec.recorder.stop() } catch (e) { /* already stopped */ }
731
582
  stopped.then(async () => {
732
583
  const blob = new Blob(rec.chunks, { type: rec.mime })
733
584
  rec.chunks = []
@@ -735,17 +586,17 @@ window.__ModuleLoader__.load({
735
586
  rec.hadSpeech = false
736
587
  rec.streamMs = 0
737
588
  if (!rec.closing) {
738
- try { rec.recorder.start() } catch (e) { /* поток закрылся */ }
589
+ try { rec.recorder.start() } catch (e) { /* stream already closed */ }
739
590
  }
740
591
  rec.cutting = false
741
- if (blob.size < 1200) return // слишком короткий кусок — это не речь
592
+ if (blob.size < 1200) return // too short — not speech
742
593
  try {
743
594
  const out = await sendAudio(blob, rec.mime, 'dictation')
744
595
  const text = out && out.text ? out.text : ''
745
596
  const delay = Number(voice.settings.sendDelayMs) || 0
746
597
  if (text && delay > 0 && !voice.holding) {
747
- // Отложенная вставка с окном отмены (#29-5): текст уже вставлен,
748
- // окно позволяет его отменить.
598
+ // Delayed insert with a cancel window (#29-5): text is already
599
+ // inserted; the window only allows undoing it.
749
600
  appendDraft(text)
750
601
  voice.set({ phase: 'pending', pending: { text, undoOnly: true, leftMs: delay } })
751
602
  return
@@ -757,8 +608,8 @@ window.__ModuleLoader__.load({
757
608
  })
758
609
  }
759
610
 
760
- // Распознавание браузером вместо записи файла. Возвращает false, если
761
- // браузер этого не умеет, — тогда идём обычным путём.
611
+ // Browser recognition instead of file recording. Returns false when the
612
+ // browser cannot do it — then fall back to the normal path.
762
613
  function startBrowserLeg(mode, language) {
763
614
  if (!browserRecognitionAvailable()) return false
764
615
  const finals = []
@@ -768,8 +619,8 @@ window.__ModuleLoader__.load({
768
619
  continuous: true,
769
620
  onInterim: (text) => {
770
621
  voice.caption = text; voice.notify()
771
- // Wake-word (#45): если interim начинается с ключевой фразы —
772
- // обрываем прослушивание browser и уходим в обычную запись.
622
+ // Wake-word (#45): if interim starts with the trigger phrase, stop
623
+ // browser listening and switch to normal recording.
773
624
  const ww = String(voice.settings.wakeWord || '').trim().toLowerCase()
774
625
  if (ww && mode === 'dictation' && !voice.rec) {
775
626
  const t = String(text || '').trim().toLowerCase()
@@ -777,7 +628,14 @@ window.__ModuleLoader__.load({
777
628
  voice.browser = null
778
629
  voice.caption = ''
779
630
  openMic(mode)
780
- .then((rec) => { voice.rec = rec; voice.notify() })
631
+ .then((rec) => {
632
+ if (voice.phase !== 'recording') {
633
+ teardown(rec)
634
+ return
635
+ }
636
+ voice.rec = rec
637
+ voice.notify()
638
+ })
781
639
  .catch((err) => voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null }))
782
640
  }
783
641
  }
@@ -785,13 +643,12 @@ window.__ModuleLoader__.load({
785
643
  onFinal: (text) => {
786
644
  finals.push(text)
787
645
  voice.caption = ''
788
- // Диктовка дописывает сразу, голосовое копит до отпускания.
646
+ // Dictation appends immediately; messages accumulate until release.
789
647
  if (mode === 'dictation') appendDraft(text)
790
648
  else voice.notify()
791
649
  },
792
650
  onError: (reason) => {
793
- // Браузер отказал уже после старта — честно говорим об этом, а не
794
- // делаем вид, что слушаем.
651
+ // Browser failed after start — say so instead of pretending to listen.
795
652
  voice.browser = null
796
653
  voice.set({ phase: 'error', error: t('browserFailed') + reason })
797
654
  },
@@ -803,15 +660,22 @@ window.__ModuleLoader__.load({
803
660
 
804
661
  function startRecording(mode) {
805
662
  if (voice.phase !== 'idle' && voice.phase !== 'error') return
806
- // Кричим до открытия микрофона, а не после: чем раньше замолчит
807
- // озвучка, тем меньше её попадёт в запись.
663
+ // Announce before opening the mic: the sooner playback mutes, the less
664
+ // of it ends up in the recording.
808
665
  announceVoice('start')
809
666
  voice.set({ phase: 'recording', mode, error: '', levels: [], caption: '' })
810
667
  modeChain(mode).then((info) => {
811
- // Браузерная нога — только если её прямо поставили первой в цепочке.
668
+ // Browser leg only when it is explicitly first in the chain.
812
669
  if (info.chain[0] === 'browser' && startBrowserLeg(mode, info.language)) return
813
670
  openMic(mode)
814
- .then((rec) => { voice.rec = rec; voice.notify() })
671
+ .then((rec) => {
672
+ if (voice.phase !== 'recording') {
673
+ teardown(rec)
674
+ return
675
+ }
676
+ voice.rec = rec
677
+ voice.notify()
678
+ })
815
679
  .catch((err) => voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null }))
816
680
  })
817
681
  }
@@ -819,12 +683,12 @@ window.__ModuleLoader__.load({
819
683
  function startDictation() { startRecording('dictation') }
820
684
  function startMessage() { startRecording('message') }
821
685
 
822
- // --------------------------------------------------------- удержание
686
+ // --------------------------------------------------------- hold gesture
823
687
  //
824
- // Два жеста одной кнопкой: короткое нажатие включает запись до второго
825
- // нажатия (как было), удержание пишет ровно пока держишь. Отпустил — ушло.
688
+ // Two gestures on one button: a short click toggles recording until the
689
+ // next click; hold records only while pressed. Release sends.
826
690
  //
827
- // Различаем по времени: если отпустили раньше порога — это клик.
691
+ // Distinguished by time: release before the threshold is a click.
828
692
  const HOLD_THRESHOLD_MS = 350
829
693
 
830
694
  const hold = { active: false, mode: null, startedAt: 0, armed: false }
@@ -835,7 +699,7 @@ window.__ModuleLoader__.load({
835
699
  hold.mode = mode
836
700
  hold.startedAt = Date.now()
837
701
  hold.active = false
838
- // Запись начинаем сразу: ждать порога — значит потерять первое слово.
702
+ // Start recording immediately: waiting for the threshold loses the first word.
839
703
  startRecording(mode)
840
704
  voice.holding = true
841
705
  voice.notify()
@@ -847,15 +711,15 @@ window.__ModuleLoader__.load({
847
711
  hold.armed = false
848
712
  hold.active = false
849
713
  voice.holding = false
850
- // Короткое нажатие — это клик: запись уже идёт, оставляем её включённой,
851
- // остановит второе нажатие.
714
+ // Short press is a click: recording is already on, leave it running;
715
+ // the second click will stop it.
852
716
  if (!cancelled && heldMs < HOLD_THRESHOLD_MS) { voice.notify(); return }
853
717
  if (cancelled) cancelCurrent()
854
718
  else stopCurrent()
855
719
  }
856
720
 
857
- // Горячая клавиша: держать её удобнее, чем целиться мышью. Пока клавиша
858
- // зажата — идёт запись, Esc отменяет.
721
+ // Hotkey: holding a key is easier than aiming the mouse. While the key is
722
+ // down we record; Escape cancels.
859
723
  function hotkeyMatches(event, name) {
860
724
  if (name === 'Control') return event.key === 'Control'
861
725
  if (name === 'Alt') return event.key === 'Alt'
@@ -867,8 +731,8 @@ window.__ModuleLoader__.load({
867
731
  if (typeof document === 'undefined' || !keyName) return () => {}
868
732
  const down = (event) => {
869
733
  if (event.repeat) return
870
- // В поле ввода горячая клавиша-модификатор не мешает: она сама по себе
871
- // ничего не печатает. А вот обычную букву перехватывать нельзя.
734
+ // A modifier hotkey does not fight the text field: modifiers do not
735
+ // type. A plain letter key must not be hijacked while typing.
872
736
  if (hotkeyMatches(event, keyName)) beginHold(mode)
873
737
  }
874
738
  const up = (event) => {
@@ -900,12 +764,12 @@ window.__ModuleLoader__.load({
900
764
  if (!rec) { voice.set({ phase: 'idle', error: '' }); return }
901
765
  rec.closing = true
902
766
  const stopped = waitStop(rec.recorder)
903
- try { rec.recorder.stop() } catch (e) { /* уже остановлен */ }
767
+ try { rec.recorder.stop() } catch (e) { /* already stopped */ }
904
768
  stopped.then(() => { teardown(rec); voice.rec = null; voice.set({ phase: 'idle', error: '' }) })
905
769
  }
906
770
 
907
- // Останов по второму нажатию: диктовка досылает хвост, голосовое —
908
- // отправляет всю запись и открывает окно отмены.
771
+ // Stop on the second click: dictation flushes the tail; a voice message
772
+ // sends the whole recording and opens the cancel window.
909
773
  function stopCurrent() {
910
774
  announceVoice('end')
911
775
  if (voice.browser) {
@@ -920,7 +784,7 @@ window.__ModuleLoader__.load({
920
784
  appendDraft(said)
921
785
  voice.set({ phase: 'pending', pending: { text: said, leftMs: voice.settings.autoSendMs } })
922
786
  } else {
923
- // Диктовка дописывала по ходу — добавлять нечего.
787
+ // Dictation already appended while speaking — nothing extra to add.
924
788
  voice.set({ phase: 'idle' })
925
789
  }
926
790
  return
@@ -930,17 +794,17 @@ window.__ModuleLoader__.load({
930
794
  rec.closing = true
931
795
  const mode = rec.mode
932
796
  const stopped = waitStop(rec.recorder)
933
- try { rec.recorder.stop() } catch (e) { /* уже остановлен */ }
797
+ try { rec.recorder.stop() } catch (e) { /* already stopped */ }
934
798
  stopped.then(async () => {
935
799
  const blob = new Blob(rec.chunks, { type: rec.mime })
936
800
  teardown(rec)
937
801
  voice.rec = null
938
802
  if (blob.size < 1200) { voice.set({ phase: 'idle' }); return }
939
803
  if (voice.lastNote && voice.lastNote.url) {
940
- try { URL.revokeObjectURL(voice.lastNote.url) } catch (e) { /* игнор */ }
804
+ try { URL.revokeObjectURL(voice.lastNote.url) } catch (e) { /* ignore */ }
941
805
  }
942
806
  let noteUrl = ''
943
- try { noteUrl = URL.createObjectURL(blob) } catch (e) { /* игнор */ }
807
+ try { noteUrl = URL.createObjectURL(blob) } catch (e) { /* ignore */ }
944
808
  voice.lastNote = { blob, url: noteUrl, mime: rec.mime, text: '' }
945
809
  voice.set({ phase: 'processing' })
946
810
  try {
@@ -966,7 +830,7 @@ window.__ModuleLoader__.load({
966
830
  voice.set({ phase: 'idle' })
967
831
  const actions = voice.inputActions
968
832
  if (!actions || typeof actions.submit !== 'function') return
969
- // Полировка всего draft перед отправкой (#46). Ошибка не блокирует.
833
+ // Polish the whole draft before submit (#46). Errors do not block.
970
834
  if (voice.settings.polishSend) {
971
835
  const run = async () => {
972
836
  try {
@@ -982,15 +846,15 @@ window.__ModuleLoader__.load({
982
846
  actions.setDraft(parsed.text.trim())
983
847
  }
984
848
  }
985
- } catch (e) { /* полировка best-effort */ }
849
+ } catch (e) { /* polish is best-effort */ }
986
850
  }
987
- run().finally(() => setTimeout(() => { try { actions.submit() } catch (e) { /* занят */ } }, 0))
851
+ run().finally(() => setTimeout(() => { try { actions.submit() } catch (e) { /* busy */ } }, 0))
988
852
  return
989
853
  }
990
- setTimeout(() => { try { actions.submit() } catch (e) { /* композер занят */ } }, 0)
854
+ setTimeout(() => { try { actions.submit() } catch (e) { /* composer busy */ } }, 0)
991
855
  }
992
856
 
993
- // Голосовые команды сессии (#48): чистые «отправь/отмени/стоп/продолжи».
857
+ // Session voice commands (#48): clean "send/cancel/stop/continue".
994
858
  function runSessionCommand(cmd) {
995
859
  const actions = voice.inputActions
996
860
  voice.set({ phase: 'idle', pending: null })
@@ -1001,7 +865,7 @@ window.__ModuleLoader__.load({
1001
865
  break
1002
866
  case 'cancel':
1003
867
  case 'stop':
1004
- // Сбрасываем ожидание/запись; текст draft намеренно не трогаем.
868
+ // Clear wait/recording; leave the draft text intentionally untouched.
1005
869
  break
1006
870
  default:
1007
871
  break
@@ -1028,7 +892,7 @@ window.__ModuleLoader__.load({
1028
892
  React.createElement('button', {
1029
893
  type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
1030
894
  title: err ? v.error : t('messageBtn'),
1031
- // Удержание: пишет, пока держишь; увёл курсор далеко вверх — отмена.
895
+ // Hold: record while pressed; leaving the control cancels.
1032
896
  onPointerDown: (e) => { e.preventDefault(); beginHold('message') },
1033
897
  onPointerUp: () => endHold(false),
1034
898
  onPointerLeave: () => { if (hold.armed) endHold(true) },
@@ -1044,7 +908,26 @@ window.__ModuleLoader__.load({
1044
908
  }
1045
909
 
1046
910
  // ----------------------------------------------------------- visualizers
1047
- // 1. Liquid Wave: органическая текучая многослойная волна
911
+ function accentColor(fallback) {
912
+ try {
913
+ const root = document.documentElement
914
+ const s = getComputedStyle(root)
915
+ const pick = s.getPropertyValue('--dsw-alias-state-info-primary').trim()
916
+ || s.getPropertyValue('--dsw-alias-label-primary').trim()
917
+ if (pick) return pick
918
+ } catch (noTheme) { /* canvas-only fallback */ }
919
+ return fallback || voice.waveColor || 'currentColor'
920
+ }
921
+ function softColor(fallback) {
922
+ try {
923
+ const s = getComputedStyle(document.documentElement)
924
+ const pick = s.getPropertyValue('--dsw-alias-bg-layer-3').trim()
925
+ || s.getPropertyValue('--dsw-alias-label-primary').trim()
926
+ if (pick) return pick
927
+ } catch (noTheme) { /* fallback */ }
928
+ return fallback || voice.waveColor || 'currentColor'
929
+ }
930
+ // 1. Liquid Wave: organic multi-layer wave
1048
931
  function drawLiquidWave(g, w, h, levels, color, time) {
1049
932
  const midY = h / 2
1050
933
  const curLevel = levels.length ? levels[levels.length - 1] : 0
@@ -1063,7 +946,7 @@ window.__ModuleLoader__.load({
1063
946
 
1064
947
  const grad = g.createLinearGradient(0, 0, w, 0)
1065
948
  grad.addColorStop(0, color)
1066
- grad.addColorStop(0.5, '#38bdf8')
949
+ grad.addColorStop(0.5, accentColor(color))
1067
950
  grad.addColorStop(1, color)
1068
951
  g.strokeStyle = grad
1069
952
  g.lineWidth = layerIdx === 2 ? 2.5 : 1.5
@@ -1082,7 +965,7 @@ window.__ModuleLoader__.load({
1082
965
  g.globalAlpha = 1
1083
966
  }
1084
967
 
1085
- // 2. Dynamic Orb: интерактивная пульсирующая сфера по центру
968
+ // 2. Dynamic Orb: interactive pulsing sphere in the center
1086
969
  function drawDynamicOrb(g, w, h, levels, color, time) {
1087
970
  const cx = w / 2
1088
971
  const cy = h / 2
@@ -1102,7 +985,7 @@ window.__ModuleLoader__.load({
1102
985
  const rRing = 14 + smoothLevel * 14 + Math.sin(time * 0.08) * 3
1103
986
  g.beginPath()
1104
987
  g.arc(cx, cy, rRing, 0, Math.PI * 2)
1105
- g.strokeStyle = '#38bdf8'
988
+ g.strokeStyle = accentColor(color)
1106
989
  g.globalAlpha = 0.35 + smoothLevel * 0.4
1107
990
  g.lineWidth = 1.5
1108
991
  g.stroke()
@@ -1118,8 +1001,8 @@ window.__ModuleLoader__.load({
1118
1001
 
1119
1002
  const rCore = 6 + smoothLevel * 8 + Math.sin(time * 0.12) * 1.5
1120
1003
  const radial = g.createRadialGradient(cx, cy, 1, cx, cy, rCore + 4)
1121
- radial.addColorStop(0, '#ffffff')
1122
- radial.addColorStop(0.4, '#38bdf8')
1004
+ radial.addColorStop(0, softColor(color))
1005
+ radial.addColorStop(0.4, accentColor(color))
1123
1006
  radial.addColorStop(1, color)
1124
1007
  g.beginPath()
1125
1008
  g.arc(cx, cy, rCore, 0, Math.PI * 2)
@@ -1130,7 +1013,7 @@ window.__ModuleLoader__.load({
1130
1013
  g.globalAlpha = 1
1131
1014
  }
1132
1015
 
1133
- // 3. Classic Bars: классические вертикальные столбики
1016
+ // 3. Classic Bars: classic vertical bars
1134
1017
  function drawClassicBars(g, w, h, levels, color) {
1135
1018
  const midY = h / 2
1136
1019
  for (let i = 0; i < levels.length && i * 7 < w; i++) {
@@ -1154,7 +1037,7 @@ window.__ModuleLoader__.load({
1154
1037
  voice.input = props.input
1155
1038
  const ctx = props.ctx
1156
1039
 
1157
- // VAD: копим тишину и режем фразу, когда пауза превысила порог.
1040
+ // VAD: accumulate silence and cut the phrase when the pause exceeds the threshold.
1158
1041
  React.useEffect(() => {
1159
1042
  if (v.phase !== 'recording') return
1160
1043
  const tick = 50
@@ -1171,16 +1054,16 @@ window.__ModuleLoader__.load({
1171
1054
  const adapt = Number(voice.settings.vadAdapt) || 0
1172
1055
  let effectiveVad = Number(voice.settings.vadSilenceMs) || 700
1173
1056
  if (adapt > 0 && rec.hadSpeech) {
1174
- // Адаптивный порог (#41): считаем плотность речи за последние
1175
- // ~1s (20 сэмплов). Плотная речь -> порог ниже (точнее режем),
1176
- // паузная -> порог растёт к базе (не режем на вдохе).
1057
+ // Adaptive threshold (#41): speech density over ~1s (20 samples).
1058
+ // Dense speech -> lower threshold (cut more precisely);
1059
+ // pause-heavy -> threshold rises toward base (do not cut on breaths).
1177
1060
  const win = voice.levels.slice(-20)
1178
1061
  const density = win.length ? win.filter((v) => v > 0.06).length / win.length : 0
1179
1062
  const k = adapt * (density - 0.5) * 2
1180
1063
  effectiveVad = Math.max(150, Math.round(Number(voice.settings.vadSilenceMs) * (1 - k)))
1181
1064
  }
1182
- // Непрерывный режим (#40): режем по таймеру непрерывной речи,
1183
- // не дожидаясь длинной паузы.
1065
+ // Continuous mode (#40): cut on a timer while speech continues,
1066
+ // without waiting for a long pause.
1184
1067
  const stream = !!voice.settings.stream && rec.mode === 'dictation'
1185
1068
  if (stream && rec.hadSpeech && !rec.cutting) {
1186
1069
  rec.streamMs += tick
@@ -1197,7 +1080,7 @@ window.__ModuleLoader__.load({
1197
1080
  return () => dispose()
1198
1081
  }, [v.phase])
1199
1082
 
1200
- // Анимация осциллограммы / визуализатора.
1083
+ // Waveform / visualizer animation.
1201
1084
  React.useEffect(() => {
1202
1085
  if (v.phase !== 'recording') return
1203
1086
  let frame = 0
@@ -1238,7 +1121,7 @@ window.__ModuleLoader__.load({
1238
1121
  return () => dispose()
1239
1122
  }, [v.phase])
1240
1123
 
1241
- // Окно отмены режима message.
1124
+ // Message-mode cancel window.
1242
1125
  React.useEffect(() => {
1243
1126
  if (v.phase !== 'pending') return
1244
1127
  const tick = 100
@@ -1246,8 +1129,8 @@ window.__ModuleLoader__.load({
1246
1129
  const p = voice.pending
1247
1130
  if (!p) return
1248
1131
  if (p.undoOnly) {
1249
- // Режим «только отмена вставки» (#29-5): по истечении окна просто
1250
- // прячем панель; текст либо остался, либо уже откатили руками.
1132
+ // Undo-only mode (#29-5): when the window expires just hide the
1133
+ // panel; the text either stayed or was already undone.
1251
1134
  p.leftMs -= tick
1252
1135
  if (p.leftMs <= 0) voice.set({ phase: 'idle' })
1253
1136
  else voice.notify()
@@ -1298,8 +1181,8 @@ window.__ModuleLoader__.load({
1298
1181
  const inBrowser = !!voice.browser
1299
1182
  const rec = voice.rec
1300
1183
  const hint = v.mode === 'dictation' ? t('dictationPill') : t('messagePill')
1301
- // Живая подпись: что слышно прямо сейчас. Пока браузер не выдал
1302
- // окончательный кусок, текст черновой и меняется на глазах.
1184
+ // Live caption of what is heard right now. Until the browser emits a
1185
+ // final chunk the text is interim and changes on screen.
1303
1186
  const caption = voice.caption || (inBrowser ? '' : null)
1304
1187
  return React.createElement('div', { className: 'dvo-pill' },
1305
1188
  React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('cancel'), onClick: cancelCurrent }, xIcon()),
@@ -1326,7 +1209,7 @@ window.__ModuleLoader__.load({
1326
1209
  if (v.phase === 'pending') {
1327
1210
  const left = Math.max(0, Math.ceil((voice.pending ? voice.pending.leftMs : 0) / 1000))
1328
1211
  if (voice.pending && voice.pending.undoOnly) {
1329
- // Окно отмены отложенной вставки диктовки (#29-5).
1212
+ // Cancel window for delayed dictation insert (#29-5).
1330
1213
  return React.createElement('div', { className: 'dvo-pill' },
1331
1214
  React.createElement('span', { className: 'dvo-status' }, t('undo'), ': ', left, t('secondsShort')),
1332
1215
  React.createElement('button', {
@@ -1374,8 +1257,8 @@ window.__ModuleLoader__.load({
1374
1257
 
1375
1258
  // --------------------------------------------------------------- slots
1376
1259
  function registerComposer(ctx) {
1377
- // Горячая клавиша живёт всё время, пока плагин применён, и меняется без
1378
- // перезапуска: карточка настроек кричит в окно, композер перечитывает.
1260
+ // The hotkey lives for as long as the plugin is applied and can change
1261
+ // without restart: the settings card broadcasts and the composer reloads.
1379
1262
  ctx.effect(() => {
1380
1263
  let dispose = () => {}
1381
1264
  let alive = true
@@ -1389,7 +1272,7 @@ window.__ModuleLoader__.load({
1389
1272
  dispose = () => {}
1390
1273
  const key = data && data.hotkey
1391
1274
  if (key) dispose = installHotkey(ctx, key, 'message')
1392
- // Новые настройки композеру нужны без открытия карточки.
1275
+ // The composer needs fresh settings without opening the card.
1393
1276
  Object.assign(voice.settings, {
1394
1277
  beep: !!(data && data.beep),
1395
1278
  micDeviceId: String((data && data.micDeviceId) || ''),
@@ -1408,7 +1291,7 @@ window.__ModuleLoader__.load({
1408
1291
  visualizerStyle: (data && data.visualizerStyle) || 'liquid-wave',
1409
1292
  })
1410
1293
  })
1411
- .catch(() => { /* без подсказки хоста клавиши просто не будет */ })
1294
+ .catch(() => { /* no host hint — no hotkey */ })
1412
1295
  }
1413
1296
 
1414
1297
  reload()
@@ -1418,7 +1301,7 @@ window.__ModuleLoader__.load({
1418
1301
  window.removeEventListener('dsh-voice:settings-saved', reload)
1419
1302
  dispose()
1420
1303
  }
1421
- }, 'dsh-voice: горячая клавиша удержания')
1304
+ }, 'dsh-voice: hold hotkey')
1422
1305
 
1423
1306
  ctx.slots.inject('conversation.input.right', () => ctx.slots.register(
1424
1307
  { name: 'conversation.input.right', id: '@goodandready/dsh-voice', order: 6, label: () => t('title') },
@@ -1433,21 +1316,31 @@ window.__ModuleLoader__.load({
1433
1316
  // ------------------------------------------------------- settings page
1434
1317
  const BUILTIN = [
1435
1318
  'browser', 'deepgram', 'groq', 'hf', 'local-whisper', 'sensevoice',
1436
- // Заготовки: адрес и модель уже прописаны на хосте, нужен только ключ.
1319
+ // Presets: address and model are filled on the host; only a key is needed.
1437
1320
  'openai', 'siliconflow', 'deepinfra', 'fireworks', 'mistral', 'openrouter',
1438
1321
  ]
1439
1322
  const TEMPLATES = ['openai-transcriptions', 'openai-chat-audio']
1440
- const MODEL_HINT = {
1441
- browser: t('browserHint'),
1442
- openai: t('openaiHint'),
1443
- siliconflow: t('siliconflowHint'),
1444
- deepinfra: t('deepinfraHint'),
1445
- fireworks: t('fireworksHint'),
1446
- mistral: t('mistralHint'),
1447
- openrouter: t('openrouterHint'),
1448
- deepgram: 'nova-2', groq: 'whisper-large-v3-turbo',
1449
- hf: 'openai/whisper-large-v3', 'local-whisper': t('localHint'),
1450
- sensevoice: t('sensevoiceHint'),
1323
+ // Resolve at render time: t() must not be captured before locale bind.
1324
+ const MODEL_HINT_KEYS = {
1325
+ browser: 'browserHint',
1326
+ openai: 'openaiHint',
1327
+ siliconflow: 'siliconflowHint',
1328
+ deepinfra: 'deepinfraHint',
1329
+ fireworks: 'fireworksHint',
1330
+ mistral: 'mistralHint',
1331
+ openrouter: 'openrouterHint',
1332
+ 'local-whisper': 'localHint',
1333
+ sensevoice: 'sensevoiceHint',
1334
+ }
1335
+ const MODEL_HINT_STATIC = {
1336
+ deepgram: 'nova-2',
1337
+ groq: 'whisper-large-v3-turbo',
1338
+ hf: 'openai/whisper-large-v3',
1339
+ }
1340
+ function modelHint(provider) {
1341
+ const key = MODEL_HINT_KEYS[provider]
1342
+ if (key) return t(key)
1343
+ return MODEL_HINT_STATIC[provider] || ''
1451
1344
  }
1452
1345
  const LANGS = ['auto', 'ru', 'en', 'uk', 'de']
1453
1346
 
@@ -1477,15 +1370,15 @@ window.__ModuleLoader__.load({
1477
1370
  '.dvo-pcardOpen .dvo-pchev{transform:rotate(180deg)}' +
1478
1371
  '.dvo-pbody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}'
1479
1372
  const setCssId = 'dsh-voice/settings.module.css'
1480
- if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="' + setCssId + '"]')) {
1373
+ if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + setCssId + '"]')) {
1481
1374
  const tag = document.createElement('style')
1482
1375
  tag.textContent = SET_CSS
1483
- tag.setAttribute('data-plugin', 'dsh-voice')
1376
+ tag.setAttribute('data-dsh-plugin', 'dsh-voice')
1484
1377
  tag.dataset.pluginCss = setCssId
1485
1378
  document.head.appendChild(tag)
1486
1379
  }
1487
1380
 
1488
- // Редактор одной цепочки: строки «провайдер + модель» с перестановкой.
1381
+ // One chain editor: provider+model rows with reordering.
1489
1382
  function ChainEditor(props) {
1490
1383
  const rows = Array.isArray(props.value) ? props.value : []
1491
1384
  const change = (i, patch) => {
@@ -1512,7 +1405,7 @@ window.__ModuleLoader__.load({
1512
1405
  .map((p) => React.createElement('option', { key: p, value: p }, p))),
1513
1406
  React.createElement('input', {
1514
1407
  className: 'dvs-model', value: row.model || '', disabled: !props.writable,
1515
- placeholder: MODEL_HINT[row.provider] || '', onChange: (e) => change(i, { model: e.target.value }),
1408
+ placeholder: modelHint(row.provider), onChange: (e) => change(i, { model: e.target.value }),
1516
1409
  }),
1517
1410
  React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '↑'),
1518
1411
  React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, '↓'),
@@ -1525,7 +1418,7 @@ window.__ModuleLoader__.load({
1525
1418
  )
1526
1419
  }
1527
1420
 
1528
- // Свои провайдеры: имя, шаблон API, куда ходить и чем авторизоваться.
1421
+ // Custom providers: name, API template, where to call, how to authorize.
1529
1422
  function CustomEditor(props) {
1530
1423
  const rows = Array.isArray(props.value) ? props.value : []
1531
1424
  const change = (i, patch) => props.onChange(rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r)))
@@ -1577,24 +1470,23 @@ window.__ModuleLoader__.load({
1577
1470
  function VoiceSection(props) {
1578
1471
  const t = (props && props.t) || moduleT
1579
1472
  const ctx = props.ctx
1580
- // На странице, открытой не с localhost, ядро отключает настройки целиком:
1581
- // общий вид документа не читается, каждый раздел получает "unavailable",
1582
- // запись выбрасывается. Сервер это ограничение не разделяет, и плагин
1583
- // dsh-lanmode поднимает ту же механику поверх тех же вызовов. Если он
1584
- // установлен — берём его службу, иначе ядровую: на loopback-странице она
1585
- // работает штатно.
1473
+ // On a non-localhost page the kernel disables settings entirely: the
1474
+ // shared document is not readable, every section gets "unavailable" and
1475
+ // writes are dropped. The server does not share that restriction, and
1476
+ // dsh-lanmode rebuilds the same mechanics on the same calls. Prefer its
1477
+ // service when installed, otherwise the kernel one (fine on loopback).
1586
1478
  const scope = ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).bind({ namespace: NS })
1587
1479
  const [snap, setSnap] = React.useState(null)
1588
1480
  const [draft, setDraft] = React.useState(null)
1589
1481
  const [saved, setSaved] = React.useState(false)
1590
1482
  const [err, setErr] = React.useState('')
1591
1483
 
1592
- // Выбор клавиши: не поле для ввода имени, а «нажмите ту, которую хотите».
1593
- // Имена вроде KeyR человек знать не обязан.
1484
+ // Hotkey picker: not a name field — press the key you want. Users should
1485
+ // not need to know codes like KeyR.
1594
1486
  //
1595
- // Хуки объявлены здесь, выше всех возвратов: если объявить их ниже, при
1596
- // неготовых настройках карточка вернётся раньше, хуков окажется меньше,
1597
- // и React снимет весь раздел с ошибкой.
1487
+ // Hooks are declared here, above every return: declaring them later
1488
+ // would leave fewer hooks on the not-ready branch and React would
1489
+ // unmount the section with an error.
1598
1490
  const [catching, setCatching] = React.useState(false)
1599
1491
  React.useEffect(() => {
1600
1492
  if (!catching) return undefined
@@ -1626,39 +1518,38 @@ window.__ModuleLoader__.load({
1626
1518
  .catch(() => {})
1627
1519
  }, [])
1628
1520
 
1629
- // Снимок приходит со статусом, и он важнее самого значения.
1630
- // loading — ответа хоста ещё нет;
1631
- // unavailable — хост ответил, но наш namespace ему пока неизвестен:
1632
- // так бывает, если страницу открыли в первые секунды
1633
- // после старта, пока плагины ещё регистрируются;
1634
- // ready — значения на месте.
1635
- // При unavailable поле writable берётся из документа и остаётся true,
1636
- // поэтому без проверки статуса карточка рисует пустую, но с виду рабочую
1637
- // форму — именно так этот баг и выглядел.
1521
+ // The snapshot carries a status, and status matters more than value.
1522
+ // loading — no host answer yet;
1523
+ // unavailable — host answered but does not know this namespace yet
1524
+ // (page opened in the first seconds while plugins register);
1525
+ // ready — values are present.
1526
+ // At unavailable, writable comes from the document and stays true, so
1527
+ // without a status check the card draws an empty but seemingly working
1528
+ // form — that is exactly how this bug looked.
1638
1529
  const ready = !!snap && snap.status === 'ready'
1639
1530
  const value = ready && snap.value ? snap.value : {}
1640
1531
  const writable = ready && snap.writable !== false
1641
1532
 
1642
- // Зеркало настроек в браузере перечитывается только на запись значения и
1643
- // на переподключение; появление namespace таким сигналом не считается.
1644
- // Значит само оно не починится — просим перечитать, пока не готово.
1645
- // Зеркало общее, так что это чинит и остальные разделы настроек.
1533
+ // The browser settings mirror reloads only on value writes and
1534
+ // reconnect; a new namespace is not such a signal. It will not fix
1535
+ // itself — ask it to reload until ready. The mirror is shared, so this
1536
+ // also repairs other settings sections.
1646
1537
  React.useEffect(() => {
1647
1538
  if (ready) return undefined
1648
1539
  let tries = 0
1649
1540
  const timer = setInterval(() => {
1650
1541
  if (tries >= 15) { clearInterval(timer); return }
1651
1542
  tries += 1
1652
- try { ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).describe().load() } catch (e) { /* сервис ещё не поднялся */ }
1543
+ try { ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).describe().load() } catch (e) { /* service not up yet */ }
1653
1544
  }, 1000)
1654
1545
  return () => clearInterval(timer)
1655
1546
  }, [ready])
1656
1547
 
1657
- // Черновик сеем только из готового снимка. Раньше он заполнялся из
1658
- // первого пришедшего, и пустой замораживался навсегда: следующий снимок
1659
- // уже не пересевал его, и карточка оставалась пустой до перезагрузки.
1548
+ // Seed the draft only from a ready snapshot. Previously it was filled
1549
+ // from the first snapshot and an empty draft froze forever: later
1550
+ // snapshots did not reseed it and the card stayed empty until reload.
1660
1551
  React.useEffect(() => { if (ready && draft === null) setDraft(JSON.parse(JSON.stringify(value))) }, [ready, draft, value])
1661
- // Клиент должен знать порог VAD и окно отмены — они живут в тех же настройках.
1552
+ // The client needs the VAD threshold and cancel window from the same settings.
1662
1553
  React.useEffect(() => {
1663
1554
  if (!ready) return
1664
1555
  voice.settings = Object.assign({}, voice.settings, {
@@ -1718,11 +1609,10 @@ window.__ModuleLoader__.load({
1718
1609
  setErr(''); setSaved(false)
1719
1610
  if (!draft) return
1720
1611
 
1721
- // Поля пишутся по одному, и раньше первая же неудача обрывала цикл:
1722
- // остальные поля не записывались, композер не получал сигнала
1723
- // перечитать клавишу, а на кнопку это было похоже на «ничего не
1724
- // происходит». Теперь пишутся все, а неудачи собираются и называются
1725
- // поимённо — иначе непонятно, какое поле виновато.
1612
+ // Fields are written one by one. Previously the first failure broke
1613
+ // the loop: later fields were never saved, the composer never got the
1614
+ // hotkey reload signal, and the button looked like "nothing happens".
1615
+ // Now every field is attempted and failures are collected by name.
1726
1616
  const failed = []
1727
1617
  for (const k of Object.keys(draft)) {
1728
1618
  try {
@@ -1747,9 +1637,9 @@ window.__ModuleLoader__.load({
1747
1637
  contextGlossary: draft.contextGlossary !== false,
1748
1638
  visualizerStyle: draft.visualizerStyle || 'liquid-wave',
1749
1639
  })
1750
- // Композер держит обработчик клавиши: пусть перечитает настройку,
1751
- // иначе новая клавиша заработает только после перезагрузки страницы.
1752
- try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* некому */ }
1640
+ // The composer owns the hotkey handler — tell it to reload, otherwise
1641
+ // a new key only works after a full page reload.
1642
+ try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* nobody */ }
1753
1643
 
1754
1644
  if (failed.length) { setErr(t('saveFailed') + ' ' + failed.join('; ')); return }
1755
1645
  setSaved(true); setTimeout(() => setSaved(false), 2000)
@@ -1760,8 +1650,8 @@ window.__ModuleLoader__.load({
1760
1650
  return m && m[key] !== undefined ? m[key] : fallback
1761
1651
  }
1762
1652
 
1763
- // Имена своих провайдеров берём из черновика, чтобы только что
1764
- // добавленный сразу появлялся в списках цепочек.
1653
+ // Custom provider names come from the draft so a just-added entry
1654
+ // appears in chain selects immediately.
1765
1655
  const chainOptions = BUILTIN.concat(
1766
1656
  (draft && Array.isArray(draft.customProviders) ? draft.customProviders : [])
1767
1657
  .map((c) => String(c && c.key || '').trim())
@@ -1888,7 +1778,7 @@ window.__ModuleLoader__.load({
1888
1778
  hotkeyField(),
1889
1779
  textField('whisperUrl', t('whisperEndpoint'), t('whisperEndpointHint')),
1890
1780
  textField('whisperBin', t('whisperBin'), t('whisperBinHint')),
1891
- textField('whisperModel', t('whisperModel'), t('whisperBinHint')),
1781
+ textField('whisperModel', t('whisperModel'), t('whisperModelHint')),
1892
1782
  textField('deepgramBaseUrl', t('deepgramEndpoint'), t('deepgramEndpointHint')),
1893
1783
  React.createElement('label', { className: 'dvs-field' }, t('whisperAutostart'),
1894
1784
  React.createElement('input', {
@@ -1964,31 +1854,13 @@ window.__ModuleLoader__.load({
1964
1854
  React.createElement('div', { className: 'dvs-sub' }, t('sensevoiceHint')),
1965
1855
  textField('sensevoiceUrl', t('sensevoiceEndpoint'), t('sensevoiceEndpointHint')),
1966
1856
  textField('sensevoiceBin', t('sensevoiceBin'), t('sensevoiceBinHint')),
1967
- textField('sensevoiceModel', t('sensevoiceModel'), t('sensevoiceBinHint')),
1857
+ textField('sensevoiceModel', t('sensevoiceModel'), t('sensevoiceModelHint')),
1968
1858
  React.createElement('label', { className: 'dvs-field' }, t('sensevoiceAutostart'),
1969
1859
  React.createElement('input', {
1970
1860
  type: 'checkbox', checked: !!(draft && draft.sensevoiceAutostart), disabled: !writable,
1971
1861
  onChange: (e) => setTop('sensevoiceAutostart', e.target.checked),
1972
1862
  })),
1973
1863
  ),
1974
- React.createElement('div', { className: 'dvs-block' },
1975
- React.createElement('div', { className: 'dvs-h' }, t('realtimeStreaming')),
1976
- React.createElement('div', { className: 'dvs-sub' }, t('realtimeStreamingHint')),
1977
- React.createElement('label', { className: 'dvs-field' }, t('realtimeStreaming'),
1978
- React.createElement('input', {
1979
- type: 'checkbox', checked: !!(draft && draft.realtimeStreaming), disabled: !writable,
1980
- onChange: (e) => setTop('realtimeStreaming', e.target.checked),
1981
- })),
1982
- React.createElement('label', { className: 'dvs-field' }, t('realtimeProvider'),
1983
- React.createElement('select', {
1984
- value: String((draft && draft.realtimeProvider) || 'openai'), disabled: !writable,
1985
- onChange: (e) => setTop('realtimeProvider', e.target.value),
1986
- },
1987
- React.createElement('option', { value: 'openai' }, 'OpenAI Realtime API'),
1988
- React.createElement('option', { value: 'sherpa-onnx' }, 'Sherpa-ONNX (local)'),
1989
- )),
1990
- textField('realtimeModel', t('realtimeModel'), ''),
1991
- ),
1992
1864
  React.createElement('div', { className: 'dvs-block' },
1993
1865
  React.createElement('div', { className: 'dvs-h' }, t('providerDashboard')),
1994
1866
  React.createElement('div', { className: 'dvo-dash' },
@@ -2039,9 +1911,9 @@ window.__ModuleLoader__.load({
2039
1911
  )
2040
1912
  }
2041
1913
 
2042
- // Карточка во вкладке «Настройки плагинов» (#18): ядро даёт только рамку
2043
- // списка, поэтому заголовок, пояснение и сворачивание — наши.
2044
- // Тело монтируется при первом раскрытии: снапшот настроек тогда и придёт.
1914
+ // Card in Settings → Plugins → Plugin settings (#18): the kernel only
1915
+ // draws the list shell, so title, hint and collapse are ours.
1916
+ // Body mounts on first expand; the settings snapshot arrives then.
2045
1917
  function PluginCard(props) {
2046
1918
  const [open, setOpen] = React.useState(false)
2047
1919
  const tt = (props && props.t) || t
@@ -2066,14 +1938,14 @@ window.__ModuleLoader__.load({
2066
1938
  }
2067
1939
 
2068
1940
  function registerSettings(ctx) {
2069
- // Карточка настроек во вкладке «Настройки → Плагины → Настройки плагинов» (#18).
2070
- // Ключ регистрации обязан равняться NS (пространству настроек), иначе
2071
- // вкладка молча пропустит слот.
1941
+ // Settings card under Settings → Plugins → Plugin settings (#18).
1942
+ // The registration key must equal NS (the settings namespace), otherwise
1943
+ // the tab silently skips the slot.
2072
1944
  ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
2073
1945
  {
2074
1946
  name: 'settings.plugin.item',
2075
1947
  key: NS,
2076
- // locale в записи слота — то, из-за чего компонент получает props.t.
1948
+ // locale on the slot record is what makes the component receive props.t.
2077
1949
  locale: NS,
2078
1950
  inject: () => ({ ctx: ctx }),
2079
1951
  },
@@ -2083,14 +1955,9 @@ window.__ModuleLoader__.load({
2083
1955
 
2084
1956
  exports.inject = ['timer', 'slots', 'settingsScope', 'locale']
2085
1957
  exports.apply = function apply(ctx) {
2086
- // Язык может принести не только плагин: словарные пакеты объявляют
2087
- // русский для чужих пространств. Ядро на повторное объявление той же
2088
- // пары «пространство + язык» бросает исключение, и незащищённый вызов
2089
- // уносил с собой весь плагин — в интерфейсе это выглядело как «Failed to
2090
- // load plugins» с перечнем ни в чём не повинных соседей.
2091
- //
2092
- // Поэтому каждый язык объявляется отдельно и по-хорошему: заняли до нас —
2093
- // уступаем, свой английский при этом всё равно встаёт на место.
1958
+ // English is the source language. Other languages come from the
1959
+ // separate translation plugin at runtime. Re-registering the same
1960
+ // namespace+language throws, so tolerate a pre-registered dictionary.
2094
1961
  const addLocale = (locale, dictionary) => {
2095
1962
  try {
2096
1963
  return ctx.locale.register(NS, locale, dictionary)
@@ -2099,9 +1966,9 @@ window.__ModuleLoader__.load({
2099
1966
  }
2100
1967
  }
2101
1968
  ctx.effect(() => {
2102
- const undo = [addLocale('en', en), addLocale('ru', ru)]
1969
+ const undo = [addLocale('en', en)]
2103
1970
  return () => { for (const off of undo) off() }
2104
- }, 'dsh-voice: словари')
1971
+ }, 'dsh-voice: locale dictionaries')
2105
1972
  moduleT = ctx.locale.bind(NS)
2106
1973
  registerComposer(ctx)
2107
1974
  registerSettings(ctx)