`;
}
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 ${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 = {};
// 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-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.']
};
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 = `