@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/README.md +20 -0
- package/README.ru.md +20 -0
- package/README.zh.md +16 -0
- package/lib/chain.js +4 -4
- package/lib/client-src/00-open.js +24 -0
- package/lib/client-src/10-locale.js +148 -0
- package/lib/client-src/20-css.js +39 -0
- package/lib/client-src/30-core.js +284 -0
- package/lib/client-src/40-recording.js +384 -0
- package/lib/client-src/41-buttons.js +30 -0
- package/lib/client-src/50-visualizers.js +121 -0
- package/lib/client-src/60-composer.js +285 -0
- package/lib/client-src/70-settings.js +640 -0
- package/lib/client-src/90-close.js +23 -0
- package/lib/client.js +218 -334
- package/lib/index.js +41 -42
- package/lib/normalize.js +2 -2
- package/lib/providers.js +39 -43
- package/lib/stats.js +2 -2
- package/lib/wav.js +8 -8
- package/package.json +4 -2
package/lib/client.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
// dsh-voice —
|
|
1
|
+
// dsh-voice — client half (browser).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
10
|
-
// settings.
|
|
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
|
-
//
|
|
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} —
|
|
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) { /*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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):
|
|
759
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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) { /*
|
|
849
|
+
} catch (e) { /* polish is best-effort */ }
|
|
987
850
|
}
|
|
988
|
-
run().finally(() => setTimeout(() => { try { actions.submit() } catch (e) { /*
|
|
851
|
+
run().finally(() => setTimeout(() => { try { actions.submit() } catch (e) { /* busy */ } }, 0))
|
|
989
852
|
return
|
|
990
853
|
}
|
|
991
|
-
setTimeout(() => { try { actions.submit() } catch (e) { /*
|
|
854
|
+
setTimeout(() => { try { actions.submit() } catch (e) { /* composer busy */ } }, 0)
|
|
992
855
|
}
|
|
993
856
|
|
|
994
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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,
|
|
1123
|
-
radial.addColorStop(0.4,
|
|
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
|
-
//
|
|
1176
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
sensevoice:
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1582
|
-
//
|
|
1583
|
-
//
|
|
1584
|
-
// dsh-lanmode
|
|
1585
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 —
|
|
1633
|
-
//
|
|
1634
|
-
//
|
|
1635
|
-
//
|
|
1636
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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('
|
|
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('
|
|
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
|
-
//
|
|
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
|
-
//
|
|
2053
|
-
//
|
|
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
|
|
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)
|
|
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)
|