tts-voice-creator-clone-and.../static/js/settings.js

457 lines
24 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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 (/(cloud|api|elevenlabs|groq)/.test(text)) return 'Cloud';
if (/(cpu|metal)/.test(text) && !/(cuda|gpu|vram|rtx|dgx)/.test(text)) return 'CPU';
if (/(cuda|gpu|vram|rtx|dgx)/.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;
}
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;
async function loadSettings() {
const s = await fetch('/api/settings').then(r => r.json());
$('s-whisper-url').value = s.whisper_url || '';
$('s-whisper-key').value = s.whisper_api_key || '';
$('s-tts-url').value = s.tts_url || '';
$('s-faster-whisper-url').value = s.faster_whisper_url || '';
$('s-whisper-cpp-url').value = s.whisper_cpp_url || '';
$('s-groq-api-key').value = s.groq_api_key || '';
$('s-kokoro-url').value = s.kokoro_url || '';
$('s-vibevoice-url').value = s.vibevoice_url || '';
$('s-xtts-url').value = s.xtts_url || '';
const llmUrlEl = $('s-llm-url'); if (llmUrlEl) llmUrlEl.value = 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;
$('s-tts-stream-url').value = s.tts_stream_url || '';
$('s-customvoice-url').value = s.customvoice_url || 'http://host.docker.internal:8022';
$('s-nvidia-router-url').value = s.nvidia_router_url || 'http://host.docker.internal:8090';
$('s-nvidia-tts-url').value = s.nvidia_tts_url || 'http://host.docker.internal:8091';
$('s-nvidia-asr-url').value = s.nvidia_asr_url || 'http://host.docker.internal:8092';
$('s-nvidia-zeroshot-url').value = s.nvidia_zeroshot_url || s.nvidia_clone_url || 'http://host.docker.internal:8093';
$('s-nvidia-flow-url').value = s.nvidia_flow_url || 'http://host.docker.internal:8094';
$('s-tts-stream-mode').value = s.tts_stream_mode || 'auto';
$('s-tts-key').value = s.tts_api_key || '';
$('s-tts-backend').value = s.tts_backend || 'openai';
const defaultTtsParams = {temperature:0.1, top_p:0.8, seed:0};
const byBackend = s.tts_extra_params_by_backend || {};
$('s-tts-extra-voice-clone').value = JSON.stringify(byBackend.voice_clone || s.tts_extra_params || defaultTtsParams, null, 2);
$('s-tts-extra-streaming').value = JSON.stringify(byBackend.streaming || s.tts_extra_params || defaultTtsParams, null, 2);
$('s-tts-extra-customvoice').value = JSON.stringify(byBackend.customvoice || s.tts_extra_params || defaultTtsParams, null, 2);
$('s-tts-extra-voice-design').value = JSON.stringify(byBackend.voice_design || s.tts_extra_params || defaultTtsParams, null, 2);
$('s-tts-extra-nvidia-magpie').value = JSON.stringify(byBackend.nvidia_magpie || {}, null, 2);
$('s-tts-extra-nvidia-zeroshot').value = JSON.stringify(byBackend.nvidia_zeroshot || {}, null, 2);
$('s-tts-extra-nvidia-flow').value = JSON.stringify(byBackend.nvidia_flow || {}, null, 2);
$('s-tts-extra-kokoro').value = JSON.stringify(byBackend.kokoro || {}, null, 2);
const vibevoiceExtraEl = $('s-tts-extra-vibevoice');
if (vibevoiceExtraEl) vibevoiceExtraEl.value = JSON.stringify(byBackend.vibevoice || {}, null, 2);
$('s-voice-design-url').value = s.voice_design_url || 'http://host.docker.internal:8021';
$('s-vd-key').value = s.voice_design_api_key || '';
$('s-voices-scan-dir').value = s.voices_scan_dir || '';
$('s-output-dir').value = s.output_dir || '';
const themeEl = $('s-theme-select');
if (themeEl) themeEl.value = document.documentElement.dataset.theme || 'dark';
// 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();
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,
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 || '',
}) });
_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;
_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);
});
// ── 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'));
});