@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/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,6 +155,7 @@ 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
160
  'visualizerStyle': 'Audio visualizer style',
159
161
  'visualizerStyleHint': 'Waveform animation inside the recording pill',
@@ -162,149 +164,9 @@ window.__ModuleLoader__.load({
162
164
  'visBars': 'Classic Bars',
163
165
  'visOff': 'Off',
164
166
  }
165
- const ru = {
166
- 'saveFailed': 'Часть полей не сохранилась —',
167
- 'cardHint': 'Диктовка и голосовые сообщения: провайдеры, цепочки, локальный whisper',
168
- 'expand': 'Развернуть',
169
- 'collapse': 'Свернуть',
170
- 'composerUnavailable': 'Композер недоступен',
171
- 'keySpace': 'Пробел',
172
- 'keyUnset': 'не задана',
173
- 'recognitionError': 'ошибка распознавания',
174
- 'micUnavailable': 'Микрофон недоступен: нужен HTTPS или localhost',
175
- 'noRecorder': 'MediaRecorder не поддерживается браузером',
176
- 'browserFailed': 'Браузер не распознал: ',
177
- 'nothingHeard': 'Речь не распознана',
178
- 'dictationBtn': 'Голосовой набор',
179
- 'messageBtn': 'Голосовое сообщение — нажать или удерживать',
180
- 'dictationPill': 'Диктовка — текст дописывается в строку',
181
- 'messagePill': 'Запись голосового',
182
- 'cancel': 'Отмена',
183
- 'holdHint': 'Держите — отпустите, чтобы отправить',
184
- 'listening': 'Слушаю в браузере…',
185
- 'stop': 'Стоп',
186
- 'transcribing': 'Распознаю…',
187
- 'sendingIn': 'Отправляю агенту через',
188
- 'secondsShort': ' с',
189
- 'keepPending': 'Отменить отправку',
190
- 'hide': 'Скрыть',
191
- 'title': 'Голос',
192
- 'recordSlot': 'Запись голоса',
193
- 'browserHint': 'распознаёт сам браузер, ключ не нужен',
194
- 'openaiHint': 'whisper-1 · ключ OPENAI_API_KEY',
195
- 'siliconflowHint': 'FunAudioLLM/SenseVoiceSmall · ключ SILICONFLOW_API_KEY',
196
- 'deepinfraHint': 'openai/whisper-large-v3-turbo · ключ DEEPINFRA_API_KEY',
197
- 'fireworksHint': 'whisper-v3-turbo · ключ FIREWORKS_API_KEY',
198
- 'mistralHint': 'voxtral-mini-latest · ключ MISTRAL_API_KEY',
199
- 'openrouterHint': 'google/gemini-2.5-flash · ключ OPENROUTER_API_KEY',
200
- 'localHint': 'задаётся при запуске сервера',
201
- 'up': 'Выше',
202
- 'down': 'Ниже',
203
- 'remove': 'Убрать',
204
- 'addProvider': 'Добавить провайдера',
205
- 'chainHint': 'Порядок сверху вниз — порядок попыток',
206
- 'customName': 'имя для цепочки',
207
- 'customModel': 'модель',
208
- 'customKeyName': 'имя ключа',
209
- 'customModelHint': 'указание модели (пусто — встроенное)',
210
- 'addCustom': 'Добавить своего провайдера',
211
- 'loadingSettings': 'Загрузка настроек…',
212
- 'notReady1': 'Харнесс ещё не объявил настройки плагина. Если он только что перезапустился, ',
213
- 'notReady2': 'раздел появится сам через несколько секунд.',
214
- 'hotkey': 'Клавиша для голосового сообщения',
215
- 'pressKey': 'Нажмите клавишу…',
216
- 'clearKey': 'Убрать клавишу',
217
- 'hotkeyHint1': 'Держите её — идёт запись, отпустите — уйдёт агенту, Esc — отмена. ',
218
- 'hotkeyHint2': 'Можно любую: буква, F-клавиша или модификатор.',
219
- 'language': 'Язык',
220
- 'dictationHint': 'Речь режется по паузам, текст дописывается в строку ввода.',
221
- 'pauseMs': 'Пауза до конца фразы, мс',
222
- 'pauseHint': 'Меньше — чаще куски и быстрее текст, но выше риск обрезать слово',
223
- 'speaking': 'Вы говорите…',
224
- 'silence': 'Пауза…',
225
- 'normalizeTranscript': 'Нормализация расшифровок',
226
- 'undo': 'Отменить вставку',
227
- 'undone': 'Вставка отменена',
228
- 'nothingToUndo': 'Отменять нечего',
229
- 'beep': 'Звук старта/стопа',
230
- 'localOnly': 'Только локальный whisper',
231
- 'localOnlyHint': 'Обе цепочки — только локальный сервер whisper.cpp: полностью офлайн.',
232
- 'sendDelay': 'Задержка вставки диктовки (мс)',
233
- 'sendDelayHint': 'Пауза перед вставкой фразы с окном отмены. 0 — выкл',
234
- 'mic': 'Микрофон',
235
- 'micDefault': 'Системной по умолчанию',
236
- 'vocabulary': 'Свой словарь (одно слово в строке)',
237
- 'polish': 'Полировка текста моделью',
238
- 'polishHint': 'Пунктуация и слова-паразиты через модель харнесса перед вставкой',
239
- 'stream': 'Непрерывная диктовка',
240
- 'streamHint': 'Резать фразы по таймеру во время речи, а не по длинной паузе',
241
- 'streamChunkMs': 'Кусок потока (мс)',
242
- 'vadAdapt': 'Адаптивная тишина',
243
- 'vadAdaptHint': 'Авто-подстройка порога под темп речи. 0 = фикс. поведение',
244
- 'wakeWord': 'Слово-активатор',
245
- 'wakeWordHint': 'Распознавание в браузере начинает запись, если речь начинается с этой фразы. Пусто = выкл',
246
- 'bargeIn': 'Перебивание',
247
- 'polishSend': 'Полировка всего текста перед отправкой',
248
- 'polishSendHint': 'Прогнать весь текст через модель перед отправкой агенту',
249
- 'sessionCommands': 'Голосовые команды сессии',
250
- 'sessionCommandsHint': '«отправь», «отмени», «стоп», «продолжи» — действия сессии, а не текст',
251
- 'polishBaseUrl': 'Локальный ендпоинт полировки',
252
- 'polishBaseUrlHint': 'OpenAI-совместимый /chat/completions базовый URL, напр. локальный Ollama. Пусто = модель харнесса',
253
- 'polishModel': 'Модель офлайн-полировки',
254
- 'polishKeyEnv': 'Ключ офлайн-полировки',
255
- 'voiceCommandsLabel': 'Голосовые команды («с новой строки», «абзац»)',
256
- 'normalizeTranscriptHint': 'transcribe_audio: числа словами — в цифры, аккуратная пунктуация',
257
- 'messageTitle': 'Голосовое сообщение',
258
- 'messageHint': 'Одна запись целиком, после распознавания уходит агенту.',
259
- 'undoMs': 'Окно отмены, мс',
260
- 'undoHint': 'Сколько времени можно отменить автоматическую отправку',
261
- 'customTitle': 'Свои провайдеры',
262
- 'customHint': 'Любой OpenAI-совместимый API. Имя становится доступным в цепочках выше.',
263
- 'general': 'Общее',
264
- 'whisperEndpoint': 'Локальный whisper: endpoint',
265
- 'whisperEndpointHint': 'POST /inference сервера whisper.cpp',
266
- 'deepgramEndpoint': 'Deepgram: базовый URL',
267
- 'deepgramEndpointHint': 'Базовый адрес Deepgram или self-hosted развёртывания (по умолчанию https://api.deepgram.com)',
268
- 'whisperBin': 'Локальный whisper: бинарь',
269
- 'whisperBinHint': 'используется при автозапуске',
270
- 'whisperModel': 'Локальный whisper: модель',
271
- 'whisperAutostart': 'Автозапуск локального whisper',
272
- 'save': 'Сохранить',
273
- 'saved': 'Сохранено ✓',
274
- 'openrouterWarning': 'У OpenRouter нет /audio/transcriptions \u2014 там нужен шаблон openai-chat-audio',
275
- 'noiseSuppression': 'Аппаратное шумоподавление',
276
- 'noiseSuppressionHint': 'Включить шумоподавление, эхоподавление и АРУ микрофона в браузере',
277
- 'contextGlossary': 'Контекстный словарь терминов',
278
- 'contextGlossaryHint': 'Авто-извлечение кода и терминов из композера для повышения точности STT',
279
- 'providerDashboard': 'Задержка и здоровье провайдеров',
280
- 'avgLatency': 'Ср. задержка',
281
- 'successRate': 'Успешность',
282
- 'fast': 'Быстро',
283
- 'normal': 'Норма',
284
- 'slow': 'Медленно',
285
- 'error': 'Сбой',
286
- 'idle': 'Нет вызовов',
287
- 'play': 'Слушать',
288
- 'pause': 'Пауза',
289
- 'listenBack': 'Прослушать запись',
290
- 'lastRecording': 'Последняя запись',
291
- 'sensevoiceHint': 'SenseVoice-ONNX / Sherpa-ONNX · сверхбыстрый локальный STT (~50мс)',
292
- 'sensevoiceEndpoint': 'SenseVoice: endpoint',
293
- 'sensevoiceEndpointHint': 'POST endpoint сервера sherpa-onnx или совместимого',
294
- 'sensevoiceBin': 'SenseVoice: бинарь',
295
- 'sensevoiceBinHint': 'используется при автозапуске',
296
- 'sensevoiceModel': 'SenseVoice: путь к модели',
297
- 'sensevoiceAutostart': 'Автозапуск сервера SenseVoice',
298
- 'visualizerStyle': 'Стиль визуализатора звука',
299
- 'visualizerStyleHint': 'Анимация волны внутри полоски записи',
300
- 'visLiquidWave': 'Жидкая волна (Liquid Wave)',
301
- 'visDynamicOrb': 'Динамическая сфера (Dynamic Orb)',
302
- 'visBars': 'Классические столбики',
303
- 'visOff': 'Выключен',
304
- }
305
167
 
306
- // Строки нужны и вне компонентов — в обработчиках записи, в подписях
307
- // слотов. Поэтому переводчик модульный, а не только через 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.
308
170
  let moduleT = (key) => key
309
171
  const t = (key) => moduleT(key)
310
172
 
@@ -339,10 +201,10 @@ window.__ModuleLoader__.load({
339
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)}' +
340
202
  '.dvo-badge-idle{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary)}'
341
203
  const cssId = 'dsh-voice/client.module.css'
342
- 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 + '"]')) {
343
205
  const tag = document.createElement('style')
344
206
  tag.textContent = CSS
345
- tag.setAttribute('data-plugin', 'dsh-voice')
207
+ tag.setAttribute('data-dsh-plugin', 'dsh-voice')
346
208
  tag.dataset.pluginCss = cssId
347
209
  document.head.appendChild(tag)
348
210
  }
