// ── 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 '';
const current = selected || backends[0].id;
return backends.map(b => ``).join('');
}
function styleBackendOptions(selected = 'customvoice', preferStyleAware = false) {
const backends = availableTtsBackends();
if (!backends.length) return '';
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 => ``).join('');
}
function backendById(id) {
return availableTtsBackends().find(b => b.id === id) || availableTtsBackends()[0] || null;
}
function backendHelpHtml(b, compact = false) {
if (!b) return 'No TTS backend is reachable.
Start at least one TTS service or check Settings URLs.
';
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]) => `${escHtml(text)}`).join('');
const metricParts = [];
if (b.speed) metricParts.push(` ${escHtml(b.speed)}`);
if (b.latency) metricParts.push(` ${escHtml(b.latency)}`);
if (b.quality) metricParts.push(` ${escHtml(b.quality)}`);
if (b.ram) metricParts.push(` ${escHtml(b.ram)}`);
const metrics = metricParts.length ? `${metricParts.join('')}
` : '';
const detail = compact ? escHtml(b.best_for || '') : `${escHtml(b.purpose || '')}
Identity: ${escHtml(b.identity || '')}
Style: ${escHtml(b.style || '')}
Best for: ${escHtml(b.best_for || '')}`;
return `${escHtml(b.label)}${tags}
${metrics}${detail}
`;
}
function sttBackendHelpHtml(b) {
if (!b) return 'No STT engine selected.';
const m = b.metrics || {};
const metricParts = [];
if (m.speed) metricParts.push(` ${escHtml(m.speed)}`);
if (m.latency) metricParts.push(` ${escHtml(m.latency)}`);
if (m.quality) metricParts.push(` ${escHtml(m.quality)}`);
if (m.ram) metricParts.push(` ${escHtml(m.ram)}`);
const metrics = metricParts.length ? `${metricParts.join('')}
` : '';
const modelList = Array.isArray(b.models) && b.models.length ? ' Models: ' + b.models.slice(0, 4).join(', ') + '.' : '';
const avail = b.available ? `ready` : `unavailable`;
return `${escHtml(b.label)} ${avail}${metrics}${escHtml(b.url)}${escHtml(modelList)}
`;
}
function updateBackendHelp() {
const b = backendById($('tts-backend-select')?.value || '');
const help = $('tts-backend-help');
if (help) help.innerHTML = backendHelpHtml(b);
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 ${escHtml(styleAwareBacks[0].label)} instead.`
: ' No style-aware backend is currently reachable.';
box.innerHTML += ` This backend ignores the style instruction — output will sound the same regardless of what you type.${suggestion}
`;
}
});
}
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 = {};
document.querySelectorAll('[data-llm-local-key]').forEach(i => { if (i.value) urls[i.dataset.llmLocalKey] = i.value; });
_patchSettings({ engine_local_urls: urls });
if (_appSettings) _appSettings.engine_local_urls = urls;
}, 800);
}
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;
});
// 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 = '' +
(window._voices || []).map(v => ``).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'));
});