@goodandready/dsh-tts 0.3.23 → 0.4.1

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.
@@ -0,0 +1,290 @@
1
+ // Player.
2
+ //
3
+ // Each incoming chunk used to replace the previous one immediately; while reading
4
+ // For full replies this was invisible; with speak-as-it-goes only the last
5
+ // chunk would be heard. Now items queue: the next starts after the previous ends.
6
+ const player = {
7
+ audio: null,
8
+ after: '',
9
+ enabled: false,
10
+ rate: 1,
11
+ chime: 'ding',
12
+ queue: [],
13
+ bargeIn: true,
14
+ busy: false,
15
+ paused: false,
16
+ listeners: new Set(),
17
+ }
18
+ const seenIds = new Set()
19
+ let currentAudioResolve = null
20
+
21
+ function playerChanged() {
22
+ for (const listener of [...player.listeners]) {
23
+ try { listener() } catch (listenerFailure) { /* чужой слушатель не наша забота */ }
24
+ }
25
+ }
26
+
27
+ function usePlayer() {
28
+ const [, force] = React.useReducer((n) => n + 1, 0)
29
+ React.useEffect(() => {
30
+ player.listeners.add(force)
31
+ return () => { player.listeners.delete(force) }
32
+ }, [])
33
+ return player
34
+ }
35
+
36
+ // Short chime is synthesized in-browser: no file to download, store, or configure.
37
+ function playChime(kind) {
38
+ return new Promise((resolve) => {
39
+ try {
40
+ const AC = window.AudioContext || window.webkitAudioContext
41
+ if (!AC) { resolve(); return }
42
+ const audioCtx = new AC()
43
+ const gain = audioCtx.createGain()
44
+ gain.connect(audioCtx.destination)
45
+ const tones = kind === 'beep' ? [880, 880] : [660, 990]
46
+ let at = audioCtx.currentTime
47
+ for (const freq of tones) {
48
+ const osc = audioCtx.createOscillator()
49
+ osc.frequency.value = freq
50
+ osc.connect(gain)
51
+ gain.gain.setValueAtTime(0.0001, at)
52
+ gain.gain.exponentialRampToValueAtTime(0.25, at + 0.02)
53
+ gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.18)
54
+ osc.start(at)
55
+ osc.stop(at + 0.2)
56
+ at += 0.22
57
+ }
58
+ setTimeout(() => { try { audioCtx.close() } catch (already) { /* закрыт */ } resolve() }, 600)
59
+ } catch (noAudio) { resolve() }
60
+ })
61
+ }
62
+
63
+ // Provider failed — fall back to the browser voice. Worse quality, but silence
64
+ // is worse: the user would not know the reply is ready.
65
+ function speakInBrowser(text) {
66
+ return new Promise((resolve) => {
67
+ try {
68
+ if (!window.speechSynthesis || !text) { resolve(); return }
69
+ const utterance = new SpeechSynthesisUtterance(String(text))
70
+ utterance.rate = Math.max(0.5, Math.min(2, player.rate || 1))
71
+ utterance.onend = () => resolve()
72
+ utterance.onerror = () => resolve()
73
+ window.speechSynthesis.speak(utterance)
74
+ } catch (noSpeech) { resolve() }
75
+ })
76
+ }
77
+
78
+ function prepareAudioItem(item) {
79
+ if (!item || !item.audioBase64 || item.prepared) return item
80
+ try {
81
+ const bin = atob(item.audioBase64)
82
+ const bytes = new Uint8Array(bin.length)
83
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
84
+ const blob = new Blob([bytes], { type: item.mime || 'audio/mpeg' })
85
+ const url = URL.createObjectURL(blob)
86
+ const audio = new Audio(url)
87
+ audio.preload = 'auto'
88
+ item.prepared = { audio, url }
89
+ } catch (err) { /* decode error */ }
90
+ return item
91
+ }
92
+
93
+ function prebufferNext() {
94
+ for (const next of player.queue) {
95
+ if (next && next.audioBase64 && !next.prepared) {
96
+ prepareAudioItem(next)
97
+ break
98
+ }
99
+ }
100
+ }
101
+
102
+ function playAudio(item) {
103
+ return new Promise((resolve) => {
104
+ try {
105
+ prepareAudioItem(item)
106
+ if (!item.prepared) return resolve()
107
+ const { audio, url } = item.prepared
108
+ audio.playbackRate = Math.max(0.5, Math.min(2, player.rate || 1))
109
+ player.audio = audio
110
+ playerChanged()
111
+ prebufferNext()
112
+ let doneCalled = false
113
+ const done = () => {
114
+ if (doneCalled) return
115
+ doneCalled = true
116
+ currentAudioResolve = null
117
+ try { URL.revokeObjectURL(url) } catch (_) {}
118
+ item.prepared = null
119
+ player.audio = null
120
+ playerChanged()
121
+ resolve()
122
+ }
123
+ currentAudioResolve = done
124
+ audio.onended = done
125
+ audio.onerror = done
126
+ audio.play().catch(() => {
127
+ // Autoplay without a user gesture is blocked — not a bug; just no sound.
128
+ done()
129
+ })
130
+ } catch (cannotDecode) {
131
+ currentAudioResolve = null
132
+ resolve()
133
+ }
134
+ })
135
+ }
136
+
137
+ async function drainQueue() {
138
+ if (player.busy) return
139
+ player.busy = true
140
+ playerChanged()
141
+ try {
142
+ while (player.queue.length) {
143
+ if (player.paused) break
144
+ const item = player.queue.shift()
145
+ if (!item || item.kind === 'reserved') continue
146
+ if (item.kind === 'chime') { await playChime(item.chime || player.chime); continue }
147
+ if (item.audioBase64) await playAudio(item)
148
+ else if (item.error && item.text) await speakInBrowser(item.text)
149
+ }
150
+ } finally {
151
+ player.busy = false
152
+ playerChanged()
153
+ }
154
+ }
155
+
156
+ function stopPlayback() {
157
+ for (const item of player.queue) {
158
+ if (item && item.prepared && item.prepared.url) {
159
+ try { URL.revokeObjectURL(item.prepared.url) } catch (_) {}
160
+ item.prepared = null
161
+ }
162
+ }
163
+ player.queue.length = 0
164
+ player.paused = false
165
+ if (currentAudioResolve) {
166
+ currentAudioResolve()
167
+ } else if (player.audio) {
168
+ try { player.audio.pause() } catch (already) { /* стоит */ }
169
+ player.audio = null
170
+ }
171
+ try { if (window.speechSynthesis) window.speechSynthesis.cancel() } catch (noSpeech) { /* нет синтеза */ }
172
+ playerChanged()
173
+ }
174
+
175
+ function togglePause() {
176
+ player.paused = !player.paused
177
+ if (player.audio) {
178
+ try { player.paused ? player.audio.pause() : player.audio.play() } catch (already) { /* всё равно */ }
179
+ }
180
+ if (!player.paused) drainQueue()
181
+ playerChanged()
182
+ }
183
+
184
+ function skipCurrent() {
185
+ if (currentAudioResolve) {
186
+ currentAudioResolve()
187
+ } else if (player.audio) {
188
+ try { player.audio.pause() } catch (already) { /* стоит */ }
189
+ player.audio = null
190
+ playerChanged()
191
+ if (player.queue.length) drainQueue()
192
+ }
193
+ }
194
+
195
+ // User started talking — stop playback.
196
+ //
197
+ // The event may come from a voice-input plugin, but there is no hard dependency:
198
+ // without that plugin the event simply never fires. We listen on window on purpose.
199
+ function listenForVoice() {
200
+ if (typeof window === 'undefined') return () => {}
201
+ const onSpeaking = (event) => {
202
+ const phase = event && event.detail && event.detail.phase
203
+ if (phase !== 'start') return
204
+ if (!player.bargeIn) return
205
+ if (!player.audio && !player.queue.length && !player.busy) return
206
+ stopPlayback()
207
+ }
208
+ window.addEventListener('dsh-voice:speaking', onSpeaking)
209
+ return () => window.removeEventListener('dsh-voice:speaking', onSpeaking)
210
+ }
211
+
212
+ async function pollPending() {
213
+ try {
214
+ const st = await fetch('/dsh-tts/status', { cache: 'no-store' })
215
+ if (!st.ok) return
216
+ const meta = await st.json()
217
+ player.enabled = !!(meta && meta.speakReplies)
218
+ if (meta && typeof meta.rate === 'number') player.rate = meta.rate
219
+ if (meta && meta.chime) player.chime = meta.chime
220
+ if (meta && meta.roles) player.roles = meta.roles
221
+ if (meta && typeof meta.bargeIn === 'boolean') player.bargeIn = meta.bargeIn
222
+ if (!player.enabled) return
223
+ const res = await fetch('/dsh-tts/pending?after=' + encodeURIComponent(player.after || ''), { cache: 'no-store' })
224
+ if (!res.ok) return
225
+ const data = await res.json()
226
+ const items = data && Array.isArray(data.items) ? data.items : []
227
+ for (const item of items) {
228
+ // Queue slot reserved but synthesis still running — wait for the next poll
229
+ // or phrase order would break.
230
+ if (item.kind === 'reserved') break
231
+ player.after = item.id
232
+ if (seenIds.has(item.id)) continue
233
+ seenIds.add(item.id)
234
+ if (seenIds.size > 500) {
235
+ const first = seenIds.values().next().value
236
+ seenIds.delete(first)
237
+ }
238
+ if (item.text) recent.add(item.text)
239
+ player.queue.push(item)
240
+ }
241
+ if (player.queue.length) drainQueue()
242
+ } catch (hostRestarting) { /* хост перезапускается */ }
243
+ }
244
+
245
+ // Recent utterances: last reply texts and favorites (localStorage).
246
+ function recentStore() {
247
+ const KEY = 'dsh-tts/recent'
248
+ let st
249
+ try {
250
+ const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null
251
+ st = JSON.parse(raw) || { items: [], favs: [] }
252
+ } catch (broken) { st = { items: [], favs: [] } }
253
+ const persist = () => { try { if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(st)) } catch (full) { /* приватный режим */ } }
254
+ return {
255
+ add(text) {
256
+ if (!text || text.length < 2) return
257
+ st.items = [text, ...st.items.filter((x) => x !== text)].slice(0, 30)
258
+ persist()
259
+ },
260
+ toggleFav(text) {
261
+ st.favs = st.favs.includes(text)
262
+ ? st.favs.filter((x) => x !== text)
263
+ : [text, ...st.favs].slice(0, 20)
264
+ persist()
265
+ },
266
+ list() { return { items: st.items, favs: st.favs } },
267
+ }
268
+ }
269
+ const recent = recentStore()
270
+
271
+ // One-shot playback outside the queue: replay from recents.
272
+ function playStandalone(text) {
273
+ fetch('/dsh-tts/speak', {
274
+ method: 'POST',
275
+ headers: { 'Content-Type': 'application/json' },
276
+ body: JSON.stringify({ text }),
277
+ })
278
+ .then((r) => r.json())
279
+ .then((d) => {
280
+ if (!d || !d.ok) return
281
+ const audio = new Audio('data:' + (d.mime || 'audio/mpeg') + ';base64,' + d.audioBase64)
282
+ audio.play().catch(() => {})
283
+ })
284
+ .catch(() => {})
285
+ }
286
+
287
+ function credOf(map, provider) {
288
+ return (map && map[provider]) || { configured: false, writable: true, ref: '' }
289
+ }
290
+
@@ -0,0 +1,325 @@
1
+ const en = {
2
+ 'statusOnline': 'Host online',
3
+ 'statusOffline': 'Host unreachable',
4
+ 'cacheStats': 'Cache',
5
+
6
+ 'general': 'General',
7
+ 'loading': 'Loading\u2026',
8
+ 'chainHintCard': 'Pick a provider, paste its API key, and Save (or leave the key field). The key is written to the host credentials store immediately. The browser never reads it back.',
9
+ 'speakRepliesHint': 'When on, each finished reply is synthesized on the host and played in this browser. Audio is not sent back to the model.',
10
+ 'chainHint': 'Top to bottom is the fallback order. Cloud keys are saved to the host credentials store, never into plugin settings.',
11
+ 'title': 'Speech',
12
+ 'speakReplies': 'Speak agent replies',
13
+ 'speakAsItGoes': 'Speak as it goes',
14
+ 'speakAsItGoesHint': 'Read each reply the moment it lands, sentence by sentence, instead of waiting for the whole turn.',
15
+ 'rate': 'Playback speed',
16
+ 'rateHint': '0.5 to 2. Synthesis is untouched; the browser plays faster or slower.',
17
+ 'bargeIn': 'Fall silent when you speak',
18
+ 'bargeInHint': 'Stop reading the moment the microphone opens.',
19
+ 'announce': 'Announce approvals',
20
+ 'announceHint': 'Say it out loud and play a chime when the agent stops for an approval or asks a question.',
21
+ 'approvalText': 'Approval wording',
22
+ 'approvalHint': 'Said when an approval is asked for; the tool name is appended.',
23
+ 'questionText': 'Question wording',
24
+ 'questionHint': 'Said when the agent asks a question. Empty disables it.',
25
+ 'chime': 'Chime',
26
+ 'chimeHint': 'ding, beep or none.',
27
+ 'language': 'Language',
28
+ 'languageHint': 'Hint for voices that need a locale (eSpeak, Azure SSML).',
29
+ 'piperModel': 'Piper model path',
30
+ 'piperModelHint': 'Absolute path to an ONNX model. Leave empty to skip Piper.',
31
+ 'piperBin': 'Piper binary',
32
+ 'binHint': 'Looked up in PATH unless absolute.',
33
+ 'espeakBin': 'eSpeak binary',
34
+ 'espeakHint': 'Usually espeak-ng.',
35
+ 'edgeBin': 'edge-tts binary',
36
+ 'edgeHint': 'Python CLI from the edge-tts package.',
37
+ 'azureRegion': 'Azure region',
38
+ 'azureHint': 'Required for the azure provider, e.g. eastus.',
39
+ 'openaiBaseUrl': 'OpenAI base URL',
40
+ 'openaiBaseUrlHint': 'OpenAI-compatible origin for the openai provider.',
41
+ 'mimoBaseUrl': 'MiMo base URL',
42
+ 'mimoBaseUrlHint': 'MiMo API root; synthesis uses its chat endpoint.',
43
+ 'mimoFormat': 'MiMo format',
44
+ 'mimoFormatHint': 'mp3 or wav.',
45
+ 'minimaxBin': 'MiniMax CLI',
46
+ 'minimaxBinHint': 'Looked up in PATH unless absolute; install and log in separately.',
47
+ 'advancedTitle': 'Advanced',
48
+ 'advancedHint': 'Limits and endpoint overrides. Provider API keys stay in the chain editor; credential slot names (*KeyEnv) are config-only.',
49
+ 'maxChars': 'Max characters',
50
+ 'maxCharsHint': 'Hard cap before long-reply handling; 0 disables.',
51
+ 'sentenceChars': 'Sentence chunk size',
52
+ 'sentenceCharsHint': 'Max characters per streaming sentence piece.',
53
+ 'timeoutMs': 'Provider timeout (ms)',
54
+ 'timeoutMsHint': 'Abort a provider attempt after this many milliseconds.',
55
+ 'maxQueue': 'Max queue length',
56
+ 'maxQueueHint': 'Drop oldest pending pieces beyond this count.',
57
+ 'chainTitle': 'Provider chain',
58
+ 'addProvider': 'Add provider',
59
+ 'localProvider': 'Local / CLI provider — no API key',
60
+ 'configured': 'Configured',
61
+ 'notSet': 'Not set',
62
+ 'fromEnv': 'Set in environment',
63
+ 'clear': 'Clear',
64
+ 'remove': 'Remove',
65
+ 'up': 'Up',
66
+ 'down': 'Down',
67
+ 'save': 'Save',
68
+ 'saved': 'Saved',
69
+ 'cardHint': 'Provider chain, voices and playback options.',
70
+ 'skipCode': 'Skip code blocks',
71
+ 'skipCodeHint': 'Say a short notice instead of reading code aloud.',
72
+ 'cache': 'Synthesis cache',
73
+ 'cacheHint': 'Reuse synthesized audio for repeated phrases.',
74
+ 'cacheMaxMb': 'Cache size limit, MB',
75
+ 'cacheMaxMbHint': 'Least recently used items are evicted first.',
76
+ 'clearCache': 'Clear cache',
77
+ 'pronTitle': 'Pronunciation',
78
+ 'pronHint': 'Rules run top-down. Wrap left side in /…/ for a regex; Lang filters the rule.',
79
+ 'pronFrom': 'Replace',
80
+ 'pronTo': 'With',
81
+ 'pronWhole': 'Whole word',
82
+ 'pronLang': 'Lang',
83
+ 'addRule': 'Add rule',
84
+ 'rolesTitle': 'Voice roles',
85
+ 'rolesHint': 'Empty fields inherit the main chain.',
86
+ 'roleReply': 'Reply',
87
+ 'roleApproval': 'Approval',
88
+ 'roleError': 'Error',
89
+ 'longReply': 'Long replies',
90
+ 'longReplyHint': 'truncate: cut at the limit; summarize: short retelling; full: read everything.',
91
+ 'summaryModel': 'Summary model',
92
+ 'summaryModelHint': 'provider/model, empty = conversation default.',
93
+ 'summarySentences': 'Retelling length',
94
+ 'summarySentencesHint': 'Sentences in the spoken retelling.',
95
+ 'preview': 'Preview',
96
+ 'statsTitle': 'Statistics',
97
+ 'statTotal': 'Total',
98
+ 'statHits': 'Cache hits',
99
+ 'statErrors': 'Errors',
100
+ 'statReset': 'Reset stats',
101
+
102
+ 'autoDetect': 'Auto language per piece',
103
+ 'autoDetectHint': 'Guess ru/en per spoken piece instead of the language setting.',
104
+ 'narrateQuotesOnly': 'Only quotes',
105
+ 'narrateQuotesHint': 'Read only «quoted» fragments of a reply.',
106
+ 'skipActions': 'Skip *actions*',
107
+ 'skipActionsHint': 'Drop asterisk action blocks.',
108
+ 'removeRegex': 'Remove regex',
109
+ 'removeRegexHint': 'Global regex; matches are removed before synthesis.',
110
+ 'customBaseUrl': 'Custom TTS endpoint',
111
+ 'customBaseUrlHint': 'OpenAI-compatible origin, e.g. http://localhost:8880/v1; empty = off.',
112
+ 'customKeyEnv': 'Custom key env',
113
+ 'customKeyEnvHint': 'Credential name for the custom endpoint bearer key.',
114
+
115
+ 'localEnginesTitle': 'Local Engines (Offline)',
116
+ 'localEnginesHint': 'Run speech synthesis 100% offline without API keys.',
117
+ 'enableLocalEngines': 'Enable local engines',
118
+ 'enableLocalEnginesHint': 'Use Kokoro or F5-TTS when installed.',
119
+ 'streamingEnabled': 'Instant streaming (< 300 ms)',
120
+ 'streamingEnabledHint': 'Play audio via AudioWorklet and SSE in real-time.',
121
+ 'useKokoro': 'Enable Kokoro-82M (CPU)',
122
+ 'useKokoroHint': 'Fast neural synthesis on CPU.',
123
+ 'useF5': 'Enable F5-TTS (GPU)',
124
+ 'useF5Hint': 'High-fidelity zero-shot synthesis on NVIDIA GPU.',
125
+ 'installed': 'Installed',
126
+ 'notInstalled': 'Not installed',
127
+ 'downloading': 'Downloading',
128
+ 'installKokoro': 'Install (350 MB)',
129
+ 'installF5': 'Install (2.1 GB)',
130
+ 'deleteModel': 'Delete',
131
+
132
+ 'voiceDuplexTitle': 'Voice Duplex (Voice-to-Voice)',
133
+ 'voiceDuplexHint': 'Interactive voice conversation integrated with dsh-voice.',
134
+ 'voiceNotInstalledTitle': 'Required plugin @goodandready/dsh-voice is not installed',
135
+ 'voiceInstallHint': 'To enable voice duplex, install the plugin with',
136
+ 'voiceDuplex': 'Voice-to-Voice mode',
137
+ 'voiceDuplexHintToggle': 'Automatically synthesize agent reply when finished talking.',
138
+ 'vadBargeIn': 'VAD Barge-in',
139
+ 'vadBargeInHint': 'Mute speech synthesis immediately when user voice is detected.',
140
+ 'newSubagentRole': 'Subagent name (e.g. coder, reviewer)',
141
+ 'addSubagentRole': 'Add Subagent',
142
+ 'role_coder': 'Coder',
143
+ 'role_reviewer': 'Reviewer',
144
+ 'role_planner': 'Planner',
145
+
146
+ 'enableItDictionary': 'Built-in IT dictionary',
147
+ 'enableItDictionaryHint': 'Auto-correct pronunciation for SQL, Nginx, K8s, Docker, API, JSON, YAML, etc.',
148
+ 'loadItDictionary': 'Populate IT terms',
149
+ 'previewRule': 'Listen',
150
+
151
+ 'messengerIntegrationTitle': 'Messenger Integration (Telegram, Discord)',
152
+ 'messengerIntegrationHint': 'Voice replies and notes sent through @goodandready/dsh-messenger-gateway.',
153
+ 'messengerNotInstalledTitle': 'Required plugin @goodandready/dsh-messenger-gateway is not installed',
154
+ 'messengerInstallHint': 'To enable voice notes in messengers, install the plugin with',
155
+ 'messengerTtsEnabled': 'Voice replies in messengers',
156
+ 'messengerTtsEnabledHint': 'Allow messenger bridge to synthesize voice notes for agent replies.',
157
+
158
+ 'dockLabel': 'Speech',
159
+ 'dockPause': 'Pause',
160
+ 'dockResume': 'Resume speech',
161
+ 'dockStop': 'Stop speech',
162
+ }
163
+ const ru = {
164
+ 'statusOnline': 'Хост на связи',
165
+ 'statusOffline': 'Хост недоступен',
166
+ 'cacheStats': 'Кэш',
167
+
168
+ 'dockLabel': 'Чтение',
169
+ 'dockPause': 'Пауза',
170
+ 'dockResume': 'Продолжить чтение',
171
+ 'dockStop': 'Прекратить чтение',
172
+
173
+ 'general': 'Общее',
174
+ 'loading': 'Загрузка…',
175
+ 'chainHintCard': 'Выберите провайдера, вставьте ключ и нажмите «Сохранить» (или просто уйдите из поля). Ключ сразу попадает в хранилище учётных данных харнесса; браузер его обратно не читает.',
176
+ 'speakRepliesHint': 'Каждый законченный ответ озвучивается на стороне харнесса и играет в этом браузере. Звук модели не отправляется.',
177
+ 'chainHint': 'Порядок сверху вниз — порядок отката. Ключи облачных провайдеров попадают в хранилище учётных данных харнесса, а не в настройки плагина.',
178
+ 'title': 'Озвучка',
179
+ 'speakReplies': 'Читать ответы агента',
180
+ 'speakAsItGoes': 'Читать по ходу',
181
+ 'speakAsItGoesHint': 'Читать каждый ответ, как только он пришёл, предложениями, не дожидаясь конца хода.',
182
+ 'rate': 'Скорость чтения',
183
+ 'rateHint': 'От 0.5 до 2. Синтез не меняется — быстрее или медленнее играет браузер.',
184
+ 'bargeIn': 'Замолкать, когда вы заговорили',
185
+ 'bargeInHint': 'Прекращать чтение, как только открылся микрофон.',
186
+ 'announce': 'Объявлять запрос подтверждения',
187
+ 'announceHint': 'Говорить вслух и подавать сигнал, когда агент остановился ради подтверждения или задал вопрос.',
188
+ 'approvalText': 'Что говорить о подтверждении',
189
+ 'approvalHint': 'Произносится при запросе подтверждения; имя инструмента добавляется следом.',
190
+ 'questionText': 'Что говорить о вопросе',
191
+ 'questionHint': 'Произносится, когда агент задал вопрос. Пусто — не произносить.',
192
+ 'chime': 'Сигнал',
193
+ 'chimeHint': 'ding, beep или none.',
194
+ 'language': 'Язык',
195
+ 'languageHint': 'Подсказка для голосов, которым нужен язык (eSpeak, Azure SSML).',
196
+ 'piperModel': 'Piper: путь к модели',
197
+ 'piperModelHint': 'Абсолютный путь к модели ONNX. Пусто — Piper пропускается.',
198
+ 'piperBin': 'Piper: бинарь',
199
+ 'binHint': 'Ищется в PATH, если путь не абсолютный.',
200
+ 'espeakBin': 'eSpeak: бинарь',
201
+ 'espeakHint': 'Обычно espeak-ng.',
202
+ 'edgeBin': 'edge-tts: бинарь',
203
+ 'edgeHint': 'Консольная утилита из пакета edge-tts.',
204
+ 'azureRegion': 'Azure: регион',
205
+ 'azureHint': 'Нужен провайдеру azure, например eastus.',
206
+ 'openaiBaseUrl': 'OpenAI base URL',
207
+ 'openaiBaseUrlHint': 'OpenAI-совместимый origin для провайдера openai.',
208
+ 'mimoBaseUrl': 'MiMo base URL',
209
+ 'mimoBaseUrlHint': 'Корень API MiMo; синтез идёт через chat endpoint.',
210
+ 'mimoFormat': 'Формат MiMo',
211
+ 'mimoFormatHint': 'mp3 или wav.',
212
+ 'minimaxBin': 'MiniMax CLI',
213
+ 'minimaxBinHint': 'Ищется в PATH, если путь не абсолютный; вход в CLI — отдельно.',
214
+ 'advancedTitle': 'Дополнительно',
215
+ 'advancedHint': 'Лимиты и override endpoint. API-ключи — в редакторе цепочки; имена credential-слотов (*KeyEnv) — только в конфиге.',
216
+ 'maxChars': 'Максимум символов',
217
+ 'maxCharsHint': 'Жёсткий лимит до обработки длинных ответов; 0 — без лимита.',
218
+ 'sentenceChars': 'Размер куска (символы)',
219
+ 'sentenceCharsHint': 'Максимум символов в streaming-куске предложения.',
220
+ 'timeoutMs': 'Таймаут провайдера (мс)',
221
+ 'timeoutMsHint': 'Прервать попытку провайдера через столько миллисекунд.',
222
+ 'maxQueue': 'Длина очереди',
223
+ 'maxQueueHint': 'Отбрасывать самые старые куски сверх этого числа.',
224
+ 'chainTitle': 'Цепочка провайдеров',
225
+ 'addProvider': 'Добавить провайдера',
226
+ 'localProvider': 'Локальный провайдер — ключ не нужен',
227
+ 'configured': 'Ключ задан',
228
+ 'notSet': 'Ключа нет',
229
+ 'fromEnv': 'Задан в окружении',
230
+ 'clear': 'Убрать',
231
+ 'remove': 'Удалить',
232
+ 'up': 'Выше',
233
+ 'down': 'Ниже',
234
+ 'save': 'Сохранить',
235
+ 'saved': 'Сохранено',
236
+ 'cardHint': 'Цепочка провайдеров, голоса и параметры чтения.',
237
+ 'skipCode': 'Пропускать код',
238
+ 'skipCodeHint': 'Вместо чтения кода вслух — короткая пометка.',
239
+ 'cache': 'Кэш синтеза',
240
+ 'cacheHint': 'Повторяющиеся фразы не синтезируются заново.',
241
+ 'cacheMaxMb': 'Лимит кэша, МБ',
242
+ 'cacheMaxMbHint': 'Сначала вытесняется самое давнее по использованию.',
243
+ 'clearCache': 'Очистить кэш',
244
+ 'pronTitle': 'Произношение',
245
+ 'pronHint': 'Правила работают сверху вниз; /…/ в левой части — регулярка; Язк ограничивает правило.',
246
+ 'pronFrom': 'Заменить',
247
+ 'pronTo': 'На',
248
+ 'pronWhole': 'Целое слово',
249
+ 'pronLang': 'Язк',
250
+ 'addRule': 'Добавить правило',
251
+ 'rolesTitle': 'Голоса ролей',
252
+ 'rolesHint': 'Пустые поля наследуют основную цепочку.',
253
+ 'roleReply': 'Ответ',
254
+ 'roleApproval': 'Подтверждение',
255
+ 'roleError': 'Ошибка',
256
+ 'longReply': 'Длинные ответы',
257
+ 'longReplyHint': 'truncate: обрезать; summarize: короткий пересказ; full: читать целиком.',
258
+ 'summaryModel': 'Модель пересказа',
259
+ 'summaryModelHint': 'provider/model, пусто — как у разговора.',
260
+ 'summarySentences': 'Длина пересказа',
261
+ 'summarySentencesHint': 'Предложений в озвучиваемом пересказе.',
262
+ 'preview': 'Проба',
263
+ 'statsTitle': 'Статистика',
264
+ 'statTotal': 'Всего',
265
+ 'statHits': 'Кэш-хиты',
266
+ 'statErrors': 'Ошибки',
267
+ 'statReset': 'Сбросить',
268
+
269
+ 'autoDetect': 'Автоязык по куску',
270
+ 'autoDetectHint': 'Подсказывать провайдеру ru/en по каждому куску вместо общей настройки.',
271
+ 'narrateQuotesOnly': 'Только цитаты',
272
+ 'narrateQuotesHint': 'Читать только фрагменты в кавычках.',
273
+ 'skipActions': 'Пропускать *действия*',
274
+ 'skipActionsHint': 'Вырезать блоки в звёздочках.',
275
+ 'removeRegex': 'Регулярка вырезания',
276
+ 'removeRegexHint': 'Глобальная регулярка: совпадения удаляются перед синтезом.',
277
+ 'customBaseUrl': 'Свой TTS endpoint',
278
+ 'customBaseUrlHint': 'OpenAI-compatible base URL, e.g. http://localhost:8880/v1; empty disables it.',
279
+ 'customKeyEnv': 'Имя ключа для endpoint',
280
+ 'customKeyEnvHint': 'Имя credential для Bearer-ключа своего endpointа.',
281
+
282
+ 'localEnginesTitle': 'Локальные движки (Offline)',
283
+ 'localEnginesHint': 'Автономный синтез без API-ключей и интернета.',
284
+ 'enableLocalEngines': 'Включить локальные движки',
285
+ 'enableLocalEnginesHint': 'Использовать Kokoro или F5-TTS при наличии моделей.',
286
+ 'streamingEnabled': 'Мгновенный потоковый звук (< 300 мс)',
287
+ 'streamingEnabledHint': 'Воспроизводить звук через AudioWorklet и SSE без задержек.',
288
+ 'useKokoro': 'Использовать Kokoro-82M (CPU)',
289
+ 'useKokoroHint': 'Быстрый нейросинтез на процессоре.',
290
+ 'useF5': 'Использовать F5-TTS (GPU)',
291
+ 'useF5Hint': 'Высокоточный синтез на видеокарте NVIDIA.',
292
+ 'installed': 'Установлена',
293
+ 'notInstalled': 'Не установлена',
294
+ 'downloading': 'Загрузка',
295
+ 'installKokoro': 'Установить (350 МБ)',
296
+ 'installF5': 'Установить (2.1 ГБ)',
297
+ 'deleteModel': 'Удалить',
298
+
299
+ 'voiceDuplexTitle': 'Голосовой диалог (Voice-to-Voice)',
300
+ 'voiceDuplexHint': 'Интерактивный голосовой разговор при наличии плагина dsh-voice.',
301
+ 'voiceNotInstalledTitle': 'Плагин @goodandready/dsh-voice не установлен',
302
+ 'voiceInstallHint': 'Для включения голосового диалога установите плагин командой',
303
+ 'voiceDuplex': 'Voice-to-Voice режим',
304
+ 'voiceDuplexHintToggle': 'Автоматически отвечать голосом после завершения голосовой реплики.',
305
+ 'vadBargeIn': 'VAD Barge-in',
306
+ 'vadBargeInHint': 'Мгновенно глушить речь ассистента при обнаружении голоса пользователя.',
307
+ 'newSubagentRole': 'Имя субагента (например, coder, reviewer)',
308
+ 'addSubagentRole': 'Добавить субагента',
309
+ 'role_coder': 'Кодер',
310
+ 'role_reviewer': 'Ревьюер',
311
+ 'role_planner': 'Планировщик',
312
+
313
+ 'enableItDictionary': 'Предустановленный словарь IT-терминов',
314
+ 'enableItDictionaryHint': 'Автоматически заменять SQL, Nginx, K8s, Docker, API, JSON, YAML и др. на правильное произношение.',
315
+ 'loadItDictionary': 'Вставить IT-термины в таблицу',
316
+ 'previewRule': 'Прослушать',
317
+
318
+ 'messengerIntegrationTitle': 'Интеграция с мессенджерами (Telegram, Discord)',
319
+ 'messengerIntegrationHint': 'Озвучивание ответов и голосовые заметки через @goodandready/dsh-messenger-gateway.',
320
+ 'messengerNotInstalledTitle': 'Плагин @goodandready/dsh-messenger-gateway не установлен',
321
+ 'messengerInstallHint': 'Для отправки голосовых сообщений в мессенджеры установите плагин командой',
322
+ 'messengerTtsEnabled': 'Голосовые ответы в мессенджеры',
323
+ 'messengerTtsEnabledHint': 'Разрешить мосту мессенджеров озвучивать ответы голосовыми заметками.',
324
+ }
325
+