@@ -354,8 +216,8 @@ window.__ModuleLoader__.load({
354
216
  error: '',
355
217
  rec: null,
356
218
  levels: [],
357
- pending: null, // {text, leftMs} — окно отмены режима message
358
- lastNote: null, // {blob, url, mime, text} — последняя запись
219
+ pending: null, // {text, leftMs} — message-mode cancel window
220
+ lastNote: null, // {blob, url, mime, text} — last recording
359
221
  showPlayer: false,
360
222
  inputActions: null,
361
223
  input: null,
@@ -444,7 +306,7 @@ window.__ModuleLoader__.load({
444
306
  body: JSON.stringify(payload),
445
307
  })
446
308
  let parsed = null
447
- try { parsed = await res.json() } catch (e) { /* не json */ }
309
+ try { parsed = await res.json() } catch (e) { /* not json */ }
448
310
  if (!res.ok || !parsed || !parsed.ok) {
449
311
  throw new Error((parsed && parsed.error && parsed.error.message) || `HTTP ${res.status}`)
450
312
  }
@@ -460,13 +322,16 @@ window.__ModuleLoader__.load({
460
322
  return s
461
323
  }
462
324
 
463
- // Голосовые команды редактирования (#37): «с новой строки» -> \n и т.п.
464
- // Применяются до нормализации, только когда включены в настройках.
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.
465
328
  const VOICE_COMMANDS = [
466
329
  [/(^|[\s,.!?])с новой строки([\s,.!?]|$)/gi, '$1\n$2'],
467
330
  [/(^|[\s,.!?])новая строка([\s,.!?]|$)/gi, '$1\n$2'],
468
331
  [/(^|[\s,.!?])абзац([\s,.!?]|$)/gi, '$1\n\n$2'],
469
332
  [/(^|\s)тире(\s|$)/gi, '$1—$2'],
333
+ [/(^|[\s,.!?])new line([\s,.!?]|$)/gi, '$1\n$2'],
334
+ [/(^|[\s,.!?])paragraph([\s,.!?]|$)/gi, '$1\n\n$2'],
470
335
  ]
471
336
 
472
337
  function applyVoiceCommands(text) {
@@ -475,7 +340,7 @@ window.__ModuleLoader__.load({
475
340
  return s.replace(/[ \t]+/g, ' ').trim()
476
341
  }
477
342
 
478
- // История вставок для undo (#29-9). Хранится только в браузере.
343
+ // Insert history for undo (#29-9). Browser-only storage.
479
344
  const insertHistory = []
480
345
 
481
346
  async function undoLastInsert() {
@@ -486,7 +351,7 @@ window.__ModuleLoader__.load({
486
351
  const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
487
352
  if (draft === last.after) actions.setDraft(last.before)
488
353
  else {
489
- // Текст уже менялся руками — вырезаем последнюю вставку как подстроку.
354
+ // Draft was edited manually — cut the last insert as a substring.
490
355
  const i = draft.lastIndexOf(last.added)
491
356
  if (i < 0) { insertHistory.push(last); return t('nothingToUndo') }
492
357
  actions.setDraft((draft.slice(0, i) + draft.slice(i + last.added.length)).replace(/\s+$/, ''))
@@ -512,7 +377,7 @@ window.__ModuleLoader__.load({
512
377
  }
513
378
  }
514
379
 
515
- // Как клавиша называется на человеческом языке.
380
+ // Human-readable key name.
516
381
  const KEY_LABELS = {
517
382
  Control: 'Ctrl', Alt: 'Alt', Shift: 'Shift', Meta: 'Win',
518
383
  Escape: 'Esc',
@@ -522,32 +387,31 @@ window.__ModuleLoader__.load({
522
387
  if (!name) return t('keyUnset')
523
388
  if (name === 'Space') return t('keySpace')
524
389
  if (KEY_LABELS[name]) return KEY_LABELS[name]
525
- // Коды вида KeyR и Digit5 показываем без служебной приставки.
390
+ // Drop the Key/Digit prefix from codes like KeyR and Digit5.
526
391
  return String(name).replace(/^Key/, '').replace(/^Digit/, '')
527
392
  }
528
393
 
529
- // Что записать в настройку по нажатию. Чистый модификатор запоминаем по
530
- // имени: код у левого и правого разный, а человек нажимает «какой-нибудь».
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".
531
396
  function keyFromEvent(event) {
532
397
  if (['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)) return event.key
533
398
  if (event.code) return event.code
534
399
  return event.key || ''
535
400
  }
536
401
 
537
- // Объявление о том, что человек заговорил.
402
+ // Announce that the user started speaking.
538
403
  //
539
- // Нужно, чтобы озвучка немедленно замолчала: слушать ответ и говорить
540
- // одновременно невозможно, а перекрикивать собственный плагин — глупо.
541
- // Связи между плагинами нет: голос кричит в окно, кто хочет — слышит.
542
- // Поэтому оба плагина работают и поодиночке.
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.
543
407
  function announceVoice(phase) {
544
408
  try {
545
409
  window.dispatchEvent(new CustomEvent('dsh-voice:speaking', { detail: { phase } }))
546
- } catch (noEvents) { /* окна нет — значит и слушать некому */ }
410
+ } catch (noEvents) { /* no window — nobody to hear it */ }
547
411
  if (voice.settings.beep) playBeep(phase === 'start' ? 880 : 660)
548
412
  }
549
413
 
550
- // Короткий пик через WebAudio: слышно без взгляда на экран (#29-6).
414
+ // Short WebAudio beep so start/stop is audible without looking (#29-6).
551
415
  function playBeep(freq) {
552
416
  try {
553
417
  const AC = typeof AudioContext !== 'undefined' ? AudioContext
@@ -562,18 +426,18 @@ window.__ModuleLoader__.load({
562
426
  gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.09)
563
427
  osc.connect(gain); gain.connect(ac.destination)
564
428
  osc.start(); osc.stop(ac.currentTime + 0.1)
565
- osc.onended = () => { try { ac.close() } catch (e) { /* уже закрыт */ } }
566
- } catch (noAudio) { /* без звука — не критично */ }
429
+ osc.onended = () => { try { ac.close() } catch (e) { /* already closed */ } }
430
+ } catch (noAudio) { /* no audio — not critical */ }
567
431
  }
568
432
 
569
- // ------------------------------------------------- распознавание в браузере
433
+ // ------------------------------------------------- browser recognition
570
434
  //
571
- // Отдельная нога, не похожая на все остальные: речь распознаёт сам браузер,
572
- // на хост ничего не уходит, ключи не нужны, а текст появляется по словам
573
- // прямо во время речи.
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.
574
438
  //
575
- // Плата за это: в Chrome звук уходит на серверы Google. Поэтому провайдер
576
- // никогда не включается сам — только если его прямо поставили в цепочку.
439
+ // Cost: in Chrome audio goes to Google servers. This provider is never
440
+ // enabled automatically — only when placed explicitly in a chain.
577
441
  function speechRecognitionCtor() {
578
442
  if (typeof window === 'undefined') return null
579
443
  return window.SpeechRecognition || window.webkitSpeechRecognition || null
@@ -607,29 +471,29 @@ window.__ModuleLoader__.load({
607
471
  options.onInterim(interim)
608
472
  }
609
473
  recognition.onerror = (event) => {
610
- // no-speech и aborted — обычная жизнь, а не поломка.
474
+ // no-speech and aborted are normal life, not failures.
611
475
  const code = event && event.error
612
476
  if (code === 'no-speech' || code === 'aborted') return
613
477
  options.onError(code || t('recognitionError'))
614
478
  }
615
- // Браузер обрывает распознавание сам: на паузах, по таймауту. Пока нас не
616
- // остановили — поднимаем заново, иначе диктовка молча умрёт на первой паузе.
479
+ // The browser ends recognition on its own (pauses, timeout). Restart
480
+ // until we stop it, otherwise dictation dies silently on the first pause.
617
481
  recognition.onend = () => {
618
482
  if (stopped) return
619
- try { recognition.start() } catch (alreadyRunning) { /* уже поднято */ }
483
+ try { recognition.start() } catch (alreadyRunning) { /* already running */ }
620
484
  }
621
485
 
622
486
  try { recognition.start() } catch (cannotStart) {
623
487
  options.onError(String(cannotStart && cannotStart.message || cannotStart))
624
488
  }
625
489
  return {
626
- stop() { stopped = true; try { recognition.stop() } catch (already) { /* уже стоит */ } },
627
- 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 */ } },
628
492
  }
629
493
  }
630
494
 
631
- // Какие провайдеры стоят в цепочке режима — узнаём у хоста один раз.
632
- // Нужно только чтобы понять, идти в браузер или писать файл.
495
+ // Fetch the mode chain from the host once. Only needed to decide whether
496
+ // to use browser recognition or record a file.
633
497
  let chainsPromise = null
634
498
  function modeChain(mode) {
635
499
  if (!chainsPromise) {
@@ -650,8 +514,8 @@ window.__ModuleLoader__.load({
650
514
  // ------------------------------------------------------------ recording
651
515
  function teardown(rec) {
652
516
  if (!rec) return
653
- try { rec.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* уже остановлен */ }
654
- 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 */ } }
655
519
  }
656
520
 
657
521
  function waitStop(recorder) {
@@ -693,7 +557,7 @@ window.__ModuleLoader__.load({
693
557
  rec.analyser.fftSize = 128
694
558
  src.connect(rec.analyser)
695
559
  }
696
- // Без timeslice: только тогда каждый stop() даёт самостоятельный webm-файл.
560
+ // No timeslice: only then each stop() yields a standalone webm file.
697
561
  recorder.start()
698
562
  return rec
699
563
  }
@@ -707,14 +571,14 @@ window.__ModuleLoader__.load({
707
571
  return Math.min(1, (sum / data.length / 255) * 2.2)
708
572
  }
709
573
 
710
- // Режет текущую фразу: останавливает рекордер, отправляет готовый файл и
711
- // немедленно начинает новую запись тем же рекордером.
574
+ // Cut the current phrase: stop the recorder, send the finished file and
575
+ // immediately start a new recording with the same recorder.
712
576
  function cutPhrase() {
713
577
  const rec = voice.rec
714
578
  if (!rec || rec.cutting || rec.closing) return
715
579
  rec.cutting = true
716
580
  const stopped = waitStop(rec.recorder)
717
- try { rec.recorder.stop() } catch (e) { /* уже остановлен */ }
581
+ try { rec.recorder.stop() } catch (e) { /* already stopped */ }
718
582
  stopped.then(async () => {
719
583
  const blob = new Blob(rec.chunks, { type: rec.mime })
720
584
  rec.chunks = []
@@ -722,17 +586,17 @@ window.__ModuleLoader__.load({
722
586
  rec.hadSpeech = false
723
587
  rec.streamMs = 0
724
588
  if (!rec.closing) {
725
- try { rec.recorder.start() } catch (e) { /* поток закрылся */ }
589
+ try { rec.recorder.start() } catch (e) { /* stream already closed */ }
726
590
  }
727
591
  rec.cutting = false
728
- if (blob.size < 1200) return // слишком короткий кусок — это не речь
592
+ if (blob.size < 1200) return // too short — not speech
729
593
  try {
730
594
  const out = await sendAudio(blob, rec.mime, 'dictation')
731
595
  const text = out && out.text ? out.text : ''
732
596
  const delay = Number(voice.settings.sendDelayMs) || 0
733
597
  if (text && delay > 0 && !voice.holding) {
734
- // Отложенная вставка с окном отмены (#29-5): текст уже вставлен,
735
- // окно позволяет его отменить.
598
+ // Delayed insert with a cancel window (#29-5): text is already
599
+ // inserted; the window only allows undoing it.
736
600
  appendDraft(text)
737
601
  voice.set({ phase: 'pending', pending: { text, undoOnly: true, leftMs: delay } })
738
602
  return
@@ -744,8 +608,8 @@ window.__ModuleLoader__.load({
744
608
  })
745
609
  }
746
610
 
747
- // Распознавание браузером вместо записи файла. Возвращает false, если
748
- // браузер этого не умеет, — тогда идём обычным путём.
611
+ // Browser recognition instead of file recording. Returns false when the
612
+ // browser cannot do it — then fall back to the normal path.
749
613
  function startBrowserLeg(mode, language) {
750
614
  if (!browserRecognitionAvailable()) return false
751
615
  const finals = []
@@ -755,8 +619,8 @@ window.__ModuleLoader__.load({
755
619
  continuous: true,
756
620
  onInterim: (text) => {
757
621
  voice.caption = text; voice.notify()
758
- // Wake-word (#45): если interim начинается с ключевой фразы —
759
- // обрываем прослушивание browser и уходим в обычную запись.
622
+ // Wake-word (#45): if interim starts with the trigger phrase, stop
623
+ // browser listening and switch to normal recording.
760
624
  const ww = String(voice.settings.wakeWord || '').trim().toLowerCase()
761
625
  if (ww && mode === 'dictation' && !voice.rec) {
762
626
  const t = String(text || '').trim().toLowerCase()
@@ -779,13 +643,12 @@ window.__ModuleLoader__.load({
779
643
  onFinal: (text) => {
780
644
  finals.push(text)
781
645
  voice.caption = ''
782
- // Диктовка дописывает сразу, голосовое копит до отпускания.
646
+ // Dictation appends immediately; messages accumulate until release.
783
647
  if (mode === 'dictation') appendDraft(text)
784
648
  else voice.notify()
785
649
  },
786
650
  onError: (reason) => {
787
- // Браузер отказал уже после старта — честно говорим об этом, а не
788
- // делаем вид, что слушаем.
651
+ // Browser failed after start — say so instead of pretending to listen.
789
652
  voice.browser = null
790
653
  voice.set({ phase: 'error', error: t('browserFailed') + reason })
791
654
  },
@@ -797,12 +660,12 @@ window.__ModuleLoader__.load({
797
660
 
798
661
  function startRecording(mode) {
799
662
  if (voice.phase !== 'idle' && voice.phase !== 'error') return
800
- // Кричим до открытия микрофона, а не после: чем раньше замолчит
801
- // озвучка, тем меньше её попадёт в запись.
663
+ // Announce before opening the mic: the sooner playback mutes, the less
664
+ // of it ends up in the recording.
802
665
  announceVoice('start')
803
666
  voice.set({ phase: 'recording', mode, error: '', levels: [], caption: '' })
804
667
  modeChain(mode).then((info) => {
805
- // Браузерная нога — только если её прямо поставили первой в цепочке.
668
+ // Browser leg only when it is explicitly first in the chain.
806
669
  if (info.chain[0] === 'browser' && startBrowserLeg(mode, info.language)) return
807
670
  openMic(mode)
808
671
  .then((rec) => {
@@ -820,12 +683,12 @@ window.__ModuleLoader__.load({
820
683
  function startDictation() { startRecording('dictation') }
821
684
  function startMessage() { startRecording('message') }
822
685
 
823
- // --------------------------------------------------------- удержание
686
+ // --------------------------------------------------------- hold gesture
824
687
  //
825
- // Два жеста одной кнопкой: короткое нажатие включает запись до второго
826
- // нажатия (как было), удержание пишет ровно пока держишь. Отпустил — ушло.
688
+ // Two gestures on one button: a short click toggles recording until the
689
+ // next click; hold records only while pressed. Release sends.
827
690
  //
828
- // Различаем по времени: если отпустили раньше порога — это клик.
691
+ // Distinguished by time: release before the threshold is a click.
829
692
  const HOLD_THRESHOLD_MS = 350
830
693
 
831
694
  const hold = { active: false, mode: null, startedAt: 0, armed: false }
@@ -836,7 +699,7 @@ window.__ModuleLoader__.load({
836
699
  hold.mode = mode
837
700
  hold.startedAt = Date.now()
838
701
  hold.active = false
839
- // Запись начинаем сразу: ждать порога — значит потерять первое слово.
702
+ // Start recording immediately: waiting for the threshold loses the first word.
840
703
  startRecording(mode)
841
704
  voice.holding = true
842
705
  voice.notify()
@@ -848,15 +711,15 @@ window.__ModuleLoader__.load({
848
711
  hold.armed = false
849
712
  hold.active = false
850
713
  voice.holding = false
851
- // Короткое нажатие — это клик: запись уже идёт, оставляем её включённой,
852
- // остановит второе нажатие.
714
+ // Short press is a click: recording is already on, leave it running;
715
+ // the second click will stop it.
853
716
  if (!cancelled && heldMs < HOLD_THRESHOLD_MS) { voice.notify(); return }
854
717
  if (cancelled) cancelCurrent()
855
718
  else stopCurrent()
856
719
  }
857
720
 
858
- // Горячая клавиша: держать её удобнее, чем целиться мышью. Пока клавиша
859
- // зажата — идёт запись, Esc отменяет.
721
+ // Hotkey: holding a key is easier than aiming the mouse. While the key is
722
+ // down we record; Escape cancels.
860
723
  function hotkeyMatches(event, name) {
861
724
  if (name === 'Control') return event.key === 'Control'
862
725
  if (name === 'Alt') return event.key === 'Alt'
@@ -868,8 +731,8 @@ window.__ModuleLoader__.load({
868
731
  if (typeof document === 'undefined' || !keyName) return () => {}
869
732
  const down = (event) => {
870
733
  if (event.repeat) return
871
- // В поле ввода горячая клавиша-модификатор не мешает: она сама по себе
872
- // ничего не печатает. А вот обычную букву перехватывать нельзя.
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.
873
736
  if (hotkeyMatches(event, keyName)) beginHold(mode)
874
737
  }
875
738
  const up = (event) => {
@@ -901,12 +764,12 @@ window.__ModuleLoader__.load({
901
764
  if (!rec) { voice.set({ phase: 'idle', error: '' }); return }
902
765
  rec.closing = true
903
766
  const stopped = waitStop(rec.recorder)
904
- try { rec.recorder.stop() } catch (e) { /* уже остановлен */ }
767
+ try { rec.recorder.stop() } catch (e) { /* already stopped */ }
905
768
  stopped.then(() => { teardown(rec); voice.rec = null; voice.set({ phase: 'idle', error: '' }) })
906
769
  }
907
770
 
908
- // Останов по второму нажатию: диктовка досылает хвост, голосовое —
909
- // отправляет всю запись и открывает окно отмены.
771
+ // Stop on the second click: dictation flushes the tail; a voice message
772
+ // sends the whole recording and opens the cancel window.
910
773
  function stopCurrent() {
911
774
  announceVoice('end')
912
775
  if (voice.browser) {
@@ -921,7 +784,7 @@ window.__ModuleLoader__.load({
921
784
  appendDraft(said)
922
785
  voice.set({ phase: 'pending', pending: { text: said, leftMs: voice.settings.autoSendMs } })
923
786
  } else {
924
- // Диктовка дописывала по ходу — добавлять нечего.
787
+ // Dictation already appended while speaking — nothing extra to add.
925
788
  voice.set({ phase: 'idle' })
926
789
  }
927
790
  return
@@ -931,17 +794,17 @@ window.__ModuleLoader__.load({
931
794
  rec.closing = true
932
795
  const mode = rec.mode
933
796
  const stopped = waitStop(rec.recorder)
934
- try { rec.recorder.stop() } catch (e) { /* уже остановлен */ }
797
+ try { rec.recorder.stop() } catch (e) { /* already stopped */ }
935
798
  stopped.then(async () => {
936
799
  const blob = new Blob(rec.chunks, { type: rec.mime })
937
800
  teardown(rec)
938
801
  voice.rec = null
939
802
  if (blob.size < 1200) { voice.set({ phase: 'idle' }); return }
940
803
  if (voice.lastNote && voice.lastNote.url) {
941
- try { URL.revokeObjectURL(voice.lastNote.url) } catch (e) { /* игнор */ }
804
+ try { URL.revokeObjectURL(voice.lastNote.url) } catch (e) { /* ignore */ }
942
805
  }
943
806
  let noteUrl = ''
944
- try { noteUrl = URL.createObjectURL(blob) } catch (e) { /* игнор */ }
807
+ try { noteUrl = URL.createObjectURL(blob) } catch (e) { /* ignore */ }
945
808
  voice.lastNote = { blob, url: noteUrl, mime: rec.mime, text: '' }
946
809
  voice.set({ phase: 'processing' })
947
810
  try {
@@ -967,7 +830,7 @@ window.__ModuleLoader__.load({
967
830
  voice.set({ phase: 'idle' })
968
831
  const actions = voice.inputActions
969
832
  if (!actions || typeof actions.submit !== 'function') return
970
- // Полировка всего draft перед отправкой (#46). Ошибка не блокирует.
833
+ // Polish the whole draft before submit (#46). Errors do not block.
971
834
  if (voice.settings.polishSend) {
972
835
  const run = async () => {
973
836
  try {
@@ -983,15 +846,15 @@ window.__ModuleLoader__.load({
983
846
  actions.setDraft(parsed.text.trim())
984
847
  }
985
848
  }
986
- } catch (e) { /* полировка best-effort */ }
849
+ } catch (e) { /* polish is best-effort */ }
987
850
  }
988
- run().finally(() => setTimeout(() => { try { actions.submit() } catch (e) { /* занят */ } }, 0))
851
+ run().finally(() => setTimeout(() => { try { actions.submit() } catch (e) { /* busy */ } }, 0))
989
852
  return
990
853
  }
991
- setTimeout(() => { try { actions.submit() } catch (e) { /* композер занят */ } }, 0)
854
+ setTimeout(() => { try { actions.submit() } catch (e) { /* composer busy */ } }, 0)
992
855
  }
993
856
 
994
- // Голосовые команды сессии (#48): чистые «отправь/отмени/стоп/продолжи».
857
+ // Session voice commands (#48): clean "send/cancel/stop/continue".
995
858
  function runSessionCommand(cmd) {
996
859
  const actions = voice.inputActions
997
860
  voice.set({ phase: 'idle', pending: null })
@@ -1002,7 +865,7 @@ window.__ModuleLoader__.load({
1002
865
  break
1003
866
  case 'cancel':
1004
867
  case 'stop':
1005
- // Сбрасываем ожидание/запись; текст draft намеренно не трогаем.
868
+ // Clear wait/recording; leave the draft text intentionally untouched.
1006
869
  break
1007
870
  default:
1008
871
  break
@@ -1029,7 +892,7 @@ window.__ModuleLoader__.load({
1029
892
  React.createElement('button', {
1030
893
  type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
1031
894
  title: err ? v.error : t('messageBtn'),
1032
- // Удержание: пишет, пока держишь; увёл курсор далеко вверх — отмена.
895
+ // Hold: record while pressed; leaving the control cancels.
1033
896
  onPointerDown: (e) => { e.preventDefault(); beginHold('message') },
1034
897
  onPointerUp: () => endHold(false),
1035
898
  onPointerLeave: () => { if (hold.armed) endHold(true) },
@@ -1045,7 +908,26 @@ window.__ModuleLoader__.load({
1045
908
  }
1046
909
 
1047
910
  // ----------------------------------------------------------- visualizers
1048
- // 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
1049
931
  function drawLiquidWave(g, w, h, levels, color, time) {
1050
932
  const midY = h / 2
1051
933
  const curLevel = levels.length ? levels[levels.length - 1] : 0
@@ -1064,7 +946,7 @@ window.__ModuleLoader__.load({
1064
946
 
1065
947
  const grad = g.createLinearGradient(0, 0, w, 0)
1066
948
  grad.addColorStop(0, color)
1067
- grad.addColorStop(0.5, '#38bdf8')
949
+ grad.addColorStop(0.5, accentColor(color))
1068
950
  grad.addColorStop(1, color)
1069
951
  g.strokeStyle = grad
1070
952
  g.lineWidth = layerIdx === 2 ? 2.5 : 1.5
@@ -1083,7 +965,7 @@ window.__ModuleLoader__.load({
1083
965
  g.globalAlpha = 1
1084
966
  }
1085
967
 
1086
- // 2. Dynamic Orb: интерактивная пульсирующая сфера по центру
968
+ // 2. Dynamic Orb: interactive pulsing sphere in the center
1087
969
  function drawDynamicOrb(g, w, h, levels, color, time) {
1088
970
  const cx = w / 2
1089
971
  const cy = h / 2
@@ -1103,7 +985,7 @@ window.__ModuleLoader__.load({
1103
985
  const rRing = 14 + smoothLevel * 14 + Math.sin(time * 0.08) * 3
1104
986
  g.beginPath()
1105
987
  g.arc(cx, cy, rRing, 0, Math.PI * 2)
1106
- g.strokeStyle = '#38bdf8'
988
+ g.strokeStyle = accentColor(color)
1107
989
  g.globalAlpha = 0.35 + smoothLevel * 0.4
1108
990
  g.lineWidth = 1.5
1109
991
  g.stroke()
@@ -1119,8 +1001,8 @@ window.__ModuleLoader__.load({
1119
1001
 
1120
1002
  const rCore = 6 + smoothLevel * 8 + Math.sin(time * 0.12) * 1.5
1121
1003
  const radial = g.createRadialGradient(cx, cy, 1, cx, cy, rCore + 4)
1122
- radial.addColorStop(0, '#ffffff')
1123
- radial.addColorStop(0.4, '#38bdf8')
1004
+ radial.addColorStop(0, softColor(color))
1005
+ radial.addColorStop(0.4, accentColor(color))
1124
1006
  radial.addColorStop(1, color)
1125
1007
  g.beginPath()
1126
1008
  g.arc(cx, cy, rCore, 0, Math.PI * 2)
@@ -1131,7 +1013,7 @@ window.__ModuleLoader__.load({
1131
1013
  g.globalAlpha = 1
1132
1014
  }
1133
1015
 
1134
- // 3. Classic Bars: классические вертикальные столбики
1016
+ // 3. Classic Bars: classic vertical bars
1135
1017
  function drawClassicBars(g, w, h, levels, color) {
1136
1018
  const midY = h / 2
1137
1019
  for (let i = 0; i < levels.length && i * 7 < w; i++) {
@@ -1155,7 +1037,7 @@ window.__ModuleLoader__.load({
1155
1037
  voice.input = props.input
1156
1038
  const ctx = props.ctx
1157
1039
 
1158
- // VAD: копим тишину и режем фразу, когда пауза превысила порог.
1040
+ // VAD: accumulate silence and cut the phrase when the pause exceeds the threshold.
1159
1041
  React.useEffect(() => {
1160
1042
  if (v.phase !== 'recording') return
1161
1043
  const tick = 50
@@ -1172,16 +1054,16 @@ window.__ModuleLoader__.load({
1172
1054
  const adapt = Number(voice.settings.vadAdapt) || 0
1173
1055
  let effectiveVad = Number(voice.settings.vadSilenceMs) || 700
1174
1056
  if (adapt > 0 && rec.hadSpeech) {
1175
- // Адаптивный порог (#41): считаем плотность речи за последние
1176
- // ~1s (20 сэмплов). Плотная речь -> порог ниже (точнее режем),
1177
- // паузная -> порог растёт к базе (не режем на вдохе).
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).
1178
1060
  const win = voice.levels.slice(-20)
1179
1061
  const density = win.length ? win.filter((v) => v > 0.06).length / win.length : 0
1180
1062
  const k = adapt * (density - 0.5) * 2
1181
1063
  effectiveVad = Math.max(150, Math.round(Number(voice.settings.vadSilenceMs) * (1 - k)))
1182
1064
  }
1183
- // Непрерывный режим (#40): режем по таймеру непрерывной речи,
1184
- // не дожидаясь длинной паузы.
1065
+ // Continuous mode (#40): cut on a timer while speech continues,
1066
+ // without waiting for a long pause.
1185
1067
  const stream = !!voice.settings.stream && rec.mode === 'dictation'
1186
1068
  if (stream && rec.hadSpeech && !rec.cutting) {
1187
1069
  rec.streamMs += tick
@@ -1198,7 +1080,7 @@ window.__ModuleLoader__.load({
1198
1080
  return () => dispose()
1199
1081
  }, [v.phase])
1200
1082
 
1201
- // Анимация осциллограммы / визуализатора.
1083
+ // Waveform / visualizer animation.
1202
1084
  React.useEffect(() => {
1203
1085
  if (v.phase !== 'recording') return
1204
1086
  let frame = 0
@@ -1239,7 +1121,7 @@ window.__ModuleLoader__.load({
1239
1121
  return () => dispose()
1240
1122
  }, [v.phase])
1241
1123
 
1242
- // Окно отмены режима message.
1124
+ // Message-mode cancel window.
1243
1125
  React.useEffect(() => {
1244
1126
  if (v.phase !== 'pending') return
1245
1127
  const tick = 100
@@ -1247,8 +1129,8 @@ window.__ModuleLoader__.load({
1247
1129
  const p = voice.pending
1248
1130
  if (!p) return
1249
1131
  if (p.undoOnly) {
1250
- // Режим «только отмена вставки» (#29-5): по истечении окна просто
1251
- // прячем панель; текст либо остался, либо уже откатили руками.
1132
+ // Undo-only mode (#29-5): when the window expires just hide the
1133
+ // panel; the text either stayed or was already undone.
1252
1134
  p.leftMs -= tick
1253
1135
  if (p.leftMs <= 0) voice.set({ phase: 'idle' })
1254
1136
  else voice.notify()
@@ -1299,8 +1181,8 @@ window.__ModuleLoader__.load({
1299
1181
  const inBrowser = !!voice.browser
1300
1182
  const rec = voice.rec
1301
1183
  const hint = v.mode === 'dictation' ? t('dictationPill') : t('messagePill')
1302
- // Живая подпись: что слышно прямо сейчас. Пока браузер не выдал
1303
- // окончательный кусок, текст черновой и меняется на глазах.
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.
1304
1186
  const caption = voice.caption || (inBrowser ? '' : null)
1305
1187
  return React.createElement('div', { className: 'dvo-pill' },
1306
1188
  React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('cancel'), onClick: cancelCurrent }, xIcon()),
@@ -1327,7 +1209,7 @@ window.__ModuleLoader__.load({
1327
1209
  if (v.phase === 'pending') {
1328
1210
  const left = Math.max(0, Math.ceil((voice.pending ? voice.pending.leftMs : 0) / 1000))
1329
1211
  if (voice.pending && voice.pending.undoOnly) {
1330
- // Окно отмены отложенной вставки диктовки (#29-5).
1212
+ // Cancel window for delayed dictation insert (#29-5).
1331
1213
  return React.createElement('div', { className: 'dvo-pill' },
1332
1214
  React.createElement('span', { className: 'dvo-status' }, t('undo'), ': ', left, t('secondsShort')),
1333
1215
  React.createElement('button', {
@@ -1375,8 +1257,8 @@ window.__ModuleLoader__.load({
1375
1257
 
1376
1258
  // --------------------------------------------------------------- slots
1377
1259
  function registerComposer(ctx) {
1378
- // Горячая клавиша живёт всё время, пока плагин применён, и меняется без
1379
- // перезапуска: карточка настроек кричит в окно, композер перечитывает.
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.
1380
1262
  ctx.effect(() => {
1381
1263
  let dispose = () => {}
1382
1264
  let alive = true
@@ -1390,7 +1272,7 @@ window.__ModuleLoader__.load({
1390
1272
  dispose = () => {}
1391
1273
  const key = data && data.hotkey
1392
1274
  if (key) dispose = installHotkey(ctx, key, 'message')
1393
- // Новые настройки композеру нужны без открытия карточки.
1275
+ // The composer needs fresh settings without opening the card.
1394
1276
  Object.assign(voice.settings, {
1395
1277
  beep: !!(data && data.beep),
1396
1278
  micDeviceId: String((data && data.micDeviceId) || ''),
@@ -1409,7 +1291,7 @@ window.__ModuleLoader__.load({
1409
1291
  visualizerStyle: (data && data.visualizerStyle) || 'liquid-wave',
1410
1292
  })
1411
1293
  })
1412
- .catch(() => { /* без подсказки хоста клавиши просто не будет */ })
1294
+ .catch(() => { /* no host hint — no hotkey */ })
1413
1295
  }
1414
1296
 
1415
1297
  reload()
@@ -1419,7 +1301,7 @@ window.__ModuleLoader__.load({
1419
1301
  window.removeEventListener('dsh-voice:settings-saved', reload)
1420
1302
  dispose()
1421
1303
  }
1422
- }, 'dsh-voice: горячая клавиша удержания')
1304
+ }, 'dsh-voice: hold hotkey')
1423
1305
 
1424
1306
  ctx.slots.inject('conversation.input.right', () => ctx.slots.register(
1425
1307
  { name: 'conversation.input.right', id: '@goodandready/dsh-voice', order: 6, label: () => t('title') },
@@ -1434,21 +1316,31 @@ window.__ModuleLoader__.load({
1434
1316
  // ------------------------------------------------------- settings page
1435
1317
  const BUILTIN = [
1436
1318
  'browser', 'deepgram', 'groq', 'hf', 'local-whisper', 'sensevoice',
1437
- // Заготовки: адрес и модель уже прописаны на хосте, нужен только ключ.
1319
+ // Presets: address and model are filled on the host; only a key is needed.
1438
1320
  'openai', 'siliconflow', 'deepinfra', 'fireworks', 'mistral', 'openrouter',
1439
1321
  ]
1440
1322
  const TEMPLATES = ['openai-transcriptions', 'openai-chat-audio']
1441
- const MODEL_HINT = {
1442
- browser: t('browserHint'),
1443
- openai: t('openaiHint'),
1444
- siliconflow: t('siliconflowHint'),
1445
- deepinfra: t('deepinfraHint'),
1446
- fireworks: t('fireworksHint'),
1447
- mistral: t('mistralHint'),
1448
- openrouter: t('openrouterHint'),
1449
- deepgram: 'nova-2', groq: 'whisper-large-v3-turbo',
1450
- hf: 'openai/whisper-large-v3', 'local-whisper': t('localHint'),
1451
- 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] || ''
1452
1344
  }
1453
1345
  const LANGS = ['auto', 'ru', 'en', 'uk', 'de']
1454
1346
 
@@ -1478,15 +1370,15 @@ window.__ModuleLoader__.load({
1478
1370
  '.dvo-pcardOpen .dvo-pchev{transform:rotate(180deg)}' +
1479
1371
  '.dvo-pbody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}'
1480
1372
  const setCssId = 'dsh-voice/settings.module.css'
1481
- 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 + '"]')) {
1482
1374
  const tag = document.createElement('style')
1483
1375
  tag.textContent = SET_CSS
1484
- tag.setAttribute('data-plugin', 'dsh-voice')
1376
+ tag.setAttribute('data-dsh-plugin', 'dsh-voice')
1485
1377
  tag.dataset.pluginCss = setCssId
1486
1378
  document.head.appendChild(tag)
1487
1379
  }
1488
1380
 
1489
- // Редактор одной цепочки: строки «провайдер + модель» с перестановкой.
1381
+ // One chain editor: provider+model rows with reordering.
1490
1382
  function ChainEditor(props) {
1491
1383
  const rows = Array.isArray(props.value) ? props.value : []
1492
1384
  const change = (i, patch) => {
@@ -1513,7 +1405,7 @@ window.__ModuleLoader__.load({
1513
1405
  .map((p) => React.createElement('option', { key: p, value: p }, p))),
1514
1406
  React.createElement('input', {
1515
1407
  className: 'dvs-model', value: row.model || '', disabled: !props.writable,
1516
- 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 }),
1517
1409
  }),
1518
1410
  React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '↑'),
1519
1411
  React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, '↓'),
@@ -1526,7 +1418,7 @@ window.__ModuleLoader__.load({
1526
1418
  )
1527
1419
  }
1528
1420
 
1529
- // Свои провайдеры: имя, шаблон API, куда ходить и чем авторизоваться.
1421
+ // Custom providers: name, API template, where to call, how to authorize.
1530
1422
  function CustomEditor(props) {
1531
1423
  const rows = Array.isArray(props.value) ? props.value : []
1532
1424
  const change = (i, patch) => props.onChange(rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r)))
@@ -1578,24 +1470,23 @@ window.__ModuleLoader__.load({
1578
1470
  function VoiceSection(props) {
1579
1471
  const t = (props && props.t) || moduleT
1580
1472
  const ctx = props.ctx
1581
- // На странице, открытой не с localhost, ядро отключает настройки целиком:
1582
- // общий вид документа не читается, каждый раздел получает "unavailable",
1583
- // запись выбрасывается. Сервер это ограничение не разделяет, и плагин
1584
- // dsh-lanmode поднимает ту же механику поверх тех же вызовов. Если он
1585
- // установлен — берём его службу, иначе ядровую: на loopback-странице она
1586
- // работает штатно.
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).
1587
1478
  const scope = ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).bind({ namespace: NS })
1588
1479
  const [snap, setSnap] = React.useState(null)
1589
1480
  const [draft, setDraft] = React.useState(null)
1590
1481
  const [saved, setSaved] = React.useState(false)
1591
1482
  const [err, setErr] = React.useState('')
1592
1483
 
1593
- // Выбор клавиши: не поле для ввода имени, а «нажмите ту, которую хотите».
1594
- // Имена вроде KeyR человек знать не обязан.
1484
+ // Hotkey picker: not a name field — press the key you want. Users should
1485
+ // not need to know codes like KeyR.
1595
1486
  //
1596
- // Хуки объявлены здесь, выше всех возвратов: если объявить их ниже, при
1597
- // неготовых настройках карточка вернётся раньше, хуков окажется меньше,
1598
- // и 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.
1599
1490
  const [catching, setCatching] = React.useState(false)
1600
1491
  React.useEffect(() => {
1601
1492
  if (!catching) return undefined
@@ -1627,39 +1518,38 @@ window.__ModuleLoader__.load({
1627
1518
  .catch(() => {})
1628
1519
  }, [])
1629
1520
 
1630
- // Снимок приходит со статусом, и он важнее самого значения.
1631
- // loading — ответа хоста ещё нет;
1632
- // unavailable — хост ответил, но наш namespace ему пока неизвестен:
1633
- // так бывает, если страницу открыли в первые секунды
1634
- // после старта, пока плагины ещё регистрируются;
1635
- // ready — значения на месте.
1636
- // При unavailable поле writable берётся из документа и остаётся true,
1637
- // поэтому без проверки статуса карточка рисует пустую, но с виду рабочую
1638
- // форму — именно так этот баг и выглядел.
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.
1639
1529
  const ready = !!snap && snap.status === 'ready'
1640
1530
  const value = ready && snap.value ? snap.value : {}
1641
1531
  const writable = ready && snap.writable !== false
1642
1532
 
1643
- // Зеркало настроек в браузере перечитывается только на запись значения и
1644
- // на переподключение; появление namespace таким сигналом не считается.
1645
- // Значит само оно не починится — просим перечитать, пока не готово.
1646
- // Зеркало общее, так что это чинит и остальные разделы настроек.
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.
1647
1537
  React.useEffect(() => {
1648
1538
  if (ready) return undefined
1649
1539
  let tries = 0
1650
1540
  const timer = setInterval(() => {
1651
1541
  if (tries >= 15) { clearInterval(timer); return }
1652
1542
  tries += 1
1653
- 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 */ }
1654
1544
  }, 1000)
1655
1545
  return () => clearInterval(timer)
1656
1546
  }, [ready])
1657
1547
 
1658
- // Черновик сеем только из готового снимка. Раньше он заполнялся из
1659
- // первого пришедшего, и пустой замораживался навсегда: следующий снимок
1660
- // уже не пересевал его, и карточка оставалась пустой до перезагрузки.
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.
1661
1551
  React.useEffect(() => { if (ready && draft === null) setDraft(JSON.parse(JSON.stringify(value))) }, [ready, draft, value])
1662
- // Клиент должен знать порог VAD и окно отмены — они живут в тех же настройках.
1552
+ // The client needs the VAD threshold and cancel window from the same settings.
1663
1553
  React.useEffect(() => {
1664
1554
  if (!ready) return
1665
1555
  voice.settings = Object.assign({}, voice.settings, {
@@ -1719,11 +1609,10 @@ window.__ModuleLoader__.load({
1719
1609
  setErr(''); setSaved(false)
1720
1610
  if (!draft) return
1721
1611
 
1722
- // Поля пишутся по одному, и раньше первая же неудача обрывала цикл:
1723
- // остальные поля не записывались, композер не получал сигнала
1724
- // перечитать клавишу, а на кнопку это было похоже на «ничего не
1725
- // происходит». Теперь пишутся все, а неудачи собираются и называются
1726
- // поимённо — иначе непонятно, какое поле виновато.
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.
1727
1616
  const failed = []
1728
1617
  for (const k of Object.keys(draft)) {
1729
1618
  try {
@@ -1748,9 +1637,9 @@ window.__ModuleLoader__.load({
1748
1637
  contextGlossary: draft.contextGlossary !== false,
1749
1638
  visualizerStyle: draft.visualizerStyle || 'liquid-wave',
1750
1639
  })
1751
- // Композер держит обработчик клавиши: пусть перечитает настройку,
1752
- // иначе новая клавиша заработает только после перезагрузки страницы.
1753
- 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 */ }
1754
1643
 
1755
1644
  if (failed.length) { setErr(t('saveFailed') + ' ' + failed.join('; ')); return }
1756
1645
  setSaved(true); setTimeout(() => setSaved(false), 2000)
@@ -1761,8 +1650,8 @@ window.__ModuleLoader__.load({
1761
1650
  return m && m[key] !== undefined ? m[key] : fallback
1762
1651
  }
1763
1652
 
1764
- // Имена своих провайдеров берём из черновика, чтобы только что
1765
- // добавленный сразу появлялся в списках цепочек.
1653
+ // Custom provider names come from the draft so a just-added entry
1654
+ // appears in chain selects immediately.
1766
1655
  const chainOptions = BUILTIN.concat(
1767
1656
  (draft && Array.isArray(draft.customProviders) ? draft.customProviders : [])
1768
1657
  .map((c) => String(c && c.key || '').trim())
@@ -1889,7 +1778,7 @@ window.__ModuleLoader__.load({
1889
1778
  hotkeyField(),
1890
1779
  textField('whisperUrl', t('whisperEndpoint'), t('whisperEndpointHint')),
1891
1780
  textField('whisperBin', t('whisperBin'), t('whisperBinHint')),
1892
- textField('whisperModel', t('whisperModel'), t('whisperBinHint')),
1781
+ textField('whisperModel', t('whisperModel'), t('whisperModelHint')),
1893
1782
  textField('deepgramBaseUrl', t('deepgramEndpoint'), t('deepgramEndpointHint')),
1894
1783
  React.createElement('label', { className: 'dvs-field' }, t('whisperAutostart'),
1895
1784
  React.createElement('input', {
@@ -1965,7 +1854,7 @@ window.__ModuleLoader__.load({
1965
1854
  React.createElement('div', { className: 'dvs-sub' }, t('sensevoiceHint')),
1966
1855
  textField('sensevoiceUrl', t('sensevoiceEndpoint'), t('sensevoiceEndpointHint')),
1967
1856
  textField('sensevoiceBin', t('sensevoiceBin'), t('sensevoiceBinHint')),
1968
- textField('sensevoiceModel', t('sensevoiceModel'), t('sensevoiceBinHint')),
1857
+ textField('sensevoiceModel', t('sensevoiceModel'), t('sensevoiceModelHint')),
1969
1858
  React.createElement('label', { className: 'dvs-field' }, t('sensevoiceAutostart'),
1970
1859
  React.createElement('input', {
1971
1860
  type: 'checkbox', checked: !!(draft && draft.sensevoiceAutostart), disabled: !writable,
@@ -2022,9 +1911,9 @@ window.__ModuleLoader__.load({
2022
1911
  )
2023
1912
  }
2024
1913
 
2025
- // Карточка во вкладке «Настройки плагинов» (#18): ядро даёт только рамку
2026
- // списка, поэтому заголовок, пояснение и сворачивание — наши.
2027
- // Тело монтируется при первом раскрытии: снапшот настроек тогда и придёт.
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.
2028
1917
  function PluginCard(props) {
2029
1918
  const [open, setOpen] = React.useState(false)
2030
1919
  const tt = (props && props.t) || t
@@ -2049,14 +1938,14 @@ window.__ModuleLoader__.load({
2049
1938
  }
2050
1939
 
2051
1940
  function registerSettings(ctx) {
2052
- // Карточка настроек во вкладке «Настройки → Плагины → Настройки плагинов» (#18).
2053
- // Ключ регистрации обязан равняться NS (пространству настроек), иначе
2054
- // вкладка молча пропустит слот.
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.
2055
1944
  ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
2056
1945
  {
2057
1946
  name: 'settings.plugin.item',
2058
1947
  key: NS,
2059
- // locale в записи слота — то, из-за чего компонент получает props.t.
1948
+ // locale on the slot record is what makes the component receive props.t.
2060
1949
  locale: NS,
2061
1950
  inject: () => ({ ctx: ctx }),
2062
1951
  },
@@ -2066,14 +1955,9 @@ window.__ModuleLoader__.load({
2066
1955
 
2067
1956
  exports.inject = ['timer', 'slots', 'settingsScope', 'locale']
2068
1957
  exports.apply = function apply(ctx) {
2069
- // Язык может принести не только плагин: словарные пакеты объявляют
2070
- // русский для чужих пространств. Ядро на повторное объявление той же
2071
- // пары «пространство + язык» бросает исключение, и незащищённый вызов
2072
- // уносил с собой весь плагин — в интерфейсе это выглядело как «Failed to
2073
- // load plugins» с перечнем ни в чём не повинных соседей.
2074
- //
2075
- // Поэтому каждый язык объявляется отдельно и по-хорошему: заняли до нас —
2076
- // уступаем, свой английский при этом всё равно встаёт на место.
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.
2077
1961
  const addLocale = (locale, dictionary) => {
2078
1962
  try {
2079
1963
  return ctx.locale.register(NS, locale, dictionary)
@@ -2082,9 +1966,9 @@ window.__ModuleLoader__.load({
2082
1966
  }
2083
1967
  }
2084
1968
  ctx.effect(() => {
2085
- const undo = [addLocale('en', en), addLocale('ru', ru)]
1969
+ const undo = [addLocale('en', en)]
2086
1970
  return () => { for (const off of undo) off() }
2087
- }, 'dsh-voice: словари')
1971
+ }, 'dsh-voice: locale dictionaries')
2088
1972
  moduleT = ctx.locale.bind(NS)
2089
1973
  registerComposer(ctx)
2090
1974
  registerSettings(ctx)