The spoken text that matches this voice recording.
-
Style variation ⌄
+ ${!isClone ? `
+
Style variation
+
Create a styled variant and save it as a new voice (CustomVoice only).
-
Preview first. Saving creates a new active WAV voice from the current reference text. Same-voice style only works when the selected backend knows this voice and honors instruct; Base/Streaming are fastest but often ignore style.
+
Preview first. Saving creates a new active WAV voice from the current reference text. Same-voice style only works when the selected backend knows this voice and honors instruct; CustomVoice is style-aware; Base/Streaming are fastest but often ignore style.
@@ -4574,16 +4584,17 @@ function makeVoiceRow(v) {
Save style variation
-
+
` : ''}
-
Loudness ⌄
+
Loudness
+
Normalize the volume of the reference audio file.
Target dBFS
-
+
Auto
-
Restart TTS
+
Restart WAV engines
Rebenchmark this one
Restart TTS before benchmarking.
@@ -4596,7 +4607,8 @@ function makeVoiceRow(v) {
`;
- hydrateVoiceDuration(v, wrap.querySelector('.vr-length'));
+ const vrLengthEl = wrap.querySelector('.vr-length');
+ hydrateVoiceDuration(v, vrLengthEl);
// Photo upload
const photoCell = wrap.querySelector('.vr-photo');
@@ -4640,7 +4652,7 @@ function makeVoiceRow(v) {
markVoiceAudioChanged(v);
dbValue.textContent = fmtDbfs(v);
dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
- wrap.querySelector('.vr-length').textContent = fmtDuration(v.duration);
+ if (vrLengthEl) vrLengthEl.textContent = fmtDuration(v.duration);
toast('Normalized: ' + v.id, 'success');
status(`Normalized ${v.id} to ${target} dBFS. Restart TTS before rebenchmarking.`);
} catch(e) {
@@ -4732,6 +4744,7 @@ function makeVoiceRow(v) {
// Inline optimizer in Library
const editAudioBtn = wrap.querySelector('.edit-audio-btn');
const optPanel = wrap.querySelector('.vr-optimizer');
+ const vrTypeEl = wrap.querySelector('.vr-type');
const optCanvas = wrap.querySelector('.opt-wave');
const optStart = wrap.querySelector('.opt-start');
const optEnd = wrap.querySelector('.opt-end');
@@ -4771,8 +4784,8 @@ function makeVoiceRow(v) {
optState.buffer = null;
optState.id = null;
await loadOptimizer();
- wrap.querySelector('.vr-length').textContent = fmtDuration(v.duration);
- wrap.querySelector('.vr-length').title = String(v.duration ?? '');
+ if (vrLengthEl) vrLengthEl.textContent = fmtDuration(v.duration);
+ if (vrLengthEl) vrLengthEl.title = String(v.duration ?? '');
dbValue.textContent = fmtDbfs(v);
dbCell.title = v.loudness ? `avg ${fmtDbfs(v)} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
};
@@ -4790,8 +4803,12 @@ function makeVoiceRow(v) {
};
const redrawOpt = () => {
- if (optState.buffer) drawOptimizerWave(optCanvas, optState.buffer, parseFloat(optStart.value)||0, parseFloat(optEnd.value)||optState.duration);
+ if (!optState.buffer || optCanvas.clientWidth < 4) return;
+ drawOptimizerWave(optCanvas, optState.buffer, parseFloat(optStart.value)||0, parseFloat(optEnd.value)||optState.duration);
};
+ // Redraw whenever the canvas is resized (handles display:none → visible transition)
+ const _waveRO = new ResizeObserver(() => redrawOpt());
+ _waveRO.observe(optCanvas);
const syncCompareReferenceAudio = () => {
if (!optState.id || !optCompareRefAudio) return;
const src = '/api/audio/' + optState.id;
@@ -4897,6 +4914,7 @@ function makeVoiceRow(v) {
setOptStatus(v.needs_tts_restart ? 'Optimizer ready. Restart TTS before benchmarking this edit.' : 'Optimizer ready');
};
wrap._loadOptimizer = loadOptimizer;
+ wrap._redrawOpt = redrawOpt;
editAudioBtn.addEventListener('click', async () => {
editAudioBtn.disabled = true;
@@ -4916,16 +4934,16 @@ function makeVoiceRow(v) {
}
});
[optStart, optEnd].forEach(inp => inp.addEventListener('input', () => { redrawOpt(); syncCompareReferenceAudio(); }));
- optStyleInstruct.addEventListener('input', () => {
+ optStyleInstruct?.addEventListener('input', () => {
if (!optStyleVoiceId.value.trim()) optStyleVoiceId.value = suggestedStyleVoiceId(v.id, optStyleInstruct.value);
});
- optStyleBackend.addEventListener('change', () => updateStyleBackendHelp(wrap));
+ optStyleBackend?.addEventListener('change', () => updateStyleBackendHelp(wrap));
optCompareBackend.addEventListener('change', () => setOptStatus(`Comparison backend: ${optCompareBackend.options[optCompareBackend.selectedIndex]?.textContent || optCompareBackend.value}`));
if (optCompareBackend.value === '') {
optCompareBackend.innerHTML = styleBackendOptions('voice_clone');
optCompareBackend.disabled = !availableTtsBackends().length;
}
- updateStyleBackendHelp(wrap);
+ if (optStyleBackend) updateStyleBackendHelp(wrap);
wrap.querySelector('.opt-db-minus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) - 1).toFixed(1); });
wrap.querySelector('.opt-db-plus').addEventListener('click', () => { optTargetDb.value = (Number(optTargetDb.value || -20) + 1).toFixed(1); });
wrap.querySelector('.opt-db-auto').addEventListener('click', () => { optTargetDb.value = '-20.0'; });
@@ -5048,64 +5066,63 @@ function makeVoiceRow(v) {
markVoiceAudioChanged(v);
refInput.value = v.transcript; refInput.title = v.transcript;
refTranscribeBtn.style.display = v.transcript ? 'none' : '';
- wrap.querySelector('.vr-type').textContent = voiceFileType(v).toUpperCase();
- wrap.querySelector('.vr-type').title = voiceFileType(v);
+ if (vrTypeEl) { vrTypeEl.textContent = voiceFileType(v).toUpperCase(); vrTypeEl.title = voiceFileType(v); }
await refreshOptimizerFromVoice();
toast('Voice crop saved: ' + v.id, 'success');
markTtsRestartRequired(saved.backup ? 'Crop saved and loaded. Restart TTS before rebenchmarking; undo is available.' : 'Crop saved and loaded. Restart TTS before rebenchmarking.');
} catch(e) { toast('Save crop failed: ' + e.message, 'error'); setOptStatus('Save crop failed'); }
});
- const styleVariationInput = () => {
- const style = optStyleInstruct.value.trim();
- const text = optTranscript.value.trim() || getBenchmarkSampleText();
- const newId = optStyleVoiceId.value.trim() || suggestedStyleVoiceId(v.id, style);
- if (!style) { toast('Enter a style instruction first', 'error'); optStyleInstruct.focus(); return null; }
- if (!text) { toast('Enter reference text first', 'error'); optTranscript.focus(); return null; }
- if (!/^[A-Za-z0-9_\-.]+$/.test(newId)) { toast('Invalid characters in new voice ID', 'error'); optStyleVoiceId.focus(); return null; }
- return {style, text, newId, backend: optStyleBackend.value};
- };
-
- optPreviewStyleBtn.addEventListener('click', async () => {
- const input = styleVariationInput();
- if (!input) return;
- optPreviewStyleBtn.disabled = true;
- try {
- setOptStatus('Synthesizing style preview...');
- const blob = await fetchTtsPreviewBlob(v.id, input.text, 'wav', input.style, input.backend);
- if (optStyleAudio.src) URL.revokeObjectURL(optStyleAudio.src);
- optStyleAudio.src = URL.createObjectURL(blob);
- optStyleAudio.style.display = '';
- await optStyleAudio.play().catch(()=>{});
- setOptStatus('Style preview ready. If it sounds right, save it as a new voice.');
- } catch(e) {
- toast('Style preview failed: ' + e.message, 'error');
- setOptStatus('Style preview failed');
- } finally {
- optPreviewStyleBtn.disabled = false;
- }
- });
-
- optSaveStyleBtn.addEventListener('click', async () => {
- const input = styleVariationInput();
- if (!input) return;
- optSaveStyleBtn.disabled = true;
- try {
- setOptStatus(`Synthesizing style variation ${input.newId}...`);
- const r = await fetch('/api/tts-style-variation', {method:'POST',headers:{'Content-Type':'application/json'},
- body:JSON.stringify({source_voice:v.id, voice_id:input.newId, text:input.text, instruct:input.style, backend:input.backend})});
- if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
- const d = await r.json();
- toast('Style variation saved: ' + d.voice_id, 'success');
- setOptStatus('Style variation saved. Restart TTS so the backend scans the new voice.');
- await loadVoiceLibrary();
- renderIntegrationSnippets();
- } catch(e) {
- toast('Style variation failed: ' + e.message, 'error');
- setOptStatus('Style variation failed');
- } finally {
- optSaveStyleBtn.disabled = false;
- }
- });
+ if (optStyleInstruct) {
+ const styleVariationInput = () => {
+ const style = optStyleInstruct.value.trim();
+ const text = optTranscript.value.trim() || benchmarkSampleText();
+ const newId = optStyleVoiceId.value.trim() || suggestedStyleVoiceId(v.id, style);
+ if (!style) { toast('Enter a style instruction first', 'error'); optStyleInstruct.focus(); return null; }
+ if (!text) { toast('Enter reference text first', 'error'); optTranscript.focus(); return null; }
+ if (!/^[A-Za-z0-9_\-.]+$/.test(newId)) { toast('Invalid characters in new voice ID', 'error'); optStyleVoiceId.focus(); return null; }
+ return {style, text, newId, backend: optStyleBackend.value};
+ };
+ optPreviewStyleBtn.addEventListener('click', async () => {
+ const input = styleVariationInput();
+ if (!input) return;
+ optPreviewStyleBtn.disabled = true;
+ try {
+ setOptStatus('Synthesizing style preview...');
+ const blob = await fetchTtsPreviewBlob(v.id, input.text, 'wav', input.style, input.backend);
+ if (optStyleAudio.src) URL.revokeObjectURL(optStyleAudio.src);
+ optStyleAudio.src = URL.createObjectURL(blob);
+ optStyleAudio.style.display = '';
+ await optStyleAudio.play().catch(()=>{});
+ setOptStatus('Style preview ready. If it sounds right, save it as a new voice.');
+ } catch(e) {
+ toast('Style preview failed: ' + e.message, 'error');
+ setOptStatus('Style preview failed');
+ } finally {
+ optPreviewStyleBtn.disabled = false;
+ }
+ });
+ optSaveStyleBtn.addEventListener('click', async () => {
+ const input = styleVariationInput();
+ if (!input) return;
+ optSaveStyleBtn.disabled = true;
+ try {
+ setOptStatus(`Synthesizing style variation ${input.newId}...`);
+ const r = await fetch('/api/tts-style-variation', {method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({source_voice:v.id, voice_id:input.newId, text:input.text, instruct:input.style, backend:input.backend})});
+ if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
+ const d = await r.json();
+ toast('Style variation saved: ' + d.voice_id, 'success');
+ setOptStatus('Style variation saved. Restart TTS so the backend scans the new voice.');
+ await loadVoiceLibrary();
+ renderIntegrationSnippets();
+ } catch(e) {
+ toast('Style variation failed: ' + e.message, 'error');
+ setOptStatus('Style variation failed');
+ } finally {
+ optSaveStyleBtn.disabled = false;
+ }
+ });
+ }
wrap.querySelector('.opt-undo').addEventListener('click', async () => {
if (!confirm(`Restore the original backup for "${v.id}"?`)) return;
@@ -5120,8 +5137,7 @@ function makeVoiceRow(v) {
v.path = d.path || v.path;
v.file_type = d.file_type || v.file_type;
markVoiceAudioChanged(v);
- wrap.querySelector('.vr-type').textContent = voiceFileType(v).toUpperCase();
- wrap.querySelector('.vr-type').title = voiceFileType(v);
+ if (vrTypeEl) { vrTypeEl.textContent = voiceFileType(v).toUpperCase(); vrTypeEl.title = voiceFileType(v); }
await refreshOptimizerFromVoice();
toast('Original restored: ' + v.id, 'success');
markTtsRestartRequired('Original restored. Restart TTS before rebenchmarking.');
@@ -5149,17 +5165,19 @@ function makeVoiceRow(v) {
optRestartTtsBtn.addEventListener('click', async () => {
optRestartTtsBtn.disabled = true;
try {
- setOptStatus('Restarting TTS so edited voices are rescanned...');
+ setOptStatus('Restarting WAV backends (Voice Clone + Streaming)…');
const r = await fetch('/api/tts/restart', { method:'POST' });
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
_voices.forEach(voice => { voice.needs_tts_restart = false; });
updateLibraryInsights();
- setVoiceRestartState(false, `TTS restarted (${d.container || 'container'}). Rebenchmark now uses the edited voice.`);
- toast('TTS restarted. Voices rescanned.', 'success');
+ const names = (d.restarted || []).join(', ') || 'containers';
+ const errTxt = (d.errors || []).length ? ` (errors: ${d.errors.join('; ')})` : '';
+ setVoiceRestartState(false, `Restarted: ${names}${errTxt}. Rebenchmark now uses the edited voice.`);
+ toast(`TTS restarted: ${names}`, 'success');
} catch(e) {
toast('Restart TTS failed: ' + e.message, 'error');
- setOptStatus('Restart TTS failed');
+ setOptStatus('Restart TTS failed: ' + e.message);
} finally {
optRestartTtsBtn.disabled = false;
}
@@ -5643,6 +5661,119 @@ $('save-preview-btn').addEventListener('click', () => {
});
+// ── Performance benchmark ─────────────────────────────────────────────────
+
+(function initPerfBenchmark() {
+ const perfBackendSel = $('perf-backend-select');
+ const perfVoiceSel = $('perf-voice-select');
+ const perfFetchBtn = $('perf-fetch-voices-btn');
+ const perfRunBtn = $('perf-run-btn');
+ const perfClearBtn = $('perf-clear-btn');
+ const perfProgress = $('perf-progress');
+ const perfResultsCard = $('perf-results-card');
+ const perfSummary = $('perf-summary');
+ const perfTbody = $('perf-tbody');
+ const perfText = $('perf-text');
+ const perfRunsSel = $('perf-runs');
+ if (!perfRunBtn) return;
+
+ let perfRows = [];
+
+ function populatePerfBackends() {
+ if (!perfBackendSel) return;
+ const cur = perfBackendSel.value;
+ perfBackendSel.innerHTML = availableTtsBackends().map(b =>
+ `
${escHtml(b.label)} `
+ ).join('') || '
No backends available ';
+ }
+ populatePerfBackends();
+
+ perfFetchBtn.addEventListener('click', async () => {
+ const backend = perfBackendSel.value;
+ if (!backend) { toast('Select a backend first', 'error'); return; }
+ perfFetchBtn.disabled = true;
+ try {
+ const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
+ const cur = perfVoiceSel.value;
+ perfVoiceSel.innerHTML = rawVoices.map(v => {
+ const id = backendVoiceId(v);
+ return `
${escHtml(id)} `;
+ }).join('') || '
No voices ';
+ } catch(e) { toast('Fetch voices failed: '+e.message, 'error'); }
+ finally { perfFetchBtn.disabled = false; }
+ });
+
+ function renderPerfTable() {
+ if (!perfRows.length) { perfResultsCard.style.display='none'; return; }
+ perfResultsCard.style.display = '';
+ perfTbody.innerHTML = perfRows.map((r, i) => {
+ const rtf = r.audioDuration > 0 ? (r.latencyMs / 1000 / r.audioDuration).toFixed(2) : '—';
+ const ok = r.ok;
+ return `
+ ${i+1}
+ ${escHtml(r.backend)}
+ ${escHtml(r.voice)}
+ ${ok ? r.latencyMs : '—'}
+ ${ok && r.audioDuration > 0 ? r.audioDuration.toFixed(2) : '—'}
+ ${ok ? rtf : '—'}
+ ${ok ? 'OK ' : `${escHtml(r.error||'Error')} `}
+ `;
+ }).join('');
+ const ok = perfRows.filter(r => r.ok);
+ if (ok.length) {
+ const avg = ok.reduce((s,r) => s + r.latencyMs, 0) / ok.length;
+ const min = Math.min(...ok.map(r => r.latencyMs));
+ const max = Math.max(...ok.map(r => r.latencyMs));
+ const avgRtf = ok.filter(r=>r.audioDuration>0).reduce((s,r)=>s+(r.latencyMs/1000/r.audioDuration),0) / Math.max(1,ok.filter(r=>r.audioDuration>0).length);
+ perfSummary.innerHTML = `
+
${Math.round(avg)} ms avg latency
+
${min} ms best
+
${max} ms worst
+
${avgRtf.toFixed(2)} avg RTF
+
${avgRtf < 1 ? '✅ Real-time capable' : '⚠ Slower than real-time'}
+ `;
+ } else { perfSummary.innerHTML = '
All runs failed '; }
+ }
+
+ perfClearBtn.addEventListener('click', () => {
+ perfRows = [];
+ renderPerfTable();
+ perfProgress.style.display = 'none';
+ });
+
+ perfRunBtn.addEventListener('click', async () => {
+ const backend = perfBackendSel.value;
+ const voice = perfVoiceSel.value;
+ const text = perfText.value.trim();
+ const runs = parseInt(perfRunsSel.value) || 3;
+ if (!backend) { toast('Select a backend first', 'error'); return; }
+ if (!voice) { toast('Fetch and select a voice first', 'error'); return; }
+ if (!text) { toast('Enter sample text', 'error'); return; }
+ perfRunBtn.disabled = true;
+ perfProgress.style.display = '';
+ for (let i = 0; i < runs; i++) {
+ perfProgress.textContent = `Run ${i+1} / ${runs}…`;
+ const row = { backend, voice, ok: false, latencyMs: 0, audioDuration: 0, error: '' };
+ try {
+ const t0 = performance.now();
+ const blob = await fetchTtsPreviewBlob(voice, text, 'wav', '', backend);
+ row.latencyMs = Math.round(performance.now() - t0);
+ row.ok = true;
+ try {
+ const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+ const buf = await audioCtx.decodeAudioData(await blob.arrayBuffer());
+ row.audioDuration = buf.duration;
+ audioCtx.close();
+ } catch(_) {}
+ } catch(e) { row.error = e.message; }
+ perfRows.push(row);
+ renderPerfTable();
+ }
+ perfProgress.textContent = `Done — ${runs} run${runs>1?'s':''} completed.`;
+ perfRunBtn.disabled = false;
+ });
+})();
+
// ── STT -> TTS ───────────────────────────────────────────────────────────
let sttTtsSourceId = null;
@@ -6164,6 +6295,237 @@ loadSettings().then(() => {
})();
loadVoiceLibrary().then(renderIntegrationSnippets).catch(e => status('Voice library load failed: ' + e.message));
+// ── AI Backends section: category tabs ────────────────────────────────────
+
+(function initLlmCatTabs() {
+ const tabs = document.querySelectorAll('.llm-cat-tab[data-llm-cat]');
+ const secs = document.querySelectorAll('.llm-section[data-llm-section]');
+ if (!tabs.length) return;
+
+ function showCat(cat) {
+ tabs.forEach(t => t.classList.toggle('active', t.dataset.llmCat === cat));
+ secs.forEach(s => { s.hidden = s.dataset.llmSection !== cat; });
+ if (cat === 'local') loadLocalContainers();
+ }
+
+ tabs.forEach(t => t.addEventListener('click', () => showCat(t.dataset.llmCat)));
+ // Load containers immediately since Local is the default active tab
+ loadLocalContainers();
+})();
+
+// ── Local Docker container management ─────────────────────────────────────
+
+async function loadLocalContainers() {
+ const grid = $('dc-grid');
+ if (!grid) return;
+ grid.innerHTML = '
Checking container status…
';
+ try {
+ const r = await fetch('/api/local-containers');
+ const d = await r.json();
+ renderLocalContainers(d.containers || []);
+ } catch (e) {
+ grid.innerHTML = `
Could not reach server: ${escHtml(e.message)}
`;
+ }
+}
+
+function renderLocalContainers(containers) {
+ const grid = $('dc-grid');
+ if (!grid) return;
+ if (!containers.length) {
+ grid.innerHTML = '
No containers defined.
';
+ return;
+ }
+
+ const ROLE_LABEL = { tts: 'TTS', stt: 'STT', 'stt+tts': 'STT · TTS', llm: 'LLM' };
+ const DC_ICONS = {
+ 'faster-qwen3-tts-voiceclone': '🔊',
+ 'faster-qwen3-tts-voicedesign': '✨',
+ 'faster-qwen3-tts-customvoice': '🎭',
+ 'faster-qwen3-tts-streaming': '⚡',
+ 'parakeet-asr': '🦜',
+ 'magpie-tts': '🐦',
+ 'parakeet-rnnt-nim': '🦜',
+ };
+ const roleIcon = { tts: '🔊', stt: '🎙️', 'stt+tts': '🔄', llm: '🤖' };
+
+ grid.innerHTML = containers.map(c => {
+ const st = c.status || 'not_found';
+ const dotCls = st === 'running' ? 'dc-dot dc-running'
+ : st === 'exited' || st === 'stopped' ? 'dc-dot dc-stopped'
+ : 'dc-dot dc-absent';
+ const stLabel = st === 'running' ? 'Running'
+ : st === 'exited' ? 'Stopped'
+ : st === 'stopped' ? 'Stopped'
+ : st === 'not_found' ? 'Not installed'
+ : st;
+ const roleBadge = ROLE_LABEL[c.role] || c.role || '';
+ const portBadge = c.port ? `
:${c.port} ` : '';
+ const installed = st !== 'not_found';
+ const icon = DC_ICONS[c.name] || roleIcon[c.role] || '📦';
+
+ const n = escHtml(c.name);
+ const actions = installed
+ ? (st === 'running'
+ ? `
Stop
+
Restart `
+ : `
Start
+
Restart `)
+ : (c.repo
+ ? `
View on GitHub ↗ `
+ : '');
+
+ return `
+
+ ${icon}
+
+ ${escHtml(c.label || c.name)}
+
+ ${roleBadge ? `${escHtml(roleBadge)} ` : ''}
+ ${portBadge}
+
+
+
${escHtml(stLabel)}
+ ${c.description ? `
${escHtml(c.description)}
` : ''}
+
${actions}
+
`;
+ }).join('');
+
+ grid.querySelectorAll('.dc-btn[data-dc-action]').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const action = btn.dataset.dcAction;
+ const name = btn.dataset.dcName;
+ btn.disabled = true;
+ btn.textContent = action === 'start' ? 'Starting…' : 'Stopping…';
+ try {
+ const r = await fetch(`/api/local-containers/${encodeURIComponent(name)}/${action}`, { method: 'POST' });
+ const d = await r.json();
+ if (!d.ok) toast(d.error || `${action} failed`, 'error');
+ } catch (e) {
+ toast(`${action} failed: ${e.message}`, 'error');
+ }
+ await loadLocalContainers();
+ });
+ });
+}
+
+$('dc-refresh-btn')?.addEventListener('click', loadLocalContainers);
+
+// ── AI Backends section: copy, API keys, local service connect ─────────────
+
+(function initLlmsSection() {
+
+ // Copy buttons
+ document.querySelectorAll('.llm-copy-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const text = btn.dataset.copy || '';
+ const orig = btn.textContent;
+ const done = () => { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = orig; }, 1500); };
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(text).then(done).catch(done);
+ } else {
+ const ta = document.createElement('textarea');
+ ta.value = text; ta.style.cssText = 'position:fixed;opacity:0';
+ document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
+ done();
+ }
+ });
+ });
+
+ // API key inputs — persist to localStorage, eye toggle, saved badge
+ document.querySelectorAll('.llm-input[data-llm-key]').forEach(inp => {
+ const key = inp.dataset.llmKey;
+ const saved = localStorage.getItem('llm-key-' + key);
+ if (saved) inp.value = saved;
+
+ const eye = document.createElement('button');
+ eye.type = 'button'; eye.className = 'llm-eye-btn'; eye.title = 'Show / hide';
+ eye.textContent = '👁';
+ inp.insertAdjacentElement('afterend', eye);
+
+ const badge = document.createElement('span');
+ badge.className = 'llm-saved-badge'; badge.textContent = 'Saved'; badge.hidden = true;
+ eye.insertAdjacentElement('afterend', badge);
+
+ eye.addEventListener('click', () => {
+ inp.type = inp.type === 'password' ? 'text' : 'password';
+ eye.classList.toggle('active', inp.type === 'text');
+ });
+
+ let t;
+ inp.addEventListener('input', () => {
+ clearTimeout(t);
+ t = setTimeout(() => {
+ if (inp.value) localStorage.setItem('llm-key-' + key, inp.value);
+ else localStorage.removeItem('llm-key-' + key);
+ badge.hidden = false;
+ setTimeout(() => { badge.hidden = true; }, 1800);
+ }, 600);
+ });
+ });
+
+ // Local service URL inputs + Connect / Disconnect
+ function normalizeProbeUrl(raw) {
+ // 0.0.0.0 is a bind address, not routable; from inside Docker use host.docker.internal
+ return raw.replace(/^(https?:\/\/)0\.0\.0\.0([\/:])/, '$1host.docker.internal$2');
+ }
+
+ async function probeUrl(rawUrl) {
+ const url = normalizeProbeUrl(rawUrl);
+ const r = await fetch('/api/probe-url?' + new URLSearchParams({ url }));
+ return r.json();
+ }
+
+ function applyCardState(card, key, connected, failed) {
+ card.classList.toggle('llm-local-card-online', connected);
+ card.classList.toggle('llm-local-card-offline', !connected && !!failed);
+ localStorage.setItem('llm-local-con-' + key, connected ? '1' : '0');
+ const btn = card.querySelector('.llm-local-ping');
+ if (!btn) return;
+ if (connected) {
+ btn.textContent = '✓ Disconnect'; btn.dataset.action = 'disconnect';
+ btn.className = 'llm-local-ping ok';
+ } else {
+ btn.textContent = 'Connect'; btn.dataset.action = 'connect';
+ btn.className = 'llm-local-ping';
+ }
+ }
+
+ document.querySelectorAll('[data-llm-local-key]').forEach(inp => {
+ const key = inp.dataset.llmLocalKey;
+ const card = inp.closest('.llm-local-card');
+ if (!card) return;
+ const btn = card.querySelector('.llm-local-ping');
+
+ const savedUrl = localStorage.getItem('llm-local-url-' + key);
+ if (savedUrl) inp.value = savedUrl;
+ inp.addEventListener('input', () => { localStorage.setItem('llm-local-url-' + key, inp.value); });
+
+ if (localStorage.getItem('llm-local-con-' + key) === '1') applyCardState(card, key, true, false);
+
+ if (!btn) return;
+ btn.addEventListener('click', async () => {
+ const action = btn.dataset.action || 'connect';
+ if (action === 'disconnect') { applyCardState(card, key, false, false); return; }
+
+ const rawUrl = inp.value.trim() || inp.placeholder;
+ if (!rawUrl) return;
+ btn.disabled = true;
+ btn.textContent = 'Connecting…';
+ try {
+ const d = await probeUrl(rawUrl);
+ applyCardState(card, key, d.ok, !d.ok);
+ if (!d.ok) toast('Cannot reach ' + normalizeProbeUrl(rawUrl) + ': ' + (d.error || 'No response'), 'error');
+ } catch (e) {
+ applyCardState(card, key, false, true);
+ toast('Probe failed: ' + e.message, 'error');
+ } finally {
+ btn.disabled = false;
+ }
+ });
+ });
+
+})();
+
// ── Collapsible cards ──────────────────────────────────────────────────────
(function initCollapsibleCards() {
@@ -6199,10 +6561,11 @@ loadVoiceLibrary().then(renderIntegrationSnippets).catch(e => status('Voice libr
h2.prepend(chev);
h2.classList.add('card-collapse-h2');
- // Wrap every element after h2 in a single body div
+ // Wrap every element after h2 (skipping .card-subtitle which stays visible) in body
const body = document.createElement('div');
body.className = 'card-col-body';
let sib = h2.nextElementSibling;
+ while (sib && sib.classList.contains('card-subtitle')) sib = sib.nextElementSibling;
while (sib) { const nx = sib.nextElementSibling; body.appendChild(sib); sib = nx; }
card.appendChild(body);
diff --git a/static/index.html b/static/index.html
index 6c7b321..58d2dec 100644
--- a/static/index.html
+++ b/static/index.html
@@ -19,6 +19,7 @@
Voice Custom
TTS Generation
STT-TTS
+
Performance
Routing
How to
Get Voices
diff --git a/static/sections/s-clone.html b/static/sections/s-clone.html
index 6d533a7..c387390 100644
--- a/static/sections/s-clone.html
+++ b/static/sections/s-clone.html
@@ -10,7 +10,7 @@
Step 1 — Load audio
-
Provide the audio you want to clone. Drop a file, paste a YouTube link, or record your microphone.
+
Drop a file, paste a URL, or record directly from your microphone.
Drop an audio / video file here
WAV · MP3 · OGG · FLAC · M4A · MP4 · MKV · WEBM
@@ -20,6 +20,7 @@
YouTube / URL
+
Download audio from a direct link or YouTube video.
Download
@@ -28,6 +29,7 @@
Microphone
+
Record a fresh sample with live input level monitoring.
● Record
■ Stop
@@ -35,7 +37,8 @@