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