tts-voice-creator-clone-and.../static/js/settings.js
mARTin-B78 7e265f3e30 fix: null-safe loadSettings() — prevent crash on missing elements (v1.12.17)
loadSettings() was crashing with "Cannot set properties of null" because
many settings input elements were moved out of their original sections
during the Library restructure. All bare .value assignments are now
routed through a local sv() helper that silently skips absent elements.
This was also blocking My Voices from rendering on page load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 00:36:18 +02:00

704 lines
40 KiB
JavaScript

// ── Settings ──────────────────────────────────────────────────────────────
const SETTINGS_SEEN_KEY = 'vcf-settings-seen';
let _appSettings = {};
let _ttsBackends = [];
function availableTtsBackends() {
return (_ttsBackends || []).filter(b => b.available);
}
function ttsBackendOptions(selected = '') {
const backends = availableTtsBackends();
if (!backends.length) return '<option value="">No TTS backend available</option>';
const current = selected || backends[0].id;
return backends.map(b => `<option value="${escHtml(b.id)}" ${b.id === current ? 'selected' : ''}>${escHtml(b.label)}</option>`).join('');
}
function styleBackendOptions(selected = 'customvoice', preferStyleAware = false) {
const backends = availableTtsBackends();
if (!backends.length) return '<option value="">No backend available</option>';
const preferred = backends.some(b => b.id === selected) ? selected
: preferStyleAware
? (backends.find(b => b.style_aware)?.id || backends[0].id)
: backends[0].id;
return backends.map(b => `<option value="${escHtml(b.id)}" ${b.id === preferred ? 'selected' : ''}>${escHtml(b.label)}</option>`).join('');
}
function backendById(id) {
return availableTtsBackends().find(b => b.id === id) || availableTtsBackends()[0] || null;
}
function backendComputeDevice(id) {
const b = backendById(id);
const text = [id, b?.label, b?.speed, b?.latency, b?.quality, b?.ram, b?.purpose, b?.best_for]
.filter(Boolean).join(' ').toLowerCase();
if (/\b(cloud|api|elevenlabs|groq)\b/.test(text)) return 'Cloud';
if (/\b(cpu|metal)\b/.test(text) && !/\b(cuda|gpu|vram|rtx|dgx)\b/.test(text)) return 'CPU';
if (/\b(cuda|gpu|vram|rtx|dgx)\b/.test(text)) return 'CUDA/GPU';
return 'Unknown';
}
function backendComputeDeviceClass(id) {
const label = backendComputeDevice(id).toLowerCase();
if (label.includes('cuda') || label.includes('gpu')) return 'gpu';
if (label.includes('cpu')) return 'cpu';
if (label.includes('cloud')) return 'cloud';
return '';
}
function backendHelpHtml(b, compact = false) {
if (!b) return '<strong>No TTS backend is reachable.</strong><div>Start at least one TTS service or check Settings URLs.</div>';
const tags = [
b.uses_wav ? ['good', 'uses WAV identity'] : ['warn', 'prompt/model voice'],
b.style_aware ? ['good', 'style-aware'] : ['warn', 'weak style'],
b.true_streaming ? ['good', 'true streaming'] : ['', 'buffered/normal'],
].map(([cls, text]) => `<span class="backend-tag ${cls}">${escHtml(text)}</span>`).join('');
const metricParts = [];
if (b.speed) metricParts.push(`<span class="backend-metric-tag"><span class="mdi mdi-lightning-bolt"></span> ${escHtml(b.speed)}</span>`);
if (b.latency) metricParts.push(`<span class="backend-metric-tag"><span class="mdi mdi-clock-outline"></span> ${escHtml(b.latency)}</span>`);
if (b.quality) metricParts.push(`<span class="backend-metric-tag"><span class="mdi mdi-star-circle-outline"></span> ${escHtml(b.quality)}</span>`);
if (b.ram) metricParts.push(`<span class="backend-metric-tag"><span class="mdi mdi-memory"></span> ${escHtml(b.ram)}</span>`);
const metrics = metricParts.length ? `<div class="backend-metrics-row">${metricParts.join('')}</div>` : '';
const detail = compact ? escHtml(b.best_for || '') : `${escHtml(b.purpose || '')}<br><strong>Identity:</strong> ${escHtml(b.identity || '')}<br><strong>Style:</strong> ${escHtml(b.style || '')}<br><strong>Best for:</strong> ${escHtml(b.best_for || '')}`;
return `<strong>${escHtml(b.label)}</strong><div class="backend-help-tags">${tags}</div>${metrics}<div>${detail}</div>`;
}
function sttBackendHelpHtml(b) {
if (!b) return 'No STT engine selected.';
const m = b.metrics || {};
const metricParts = [];
if (m.speed) metricParts.push(`<span class="backend-metric-tag"><span class="mdi mdi-lightning-bolt"></span> ${escHtml(m.speed)}</span>`);
if (m.latency) metricParts.push(`<span class="backend-metric-tag"><span class="mdi mdi-clock-outline"></span> ${escHtml(m.latency)}</span>`);
if (m.quality) metricParts.push(`<span class="backend-metric-tag"><span class="mdi mdi-star-circle-outline"></span> ${escHtml(m.quality)}</span>`);
if (m.ram) metricParts.push(`<span class="backend-metric-tag"><span class="mdi mdi-memory"></span> ${escHtml(m.ram)}</span>`);
const metrics = metricParts.length ? `<div class="backend-metrics-row">${metricParts.join('')}</div>` : '';
const modelList = Array.isArray(b.models) && b.models.length ? ' Models: ' + b.models.slice(0, 4).join(', ') + '.' : '';
const avail = b.available ? `<span class="backend-tag good">ready</span>` : `<span class="backend-tag warn">unavailable</span>`;
return `<strong>${escHtml(b.label)}</strong> ${avail}${metrics}<div style="margin-top:4px;font-size:0.85em;opacity:.8">${escHtml(b.url)}${escHtml(modelList)}</div>`;
}
function updateBackendHelp() {
const b = backendById($('tts-backend-select')?.value || '');
const help = $('tts-backend-help');
if (help) help.innerHTML = backendHelpHtml(b);
// Dynamic style-support badge + warning in Try It Out
const styleSupport = $('preview-style-support');
const styleWarn = $('preview-style-warn');
const styleInput = $('preview-style-instruction');
if (b && styleSupport) {
if (b.style_aware) {
styleSupport.textContent = 'style-aware ✓';
styleSupport.style.cssText = 'font-size:11px;display:inline-block;background:rgba(166,227,161,.2);color:var(--green);border-radius:4px;padding:1px 6px;margin-left:4px';
} else {
styleSupport.textContent = 'weak style';
styleSupport.style.cssText = 'font-size:11px;display:inline-block;background:rgba(249,226,175,.2);color:var(--yellow);border-radius:4px;padding:1px 6px;margin-left:4px';
}
}
if (styleWarn) {
const hasInstruct = (styleInput?.value || '').trim().length > 0;
styleWarn.style.display = (b && !b.style_aware && hasInstruct) ? 'block' : 'none';
}
const sttB = backendById($('stt-tts-backend-select')?.value || '');
const sttHelp = $('stt-tts-backend-help');
if (sttHelp) sttHelp.innerHTML = backendHelpHtml(sttB);
}
function updateStyleBackendHelp(scope = document) {
scope.querySelectorAll('.opt-style-backend').forEach(sel => {
const box = sel.closest('.opt-style-panel')?.querySelector('.opt-style-backend-help');
if (!box) return;
const b = backendById(sel.value);
box.innerHTML = backendHelpHtml(b, true);
if (b && !b.style_aware) {
const styleAwareBacks = availableTtsBackends().filter(x => x.style_aware);
const suggestion = styleAwareBacks.length
? ` Try <strong>${escHtml(styleAwareBacks[0].label)}</strong> instead.`
: ' No style-aware backend is currently reachable.';
box.innerHTML += `<div class="style-backend-warn"><span class="mdi mdi-alert-outline"></span> This backend ignores the style instruction — output will sound the same regardless of what you type.${suggestion}</div>`;
}
});
}
function updateBackendDependentTabs() {
const availableBackends = availableTtsBackends();
const available = new Set(availableBackends.map(b => b.id));
document.querySelectorAll('.tab[data-backend-required]').forEach(tab => {
const originalSubtitle = tab.dataset.originalSubtitle || tab.querySelector('.tab-subtitle')?.textContent || '';
const originalTooltip = tab.dataset.originalTooltip || tab.querySelector('.tab-tooltip')?.textContent || '';
tab.dataset.originalSubtitle = originalSubtitle;
tab.dataset.originalTooltip = originalTooltip;
const required = tab.dataset.backendRequired;
const ok = required === 'any_tts' ? availableBackends.length > 0 : available.has(required);
tab.hidden = false;
tab.classList.toggle('backend-unavailable', !ok);
tab.setAttribute('aria-disabled', ok ? 'false' : 'true');
tab.tabIndex = ok ? 0 : -1;
const subtitle = tab.querySelector('.tab-subtitle');
const tooltip = tab.querySelector('.tab-tooltip');
if (subtitle) subtitle.textContent = ok ? originalSubtitle : 'not running/configured';
if (tooltip) tooltip.textContent = ok ? originalTooltip : `${originalTooltip}\n\n${disabledBackendTabMessage(tab)}`;
});
const active = document.querySelector('.tab.active');
if (!active || active.classList.contains('backend-unavailable')) {
const first = document.querySelector('.tab:not(.backend-unavailable)');
if (first) switchTab(first.dataset.tab);
}
}
async function refreshTtsBackendAvailability(selected = '') {
try {
const d = await fetch('/api/tts-backends').then(r => r.json());
_ttsBackends = (d.backends || []).filter(b => b && b.id);
} catch (_) {
_ttsBackends = [];
}
const preview = $('tts-backend-select');
if (preview) {
const prev = selected || preview.value;
preview.innerHTML = ttsBackendOptions(prev);
preview.disabled = !availableTtsBackends().length;
}
const sttTtsBackend = $('stt-tts-backend-select');
if (sttTtsBackend) {
const prev = selected || sttTtsBackend.value;
sttTtsBackend.innerHTML = ttsBackendOptions(prev);
sttTtsBackend.disabled = !availableTtsBackends().length;
}
const libraryTts = $('library-tts-backend-select');
if (libraryTts) {
const prev = libraryTts.value || 'voice_clone';
libraryTts.innerHTML = ttsBackendOptions(prev);
libraryTts.disabled = !availableTtsBackends().length;
}
const readerBackend = $('reader-backend-select');
if (readerBackend) {
const prev = selected || readerBackend.value;
readerBackend.innerHTML = ttsBackendOptions(prev);
readerBackend.disabled = !availableTtsBackends().length;
if (typeof readerUpdateBackendHint === 'function') readerUpdateBackendHint();
}
document.querySelectorAll('.opt-style-backend').forEach(sel => {
const prev = sel.value;
sel.innerHTML = styleBackendOptions(prev);
sel.disabled = !availableTtsBackends().length;
});
document.querySelectorAll('.opt-compare-backend').forEach(sel => {
const prev = sel.value && sel.value !== '' ? sel.value : 'voice_clone';
sel.innerHTML = styleBackendOptions(prev);
sel.disabled = !availableTtsBackends().length;
});
const perfSel = $('perf-backend-select');
if (perfSel) {
const prev = perfSel.value;
perfSel.innerHTML = ttsBackendOptions(prev);
perfSel.disabled = !availableTtsBackends().length;
}
const batchSel = $('batch-backend-select');
if (batchSel) {
const prev = batchSel.value;
batchSel.innerHTML = ttsBackendOptions(prev);
batchSel.disabled = !availableTtsBackends().length;
}
updateBackendHelp();
updateStyleBackendHelp();
updateBackendDependentTabs();
// Call any post-refresh hooks registered by sub-sections (e.g. conversation panel)
(window._ttsRefreshHooks || []).forEach(fn => { try { fn(); } catch(_) {} });
return _ttsBackends;
}
async function _patchSettings(patch) {
try {
await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) });
} catch (e) { console.warn('[settings] patch failed', e); }
}
let _engineUrlSaveTimer = null;
function _saveEngineLocalUrls() {
clearTimeout(_engineUrlSaveTimer);
_engineUrlSaveTimer = setTimeout(() => {
const urls = {};
// Static cards (data-llm-local-key)
document.querySelectorAll('[data-llm-local-key]').forEach(i => { if (i.value) urls[i.dataset.llmLocalKey] = i.value; });
// Dynamic Docker stack card URL overrides (data-dc-url-key)
document.querySelectorAll('.dc-url-inp[data-dc-url-key]').forEach(i => {
const v = i.value.trim();
if (v && v !== i.placeholder) urls['dc-' + i.dataset.dcUrlKey] = v;
});
_patchSettings({ engine_local_urls: urls });
if (_appSettings) _appSettings.engine_local_urls = urls;
}, 800);
}
let _containerNameSaveTimer = null;
function _saveEngineContainerNames() {
clearTimeout(_containerNameSaveTimer);
_containerNameSaveTimer = setTimeout(() => {
const names = {};
document.querySelectorAll('[data-cn-key]').forEach(inp => {
const v = inp.value.trim();
if (v) names[inp.dataset.cnKey] = v;
});
_patchSettings({ engine_container_names: names });
if (_appSettings) _appSettings.engine_container_names = names;
}, 800);
}
window._saveEngineLocalUrls = _saveEngineLocalUrls;
window._saveEngineContainerNames = _saveEngineContainerNames;
// ── Settings guidance / tooltips ─────────────────────────────────────────
const SETTINGS_PAGE_GUIDES = {
general: {
icon: 'mdi-palette-outline',
title: 'Start here',
text: 'Choose the theme you prefer. Everything else can stay as-is until you connect or change an engine.'
},
connections: {
icon: 'mdi-lan-connect',
title: 'Connect engines first',
text: 'Most users only need the Docker Engines page: click Connect, then Use as TTS or Use as STT. Edit URLs here only when a service moved or runs on another host.',
steps: ['Use host.docker.internal for containers on the same Docker host.', 'Use 192.168.x.x when calling another machine on your LAN.', 'After changing URLs, Save settings, then refresh the target page.']
},
playback: {
icon: 'mdi-play-circle-outline',
title: 'Recommended playback',
text: 'Auto is the safest default: it streams when the backend supports it and falls back to normal WAV playback when saving or compatibility matters.'
},
captures: {
icon: 'mdi-record-circle-outline',
title: 'Voice capture defaults',
text: 'These choices affect microphone transcription and STT-to-TTS workflows. Pick a language only if auto-detect makes mistakes often.'
},
payloads: {
icon: 'mdi-code-json',
title: 'Advanced tuning',
text: 'Leave these as defaults unless a backend needs special parameters. Invalid JSON prevents settings from saving, so change one box at a time.'
},
storage: {
icon: 'mdi-folder-outline',
title: 'Match your volume mounts',
text: 'These are container paths, not host paths. In your Portainer stack, /voices should point to your real voice folder.'
},
apikeys: {
icon: 'mdi-key-outline',
title: 'Local-first defaults',
text: 'Local containers usually do not need real keys. Use sk-local only when a compatible server insists on an Authorization header.'
},
backup: {
icon: 'mdi-backup-restore',
title: 'Back up before big changes',
text: 'Export voices before moving folders, changing stacks, or testing new voice libraries. Keys are intentionally left out.'
},
logs: {
icon: 'mdi-text-box-outline',
title: 'Use logs when something feels stuck',
text: 'Refresh after a failed request. Error rows usually say which URL, model, or payload needs attention.'
},
about: {
icon: 'mdi-information-outline',
title: 'Version and support info',
text: 'Use this page to confirm the running app version and open release notes when behavior changed after an update.'
}
};
const SETTINGS_FIELD_HELP = {
's-theme-select': ['Theme', 'Changes only your browser UI. It is saved immediately and does not affect generated audio.'],
's-tts-url': ['Voice Clone / Base URL', 'Use this for normal cloned WAV voices. Your 8020 Qwen3 voice clone container usually belongs here.'],
's-voice-design-url': ['Voice Design URL', 'Use this for creating prompt-designed voices. Your 8021 Qwen3 Voice Design container usually belongs here.'],
's-customvoice-url': ['CustomVoice URL', 'Use this for Qwen3 CustomVoice speaker/style presets. Your 8022 container usually belongs here.'],
's-tts-stream-url': ['Streaming URL', 'Use this for lower-latency playback. Your 8023 streaming container usually belongs here.'],
's-kokoro-url': ['Kokoro URL', 'Small, fast OpenAI-compatible TTS. Good as a lightweight fallback, but it does not clone your WAV identities.'],
's-vibevoice-url': ['VibeVoice URL', 'Simple local TTS service. Good for experiments; not every Voice Creator feature maps to it.'],
's-xtts-url': ['XTTS v2 URL', 'Use only if an XTTS API server is running. It can clone from short references but has a different voice model than Qwen3.'],
's-nvidia-router-url': ['NVIDIA router URL', 'Router endpoint that can expose NVIDIA speech services through one URL. Handy when Parakeet and Magpie share a gateway.'],
's-nvidia-tts-url': ['NVIDIA Magpie TTS URL', 'Direct Magpie TTS endpoint with fixed speakers. Good quality, but not your cloned WAV voice library.'],
's-nvidia-asr-url': ['NVIDIA Parakeet ASR URL', 'Direct speech-to-text endpoint. Use this for fast local transcription when Parakeet is running.'],
's-nvidia-zeroshot-url': ['NVIDIA Zeroshot NIM URL', 'Experimental clone endpoint that uses an audio prompt. Leave empty unless that NIM is running.'],
's-nvidia-flow-url': ['NVIDIA Flow NIM URL', 'Experimental clone endpoint that uses an audio prompt plus transcript. Leave empty unless that NIM is running.'],
's-whisper-url': ['Active STT URL', 'Main transcription URL used by the app. The quick buttons below copy known engine URLs into this field.'],
's-faster-whisper-url': ['faster-whisper URL', 'Fast local Whisper endpoint, often the best general-purpose STT choice when GPU acceleration is available.'],
's-whisper-cpp-url': ['whisper.cpp URL', 'Lightweight Whisper server for CPU or small CUDA setups. Useful fallback when heavier STT is offline.'],
's-groq-api-key': ['Groq API key', 'Only needed for Groq cloud STT or LLM. Keep empty if you use only local services.'],
's-tts-stream-mode': ['Playback mode', 'Auto is recommended. Streaming feels faster; buffered is more compatible and better for saving files.'],
's-tts-backend': ['Request style', 'Choose the payload format expected by the backend connected to the normal TTS URL. Qwen3/OpenAI is the usual local stack default.'],
's-stt-language': ['Default language', 'Auto-detect is usually fine. Set this when transcription keeps choosing the wrong language.'],
's-stt-preferred-backend': ['Preferred STT backend', 'Overrides the active STT URL only for capture workflows. Leave default if you want one global STT setting.'],
's-llm-url': ['LLM base URL', 'OpenAI-compatible chat endpoint used for rewrites, casting, refinement, and voice-design assistance.'],
's-auto-refine': ['Auto-refine', 'When enabled, the app sends transcripts to the LLM for cleanup automatically. Leave off if you want raw transcripts.'],
's-refine-model': ['Refinement model', 'Optional model override for transcript cleanup. Leave empty to use the current LLM default.'],
's-audiobook-prompt': ['Audiobook prompt', 'Custom LLM instructions for character detection.'],
's-captures-default-voice': ['Default playback voice', 'Preselects a TTS voice in capture workflows so you do not have to choose it every time.'],
's-tts-extra-voice-clone': ['Voice Clone params', 'Advanced JSON sent to the 8020 backend. Temperature/top_p/seed are common controls.'],
's-tts-extra-streaming': ['Streaming params', 'Advanced JSON sent to the streaming backend. Keep close to Voice Clone params for comparable sound.'],
's-tts-extra-customvoice': ['CustomVoice params', 'Advanced JSON sent to CustomVoice. Use only backend-supported fields.'],
's-tts-extra-voice-design': ['Voice Design params', 'Advanced JSON sent when generating prompt-designed voices.'],
's-tts-extra-nvidia-magpie': ['Magpie params', 'Usually empty. Magpie uses fixed speakers and may reject unknown fields.'],
's-tts-extra-nvidia-zeroshot': ['Zeroshot params', 'Optional multipart fields for the clone NIM. The app already sends text, language, and audio prompt.'],
's-tts-extra-nvidia-flow': ['Flow params', 'Optional multipart fields for Flow. The app also sends reference transcript when available.'],
's-tts-extra-kokoro': ['Kokoro params', 'Usually empty. The selected Kokoro voice carries most of the useful information.'],
's-tts-extra-vibevoice': ['VibeVoice params', 'Usually empty. VibeVoice commonly only needs text.'],
's-voices-scan-dir': ['Voice scan directory', 'Container path that contains active_voices and hidden_voices. Usually /voices.'],
's-output-dir': ['Active voices directory', 'Container path where new cloned or exported voices are saved. Usually /voices/active_voices.'],
's-tts-key': ['TTS API key', 'Optional. For local OpenAI-compatible servers, sk-local or empty usually works.'],
's-vd-key': ['Voice Design API key', 'Optional. Use only if your Voice Design backend requires Authorization.'],
's-whisper-key': ['Whisper API key', 'Optional. Needed for cloud STT, usually empty for local Whisper-compatible containers.'],
's-seed-finder-text': ['Seed Finder text', 'Custom test sentence used when opening the Seed Finder. Leave empty to use language defaults.'],
's-seed-finder-dir': ['Seed Finder Sample Files', 'Container path for seed finder sample storage.'],
's-pt-dir': ['PT Files', 'Container path for .pt embedding storage.']
};
function enhanceSettingsHelp(root = document) {
const scope = root && root.querySelectorAll ? root : document;
scope.querySelectorAll('.s-settings-page').forEach(page => {
const key = page.dataset.page;
const guide = SETTINGS_PAGE_GUIDES[key];
const card = page.querySelector('.card');
const head = card?.querySelector('.s-page-head');
if (!guide || !card || !head || card.querySelector('.settings-guide')) return;
const box = document.createElement('div');
box.className = 'settings-guide';
box.innerHTML = `
<div class="settings-guide-icon"><span class="mdi ${escHtml(guide.icon)}"></span></div>
<div class="settings-guide-body">
<strong>${escHtml(guide.title)}</strong>
<p>${escHtml(guide.text)}</p>
${guide.steps ? `<ul>${guide.steps.map(s => `<li>${escHtml(s)}</li>`).join('')}</ul>` : ''}
</div>`;
head.insertAdjacentElement('afterend', box);
});
Object.entries(SETTINGS_FIELD_HELP).forEach(([id, help]) => {
const control = scope.getElementById ? scope.getElementById(id) : document.getElementById(id);
if (!control) return;
const field = control.closest('.s-field');
const label = field?.querySelector('label');
if (!field || !label) return;
const [title, detail] = help;
if (!label.querySelector('.s-help-tip')) {
const tipId = `${id}-tip`;
const wrap = document.createElement('span');
wrap.className = 's-help-tip';
wrap.innerHTML = `<button type="button" aria-label="Help for ${escHtml(title)}" aria-describedby="${escHtml(tipId)}"><span class="mdi mdi-information-outline" aria-hidden="true"></span></button><span class="s-tooltip" id="${escHtml(tipId)}" role="tooltip">${escHtml(detail)}</span>`;
label.appendChild(wrap);
}
if (!field.querySelector('.s-field-tip')) {
const tip = document.createElement('span');
tip.className = 's-field-tip';
tip.textContent = detail;
const hint = field.querySelector('.s-hint');
if (hint) hint.insertAdjacentElement('afterend', tip);
else field.appendChild(tip);
}
if (!control.getAttribute('aria-label')) control.setAttribute('aria-label', title);
if (!control.getAttribute('title')) control.setAttribute('title', detail);
});
}
async function loadSettings() {
const s = await fetch('/api/settings').then(r => r.json());
const sv = (id, val) => { const el = $(id); if (el) el.value = val; };
sv('s-whisper-url', s.whisper_url || '');
sv('s-whisper-key', s.whisper_api_key || '');
sv('s-tts-url', s.tts_url || '');
sv('s-faster-whisper-url', s.faster_whisper_url || '');
sv('s-whisper-cpp-url', s.whisper_cpp_url || '');
sv('s-groq-api-key', s.groq_api_key || '');
sv('s-kokoro-url', s.kokoro_url || '');
sv('s-vibevoice-url', s.vibevoice_url || '');
sv('s-xtts-url', s.xtts_url || '');
sv('s-llm-url', s.llm_url || '');
_appSettings = s;
// Restore engine URL inputs from server (overrides localStorage fallback)
const savedEngineUrls = s.engine_local_urls || {};
document.querySelectorAll('[data-llm-local-key]').forEach(inp => {
const v = savedEngineUrls[inp.dataset.llmLocalKey];
if (v) inp.value = v;
});
document.querySelectorAll('.dc-url-inp[data-dc-url-key]').forEach(inp => {
const v = savedEngineUrls['dc-' + inp.dataset.dcUrlKey];
if (v) inp.value = v;
});
// Restore container names from server — uses data-cn-key tags set by engines.js / ai-backends.js
const savedContainerNames = s.engine_container_names || {};
document.querySelectorAll('[data-cn-key]').forEach(inp => {
const name = savedContainerNames[inp.dataset.cnKey];
if (name && !inp.value) {
inp.value = name;
inp.dispatchEvent(new Event('input', { bubbles: true }));
}
});
// Restore LLM URLs for refinement and conversation panels
const refineInp = $('refine-llm-url');
if (refineInp && s.refine_llm_url) refineInp.value = s.refine_llm_url;
const convInp = $('conv-llm-url');
if (convInp && s.conv_llm_url) convInp.value = s.conv_llm_url;
sv('s-tts-stream-url', s.tts_stream_url || '');
sv('s-customvoice-url', s.customvoice_url || 'http://host.docker.internal:8022');
sv('s-nvidia-router-url', s.nvidia_router_url || 'http://host.docker.internal:8090');
sv('s-nvidia-tts-url', s.nvidia_tts_url || 'http://host.docker.internal:8091');
sv('s-nvidia-asr-url', s.nvidia_asr_url || 'http://host.docker.internal:8092');
sv('s-nvidia-zeroshot-url', s.nvidia_zeroshot_url || s.nvidia_clone_url || 'http://host.docker.internal:8093');
sv('s-nvidia-flow-url', s.nvidia_flow_url || 'http://host.docker.internal:8094');
sv('s-tts-stream-mode', s.tts_stream_mode || 'auto');
sv('s-tts-key', s.tts_api_key || '');
sv('s-tts-backend', s.tts_backend || 'openai');
const defaultTtsParams = {temperature:0.1, top_p:0.8, seed:0};
const byBackend = s.tts_extra_params_by_backend || {};
sv('s-tts-extra-voice-clone', JSON.stringify(byBackend.voice_clone || s.tts_extra_params || defaultTtsParams, null, 2));
sv('s-tts-extra-streaming', JSON.stringify(byBackend.streaming || s.tts_extra_params || defaultTtsParams, null, 2));
sv('s-tts-extra-customvoice', JSON.stringify(byBackend.customvoice || s.tts_extra_params || defaultTtsParams, null, 2));
sv('s-tts-extra-voice-design', JSON.stringify(byBackend.voice_design || s.tts_extra_params || defaultTtsParams, null, 2));
sv('s-tts-extra-nvidia-magpie', JSON.stringify(byBackend.nvidia_magpie || {}, null, 2));
sv('s-tts-extra-nvidia-zeroshot',JSON.stringify(byBackend.nvidia_zeroshot|| {}, null, 2));
sv('s-tts-extra-nvidia-flow', JSON.stringify(byBackend.nvidia_flow || {}, null, 2));
sv('s-tts-extra-kokoro', JSON.stringify(byBackend.kokoro || {}, null, 2));
sv('s-tts-extra-vibevoice', JSON.stringify(byBackend.vibevoice || {}, null, 2));
sv('s-voice-design-url', s.voice_design_url || 'http://host.docker.internal:8021');
sv('s-vd-key', s.voice_design_api_key || '');
sv('s-voices-scan-dir', s.voices_scan_dir || '');
sv('s-output-dir', s.output_dir || '');
const seedFinderDirEl = $('s-seed-finder-dir');
if (seedFinderDirEl) seedFinderDirEl.value = s.seed_finder_dir || '';
const ptDirEl = $('s-pt-dir');
if (ptDirEl) ptDirEl.value = s.pt_dir || '';
const seedTextEl = $('s-seed-finder-text');
if (seedTextEl) seedTextEl.value = s.seed_finder_text || '';
const themeEl = $('s-theme-select');
if (themeEl) themeEl.value = document.documentElement.dataset.theme || 'dark';
if (window.populateLangSelect) window.populateLangSelect();
// Captures settings
const sttLang = $('s-stt-language'); if (sttLang) sttLang.value = s.stt_language || '';
const sttPref = $('s-stt-preferred-backend'); if (sttPref) sttPref.value = s.stt_preferred_backend || '';
const autoRef = $('s-auto-refine'); if (autoRef) autoRef.value = s.auto_refine || 'off';
const refModel = $('s-refine-model'); if (refModel) refModel.value = s.refine_model || '';
const rfFill = $('s-refine-fillers'); if (rfFill) rfFill.checked = s.refine_fillers !== false;
const rfRep = $('s-refine-repetitions'); if (rfRep) rfRep.checked = s.refine_repetitions !== false;
const rfCorr = $('s-refine-corrections'); if (rfCorr) rfCorr.checked = s.refine_corrections !== false;
const rfPunc = $('s-refine-punctuation'); if (rfPunc) rfPunc.checked = s.refine_punctuation !== false;
// Default capture voice dropdown
const cvSel = $('s-captures-default-voice');
if (cvSel && window._voices) {
const cur = s.captures_default_voice || '';
cvSel.innerHTML = '<option value="">None — select manually</option>' +
(window._voices || []).map(v => `<option value="${escHtml(v.name)}" ${v.name === cur ? 'selected' : ''}>${escHtml(v.display_name || v.name)}</option>`).join('');
}
await refreshTtsBackendAvailability();
enhanceSettingsHelp(document);
renderSettingsAbout();
}
function markSettingsSeen() {
localStorage.setItem(SETTINGS_SEEN_KEY, '1');
}
function openSettings(firstRun = false) {
if (firstRun) markSettingsSeen();
switchTab('settings');
}
function closeSettings(markSeen = true) {
if (markSeen) markSettingsSeen();
}
document.querySelectorAll('.s-eye-btn').forEach(btn => {
btn.addEventListener('click', () => {
const inp = $(btn.dataset.target);
inp.type = inp.type === 'password' ? 'text' : 'password';
});
});
$('settings-btn')?.addEventListener('click', async () => { await loadSettings(); openSettings(false); });
document.addEventListener('click', async e => {
if (e.target.closest('.s-reload-btn')) { await loadSettings(); toast('Settings reloaded', 'success'); }
});
$('s-use-parakeet-asr')?.addEventListener('click', () => { $('s-whisper-url').value = $('s-nvidia-asr-url').value || 'http://host.docker.internal:8092'; });
$('s-use-nvidia-router')?.addEventListener('click', () => { const url = $('s-nvidia-router-url').value || 'http://host.docker.internal:8090'; $('s-whisper-url').value = url; $('s-nvidia-tts-url').value = url; });
$('s-use-faster-whisper')?.addEventListener('click', () => { $('s-whisper-url').value = $('s-faster-whisper-url').value || 'http://host.docker.internal:8000'; });
$('s-use-whisper-cpp')?.addEventListener('click', () => { $('s-whisper-url').value = $('s-whisper-cpp-url').value || 'http://host.docker.internal:8080'; });
document.addEventListener('click', async e => { if (!e.target.closest('.s-save-btn')) return;
{
let ttsExtraParamsByBackend = {};
const paramFields = [
['voice_clone', 's-tts-extra-voice-clone', 'Voice Clone/Base'],
['streaming', 's-tts-extra-streaming', 'Streaming'],
['customvoice', 's-tts-extra-customvoice', 'CustomVoice'],
['voice_design', 's-tts-extra-voice-design', 'Voice Design'],
['nvidia_magpie', 's-tts-extra-nvidia-magpie', 'NVIDIA Magpie'],
['nvidia_zeroshot', 's-tts-extra-nvidia-zeroshot', 'NVIDIA Zeroshot'],
['nvidia_flow', 's-tts-extra-nvidia-flow', 'NVIDIA Flow'],
['kokoro', 's-tts-extra-kokoro', 'Kokoro'],
['vibevoice', 's-tts-extra-vibevoice', 'VibeVoice'],
];
try {
for (const [key, id, label] of paramFields) {
ttsExtraParamsByBackend[key] = JSON.parse($(id).value || '{}');
}
} catch (e) {
toast('TTS params JSON is invalid: ' + e.message, 'error');
return;
}
await fetch('/api/settings', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
whisper_url: $('s-whisper-url').value,
whisper_api_key: $('s-whisper-key').value,
faster_whisper_url: $('s-faster-whisper-url').value,
whisper_cpp_url: $('s-whisper-cpp-url').value,
groq_api_key: $('s-groq-api-key').value,
tts_url: $('s-tts-url').value,
kokoro_url: $('s-kokoro-url').value,
vibevoice_url: $('s-vibevoice-url').value,
xtts_url: $('s-xtts-url')?.value || '',
llm_url: $('s-llm-url')?.value || '',
tts_stream_url: $('s-tts-stream-url').value,
customvoice_url: $('s-customvoice-url').value,
nvidia_router_url: $('s-nvidia-router-url').value,
nvidia_tts_url: $('s-nvidia-tts-url').value,
nvidia_asr_url: $('s-nvidia-asr-url').value,
nvidia_clone_url: $('s-nvidia-zeroshot-url').value,
nvidia_zeroshot_url: $('s-nvidia-zeroshot-url').value,
nvidia_flow_url: $('s-nvidia-flow-url').value,
tts_stream_mode: $('s-tts-stream-mode').value,
tts_api_key: $('s-tts-key').value,
tts_backend: $('s-tts-backend').value,
tts_extra_params_by_backend: ttsExtraParamsByBackend,
voice_design_url: $('s-voice-design-url').value,
voice_design_api_key: $('s-vd-key').value,
voices_scan_dir: $('s-voices-scan-dir').value,
output_dir: $('s-output-dir').value,
seed_finder_dir: $('s-seed-finder-dir')?.value || '',
pt_dir: $('s-pt-dir')?.value || '',
stt_language: $('s-stt-language')?.value || '',
stt_preferred_backend: $('s-stt-preferred-backend')?.value || '',
auto_refine: $('s-auto-refine')?.value || 'off',
refine_model: $('s-refine-model')?.value || '',
refine_fillers: $('s-refine-fillers')?.checked ?? true,
refine_repetitions: $('s-refine-repetitions')?.checked ?? true,
refine_corrections: $('s-refine-corrections')?.checked ?? true,
refine_punctuation: $('s-refine-punctuation')?.checked ?? true,
captures_default_voice: $('s-captures-default-voice')?.value || '',
seed_finder_text: $('s-seed-finder-text')?.value || '',
}) });
_appSettings.tts_stream_url = $('s-tts-stream-url').value;
_appSettings.customvoice_url = $('s-customvoice-url').value;
_appSettings.nvidia_router_url = $('s-nvidia-router-url').value;
_appSettings.nvidia_tts_url = $('s-nvidia-tts-url').value;
_appSettings.nvidia_asr_url = $('s-nvidia-asr-url').value;
_appSettings.nvidia_clone_url = $('s-nvidia-zeroshot-url').value;
_appSettings.nvidia_zeroshot_url = $('s-nvidia-zeroshot-url').value;
_appSettings.nvidia_flow_url = $('s-nvidia-flow-url').value;
_appSettings.voice_design_url = $('s-voice-design-url').value;
_appSettings.vibevoice_url = $('s-vibevoice-url').value;
_appSettings.xtts_url = $('s-xtts-url')?.value || '';
_appSettings.tts_stream_mode = $('s-tts-stream-mode').value;
_appSettings.seed_finder_text = $('s-seed-finder-text')?.value || '';
_ttsStreamHealth = null;
await refreshTtsBackendAvailability($('tts-backend-select')?.value || '');
markSettingsSeen();
renderIntegrationSnippets();
toast('Settings saved', 'success');
}});
// Theme select in General settings
document.addEventListener('change', e => {
if (e.target.id === 's-theme-select') applyTheme(e.target.value);
if (e.target.id === 's-lang-select' && window.setAppLang) window.setAppLang(e.target.value);
});
window.enhanceSettingsHelp = enhanceSettingsHelp;
setTimeout(() => enhanceSettingsHelp(document), 0);
const _settingsHelpObserver = new MutationObserver((mutations) => {
if (mutations.some(m => Array.from(m.addedNodes || []).some(n => n.nodeType === 1 && (n.matches?.('.s-settings-page') || n.querySelector?.('.s-settings-page'))))) {
enhanceSettingsHelp(document);
}
});
if (document.body) _settingsHelpObserver.observe(document.body, { childList: true, subtree: true });
// ── Voice ID field (tab 3) ────────────────────────────────────────────────
function validateVoiceId(v) { return /^[A-Za-z0-9_\-\.]+$/.test(v); }
$('voice-id-input').addEventListener('input', () => {
const val = $('voice-id-input').value;
const ok = val && validateVoiceId(val);
$('voice-id-input').className = val ? (ok ? 'id-valid' : 'id-invalid') : '';
$('voice-id-hint').textContent = val && !ok ? 'Only A-Z, a-z, 0-9, _, -, . allowed' : '';
});
$('helper-apply-btn')?.addEventListener('click', () => {
const name = $('name-input').value.trim();
if (!name) { toast('Enter a name first', 'error'); return; }
$('voice-id-input').value = `${$('lang-select').value}_${$('gender-select').value}_${name}`;
$('voice-id-input').dispatchEvent(new Event('input'));
});
// ── Global Directory Browser ──────────────────────────────────────────────────
(function initGlobalDirBrowser() {
const modal = document.getElementById('global-dir-browser-modal');
const crumb = document.getElementById('global-dir-browser-crumb');
const list = document.getElementById('global-dir-browser-list');
const pathEl = document.getElementById('global-dir-browser-path');
const selectBtn = document.getElementById('global-dir-browser-select');
if (!modal || !crumb || !list || !pathEl || !selectBtn) return;
let currentTargetInput = null;
let currentPath = '/';
async function navigateTo(path) {
currentPath = path;
list.innerHTML = '<span class="vef-loading" style="padding:8px; color:var(--text-muted);">Loading…</span>';
pathEl.textContent = path;
try {
const data = await fetch('/api/browse-dirs?path=' + encodeURIComponent(path)).then(r => r.json());
const parts = data.path.split('/').filter(Boolean);
const crumbs = [{ label: '/', path: '/' }];
parts.forEach((p, i) => crumbs.push({ label: p, path: '/' + parts.slice(0, i + 1).join('/') }));
crumb.innerHTML = crumbs.map((c, i) =>
i < crumbs.length - 1
? `<button class="vef-crumb-btn" data-path="${escHtml(c.path)}" style="background:none; border:none; color:var(--text-link); cursor:pointer; font:inherit; padding:2px;">${escHtml(c.label)}</button><span class="vef-sep" style="color:var(--text-muted); margin:0 4px;">/</span>`
: `<span class="vef-crumb-cur" style="padding:2px;">${escHtml(c.label)}</span>`
).join('');
crumb.querySelectorAll('.vef-crumb-btn').forEach(b => b.addEventListener('click', () => navigateTo(b.dataset.path)));
if (!data.dirs || !data.dirs.length) {
list.innerHTML = '<span class="vef-empty" style="padding:8px; color:var(--text-muted); display:block;">No subdirectories here.</span>';
} else {
list.innerHTML = data.dirs.map(d =>
`<button class="vef-dir-item" data-path="${escHtml(data.path === '/' ? '/' + d : data.path + '/' + d)}" style="display:flex; width:100%; text-align:left; background:none; border:none; padding:6px 8px; border-radius:4px; cursor:pointer; color:var(--text); align-items:center; gap:8px;">
<span class="mdi mdi-folder-outline" style="color:var(--text-muted);"></span> ${escHtml(d)}
</button>`
).join('');
list.querySelectorAll('.vef-dir-item').forEach(b => {
b.addEventListener('click', () => navigateTo(b.dataset.path));
b.addEventListener('mouseover', () => b.style.background = 'var(--bg-hover)');
b.addEventListener('mouseout', () => b.style.background = 'none');
});
}
pathEl.textContent = data.path;
currentPath = data.path;
} catch (e) {
list.innerHTML = `<span class="vef-error" style="padding:8px; color:var(--color-danger); display:block;">Error: ${escHtml(e.message)}</span>`;
}
}
document.addEventListener('click', e => {
const btn = e.target.closest('.s-browse-dir-btn');
if (!btn) return;
currentTargetInput = document.getElementById(btn.dataset.target);
if (!currentTargetInput) return;
const startPath = currentTargetInput.value.trim() || currentTargetInput.placeholder || '/';
modal.hidden = false;
navigateTo(startPath);
});
selectBtn.addEventListener('click', () => {
if (currentTargetInput && currentPath) {
currentTargetInput.value = currentPath;
currentTargetInput.dispatchEvent(new Event('input', { bubbles: true }));
}
modal.hidden = true;
});
})();