Redesign inspector pane, add searchable pickers, and style AI Backends tab
Inspector: - Skinny 2-row header: 72px avatar + name/ID row / subtitle row / note row - Searchable flag picker (dblclick flag icon) — filtered by voice language, falls back to ALL_FLAGS - Searchable language picker (dblclick lang code) — shows full language names - Tag reuse: entered tags persist to localStorage as datalist suggestions - Compact active toggle (32×18px), slim save button (12px/4px padding) - Show/hide eye toggle and "✓ Key saved" badge on API key fields AI Backends (s-llms.html + style.css): - Full CSS design: pill tabs with active accent, animated section transitions - Service cards: icon bubbles, tier badges (Free/Demo/Paid), stat chips, endpoint rows, model tags - Highlighted recommended card with accent border - Dark code blocks for local service snippets with copy feedback - Show/hide password toggle and auto-appearing "✓ Key saved" badge per card Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6583a9fc80
commit
28f5ec2e25
586
static/app.js
586
static/app.js
@ -1,6 +1,322 @@
|
||||
// ── Utility ───────────────────────────────────────────────────────────────
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
// ── Voice avatar colors ───────────────────────────────────────────────────
|
||||
const _AVATAR_COLORS = ['#E57373','#F06292','#BA68C8','#9575CD','#7986CB',
|
||||
'#64B5F6','#4DD0E1','#4DB6AC','#81C784','#FFB74D','#FF8A65','#A1887F'];
|
||||
function avatarColor(id) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) & 0xFFFFFF;
|
||||
return _AVATAR_COLORS[Math.abs(h) % _AVATAR_COLORS.length];
|
||||
}
|
||||
|
||||
// ── Voice inspector (3-pane workbench) ───────────────────────────────────
|
||||
let _selectedVoiceWrap = null;
|
||||
|
||||
function selectVoice(wrap) {
|
||||
const inspector = document.getElementById('voices-inspector');
|
||||
if (!inspector) return;
|
||||
|
||||
function restoreToRow(targetWrap) {
|
||||
// Restore all extracted elements back to their original DOM parents
|
||||
(targetWrap._extracted || []).forEach(({el, target}) => {
|
||||
if (el && target) target.appendChild(el);
|
||||
});
|
||||
targetWrap._extracted = [];
|
||||
// Move main-row and optimizer back from inspector body to wrap
|
||||
inspector.querySelectorAll('.vr-main-row,.vr-optimizer').forEach(el => targetWrap.appendChild(el));
|
||||
}
|
||||
|
||||
// Deselect previous voice
|
||||
if (_selectedVoiceWrap && _selectedVoiceWrap !== wrap) {
|
||||
_selectedVoiceWrap.classList.remove('vr-selected');
|
||||
restoreToRow(_selectedVoiceWrap);
|
||||
}
|
||||
|
||||
if (_selectedVoiceWrap === wrap) {
|
||||
restoreToRow(wrap);
|
||||
wrap.classList.remove('vr-selected');
|
||||
_selectedVoiceWrap = null;
|
||||
inspector.innerHTML = '<div class="inspector-placeholder"><span>🔊</span><p>Pick a voice on the left<br>to edit it here</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedVoiceWrap = wrap;
|
||||
wrap.classList.add('vr-selected');
|
||||
|
||||
const voiceId = wrap.dataset.id || '';
|
||||
const color = wrap.dataset.color || '#9575CD';
|
||||
const isClone = wrap.dataset.hasRef === 'true';
|
||||
const dbfs = wrap.dataset.dbfs || '-';
|
||||
const hasPicture = wrap.dataset.hasPicture === 'true';
|
||||
|
||||
// Live voice object — authoritative for mutable fields
|
||||
const v = (_voices || []).find(vv => vv.id === voiceId) || {};
|
||||
const langCode = v.lang || wrap.dataset.lang || '';
|
||||
const flagCc = v.flag || wrap.dataset.flagCc || langCode;
|
||||
const rating = v.rating || 0;
|
||||
|
||||
const nameParts = voiceId.split('_');
|
||||
const dispName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : voiceId;
|
||||
const initial = (dispName[0] || voiceId[0] || '?').toUpperCase();
|
||||
const picSrcInsp = hasPicture ? `/api/voice/picture/${encodeURIComponent(voiceId)}` : null;
|
||||
const inspFlagCc = flagCc || langCode;
|
||||
const inspFlagIcon = inspFlagCc
|
||||
? `<span class="fi fi-${inspFlagCc.toLowerCase()}" role="img" aria-label="${langCode}"></span>` : null;
|
||||
|
||||
const avatarHtml = picSrcInsp
|
||||
? `<img src="${picSrcInsp}" alt="" class="insp-avatar-img">`
|
||||
: inspFlagIcon || initial;
|
||||
const avatarBgStyle = (picSrcInsp || inspFlagIcon) ? '' : `style="background:${color}"`;
|
||||
|
||||
const flagIconHtml = inspFlagIcon || '';
|
||||
|
||||
const makeStars = n => [1,2,3,4,5].map(i =>
|
||||
`<span class="insp-star${i <= n ? ' on' : ''}" data-val="${i}">★</span>`
|
||||
).join('');
|
||||
|
||||
const LANGS = ['EN','DE','IT','ES','FR','PT','NL','PL','ZH','JA','KO','AR','RU','TR','HI','SV','DA','FI','NB','HU','CS','RO','UK'];
|
||||
|
||||
// Gender maps — used in template AND in event handlers
|
||||
const _gM = {F:'♀', M:'♂', N:'⚥', '':'?'};
|
||||
const _gL = {F:'Female', M:'Male', N:'Diverse', '':'—'};
|
||||
const _gC = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'};
|
||||
const curGender = v.gender || 'N';
|
||||
const genderLabelHtml = `${_gM[curGender]||'?'} ${_gL[curGender]||'—'}`;
|
||||
|
||||
inspector.innerHTML = `
|
||||
<div class="inspector-header">
|
||||
<div class="insp-hd-top">
|
||||
<div class="inspector-avatar${picSrcInsp ? ' insp-avatar-photo' : ''} insp-avatar-clickable" ${avatarBgStyle} title="Click to change photo">${avatarHtml}</div>
|
||||
<div class="insp-title-stack">
|
||||
<div class="insp-hd-row1">
|
||||
<h3 class="insp-disp-name" title="Double-click to rename">${escHtml(dispName)}</h3>
|
||||
<input class="insp-name-edit" value="${escHtml(voiceId)}" spellcheck="false" style="display:none" placeholder="Voice ID">
|
||||
<div class="insp-actions-save"></div>
|
||||
</div>
|
||||
<div class="insp-hd-row2">
|
||||
<div class="insp-id-block">
|
||||
<span class="insp-full-id" title="Double-click to rename">${escHtml(voiceId)}</span>
|
||||
<button class="insp-copy-id-btn" type="button" title="Copy voice ID">copy ID</button>
|
||||
</div>
|
||||
<div class="insp-actions-active"></div>
|
||||
</div>
|
||||
<div class="insp-hd-divider"></div>
|
||||
<div class="insp-subtitle">
|
||||
<span class="insp-flag" title="Double-click to change accent flag">
|
||||
<span class="insp-flag-icon">${flagIconHtml}</span>
|
||||
</span>
|
||||
<span class="insp-lang-wrap" title="Double-click to change language">
|
||||
<span class="insp-lang-code">${escHtml(langCode)}</span>
|
||||
</span>
|
||||
<span class="insp-gender-label" title="Click to cycle gender">${escHtml(genderLabelHtml)}</span>
|
||||
<span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span>
|
||||
<span class="insp-stars-wrap">
|
||||
<span class="insp-stars">${makeStars(rating)}</span>
|
||||
<span class="insp-rating-label">${rating}/5</span>
|
||||
</span>
|
||||
<input class="insp-tag-input" type="text" placeholder="add tag…" value="${escHtml(v.tag||'')}" list="insp-tag-opts" autocomplete="off">
|
||||
</div>
|
||||
<div class="insp-note-delete-row">
|
||||
<div class="insp-note-slot"></div>
|
||||
<div class="insp-actions-delete"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inspector-body"></div>
|
||||
`;
|
||||
|
||||
const saveSlot = inspector.querySelector('.insp-actions-save');
|
||||
const activeSlot = inspector.querySelector('.insp-actions-active');
|
||||
const deleteSlot = inspector.querySelector('.insp-actions-delete');
|
||||
const body = inspector.querySelector('.inspector-body');
|
||||
|
||||
// ── Save changes → row 1 right ───────────────────────────────────────────
|
||||
const saveBtn = document.createElement('button');
|
||||
saveBtn.className = 'btn-primary insp-save-btn';
|
||||
saveBtn.textContent = 'Save changes';
|
||||
saveBtn.addEventListener('click', () => body.querySelector('.opt-save-text')?.click());
|
||||
saveSlot.appendChild(saveBtn);
|
||||
|
||||
// ── Active toggle → row 2 right ──────────────────────────────────────────
|
||||
const detailRow = wrap.querySelector('.vr-detail-row');
|
||||
const activeEl = detailRow?.querySelector('.vr-detail-active');
|
||||
const deleteEl = detailRow?.querySelector('.vr-detail-delete');
|
||||
if (activeEl) activeSlot.appendChild(activeEl);
|
||||
|
||||
// ── Delete → note row right ───────────────────────────────────────────────
|
||||
if (deleteEl) {
|
||||
deleteSlot.appendChild(deleteEl);
|
||||
const _dBtn = deleteEl.querySelector('.delete-btn');
|
||||
const _dCnl = deleteEl.querySelector('.delete-confirm-cancel');
|
||||
_dBtn?.addEventListener('click', () => deleteEl.classList.add('delete-pending'));
|
||||
_dCnl?.addEventListener('click', () => deleteEl.classList.remove('delete-pending'));
|
||||
}
|
||||
|
||||
// ── Avatar click → photo upload ───────────────────────────────────────────
|
||||
inspector.querySelector('.inspector-avatar').addEventListener('click', () => {
|
||||
body.querySelector('.photo-input')?.click();
|
||||
});
|
||||
|
||||
// ── Copy ID button ────────────────────────────────────────────────────────
|
||||
inspector.querySelector('.insp-copy-id-btn').addEventListener('click', () => {
|
||||
copyText(voiceId).then(() => toast('Copied: ' + voiceId));
|
||||
});
|
||||
|
||||
// ── Double-click name/ID → inline rename ──────────────────────────────────
|
||||
const dispNameEl = inspector.querySelector('.insp-disp-name');
|
||||
const nameEditEl = inspector.querySelector('.insp-name-edit');
|
||||
const fullIdEl = inspector.querySelector('.insp-full-id');
|
||||
|
||||
const startInspRename = () => {
|
||||
dispNameEl.style.display = 'none'; fullIdEl.style.display = 'none';
|
||||
nameEditEl.style.display = 'block';
|
||||
nameEditEl.value = voiceId; nameEditEl.focus(); nameEditEl.select();
|
||||
};
|
||||
const commitInspRename = () => {
|
||||
dispNameEl.style.display = ''; fullIdEl.style.display = '';
|
||||
nameEditEl.style.display = 'none';
|
||||
const newId = nameEditEl.value.trim();
|
||||
if (!newId || newId === voiceId) return;
|
||||
const rowInput = wrap.querySelector('.vr-name-input');
|
||||
const rowOk = wrap.querySelector('.rename-ok');
|
||||
if (rowInput && rowOk) { rowInput.value = newId; rowOk.click(); }
|
||||
};
|
||||
dispNameEl.addEventListener('dblclick', startInspRename);
|
||||
fullIdEl.addEventListener('dblclick', startInspRename);
|
||||
nameEditEl.addEventListener('blur', commitInspRename);
|
||||
nameEditEl.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') nameEditEl.blur();
|
||||
if (e.key === 'Escape') { nameEditEl.value = voiceId; nameEditEl.blur(); }
|
||||
});
|
||||
|
||||
// ── Flag (accent/country) — decoupled from language ──────────────────────
|
||||
const flagSpan = inspector.querySelector('.insp-flag');
|
||||
const flagIconEl = inspector.querySelector('.insp-flag-icon');
|
||||
|
||||
const applyFlag = async (cc) => {
|
||||
v.flag = cc; wrap.dataset.flagCc = cc;
|
||||
const fi = `<span class="fi fi-${cc.toLowerCase()}" role="img" aria-label="${cc}"></span>`;
|
||||
if (flagIconEl) flagIconEl.innerHTML = fi;
|
||||
await saveMeta(voiceId, { flag: cc });
|
||||
};
|
||||
flagSpan.addEventListener('dblclick', () => {
|
||||
const items = (FLAG_OPTIONS[langCode.toUpperCase()] || ALL_FLAGS).map(([cc, name]) => [cc.toLowerCase(), name]);
|
||||
createSearchablePicker(flagSpan, items, applyFlag, {
|
||||
placeholder: 'Country or accent…',
|
||||
renderItem: (cc, name) =>
|
||||
`<span class="fi fi-${cc}" style="width:20px;height:14px;background-size:cover;border-radius:2px;flex-shrink:0;display:inline-block"></span><span>${escHtml(name)}</span>`,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Language — double-click lang code to change ───────────────────────────
|
||||
const langCodeEl = inspector.querySelector('.insp-lang-code');
|
||||
const langWrap = inspector.querySelector('.insp-lang-wrap');
|
||||
|
||||
const applyLang = async (newLang) => {
|
||||
v.lang = newLang; wrap.dataset.lang = newLang;
|
||||
if (langCodeEl) langCodeEl.textContent = newLang;
|
||||
await saveMeta(voiceId, { lang: newLang });
|
||||
};
|
||||
langWrap?.addEventListener('dblclick', () => {
|
||||
const items = LANGS.map(l => [l, LANGUAGE_LABELS[l] ? `${LANGUAGE_LABELS[l]} (${l})` : l]);
|
||||
createSearchablePicker(langWrap, items, applyLang, {
|
||||
placeholder: 'Language…',
|
||||
renderItem: (l, label) =>
|
||||
`<span class="ipi-code">${escHtml(l)}</span><span>${escHtml(LANGUAGE_LABELS[l] || l)}</span>`,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Gender label (subtitle) — click to cycle ──────────────────────────────
|
||||
const genderLabelEl = inspector.querySelector('.insp-gender-label');
|
||||
const genderSel = inspector.querySelector('.insp-gender-sel');
|
||||
const applyGender = async (ng) => {
|
||||
v.gender = ng;
|
||||
if (genderLabelEl) genderLabelEl.textContent = `${_gM[ng]||'?'} ${_gL[ng]||'—'}`;
|
||||
if (genderSel) genderSel.value = ng;
|
||||
const gBadge = wrap.querySelector('.gender-badge');
|
||||
if (gBadge) {
|
||||
gBadge.innerHTML = `<span class="gender-sym">${_gM[ng]||'?'}</span><span class="gender-txt">${_gL[ng]||'—'}</span>`;
|
||||
gBadge.className = 'gender-badge ' + (_gC[ng]||'g-n');
|
||||
}
|
||||
await saveMeta(voiceId, { gender: ng });
|
||||
};
|
||||
genderLabelEl?.addEventListener('click', () => {
|
||||
const cycle = ['F','M','N'];
|
||||
applyGender(cycle[(cycle.indexOf(v.gender||'N')+1)%3]);
|
||||
});
|
||||
genderSel?.addEventListener('change', () => applyGender(genderSel.value));
|
||||
|
||||
// ── Tag input with reuse list ─────────────────────────────────────────────
|
||||
const tagInput = inspector.querySelector('.insp-tag-input');
|
||||
let _tagDl = document.getElementById('insp-tag-opts');
|
||||
if (!_tagDl) { _tagDl = document.createElement('datalist'); _tagDl.id = 'insp-tag-opts'; document.body.appendChild(_tagDl); }
|
||||
_tagDl.innerHTML = getStoredTags().map(t => `<option value="${escHtml(t)}">`).join('');
|
||||
|
||||
tagInput.addEventListener('change', () => {
|
||||
const t = tagInput.value.trim();
|
||||
if (t) { addStoredTag(t); _tagDl.innerHTML = getStoredTags().map(x => `<option value="${escHtml(x)}">`).join(''); }
|
||||
});
|
||||
tagInput.addEventListener('input', debounce(async () => {
|
||||
v.tag = tagInput.value;
|
||||
await saveMeta(voiceId, { tag: tagInput.value });
|
||||
}, 800));
|
||||
|
||||
// ── Interactive rating (subtitle + meta kept in sync) ─────────────────────
|
||||
const updateRating = async (newRating) => {
|
||||
v.rating = newRating; wrap.dataset.rating = newRating;
|
||||
inspector.querySelectorAll('.insp-star').forEach(s =>
|
||||
s.classList.toggle('on', parseInt(s.dataset.val) <= newRating));
|
||||
inspector.querySelectorAll('.insp-rating-label, .insp-meta-rating-label').forEach(el =>
|
||||
el.textContent = `${newRating}/5`);
|
||||
wrap.querySelectorAll('.vr-rating .star').forEach((s, i) =>
|
||||
s.classList.toggle('on', i < newRating));
|
||||
await saveMeta(voiceId, { rating: newRating });
|
||||
};
|
||||
['.insp-stars', '.insp-meta-stars'].forEach(sel => {
|
||||
const stars = [...inspector.querySelectorAll(`${sel} .insp-star`)];
|
||||
stars.forEach(s => {
|
||||
s.addEventListener('click', () => {
|
||||
const val = parseInt(s.dataset.val);
|
||||
updateRating(val === (v.rating||0) ? 0 : val);
|
||||
});
|
||||
s.addEventListener('mouseenter', () => {
|
||||
const val = parseInt(s.dataset.val);
|
||||
stars.forEach(ss => ss.classList.toggle('on', parseInt(ss.dataset.val) <= val));
|
||||
});
|
||||
s.addEventListener('mouseleave', () =>
|
||||
stars.forEach(ss => ss.classList.toggle('on', parseInt(ss.dataset.val) <= (v.rating||0))));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Note row ──────────────────────────────────────────────────────────────
|
||||
const noteEl = detailRow?.querySelector('.vr-note');
|
||||
const noteRowEl = inspector.querySelector('.insp-note-slot');
|
||||
if (noteEl && noteRowEl) noteRowEl.appendChild(noteEl);
|
||||
|
||||
// ── Move main-row and optimizer into inspector body ───────────────────────
|
||||
const mainRow = wrap.querySelector('.vr-main-row');
|
||||
const optimizer = wrap.querySelector('.vr-optimizer');
|
||||
if (mainRow) body.appendChild(mainRow);
|
||||
if (optimizer) body.appendChild(optimizer);
|
||||
|
||||
if (wrap._loadOptimizer) wrap._loadOptimizer().catch(e => console.warn('Auto-load waveform failed:', e));
|
||||
|
||||
const maintTitle = body.querySelector('.opt-maintenance .opt-group-title');
|
||||
if (maintTitle) {
|
||||
maintTitle.innerHTML = `Loudness <span class="opt-group-meta">Current ${escHtml(dbfs)} dBFS</span>`;
|
||||
}
|
||||
|
||||
// Track extracted elements for restoreToRow
|
||||
wrap._extracted = [
|
||||
activeEl ? {el: activeEl, target: detailRow} : null,
|
||||
deleteEl ? {el: deleteEl, target: detailRow} : null,
|
||||
noteEl ? {el: noteEl, target: detailRow} : null,
|
||||
].filter(Boolean);
|
||||
|
||||
}
|
||||
let _toastTimer;
|
||||
function toast(msg, type = '') {
|
||||
const el = $('toast'); el.textContent = msg; el.className = 'show ' + type;
|
||||
@ -188,6 +504,66 @@ const ALL_FLAGS = uniqueFlagOptions([ENGLISH_FLAGS, EUROPE_FLAGS, ASIA_FLAGS, LA
|
||||
['EG','EGY - Egyptian'], ['MA','MAR - Moroccan'], ['ZA','ZAF - South African'], ['NG','NGA - Nigerian'], ['KE','KEN - Kenyan'], ['GH','GHA - Ghanaian'],
|
||||
]]);
|
||||
|
||||
// ── Searchable picker dropdown ────────────────────────────────────────────
|
||||
function createSearchablePicker(anchor, items, onSelect, { placeholder = 'Search…', renderItem } = {}) {
|
||||
document.querySelector('.insp-picker')?.remove();
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'insp-picker';
|
||||
|
||||
const search = document.createElement('input');
|
||||
search.type = 'search'; search.className = 'insp-picker-search';
|
||||
search.placeholder = placeholder; search.autocomplete = 'off';
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'insp-picker-list';
|
||||
|
||||
const renderList = (q) => {
|
||||
const f = q.trim().toLowerCase();
|
||||
const filtered = f
|
||||
? items.filter(([code, name]) => name.toLowerCase().includes(f) || code.toLowerCase().includes(f))
|
||||
: items;
|
||||
list.innerHTML = '';
|
||||
if (!filtered.length) { list.innerHTML = '<div class="insp-picker-empty">No matches</div>'; return; }
|
||||
filtered.forEach(([code, name]) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'insp-picker-item'; btn.type = 'button';
|
||||
btn.innerHTML = renderItem ? renderItem(code, name) : `<span class="ipi-code">${escHtml(code)}</span><span>${escHtml(name)}</span>`;
|
||||
btn.addEventListener('mousedown', (e) => { e.preventDefault(); onSelect(code); panel.remove(); cleanup(); });
|
||||
list.appendChild(btn);
|
||||
});
|
||||
};
|
||||
search.addEventListener('input', () => renderList(search.value));
|
||||
renderList('');
|
||||
|
||||
panel.appendChild(search); panel.appendChild(list);
|
||||
document.body.appendChild(panel);
|
||||
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const pw = 244;
|
||||
let left = rect.left;
|
||||
if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8;
|
||||
panel.style.top = (rect.bottom + 4) + 'px';
|
||||
panel.style.left = Math.max(8, left) + 'px';
|
||||
|
||||
const cleanup = () => { document.removeEventListener('mousedown', onOut); document.removeEventListener('keydown', onKey); };
|
||||
const onOut = (e) => { if (!panel.contains(e.target)) { panel.remove(); cleanup(); } };
|
||||
const onKey = (e) => { if (e.key === 'Escape') { panel.remove(); cleanup(); } };
|
||||
setTimeout(() => { document.addEventListener('mousedown', onOut); document.addEventListener('keydown', onKey); }, 0);
|
||||
search.focus();
|
||||
}
|
||||
|
||||
// ── Tag reuse (localStorage) ──────────────────────────────────────────────
|
||||
const _TAG_KEY = 'ttsvc-voice-tags';
|
||||
function getStoredTags() {
|
||||
try { return JSON.parse(localStorage.getItem(_TAG_KEY) || '[]'); } catch { return []; }
|
||||
}
|
||||
function addStoredTag(tag) {
|
||||
const t = tag.trim(); if (!t) return;
|
||||
const tags = getStoredTags().filter(x => x !== t);
|
||||
tags.unshift(t);
|
||||
try { localStorage.setItem(_TAG_KEY, JSON.stringify(tags.slice(0, 60))); } catch {}
|
||||
}
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function disabledBackendTabMessage(tab) {
|
||||
@ -1247,10 +1623,13 @@ function ttsBackendOptions(selected = '') {
|
||||
return backends.map(b => `<option value="${escHtml(b.id)}" ${b.id === current ? 'selected' : ''}>${escHtml(b.label)}</option>`).join('');
|
||||
}
|
||||
|
||||
function styleBackendOptions(selected = 'customvoice') {
|
||||
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 : backends[0].id;
|
||||
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('');
|
||||
}
|
||||
|
||||
@ -1281,7 +1660,16 @@ function updateBackendHelp() {
|
||||
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) box.innerHTML = backendHelpHtml(backendById(sel.value), true);
|
||||
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">⚠ This backend ignores the style instruction — output will sound the same regardless of what you type.${suggestion}</div>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -2694,6 +3082,7 @@ async function loadVoiceLibrary() {
|
||||
const r = await fetch('/api/voices');
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
|
||||
_voices = await r.json();
|
||||
if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices);
|
||||
renderVoiceList();
|
||||
updatePreviewVoiceMatchPanel();
|
||||
status(`Loaded ${_voices.length} voices`);
|
||||
@ -3576,7 +3965,24 @@ function renderVoiceList() {
|
||||
|
||||
populateLibraryFilters();
|
||||
readLibraryFilters();
|
||||
let filtered = _voices.filter(v => showDisabled || v.enabled !== false);
|
||||
|
||||
// Apply sidebar category filter
|
||||
const cat = window._voiceSidebarCat || 'all';
|
||||
let filtered = _voices.filter(v => {
|
||||
if (cat === 'cloned') return v.has_ref;
|
||||
if (cat === 'designed') return !v.has_ref;
|
||||
if (cat === 'favorites') return (v.rating || 0) >= 4;
|
||||
if (cat === 'hidden') return v.enabled === false;
|
||||
if (cat === 'tools') return false; // no rows for tools view
|
||||
// 'all' — respect show-disabled toggle
|
||||
return showDisabled || v.enabled !== false;
|
||||
});
|
||||
// For hidden/favorites cats always show all regardless of show-disabled toggle
|
||||
if (cat !== 'all' && cat !== 'tools') {
|
||||
// already filtered above — no extra enabled filter needed
|
||||
} else if (cat === 'all') {
|
||||
// already applied enabled filter in the lambda above
|
||||
}
|
||||
const visibleCount = filtered.length;
|
||||
filtered = filtered.filter(libraryFilterMatch);
|
||||
const filterCount = filtered.length;
|
||||
@ -3599,7 +4005,67 @@ function renderVoiceList() {
|
||||
list.appendChild(note);
|
||||
}
|
||||
if (!filtered.length) {
|
||||
list.innerHTML += '<div style="padding:14px;color:var(--subtext);font-size:14px">No voices found.</div>';
|
||||
if (_voices.length === 0) {
|
||||
// True empty — render a two-case helper panel
|
||||
const emptyEl = document.createElement('div');
|
||||
emptyEl.className = 'voices-empty-state';
|
||||
emptyEl.innerHTML = `
|
||||
<div class="voices-empty-cases">
|
||||
<div class="voices-empty-case">
|
||||
<div class="voices-empty-case-icon">📁</div>
|
||||
<h4>Wrong folder path?</h4>
|
||||
<p>Set the path where your <code>.wav</code> voice files live <em>inside the container</em>.</p>
|
||||
<div class="voices-folder-row">
|
||||
<input id="voices-empty-scan-dir" type="text" class="voices-folder-input" placeholder="/voices">
|
||||
<button class="btn-primary" id="voices-empty-save-btn">Set & reload</button>
|
||||
</div>
|
||||
<p class="voices-empty-hint">Map a host folder via docker-compose:<br>
|
||||
<code>- /your/host/path:/voices:rw</code><br>
|
||||
or set <code>VOICE_HOST_DIR=/your/host/path</code> in the stack env.</p>
|
||||
</div>
|
||||
<div class="voices-empty-divider">or</div>
|
||||
<div class="voices-empty-case">
|
||||
<div class="voices-empty-case-icon">🎤</div>
|
||||
<h4>Folder is empty?</h4>
|
||||
<p>Create your first voice from a recording or download ready-made voices.</p>
|
||||
<div class="voices-empty-actions">
|
||||
<button class="btn-primary voices-empty-action-btn" id="voices-goto-clone">⚡ Clone a Voice</button>
|
||||
<button class="btn-secondary voices-empty-action-btn" id="voices-goto-studio">🌐 Get Voices Online</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(emptyEl);
|
||||
|
||||
fetch('/api/settings').then(r => r.json()).then(s => {
|
||||
const inp = document.getElementById('voices-empty-scan-dir');
|
||||
if (inp) inp.value = s.voices_scan_dir || '/voices';
|
||||
}).catch(() => {});
|
||||
|
||||
document.getElementById('voices-empty-save-btn')?.addEventListener('click', async () => {
|
||||
const inp = document.getElementById('voices-empty-scan-dir');
|
||||
const dir = inp?.value?.trim();
|
||||
if (!dir) return;
|
||||
const btn = document.getElementById('voices-empty-save-btn');
|
||||
btn.disabled = true; btn.textContent = 'Saving…';
|
||||
try {
|
||||
const s = await fetch('/api/settings').then(r => r.json());
|
||||
s.voices_scan_dir = dir;
|
||||
const r = await fetch('/api/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(s) });
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
|
||||
toast('Folder saved — reloading voices…', 'success');
|
||||
_libraryLoadPromise = null;
|
||||
await loadVoiceLibrary();
|
||||
} catch(e) {
|
||||
toast('Failed: ' + e.message, 'error');
|
||||
btn.disabled = false; btn.textContent = 'Set & reload';
|
||||
}
|
||||
});
|
||||
document.getElementById('voices-goto-clone')?.addEventListener('click', () => navTo('s-clone'));
|
||||
document.getElementById('voices-goto-studio')?.addEventListener('click', () => navTo('s-studio'));
|
||||
} else {
|
||||
list.innerHTML += '<div style="padding:14px;color:var(--subtext);font-size:14px">No voices match the current filters.</div>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
filtered.forEach(v => list.appendChild(makeVoiceRow(v)));
|
||||
@ -3684,6 +4150,13 @@ function makeVoiceRow(v) {
|
||||
wrap.className = 'vl-row' + (v.enabled===false ? ' vr-disabled' : '');
|
||||
wrap.dataset.id = v.id;
|
||||
|
||||
// Data attrs used by inspector header
|
||||
const color = avatarColor(v.id);
|
||||
wrap.dataset.color = color;
|
||||
wrap.dataset.hasRef = v.has_ref ? 'true' : 'false';
|
||||
wrap.dataset.dbfs = fmtDbfs(v);
|
||||
wrap.dataset.lang = v.lang || v.id.split('_')[0].toUpperCase();
|
||||
|
||||
const langCode = v.lang || v.id.split('_')[0].toUpperCase();
|
||||
const langOpts = FLAG_OPTIONS[langCode] || [];
|
||||
// Use language-specific variants if there are multiple; fall back to world picker otherwise
|
||||
@ -3691,6 +4164,10 @@ function makeVoiceRow(v) {
|
||||
const currentFlag = v.flag || LANG_FLAG_DEFAULT[langCode] || '';
|
||||
const flagEmoji = currentFlag ? cc2flag(currentFlag) : '🌐';
|
||||
const flagCode = currentFlag ? ccDisplay(currentFlag) : '?';
|
||||
wrap.dataset.flagEmoji = flagEmoji;
|
||||
wrap.dataset.flagCc = currentFlag || '';
|
||||
wrap.dataset.rating = v.rating || 0;
|
||||
wrap.dataset.hasPicture = v.has_picture ? 'true' : 'false';
|
||||
const fileType = voiceFileType(v);
|
||||
const dbfs = fmtDbfs(v);
|
||||
const dbTitle = v.loudness ? `avg ${dbfs} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : '';
|
||||
@ -3699,6 +4176,7 @@ function makeVoiceRow(v) {
|
||||
const benchCls = benchmarkClass(v);
|
||||
|
||||
const genderMap = {F:'♀', M:'♂', N:'⚥', '':'?'};
|
||||
const genderLabel = {F:'Female', M:'Male', N:'Diverse', '':'—'};
|
||||
const genderClass = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'};
|
||||
const gender = v.gender || '';
|
||||
|
||||
@ -3714,7 +4192,36 @@ function makeVoiceRow(v) {
|
||||
).join('')
|
||||
: `<span style="font-size:12px;color:var(--subtext);padding:3px 6px">No regional variants</span>`;
|
||||
|
||||
// Compact list item elements (visible in list pane, hidden in inspector)
|
||||
const isClone = v.has_ref;
|
||||
const initial = v.id[0] ? v.id[0].toUpperCase() : '?';
|
||||
const flagIconHtml = currentFlag
|
||||
? `<span class="fi fi-${currentFlag.toLowerCase()}" role="img" aria-label="${langCode}"></span>`
|
||||
: null;
|
||||
|
||||
// Avatar: photo > flag icon > initial letter (with color)
|
||||
const avatarClass = picSrc ? 'vl-avatar vl-avatar-photo' : flagIconHtml ? 'vl-avatar vl-avatar-flag' : 'vl-avatar vl-avatar-initial';
|
||||
const avatarStyle = picSrc || flagIconHtml ? '' : `style="background:${color}"`;
|
||||
const avatarContent = picSrc
|
||||
? `<img src="${picSrc}" alt="" class="vl-avatar-img">`
|
||||
: flagIconHtml || `<span class="vl-avatar-letter" style="color:#fff;background:${color}">${initial}</span>`;
|
||||
|
||||
wrap.innerHTML = `
|
||||
<div class="vl-compact">
|
||||
<div class="${avatarClass}" ${avatarStyle}>${avatarContent}</div>
|
||||
<div class="vl-info">
|
||||
<span class="vl-name">${escHtml(v.id)}</span>
|
||||
<div class="vl-meta">
|
||||
<span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span>
|
||||
${gender ? `<span class="vl-gender-label ${genderClass[gender]||'g-n'}" title="${genderLabel[gender]||''}">${genderMap[gender]||'?'} ${genderLabel[gender]||''}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="vr-play-group">
|
||||
<div class="vr-play vr-play-original"><button class="play-btn-orig" title="Play original recording">▶</button></div>
|
||||
<div class="vr-play vr-play-synth"><button class="play-btn-synth" title="Generate and play TTS preview">▶</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vr-main-row">
|
||||
<div class="vr-photo" title="Click to upload photo">
|
||||
${picSrc ? `<img src="${picSrc}" alt="">` : '<div class="ph-icon">👤</div>'}
|
||||
@ -3729,7 +4236,10 @@ function makeVoiceRow(v) {
|
||||
</div>
|
||||
|
||||
<div class="vr-gender">
|
||||
<span class="gender-badge ${genderClass[gender]||'g-n'}" title="Click to cycle">${genderMap[gender]||'?'}</span>
|
||||
<span class="gender-badge ${genderClass[gender]||'g-n'}" title="Click to cycle">
|
||||
<span class="gender-sym">${genderMap[gender]||'?'}</span>
|
||||
<span class="gender-txt">${genderLabel[gender]||'—'}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="vr-name">
|
||||
@ -3801,7 +4311,7 @@ function makeVoiceRow(v) {
|
||||
</div>
|
||||
|
||||
<div class="vr-detail-delete">
|
||||
<button class="delete-btn" title="Delete voice permanently">🗑</button>
|
||||
<button class="delete-btn" title="Delete voice permanently">Delete voice</button>
|
||||
<div class="delete-confirm" role="group" aria-label="Confirm delete voice">
|
||||
<strong>Delete?</strong>
|
||||
<span title="${escHtml(v.id)}">${escHtml(v.id)}</span>
|
||||
@ -3813,8 +4323,8 @@ function makeVoiceRow(v) {
|
||||
|
||||
<div class="vr-optimizer">
|
||||
<div class="optimizer-grid">
|
||||
<div class="opt-group">
|
||||
<div class="opt-group-title">1 Reference audio trim</div>
|
||||
<div class="opt-group opt-trim-panel">
|
||||
<div class="opt-group-title">Reference audio · crop</div>
|
||||
<canvas class="opt-wave"></canvas>
|
||||
<div class="opt-controls">
|
||||
<div class="opt-field"><label>Start</label><input class="opt-start" type="number" step="0.01" value="0"></div>
|
||||
@ -3825,8 +4335,8 @@ function makeVoiceRow(v) {
|
||||
<button class="btn-secondary opt-undo">Undo crop</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="opt-group">
|
||||
<div class="opt-group-title">2 Reference text</div>
|
||||
<div class="opt-group opt-text-panel">
|
||||
<div class="opt-group-title">Reference transcript</div>
|
||||
<textarea class="opt-transcript" placeholder="Reference text">${escHtml(v.transcript || '')}</textarea>
|
||||
<div class="opt-controls">
|
||||
<button class="btn-secondary opt-recognize">Re-recognise text</button>
|
||||
@ -3834,7 +4344,7 @@ function makeVoiceRow(v) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="opt-group opt-compare-panel">
|
||||
<div class="opt-group-title">3 Voice match check</div>
|
||||
<div class="opt-group-title">Voice match</div>
|
||||
<div class="opt-controls">
|
||||
<div class="opt-field wide"><label>Comparison backend</label><select class="opt-compare-backend">${styleBackendOptions('voice_clone')}</select></div>
|
||||
<button class="btn-secondary opt-play-reference">Play WAV file</button>
|
||||
@ -3854,10 +4364,10 @@ function makeVoiceRow(v) {
|
||||
</div>
|
||||
<div class="opt-group opt-style-panel">
|
||||
<div>
|
||||
<div class="opt-group-title">4 Style variation</div>
|
||||
<div class="opt-group-title">Style variation</div>
|
||||
<div class="opt-controls">
|
||||
<div class="opt-field wide"><label>Style instruction</label><input class="opt-style-instruct" type="text" placeholder="cheerful, calm, excited"></div>
|
||||
<div class="opt-field wide"><label>Style backend</label><select class="opt-style-backend">${styleBackendOptions()}</select></div>
|
||||
<div class="opt-field wide"><label>Style backend</label><select class="opt-style-backend">${styleBackendOptions('customvoice', true)}</select></div>
|
||||
<div class="opt-field wide"><label>New voice ID</label><input class="opt-style-voice-id" type="text" placeholder="DE_F_Amala_happy"></div>
|
||||
</div>
|
||||
<p class="opt-group-note">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 <code>instruct</code>; Base/Streaming are fastest but often ignore style.</p>
|
||||
@ -3872,7 +4382,7 @@ function makeVoiceRow(v) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="opt-group opt-maintenance">
|
||||
<div class="opt-group-title">5 Volume and backend refresh</div>
|
||||
<div class="opt-group-title">Loudness</div>
|
||||
<div class="opt-controls">
|
||||
<div class="opt-field"><label>Target dBFS</label><input class="opt-target-db" type="number" step="0.5" value="-20"></div>
|
||||
<button class="btn-secondary opt-db-minus">-</button>
|
||||
@ -3972,8 +4482,8 @@ function makeVoiceRow(v) {
|
||||
gBadge.addEventListener('click', async () => {
|
||||
const cycle = ['F','M','N'];
|
||||
v.gender = cycle[(cycle.indexOf(v.gender||'F')+1)%3];
|
||||
gBadge.textContent = genderMap[v.gender];
|
||||
gBadge.className = 'gender-badge ' + genderClass[v.gender];
|
||||
gBadge.innerHTML = `<span class="gender-sym">${genderMap[v.gender]||'?'}</span><span class="gender-txt">${genderLabel[v.gender]||'—'}</span>`;
|
||||
gBadge.className = 'gender-badge ' + genderClass[v.gender];
|
||||
await saveMeta(v.id, { gender: v.gender });
|
||||
});
|
||||
|
||||
@ -4015,6 +4525,12 @@ function makeVoiceRow(v) {
|
||||
renameOk.addEventListener('click', doRename);
|
||||
nameInput.addEventListener('keydown', e => { if(e.key==='Enter') doRename(); if(e.key==='Escape') cancelRename(); });
|
||||
|
||||
// Select row → open inspector (click on compact area, but not on play/buttons)
|
||||
wrap.querySelector('.vl-compact').addEventListener('click', function (e) {
|
||||
if (e.target.closest('button')) return;
|
||||
selectVoice(wrap);
|
||||
});
|
||||
|
||||
// Inline optimizer in Library
|
||||
const editAudioBtn = wrap.querySelector('.edit-audio-btn');
|
||||
const optPanel = wrap.querySelector('.vr-optimizer');
|
||||
@ -4182,6 +4698,7 @@ function makeVoiceRow(v) {
|
||||
setVoiceRestartState(Boolean(v.needs_tts_restart));
|
||||
setOptStatus(v.needs_tts_restart ? 'Optimizer ready. Restart TTS before benchmarking this edit.' : 'Optimizer ready');
|
||||
};
|
||||
wrap._loadOptimizer = loadOptimizer;
|
||||
|
||||
editAudioBtn.addEventListener('click', async () => {
|
||||
editAudioBtn.disabled = true;
|
||||
@ -4573,12 +5090,16 @@ function makeVoiceRow(v) {
|
||||
try {
|
||||
if (kind === 'synth') {
|
||||
if (v.needs_tts_restart) toast('This voice changed since backend refresh; synthesized playback may use a cached voice.', 'error');
|
||||
const text = benchmarkSampleText();
|
||||
const synthMode = document.querySelector('#vl-synth-mode-seg .vl-synth-seg-btn.active')?.dataset.mode || 'preview';
|
||||
const text = synthMode === 'transcript'
|
||||
? (v.transcript?.trim() || benchmarkSampleText())
|
||||
: benchmarkSampleText();
|
||||
const textLabel = synthMode === 'transcript' ? 'reference transcript' : 'preview text';
|
||||
const backend = libraryTtsBackend();
|
||||
const source = await createTtsAudioSource(v.id, text, backend, 'settings', '');
|
||||
audio.src = source.url;
|
||||
if (!source.streaming) _activePlayUrl = source.url;
|
||||
$('lib-audio-label').textContent = v.id + ' · synthesized sample · ' + (backendById(backend)?.label || backend);
|
||||
$('lib-audio-label').textContent = v.id + ' · synthesized ' + textLabel + ' · ' + (backendById(backend)?.label || backend);
|
||||
} else {
|
||||
audio.src = voiceFileUrl(v);
|
||||
$('lib-audio-label').textContent = v.id + ' · original recording';
|
||||
@ -5214,6 +5735,33 @@ $('stt-tts-save-wav-btn')?.addEventListener('click', () => {
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
initBenchmarkSampleControls();
|
||||
// Sync visible preview-text input with hidden benchmark-sample-text
|
||||
(function syncPreviewInput() {
|
||||
const visible = $('vl-preview-sample');
|
||||
const hidden = $('benchmark-sample-text');
|
||||
if (!visible) return;
|
||||
const stored = localStorage.getItem(BENCHMARK_SAMPLE_STORAGE_KEY);
|
||||
visible.value = stored || DEFAULT_BENCHMARK_SAMPLE_TEXT;
|
||||
if (hidden) hidden.value = visible.value;
|
||||
visible.addEventListener('input', () => {
|
||||
if (hidden) hidden.value = visible.value;
|
||||
localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, visible.value.trim());
|
||||
});
|
||||
})();
|
||||
|
||||
// Synth mode segmented control (preview text vs. reference transcript)
|
||||
(function initSynthMode() {
|
||||
const seg = $('vl-synth-mode-seg');
|
||||
const wrap = $('vl-preview-text-wrap');
|
||||
if (!seg) return;
|
||||
seg.querySelectorAll('.vl-synth-seg-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
seg.querySelectorAll('.vl-synth-seg-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
if (wrap) wrap.classList.toggle('hidden', btn.dataset.mode === 'transcript');
|
||||
});
|
||||
});
|
||||
})();
|
||||
loadSettings().then(() => {
|
||||
refreshSttBackends();
|
||||
renderIntegrationSnippets();
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
<!-- WaveSurfer must load synchronously before app.js initialises audio -->
|
||||
<script src="https://unpkg.com/wavesurfer.js@7/dist/wavesurfer.min.js"></script>
|
||||
<script src="https://unpkg.com/wavesurfer.js@7/dist/plugins/regions.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flag-icons@7.3.2/css/flag-icons.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
@ -36,8 +37,19 @@
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-group-label">Voices</div>
|
||||
<div class="nav-item active" data-nav-section="s-voices" onclick="navTo('s-voices')">
|
||||
<span class="nav-icon">🔊</span> My Voices
|
||||
<div class="nav-item nav-tree-head active" data-nav-section="s-voices" id="nav-voices-head" onclick="navTo('s-voices')">
|
||||
<span class="nav-icon">🔊</span>
|
||||
<span class="nav-label">My Voices</span>
|
||||
<span class="nav-badge" id="nav-voices-count"></span>
|
||||
<span class="nav-chevron" id="nav-voices-chevron">⌄</span>
|
||||
</div>
|
||||
<div class="nav-tree open" id="nav-voices-tree">
|
||||
<div class="nav-tree-item is-active" data-voice-cat="all" onclick="navVoicesCat('all')">All voices <span class="ntc" id="ntc-all"></span></div>
|
||||
<div class="nav-tree-item" data-voice-cat="cloned" onclick="navVoicesCat('cloned')">Cloned <span class="ntc" id="ntc-cloned"></span></div>
|
||||
<div class="nav-tree-item" data-voice-cat="designed" onclick="navVoicesCat('designed')">Designed <span class="ntc" id="ntc-designed"></span></div>
|
||||
<div class="nav-tree-item" data-voice-cat="favorites" onclick="navVoicesCat('favorites')">Favorites <span class="ntc" id="ntc-favorites"></span></div>
|
||||
<div class="nav-tree-item" data-voice-cat="hidden" onclick="navVoicesCat('hidden')">Hidden <span class="ntc" id="ntc-hidden"></span></div>
|
||||
<div class="nav-tree-item" data-voice-cat="tools" onclick="navVoicesCat('tools')">Library tools</div>
|
||||
</div>
|
||||
<div class="nav-item" data-nav-section="s-clone" onclick="navTo('s-clone')">
|
||||
<span class="nav-icon">🎤</span> Clone a Voice
|
||||
@ -53,6 +65,9 @@
|
||||
</div>
|
||||
|
||||
<div class="nav-group-label" style="margin-top:6px">Setup</div>
|
||||
<div class="nav-item" data-nav-section="s-llms" onclick="navTo('s-llms')">
|
||||
<span class="nav-icon">🧠</span> LLMs
|
||||
</div>
|
||||
<div class="nav-item" data-nav-section="s-routing" onclick="navTo('s-routing')">
|
||||
<span class="nav-icon">⇌</span> App Routing
|
||||
</div>
|
||||
@ -80,6 +95,7 @@
|
||||
<section class="page-section" id="s-routing"></section>
|
||||
<section class="page-section" id="s-connect"></section>
|
||||
<section class="page-section" id="s-settings"></section>
|
||||
<section class="page-section" id="s-llms"></section>
|
||||
</main>
|
||||
|
||||
</div><!-- /app-shell -->
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
(async function () {
|
||||
'use strict';
|
||||
|
||||
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-routing', 's-connect', 's-settings'];
|
||||
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-routing', 's-connect', 's-settings', 's-llms'];
|
||||
|
||||
function loadScript(src) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
|
||||
@ -13,28 +13,13 @@
|
||||
routing: 's-routing',
|
||||
integrations: 's-connect',
|
||||
howto: 's-connect',
|
||||
settings: 's-settings'
|
||||
settings: 's-settings',
|
||||
llms: 's-llms'
|
||||
};
|
||||
|
||||
function updateNavActive(sectionId) {
|
||||
document.querySelectorAll('[data-nav-section]').forEach(function (item) {
|
||||
item.classList.toggle('active', item.dataset.navSection === sectionId);
|
||||
});
|
||||
}
|
||||
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-routing', 's-connect', 's-settings', 's-llms'];
|
||||
|
||||
window.navTo = function (sectionId) {
|
||||
var el = document.getElementById(sectionId);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
updateNavActive(sectionId);
|
||||
// Fire lazy-loader side-effects
|
||||
var tabName = Object.keys(TAB_SECTION_MAP).find(function (k) {
|
||||
return TAB_SECTION_MAP[k] === sectionId;
|
||||
});
|
||||
if (tabName) window.switchTab(tabName);
|
||||
};
|
||||
|
||||
// Override switchTab — redirect tab switches to scroll-based navigation
|
||||
window.switchTab = function (name) {
|
||||
function runSideEffects(name) {
|
||||
if (name === 'library' && typeof loadVoiceLibrary === 'function') loadVoiceLibrary();
|
||||
if ((name === 'integrations' || name === 'howto') && typeof renderIntegrationSnippets === 'function') {
|
||||
if (typeof loadVoiceLibrary === 'function' && !(window._voices && window._voices.length)) loadVoiceLibrary();
|
||||
@ -42,26 +27,67 @@
|
||||
}
|
||||
if (name === 'routing' && typeof loadRoutingTab === 'function') loadRoutingTab();
|
||||
if (name === 'getvoices' && typeof loadGetVoices === 'function') loadGetVoices();
|
||||
}
|
||||
|
||||
function showSection(sectionId) {
|
||||
SECTIONS.forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.classList.toggle('is-active', id === sectionId);
|
||||
});
|
||||
document.querySelectorAll('[data-nav-section]').forEach(function (item) {
|
||||
item.classList.toggle('active', item.dataset.navSection === sectionId);
|
||||
});
|
||||
// Expand tree only when voices section is active
|
||||
var tree = document.getElementById('nav-voices-tree');
|
||||
var chevron = document.getElementById('nav-voices-chevron');
|
||||
var isVoices = sectionId === 's-voices';
|
||||
if (tree) tree.classList.toggle('open', isVoices);
|
||||
if (chevron) chevron.classList.toggle('open', isVoices);
|
||||
|
||||
var main = document.getElementById('main-content');
|
||||
if (main) main.scrollTop = 0;
|
||||
}
|
||||
|
||||
window.navTo = function (sectionId) {
|
||||
showSection(sectionId);
|
||||
var tabName = Object.keys(TAB_SECTION_MAP).find(function (k) {
|
||||
return TAB_SECTION_MAP[k] === sectionId;
|
||||
});
|
||||
if (tabName) runSideEffects(tabName);
|
||||
};
|
||||
|
||||
window.switchTab = function (name) {
|
||||
var sectionId = TAB_SECTION_MAP[name];
|
||||
if (sectionId) {
|
||||
var el = document.getElementById(sectionId);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
updateNavActive(sectionId);
|
||||
}
|
||||
if (sectionId) showSection(sectionId);
|
||||
runSideEffects(name);
|
||||
return true;
|
||||
};
|
||||
|
||||
// IntersectionObserver: highlight sidebar item for the section in view
|
||||
var sections = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-routing', 's-connect', 's-settings'];
|
||||
var io = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
if (entry.isIntersecting) updateNavActive(entry.target.id);
|
||||
});
|
||||
}, { threshold: 0.15 });
|
||||
// ── Voice tree sub-navigation ─────────────────────────────────────────────
|
||||
window._voiceSidebarCat = 'all';
|
||||
|
||||
sections.forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) io.observe(el);
|
||||
});
|
||||
window.navVoicesCat = function (cat) {
|
||||
navTo('s-voices');
|
||||
window._voiceSidebarCat = cat;
|
||||
document.querySelectorAll('[data-voice-cat]').forEach(function (el) {
|
||||
el.classList.toggle('is-active', el.dataset.voiceCat === cat);
|
||||
});
|
||||
if (typeof renderVoiceList === 'function') renderVoiceList();
|
||||
};
|
||||
|
||||
window.updateVoiceTree = function (voices) {
|
||||
if (!voices) return;
|
||||
function n(fn) { return voices.filter(fn).length; }
|
||||
function set(id, v) { var el = document.getElementById(id); if (el) el.textContent = v || ''; }
|
||||
set('ntc-all', n(function () { return true; }));
|
||||
set('ntc-cloned', n(function (v) { return v.has_ref; }));
|
||||
set('ntc-designed', n(function (v) { return !v.has_ref; }));
|
||||
set('ntc-favorites',n(function (v) { return (v.rating || 0) >= 4; }));
|
||||
set('ntc-hidden', n(function (v) { return v.enabled === false; }));
|
||||
set('nav-voices-count', voices.length);
|
||||
};
|
||||
|
||||
// Show default section on load
|
||||
showSection('s-voices');
|
||||
runSideEffects('library');
|
||||
})();
|
||||
|
||||
634
static/sections/s-llms.html
Normal file
634
static/sections/s-llms.html
Normal file
@ -0,0 +1,634 @@
|
||||
<div class="section-head">
|
||||
<span class="section-icon">🤖</span>
|
||||
<div class="section-title">
|
||||
<h2>AI Backends</h2>
|
||||
<p>Connect cloud or local services for speech recognition, synthesis, and text generation.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category filter tabs -->
|
||||
<div class="llm-cat-tabs" id="llm-cat-tabs">
|
||||
<button class="llm-cat-tab active" data-llm-cat="stt">🎤 ASR · STT</button>
|
||||
<button class="llm-cat-tab" data-llm-cat="tts">🔊 TTS</button>
|
||||
<button class="llm-cat-tab" data-llm-cat="llm">🤖 LLM</button>
|
||||
<button class="llm-cat-tab" data-llm-cat="local">💻 Local</button>
|
||||
</div>
|
||||
|
||||
<!-- ── STT section ─────────────────────────────────────────── -->
|
||||
<div class="llm-section" id="llm-sec-stt" data-llm-section="stt">
|
||||
<div class="llm-sec-header">
|
||||
<div>
|
||||
<h3 class="llm-sec-title">ASR · Speech-to-Text</h3>
|
||||
<p class="llm-sec-note">Used for auto-transcribing reference audio. The app calls these when you click <em>Re-recognise text</em>.</p>
|
||||
</div>
|
||||
<span class="llm-free-badge">Free tiers available</span>
|
||||
</div>
|
||||
|
||||
<div class="llm-service-grid">
|
||||
|
||||
<div class="llm-card llm-card-highlight">
|
||||
<div class="llm-card-head">
|
||||
<span class="llm-card-icon">⚡</span>
|
||||
<div>
|
||||
<div class="llm-card-name">Groq Whisper</div>
|
||||
<div class="llm-card-sub">whisper-large-v3-turbo · OpenAI-compatible</div>
|
||||
</div>
|
||||
<span class="llm-tier-badge llm-tier-free">Free</span>
|
||||
</div>
|
||||
<div class="llm-card-stats">
|
||||
<span>2 000 req / day</span>
|
||||
<span>Fastest inference</span>
|
||||
<span>OpenAI API format</span>
|
||||
</div>
|
||||
<div class="llm-field-row">
|
||||
<label class="llm-label">API key</label>
|
||||
<input type="password" class="llm-input" placeholder="gsk_…" data-llm-key="groq_stt">
|
||||
<a class="llm-link" href="https://console.groq.com" target="_blank" rel="noopener">Get key ↗</a>
|
||||
</div>
|
||||
<div class="llm-endpoint">
|
||||
<span class="llm-endpoint-label">Endpoint</span>
|
||||
<code>https://api.groq.com/openai/v1</code>
|
||||
</div>
|
||||
<div class="llm-models">
|
||||
<span class="llm-model-tag">whisper-large-v3-turbo</span>
|
||||
<span class="llm-model-tag">whisper-large-v3</span>
|
||||
<span class="llm-model-tag">distil-whisper-large-v3-en</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="llm-card">
|
||||
<div class="llm-card-head">
|
||||
<span class="llm-card-icon">🤗</span>
|
||||
<div>
|
||||
<div class="llm-card-name">HuggingFace Inference</div>
|
||||
<div class="llm-card-sub">Serverless Whisper models</div>
|
||||
</div>
|
||||
<span class="llm-tier-badge llm-tier-free">Free</span>
|
||||
</div>
|
||||
<div class="llm-card-stats">
|
||||
<span>~1 000 req / day</span>
|
||||
<span>Slower cold starts</span>
|
||||
<span>Many model variants</span>
|
||||
</div>
|
||||
<div class="llm-field-row">
|
||||
<label class="llm-label">API key</label>
|
||||
<input type="password" class="llm-input" placeholder="hf_…" data-llm-key="hf_stt">
|
||||
<a class="llm-link" href="https://huggingface.co/settings/tokens" target="_blank" rel="noopener">Get key ↗</a>
|
||||
</div>
|
||||
<div class="llm-endpoint">
|
||||
<span class="llm-endpoint-label">Endpoint</span>
|
||||
<code>https://api-inference.huggingface.co/models/openai/whisper-large-v3</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="llm-card">
|
||||
<div class="llm-card-head">
|
||||
<span class="llm-card-icon">📋</span>
|
||||
<div>
|
||||
<div class="llm-card-name">AssemblyAI</div>
|
||||
<div class="llm-card-sub">High-accuracy transcription + speaker diarization</div>
|
||||
</div>
|
||||
<span class="llm-tier-badge llm-tier-free">Free</span>
|
||||
</div>
|
||||
<div class="llm-card-stats">
|
||||
<span>100 h lifetime</span>
|
||||
<span>Speaker labels</span>
|
||||
<span>Auto-chapters</span>
|
||||
</div>
|
||||
<div class="llm-field-row">
|
||||
<label class="llm-label">API key</label>
|
||||
<input type="password" class="llm-input" placeholder="AssemblyAI key…" data-llm-key="assemblyai">
|
||||
<a class="llm-link" href="https://www.assemblyai.com" target="_blank" rel="noopener">Get key ↗</a>
|
||||
</div>
|
||||
<div class="llm-endpoint">
|
||||
<span class="llm-endpoint-label">Endpoint</span>
|
||||
<code>https://api.assemblyai.com/v2/transcript</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div><!-- /stt -->
|
||||
|
||||
<!-- ── TTS section ─────────────────────────────────────────── -->
|
||||
<div class="llm-section" id="llm-sec-tts" data-llm-section="tts" hidden>
|
||||
<div class="llm-sec-header">
|
||||
<div>
|
||||
<h3 class="llm-sec-title">Text-to-Speech</h3>
|
||||
<p class="llm-sec-note">Cloud TTS backends you can add as routing targets alongside your local cloned voices.</p>
|
||||
</div>
|
||||
<span class="llm-free-badge">Free tiers available</span>
|
||||
</div>
|
||||
|
||||
<div class="llm-service-grid">
|
||||
|
||||
<div class="llm-card llm-card-highlight">
|
||||
<div class="llm-card-head">
|
||||
<span class="llm-card-icon">💉</span>
|
||||
<div>
|
||||
<div class="llm-card-name">ElevenLabs</div>
|
||||
<div class="llm-card-sub">High-quality voice cloning & synthesis</div>
|
||||
</div>
|
||||
<span class="llm-tier-badge llm-tier-free">Free</span>
|
||||
</div>
|
||||
<div class="llm-card-stats">
|
||||
<span>10 000 chars / month</span>
|
||||
<span>2 500 char / request max</span>
|
||||
<span>Voice cloning supported</span>
|
||||
</div>
|
||||
<div class="llm-field-row">
|
||||
<label class="llm-label">API key</label>
|
||||
<input type="password" class="llm-input" placeholder="xi-api-key…" data-llm-key="elevenlabs">
|
||||
<a class="llm-link" href="https://elevenlabs.io" target="_blank" rel="noopener">Get key ↗</a>
|
||||
</div>
|
||||
<div class="llm-endpoint">
|
||||
<span class="llm-endpoint-label">Endpoint</span>
|
||||
<code>https://api.elevenlabs.io/v1/text-to-speech</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="llm-card">
|
||||
<div class="llm-card-head">
|
||||
<span class="llm-card-icon">🏭</span>
|
||||
<div>
|
||||
<div class="llm-card-name">Fish Audio</div>
|
||||
<div class="llm-card-sub">Voice cloning & multilingual TTS</div>
|
||||
</div>
|
||||
<span class="llm-tier-badge llm-tier-free">Free</span>
|
||||
</div>
|
||||
<div class="llm-card-stats">
|
||||
<span>1 h audio / month</span>
|
||||
<span>100 req / min</span>
|
||||
<span>30+ languages</span>
|
||||
</div>
|
||||
<div class="llm-field-row">
|
||||
<label class="llm-label">API key</label>
|
||||
<input type="password" class="llm-input" placeholder="Fish Audio key…" data-llm-key="fish_audio">
|
||||
<a class="llm-link" href="https://fish.audio" target="_blank" rel="noopener">Get key ↗</a>
|
||||
</div>
|
||||
<div class="llm-endpoint">
|
||||
<span class="llm-endpoint-label">Endpoint</span>
|
||||
<code>https://api.fish.audio/v1/tts</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="llm-card">
|
||||
<div class="llm-card-head">
|
||||
<span class="llm-card-icon">🎵</span>
|
||||
<div>
|
||||
<div class="llm-card-name">Kokoro TTS</div>
|
||||
<div class="llm-card-sub">82M model · HuggingFace Spaces demo</div>
|
||||
</div>
|
||||
<span class="llm-tier-badge llm-tier-demo">Demo</span>
|
||||
</div>
|
||||
<div class="llm-card-stats">
|
||||
<span>Free web demo</span>
|
||||
<span><$1 per 1M chars (paid)</span>
|
||||
<span>High naturalness</span>
|
||||
</div>
|
||||
<div class="llm-info-box">
|
||||
Use the HF Spaces web demo for quick tests, or run Kokoro locally via Docker for production use.
|
||||
<br>
|
||||
<a class="llm-link-inline" href="https://huggingface.co/spaces/hexgrad/Kokoro-TTS" target="_blank" rel="noopener">→ Open Kokoro HF Space</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div><!-- /tts -->
|
||||
|
||||
<!-- ── LLM section ─────────────────────────────────────────── -->
|
||||
<div class="llm-section" id="llm-sec-llm" data-llm-section="llm" hidden>
|
||||
<div class="llm-sec-header">
|
||||
<div>
|
||||
<h3 class="llm-sec-title">Large Language Models</h3>
|
||||
<p class="llm-sec-note">Use LLMs to generate text for TTS, write scripts, clean transcripts, or power agentic voice pipelines.</p>
|
||||
</div>
|
||||
<span class="llm-free-badge">Free tiers available</span>
|
||||
</div>
|
||||
|
||||
<div class="llm-service-grid">
|
||||
|
||||
<div class="llm-card llm-card-highlight">
|
||||
<div class="llm-card-head">
|
||||
<span class="llm-card-icon">⚡</span>
|
||||
<div>
|
||||
<div class="llm-card-name">Groq</div>
|
||||
<div class="llm-card-sub">Ultra-fast inference · OpenAI-compatible</div>
|
||||
</div>
|
||||
<span class="llm-tier-badge llm-tier-free">Free</span>
|
||||
</div>
|
||||
<div class="llm-card-stats">
|
||||
<span>30 000 tokens / min</span>
|
||||
<span>14 400 req / day</span>
|
||||
<span>Lowest latency</span>
|
||||
</div>
|
||||
<div class="llm-field-row">
|
||||
<label class="llm-label">API key</label>
|
||||
<input type="password" class="llm-input" placeholder="gsk_…" data-llm-key="groq_llm">
|
||||
<a class="llm-link" href="https://console.groq.com" target="_blank" rel="noopener">Get key ↗</a>
|
||||
</div>
|
||||
<div class="llm-endpoint">
|
||||
<span class="llm-endpoint-label">Endpoint</span>
|
||||
<code>https://api.groq.com/openai/v1</code>
|
||||
</div>
|
||||
<div class="llm-models">
|
||||
<span class="llm-model-tag">llama-3.3-70b-versatile</span>
|
||||
<span class="llm-model-tag">qwen-qwq-32b</span>
|
||||
<span class="llm-model-tag">deepseek-r1-distill-llama-70b</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="llm-card">
|
||||
<div class="llm-card-head">
|
||||
<span class="llm-card-icon">🌐</span>
|
||||
<div>
|
||||
<div class="llm-card-name">OpenRouter</div>
|
||||
<div class="llm-card-sub">50+ free models · OpenAI-compatible</div>
|
||||
</div>
|
||||
<span class="llm-tier-badge llm-tier-free">Free</span>
|
||||
</div>
|
||||
<div class="llm-card-stats">
|
||||
<span>20 req / min</span>
|
||||
<span>200 req / day (free models)</span>
|
||||
<span>Single API for all models</span>
|
||||
</div>
|
||||
<div class="llm-field-row">
|
||||
<label class="llm-label">API key</label>
|
||||
<input type="password" class="llm-input" placeholder="sk-or-…" data-llm-key="openrouter">
|
||||
<a class="llm-link" href="https://openrouter.ai" target="_blank" rel="noopener">Get key ↗</a>
|
||||
</div>
|
||||
<div class="llm-endpoint">
|
||||
<span class="llm-endpoint-label">Endpoint</span>
|
||||
<code>https://openrouter.ai/api/v1</code>
|
||||
</div>
|
||||
<div class="llm-models">
|
||||
<span class="llm-model-tag">qwen/qwen3-235b-a22b:free</span>
|
||||
<span class="llm-model-tag">deepseek/deepseek-r1-0528:free</span>
|
||||
<span class="llm-model-tag">mistralai/mistral-7b-instruct:free</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="llm-card">
|
||||
<div class="llm-card-head">
|
||||
<span class="llm-card-icon">💎</span>
|
||||
<div>
|
||||
<div class="llm-card-name">Google Gemini</div>
|
||||
<div class="llm-card-sub">Gemini 2.5 Flash · Generous free tier</div>
|
||||
</div>
|
||||
<span class="llm-tier-badge llm-tier-free">Free</span>
|
||||
</div>
|
||||
<div class="llm-card-stats">
|
||||
<span>250 000 tokens / min</span>
|
||||
<span>15 req / min free</span>
|
||||
<span>1M context window</span>
|
||||
</div>
|
||||
<div class="llm-field-row">
|
||||
<label class="llm-label">API key</label>
|
||||
<input type="password" class="llm-input" placeholder="AIza…" data-llm-key="gemini">
|
||||
<a class="llm-link" href="https://ai.google.dev" target="_blank" rel="noopener">Get key ↗</a>
|
||||
</div>
|
||||
<div class="llm-endpoint">
|
||||
<span class="llm-endpoint-label">Endpoint</span>
|
||||
<code>https://generativelanguage.googleapis.com/v1beta/openai/</code>
|
||||
</div>
|
||||
<div class="llm-info-note">Uses OpenAI-compat wrapper — use model <code>gemini-2.5-flash</code></div>
|
||||
</div>
|
||||
|
||||
<div class="llm-card">
|
||||
<div class="llm-card-head">
|
||||
<span class="llm-card-icon">🅓</span>
|
||||
<div>
|
||||
<div class="llm-card-name">Mistral AI</div>
|
||||
<div class="llm-card-sub">OpenAI-compatible · EU-based</div>
|
||||
</div>
|
||||
<span class="llm-tier-badge llm-tier-free">Free</span>
|
||||
</div>
|
||||
<div class="llm-card-stats">
|
||||
<span>1B tokens / month</span>
|
||||
<span>2 req / min free</span>
|
||||
<span>GDPR-compliant</span>
|
||||
</div>
|
||||
<div class="llm-field-row">
|
||||
<label class="llm-label">API key</label>
|
||||
<input type="password" class="llm-input" placeholder="Mistral key…" data-llm-key="mistral">
|
||||
<a class="llm-link" href="https://mistral.ai" target="_blank" rel="noopener">Get key ↗</a>
|
||||
</div>
|
||||
<div class="llm-endpoint">
|
||||
<span class="llm-endpoint-label">Endpoint</span>
|
||||
<code>https://api.mistral.ai/v1</code>
|
||||
</div>
|
||||
<div class="llm-models">
|
||||
<span class="llm-model-tag">mistral-small-latest</span>
|
||||
<span class="llm-model-tag">mistral-large-latest</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div><!-- /llm -->
|
||||
|
||||
<!-- ── Local section ───────────────────────────────────────── -->
|
||||
<div class="llm-section" id="llm-sec-local" data-llm-section="local" hidden>
|
||||
<div class="llm-sec-header">
|
||||
<div>
|
||||
<h3 class="llm-sec-title">Local AI Services</h3>
|
||||
<p class="llm-sec-note">Run everything on your own hardware — no API key, no rate limits, no data leaves your machine.</p>
|
||||
</div>
|
||||
<span class="llm-free-badge llm-free-local">100% Local</span>
|
||||
</div>
|
||||
|
||||
<!-- Local LLMs -->
|
||||
<h4 class="llm-local-cat">Language Models (LLM)</h4>
|
||||
<div class="llm-local-grid">
|
||||
|
||||
<div class="llm-local-card">
|
||||
<div class="llm-local-head">
|
||||
<span class="llm-local-name">Ollama</span>
|
||||
<span class="llm-local-compat">OpenAI-compat</span>
|
||||
</div>
|
||||
<p class="llm-local-desc">Easiest local LLM setup. Pull any model with one command. Runs Llama, Mistral, Qwen, Phi, Gemma and many more.</p>
|
||||
<div class="llm-local-url">
|
||||
<span>Default URL</span>
|
||||
<code>http://localhost:11434/v1</code>
|
||||
</div>
|
||||
<div class="llm-local-snippet">
|
||||
<div class="llm-snippet-bar">
|
||||
<span>Quick start</span>
|
||||
<button class="llm-copy-btn" data-copy="curl https://ollama.ai/install.sh | sh ollama pull llama3.3">Copy</button>
|
||||
</div>
|
||||
<pre>curl https://ollama.ai/install.sh | sh
|
||||
ollama pull llama3.3</pre>
|
||||
</div>
|
||||
<a class="llm-local-link" href="https://ollama.ai" target="_blank" rel="noopener">ollama.ai ↗</a>
|
||||
</div>
|
||||
|
||||
<div class="llm-local-card llm-local-card-running">
|
||||
<div class="llm-local-head">
|
||||
<span class="llm-local-name">vLLM ⚡</span>
|
||||
<span class="llm-local-compat llm-compat-detected">Detected in stack</span>
|
||||
</div>
|
||||
<p class="llm-local-desc">High-throughput GPU inference. Already in your Docker stack. Serve any HuggingFace model with PagedAttention.</p>
|
||||
<div class="llm-local-url">
|
||||
<span>Default URL</span>
|
||||
<code>http://localhost:8000/v1</code>
|
||||
</div>
|
||||
<div class="llm-local-snippet">
|
||||
<div class="llm-snippet-bar">
|
||||
<span>Add a model to your stack</span>
|
||||
<button class="llm-copy-btn" data-copy="--model Qwen/Qwen3-8B --served-model-name qwen3-8b">Copy</button>
|
||||
</div>
|
||||
<pre>--model Qwen/Qwen3-8B \
|
||||
--served-model-name qwen3-8b</pre>
|
||||
</div>
|
||||
<a class="llm-local-link" href="https://docs.vllm.ai" target="_blank" rel="noopener">docs.vllm.ai ↗</a>
|
||||
</div>
|
||||
|
||||
<div class="llm-local-card">
|
||||
<div class="llm-local-head">
|
||||
<span class="llm-local-name">LM Studio</span>
|
||||
<span class="llm-local-compat">OpenAI-compat</span>
|
||||
</div>
|
||||
<p class="llm-local-desc">GUI app for running quantized models. Built-in model browser, chat UI, and a local server with OpenAI API.</p>
|
||||
<div class="llm-local-url">
|
||||
<span>Default URL</span>
|
||||
<code>http://localhost:1234/v1</code>
|
||||
</div>
|
||||
<div class="llm-local-snippet">
|
||||
<div class="llm-snippet-bar"><span>Enable in LM Studio</span></div>
|
||||
<pre>Developer tab → Start server → Port 1234
|
||||
Check "Enable CORS" for browser access</pre>
|
||||
</div>
|
||||
<a class="llm-local-link" href="https://lmstudio.ai" target="_blank" rel="noopener">lmstudio.ai ↗</a>
|
||||
</div>
|
||||
|
||||
<div class="llm-local-card">
|
||||
<div class="llm-local-head">
|
||||
<span class="llm-local-name">llama.cpp</span>
|
||||
<span class="llm-local-compat">OpenAI-compat</span>
|
||||
</div>
|
||||
<p class="llm-local-desc">Lightweight GGUF model server. Minimal RAM usage. Runs on CPU or GPU. Great for embedding in Docker.</p>
|
||||
<div class="llm-local-url">
|
||||
<span>Default URL</span>
|
||||
<code>http://localhost:8080/v1</code>
|
||||
</div>
|
||||
<div class="llm-local-snippet">
|
||||
<div class="llm-snippet-bar">
|
||||
<span>Docker one-liner</span>
|
||||
<button class="llm-copy-btn" data-copy="docker run -p 8080:8080 ghcr.io/ggml-org/llama.cpp:server -hf QuantFactory/Meta-Llama-3-8B-GGUF -hff Meta-Llama-3-8B.Q4_K_M.gguf">Copy</button>
|
||||
</div>
|
||||
<pre>docker run -p 8080:8080 ghcr.io/ggml-org/llama.cpp:server \
|
||||
-hf QuantFactory/Meta-Llama-3-8B-GGUF \
|
||||
-hff Meta-Llama-3-8B.Q4_K_M.gguf</pre>
|
||||
</div>
|
||||
<a class="llm-local-link" href="https://github.com/ggml-org/llama.cpp" target="_blank" rel="noopener">github.com/ggml-org/llama.cpp ↗</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Local STT -->
|
||||
<h4 class="llm-local-cat" style="margin-top:24px">Speech Recognition (STT)</h4>
|
||||
<div class="llm-local-grid">
|
||||
|
||||
<div class="llm-local-card">
|
||||
<div class="llm-local-head">
|
||||
<span class="llm-local-name">faster-whisper-server</span>
|
||||
<span class="llm-local-compat">OpenAI-compat</span>
|
||||
</div>
|
||||
<p class="llm-local-desc">Drop-in local replacement for the Whisper API. GPU-accelerated via CTranslate2. OpenAI-compatible endpoint.</p>
|
||||
<div class="llm-local-url">
|
||||
<span>Default URL</span>
|
||||
<code>http://localhost:8000/v1</code>
|
||||
</div>
|
||||
<div class="llm-local-snippet">
|
||||
<div class="llm-snippet-bar">
|
||||
<span>docker-compose snippet</span>
|
||||
<button class="llm-copy-btn" data-copy="services: whisper: image: fedirz/faster-whisper-server:latest-cuda ports: ["8000:8000"] environment: - WHISPER__MODEL=large-v3 deploy: resources: reservations: devices: [{driver: nvidia, count: 1, capabilities: [gpu]}]">Copy</button>
|
||||
</div>
|
||||
<pre>services:
|
||||
whisper:
|
||||
image: fedirz/faster-whisper-server:latest-cuda
|
||||
ports: ["8000:8000"]
|
||||
environment:
|
||||
- WHISPER__MODEL=large-v3
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices: [{driver: nvidia, count: 1, capabilities: [gpu]}]</pre>
|
||||
</div>
|
||||
<a class="llm-local-link" href="https://github.com/fedirz/faster-whisper-server" target="_blank" rel="noopener">github.com/fedirz/faster-whisper-server ↗</a>
|
||||
</div>
|
||||
|
||||
<div class="llm-local-card">
|
||||
<div class="llm-local-head">
|
||||
<span class="llm-local-name">whisper.cpp</span>
|
||||
<span class="llm-local-compat">HTTP server</span>
|
||||
</div>
|
||||
<p class="llm-local-desc">Minimal C++ Whisper with a built-in HTTP server. CPU or Metal/CUDA. Low memory, fast on consumer hardware.</p>
|
||||
<div class="llm-local-url">
|
||||
<span>Default URL</span>
|
||||
<code>http://localhost:8080</code>
|
||||
</div>
|
||||
<div class="llm-local-snippet">
|
||||
<div class="llm-snippet-bar">
|
||||
<span>Build & run</span>
|
||||
<button class="llm-copy-btn" data-copy="git clone https://github.com/ggml-org/whisper.cpp cd whisper.cpp && cmake -B build && cmake --build build -j ./build/bin/whisper-server -m models/ggml-large-v3.bin --port 8080">Copy</button>
|
||||
</div>
|
||||
<pre>git clone https://github.com/ggml-org/whisper.cpp
|
||||
cd whisper.cpp && cmake -B build && cmake --build build -j
|
||||
./build/bin/whisper-server \
|
||||
-m models/ggml-large-v3.bin --port 8080</pre>
|
||||
</div>
|
||||
<a class="llm-local-link" href="https://github.com/ggml-org/whisper.cpp" target="_blank" rel="noopener">github.com/ggml-org/whisper.cpp ↗</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Local TTS -->
|
||||
<h4 class="llm-local-cat" style="margin-top:24px">Text-to-Speech (local TTS)</h4>
|
||||
<div class="llm-local-grid">
|
||||
|
||||
<div class="llm-local-card">
|
||||
<div class="llm-local-head">
|
||||
<span class="llm-local-name">Piper TTS</span>
|
||||
<span class="llm-local-compat">Fast · offline</span>
|
||||
</div>
|
||||
<p class="llm-local-desc">Lightning-fast offline TTS. Runs on CPU in real time. 50+ language voices available. Ideal for low-latency pipelines.</p>
|
||||
<div class="llm-local-url">
|
||||
<span>Wyoming protocol port</span>
|
||||
<code>localhost:10200</code>
|
||||
</div>
|
||||
<div class="llm-local-snippet">
|
||||
<div class="llm-snippet-bar">
|
||||
<span>Docker</span>
|
||||
<button class="llm-copy-btn" data-copy="docker run -p 10200:10200 rhasspy/wyoming-piper --voice en_US-lessac-medium">Copy</button>
|
||||
</div>
|
||||
<pre>docker run -p 10200:10200 \
|
||||
rhasspy/wyoming-piper \
|
||||
--voice en_US-lessac-medium</pre>
|
||||
</div>
|
||||
<a class="llm-local-link" href="https://github.com/rhasspy/piper" target="_blank" rel="noopener">github.com/rhasspy/piper ↗</a>
|
||||
</div>
|
||||
|
||||
<div class="llm-local-card">
|
||||
<div class="llm-local-head">
|
||||
<span class="llm-local-name">Kokoro FastAPI</span>
|
||||
<span class="llm-local-compat">OpenAI-compat TTS</span>
|
||||
</div>
|
||||
<p class="llm-local-desc">Kokoro-82M running behind an OpenAI-compatible TTS endpoint. Drop-in replacement for OpenAI’s TTS API.</p>
|
||||
<div class="llm-local-url">
|
||||
<span>Default URL</span>
|
||||
<code>http://localhost:8880/v1/audio/speech</code>
|
||||
</div>
|
||||
<div class="llm-local-snippet">
|
||||
<div class="llm-snippet-bar">
|
||||
<span>Docker</span>
|
||||
<button class="llm-copy-btn" data-copy="docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.2">Copy</button>
|
||||
</div>
|
||||
<pre>docker run -p 8880:8880 \
|
||||
ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.2
|
||||
# GPU:
|
||||
docker run -p 8880:8880 --gpus all \
|
||||
ghcr.io/remsky/kokoro-fastapi-gpu:v0.2.2</pre>
|
||||
</div>
|
||||
<a class="llm-local-link" href="https://github.com/remsky/Kokoro-FastAPI" target="_blank" rel="noopener">github.com/remsky/Kokoro-FastAPI ↗</a>
|
||||
</div>
|
||||
|
||||
<div class="llm-local-card">
|
||||
<div class="llm-local-head">
|
||||
<span class="llm-local-name">XTTS v2</span>
|
||||
<span class="llm-local-compat">Voice cloning</span>
|
||||
</div>
|
||||
<p class="llm-local-desc">Coqui XTTS — multilingual voice cloning from a 6-second sample. 17 languages. Compatible with this app’s voice library.</p>
|
||||
<div class="llm-local-url">
|
||||
<span>API endpoint</span>
|
||||
<code>http://localhost:8020/tts_to_audio</code>
|
||||
</div>
|
||||
<div class="llm-local-snippet">
|
||||
<div class="llm-snippet-bar">
|
||||
<span>Docker</span>
|
||||
<button class="llm-copy-btn" data-copy="docker run -p 8020:80 --gpus all -v /voices:/voices daswer123/xtts-api-server:latest">Copy</button>
|
||||
</div>
|
||||
<pre>docker run -p 8020:80 --gpus all \
|
||||
-v /voices:/voices \
|
||||
daswer123/xtts-api-server:latest</pre>
|
||||
</div>
|
||||
<a class="llm-local-link" href="https://github.com/daswer123/xtts-api-server" target="_blank" rel="noopener">xtts-api-server ↗</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Generic how-to tip -->
|
||||
<div class="llm-local-howto">
|
||||
<div class="llm-howto-icon">💡</div>
|
||||
<div class="llm-howto-body">
|
||||
<strong>Adding a local service to this app</strong>
|
||||
<p>All OpenAI-compatible services work the same way: open <em>App Routing</em> in the sidebar, add a new backend entry with the local URL as base endpoint, and leave the API key field empty (or enter any string — it’s ignored by local servers). Set it as the default backend for the voice type you want (clone, design, or tryout) and the app will start routing requests to it immediately.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /local -->
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// ── Category tabs ──────────────────────────────────────────
|
||||
var tabs = document.querySelectorAll('.llm-cat-tab');
|
||||
tabs.forEach(function (tab) {
|
||||
tab.addEventListener('click', function () {
|
||||
var cat = tab.dataset.llmCat;
|
||||
tabs.forEach(function (t) { t.classList.toggle('active', t === tab); });
|
||||
document.querySelectorAll('[data-llm-section]').forEach(function (sec) {
|
||||
sec.hidden = sec.dataset.llmSection !== cat;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Copy buttons ───────────────────────────────────────────
|
||||
document.querySelectorAll('.llm-copy-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var text = btn.dataset.copy.replace(/ /g, '\n');
|
||||
navigator.clipboard?.writeText(text).then(function () {
|
||||
var orig = btn.textContent;
|
||||
btn.textContent = '✓ Copied';
|
||||
setTimeout(function () { btn.textContent = orig; }, 1600);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── API key inputs: persist + show/hide toggle + saved badge ──
|
||||
document.querySelectorAll('[data-llm-key]').forEach(function (inp) {
|
||||
var k = 'llm-key-' + inp.dataset.llmKey;
|
||||
var stored = localStorage.getItem(k) || '';
|
||||
inp.value = stored;
|
||||
|
||||
// Insert show/hide toggle
|
||||
var eyeBtn = document.createElement('button');
|
||||
eyeBtn.type = 'button';
|
||||
eyeBtn.className = 'llm-eye-btn';
|
||||
eyeBtn.title = 'Show / hide key';
|
||||
eyeBtn.textContent = '👁';
|
||||
eyeBtn.addEventListener('click', function () {
|
||||
inp.type = inp.type === 'password' ? 'text' : 'password';
|
||||
eyeBtn.classList.toggle('active', inp.type === 'text');
|
||||
});
|
||||
inp.after(eyeBtn);
|
||||
|
||||
// Saved badge in parent card head
|
||||
var card = inp.closest('.llm-card');
|
||||
var savedBadge = card && card.querySelector('.llm-saved-badge');
|
||||
if (!savedBadge && card) {
|
||||
savedBadge = document.createElement('span');
|
||||
savedBadge.className = 'llm-saved-badge';
|
||||
savedBadge.textContent = '✓ Key saved';
|
||||
var head = card.querySelector('.llm-card-head');
|
||||
if (head) head.appendChild(savedBadge);
|
||||
}
|
||||
function updateBadge() {
|
||||
if (savedBadge) savedBadge.hidden = !inp.value.trim();
|
||||
}
|
||||
updateBadge();
|
||||
|
||||
inp.addEventListener('input', function () {
|
||||
localStorage.setItem(k, inp.value.trim());
|
||||
updateBadge();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@ -1,250 +1,239 @@
|
||||
<div class="section-head">
|
||||
<span class="section-icon">🔊</span>
|
||||
<div class="section-title">
|
||||
<h2>My Voice Library</h2>
|
||||
<p>All your cloned and designed voices in one place. Play, manage, benchmark, and export them.</p>
|
||||
<h2>My Voices</h2>
|
||||
<p>Pick a voice on the left, edit on the right.</p>
|
||||
</div>
|
||||
<div class="section-head-actions">
|
||||
<input type="search" class="section-search" id="section-voices-search" placeholder="Search voices…" autocomplete="off"
|
||||
oninput="var f=document.getElementById('library-filter-text');if(f){f.value=this.value;f.dispatchEvent(new Event('input'));}">
|
||||
<button class="btn-primary section-new-voice-btn" onclick="document.getElementById('add-new-voice-btn')?.click()">+ New voice</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="tab-library">
|
||||
<div class="card" style="padding-bottom:8px">
|
||||
<div class="lib-toolbar">
|
||||
<h2 style="margin:0">Voice library</h2>
|
||||
<span id="voice-count" class="note"></span>
|
||||
<div class="spacer"></div>
|
||||
</div>
|
||||
<div class="library-benchmark-panel">
|
||||
<div class="field">
|
||||
<label>Sample sentence for generated playback and benchmarks</label>
|
||||
<textarea id="benchmark-sample-text" placeholder="Sentence every voice should speak for preview and benchmark"></textarea>
|
||||
</div>
|
||||
<div class="field play-mode-field">
|
||||
<label>Select TTS Engine</label>
|
||||
<select id="library-tts-backend-select"><option value="">Checking backends...</option></select>
|
||||
</div>
|
||||
<div class="benchmark-actions volume-actions">
|
||||
<label class="target-db-field" title="Target volume for volume normalization">
|
||||
<span>Target dBFS</span>
|
||||
<input type="number" id="library-target-db" value="-20" min="-60" max="-1" step="0.5" aria-label="Target dBFS">
|
||||
</label>
|
||||
<button class="btn-secondary" id="normalize-volume-btn" title="Normalize visible WAV voices to the target volume">Normalize volume</button>
|
||||
<button class="btn-secondary" id="benchmark-reset-sample-btn">Reset sample</button>
|
||||
<button class="btn-secondary" id="benchmark-use-preview-btn">Use preview text</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="benchmark-progress" id="benchmark-progress" hidden aria-live="polite">
|
||||
<div class="benchmark-progress-head">
|
||||
<span id="benchmark-progress-label">Benchmarking voices...</span>
|
||||
<span id="benchmark-progress-count">0 / 0</span>
|
||||
</div>
|
||||
<div class="benchmark-progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
|
||||
<div id="benchmark-progress-bar"></div>
|
||||
</div>
|
||||
<div class="benchmark-live-stats" id="benchmark-live-stats">
|
||||
<span>Elapsed -</span><span>Avg -</span><span>ETA -</span><span>OK 0</span><span>Slow 0</span><span>Errors 0</span>
|
||||
</div>
|
||||
<div class="benchmark-live-last" id="benchmark-live-last"></div>
|
||||
<!-- Workbench: list pane + inspector pane -->
|
||||
<div class="voices-workbench" id="tab-library">
|
||||
|
||||
<!-- LEFT: compact voice list -->
|
||||
<div class="voices-list-pane">
|
||||
|
||||
<div class="vl-toolbar">
|
||||
<button class="btn-secondary vl-tb-btn" id="refresh-voices-btn" title="Refresh voice list">↻ Refresh</button>
|
||||
<button class="btn-secondary vl-tb-btn" id="sync-voice-folders-btn" title="Sync active/hidden folders">Sync</button>
|
||||
<button class="btn-secondary vl-tb-btn" id="calculate-db-btn" title="Calculate dBFS">Calc dB</button>
|
||||
<button class="btn-secondary vl-tb-btn" id="benchmark-voices-btn" title="Benchmark TTS speed">Benchmark</button>
|
||||
<button class="btn-secondary vl-tb-btn" id="copy-active-voices-btn" title="Copy active voice names">Copy active</button>
|
||||
</div>
|
||||
|
||||
<div class="lib-add-panel" id="lib-add-panel">
|
||||
<div class="lib-add-stack">
|
||||
<div class="lib-add-section">
|
||||
<h2 style="margin-bottom:10px">1 Load or record source</h2>
|
||||
<div class="lib-add-input-grid">
|
||||
<div class="lib-add-import-box">
|
||||
<input type="file" id="lib-add-file" accept="audio/*,video/*" style="display:none">
|
||||
<div class="lib-add-drop" id="lib-add-drop">
|
||||
<strong>Drop an audio / video file here</strong>
|
||||
<span>WAV · MP3 · OGG · FLAC · M4A · MP4 · MKV · WEBM</span>
|
||||
<span>or click to browse</span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="note" for="lib-add-url">Audio or video URL</label>
|
||||
<div class="url-row">
|
||||
<input type="text" id="lib-add-url" placeholder="Paste a direct MP3/WAV, YouTube, or Aiartes cloned-voice URL">
|
||||
<button class="btn-primary" id="lib-add-url-btn">Download URL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-add-source-box lib-add-record-box">
|
||||
<div class="record-head">
|
||||
<span class="note">Record a fresh sample</span>
|
||||
<div class="record-head-actions">
|
||||
<button class="btn-secondary" id="lib-add-mic-help-btn">How to unlock mic</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-add-recorder-tools">
|
||||
<div class="lib-add-mic-row">
|
||||
<div class="mic-buttons">
|
||||
<button class="btn-secondary" id="lib-add-monitor-btn">Check level</button>
|
||||
<button class="btn-secondary" id="lib-add-monitor-stop" disabled>Stop monitor</button>
|
||||
<button class="btn-red" id="lib-add-rec-start">● Record</button>
|
||||
<button class="btn-secondary" id="lib-add-rec-stop" disabled>■ Stop</button>
|
||||
<span class="mic-timer" id="lib-add-rec-time">0:00</span>
|
||||
</div>
|
||||
<div class="mic-monitor-box">
|
||||
<div class="mic-monitor-head">
|
||||
<span>Input level</span>
|
||||
<span class="meter-readout" id="lib-add-db-readout">-∞ dB</span>
|
||||
</div>
|
||||
<div class="mic-meter" id="lib-add-mic-meter" aria-hidden="true"></div>
|
||||
<div class="mic-gain-row">
|
||||
<label for="lib-add-mic-gain">Mic gain</label>
|
||||
<input id="lib-add-mic-gain" type="range" min="0" max="2" step="0.05" value="1">
|
||||
<span class="mic-gain-value" id="lib-add-mic-gain-value">1.00x</span>
|
||||
</div>
|
||||
<div class="note" style="margin-top:5px">Best peaks: -18 to -9 dB, never red.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sample-read-box">
|
||||
<div class="sample-head">
|
||||
<label for="lib-add-sample-lang">Read sample</label>
|
||||
<select id="lib-add-sample-lang">
|
||||
<option value="EN">English</option>
|
||||
<option value="DE">Deutsch</option>
|
||||
<option value="IT">Italiano</option>
|
||||
<option value="ES">Español</option>
|
||||
<option value="FR">Français</option>
|
||||
<option value="PT">Português</option>
|
||||
<option value="NL">Nederlands</option>
|
||||
<option value="PL">Polski</option>
|
||||
</select>
|
||||
</div>
|
||||
<textarea class="sample-sentence" id="lib-add-sample-text" spellcheck="true"></textarea>
|
||||
<div class="sample-actions">
|
||||
<button class="btn-secondary" id="lib-add-use-sample">Use as transcript</button>
|
||||
<button class="btn-secondary" id="lib-add-reset-sample">Reset sentence</button>
|
||||
<span class="note">Use this as the spoken script if you record.</span>
|
||||
</div>
|
||||
<div class="recording-tips">
|
||||
<span class="recording-tip">Quiet room</span>
|
||||
<span class="recording-tip">20 cm from mic</span>
|
||||
<span class="recording-tip">No clipping</span>
|
||||
<span class="recording-tip">Natural pace</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mic-help-panel" id="lib-add-mic-help">
|
||||
<strong>If the microphone is blocked:</strong>
|
||||
<ul>
|
||||
<li><strong>Chrome, Brave, Edge:</strong> click the lock/tune icon in the address bar, set Microphone to Allow, then reload.</li>
|
||||
<li><strong>Firefox:</strong> click the microphone or lock icon in the address bar, remove Blocked or choose Allow, then reload.</li>
|
||||
<li><strong>Safari:</strong> open Safari Settings, Websites, Microphone, then allow this site.</li>
|
||||
<li><strong>Requested device not found:</strong> choose or enable a microphone in your OS input settings, then reload.</li>
|
||||
<li>Browsers require localhost or HTTPS for microphone access.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="opt-status" id="lib-add-status">Load a file, paste a URL, or record a sample. Then trim, name, and save the voice.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-add-audio-row">
|
||||
<audio id="lib-add-audio" controls style="display:none"></audio>
|
||||
<div class="btn-row">
|
||||
<button class="btn-secondary" id="lib-add-auto-trim">Auto trim</button>
|
||||
<button class="btn-primary" id="lib-add-save-crop">Crop selection</button>
|
||||
<button class="btn-secondary" id="lib-add-play">Play crop</button>
|
||||
</div>
|
||||
</div>
|
||||
<canvas class="opt-wave" id="lib-add-wave" style="display:none;margin-top:10px"></canvas>
|
||||
<div class="opt-controls">
|
||||
<div class="opt-field"><label>Start</label><input id="lib-add-start" type="number" step="0.01" value="0"></div>
|
||||
<div class="opt-field"><label>End</label><input id="lib-add-end" type="number" step="0.01" value="0"></div>
|
||||
<button class="btn-primary" id="lib-add-save-crop-bottom">Crop selection</button>
|
||||
<span class="crop-duration-hint" id="lib-add-crop-hint">Select 3-20 seconds for best cloning.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-add-section">
|
||||
<h2 style="margin-bottom:10px">2 Name and save voice</h2>
|
||||
<div class="lib-add-name-grid">
|
||||
<div class="opt-field"><label>Language</label><select id="lib-add-lang">
|
||||
<option value="EN">EN</option><option value="DE">DE</option><option value="IT">IT</option>
|
||||
<option value="ES">ES</option><option value="FR">FR</option><option value="PT">PT</option>
|
||||
<option value="NL">NL</option><option value="PL">PL</option>
|
||||
</select></div>
|
||||
<div class="opt-field"><label>Gender</label><input id="lib-add-gender" value="N"></div>
|
||||
<div class="opt-field"><label>Voice ID</label><input id="lib-add-voice-id" placeholder="EN_N_NewVoice"></div>
|
||||
</div>
|
||||
<textarea id="lib-add-transcript" placeholder="Reference transcript: paste or recognise the exact words spoken in the source" style="margin-top:10px"></textarea>
|
||||
<div class="btn-row" style="margin-top:10px">
|
||||
<button class="btn-secondary" id="lib-add-recognize">Recognise text</button>
|
||||
<button class="btn-green" id="lib-add-save">Save voice</button>
|
||||
</div>
|
||||
<div class="vl-filters">
|
||||
<input type="search" id="library-filter-text" placeholder="Search voices…" autocomplete="off" class="vl-search">
|
||||
<select id="library-filter-lang" title="Language"><option value="">All</option></select>
|
||||
<select id="library-filter-type" title="Type"><option value="">All types</option></select>
|
||||
<label class="vl-disabled-label"><input type="checkbox" id="show-disabled-cb"> Disabled</label>
|
||||
</div>
|
||||
|
||||
<!-- TTS synth mode + preview sentence -->
|
||||
<div class="vl-synth-panel">
|
||||
<div class="vl-synth-mode-row">
|
||||
<span class="vl-synth-icon">▶▶</span>
|
||||
<span class="vl-synth-label">Synth uses</span>
|
||||
<div class="vl-synth-seg" id="vl-synth-mode-seg">
|
||||
<button class="vl-synth-seg-btn active" data-mode="preview"
|
||||
title="Synthesize the preview sentence — good for comparing voices side by side">Preview text</button>
|
||||
<button class="vl-synth-seg-btn" data-mode="transcript"
|
||||
title="Synthesize this voice’s saved reference transcript — good for quality check vs. original recording">Reference transcript</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vl-preview-text-wrap" id="vl-preview-text-wrap">
|
||||
<input type="text" class="vl-preview-input" id="vl-preview-sample"
|
||||
placeholder="Sample sentence for TTS preview…"
|
||||
oninput="var t=document.getElementById('benchmark-sample-text');if(t){t.value=this.value;t.dispatchEvent(new Event('input'));}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="library-control-panel" id="library-filter-panel">
|
||||
<div class="field">
|
||||
<label>Filter voices</label>
|
||||
<input type="search" id="library-filter-text" placeholder="Search name, reference, note..." autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Language</label>
|
||||
<select id="library-filter-lang"><option value="">All languages</option></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Sex</label>
|
||||
<select id="library-filter-sex"><option value="">All</option></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Filetype</label>
|
||||
<select id="library-filter-type"><option value="">All</option></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Rating</label>
|
||||
<select id="library-filter-rating"><option value="">Any</option><option value="5">5 stars</option><option value="4">4+ stars</option><option value="3">3+ stars</option><option value="1">Rated</option><option value="0">Unrated</option></select>
|
||||
</div>
|
||||
<button class="btn-secondary" id="library-clear-filters" type="button">Clear filters</button>
|
||||
</div>
|
||||
<div class="library-list-title"><strong>Voices</strong></div>
|
||||
<div class="library-action-row" aria-label="Voice library actions">
|
||||
<div class="voice-action-group action-add">
|
||||
<button class="btn-primary" id="add-new-voice-btn">Add new voice</button>
|
||||
</div>
|
||||
<div class="voice-action-group action-refresh">
|
||||
<button class="btn-secondary" id="refresh-voices-btn">↻ Refresh</button>
|
||||
<button class="btn-secondary" id="sync-voice-folders-btn">Sync folders</button>
|
||||
</div>
|
||||
<div class="voice-action-group action-db">
|
||||
<button class="btn-secondary" id="calculate-db-btn" title="Calculate dBFS for each voice">Calc dB</button>
|
||||
</div>
|
||||
<div class="voice-action-group action-benchmark">
|
||||
<button class="btn-secondary" id="benchmark-voices-btn" title="Measure TTS synthesis speed for active voices">Benchmark</button>
|
||||
</div>
|
||||
<div class="voice-action-group action-copy">
|
||||
<button class="btn-secondary" id="copy-active-voices-btn" title="Copy active voices as comma-separated names">Copy active voices</button>
|
||||
</div>
|
||||
<label class="show-disabled-control action-show-disabled">
|
||||
<input type="checkbox" id="show-disabled-cb"> Show disabled
|
||||
</label>
|
||||
</div>
|
||||
<div class="benchmark-confirm" id="benchmark-confirm" hidden role="group" aria-live="polite">
|
||||
<strong id="benchmark-confirm-title">Benchmark active voices?</strong>
|
||||
<span id="benchmark-confirm-text"></span>
|
||||
<button class="btn-secondary" id="benchmark-confirm-cancel" type="button">Cancel</button>
|
||||
<button class="benchmark-confirm-start" id="benchmark-confirm-start" type="button">Start benchmark</button>
|
||||
</div>
|
||||
<div class="disabled-info" id="disabled-info" style="display:none">
|
||||
<strong>About disabled voices:</strong> The Active toggle moves the complete voice package between
|
||||
<code>active_voices</code> and <code>hidden_voices</code>. Qwen3-TTS should scan only <code>active_voices</code>.
|
||||
To permanently remove a voice, delete its <code>.wav</code> and <code>.reference.txt</code> files directly.
|
||||
|
||||
<div id="voice-list"></div>
|
||||
|
||||
<div class="vl-footer">
|
||||
<span id="voice-count" class="note"></span>
|
||||
<button class="vl-add-btn" id="add-new-voice-btn" style="display:none" aria-hidden="true" tabindex="-1" title="Add a new voice to the library">+ Add voice</button>
|
||||
</div>
|
||||
<div class="vl-grid vl-header">
|
||||
<div data-sort="has_picture" title="Sort by photo">Image</div>
|
||||
<div data-sort="id" title="Sort by language, sex, and name" class="sort-asc">Language / Sex / Name</div>
|
||||
<div data-sort="file_type" title="Sort by file type">Filetype</div>
|
||||
<div data-sort="duration" title="Sort by audio length">Length</div>
|
||||
<div data-sort="dbfs" title="Sort by loudness">dB</div>
|
||||
<div data-sort="benchmark" title="Sort by synthesis benchmark">Benchmark</div>
|
||||
<div data-sort="rating" title="Sort by rating">Rating</div>
|
||||
<div class="no-sort" title="Original and synthesized playback">Play / Pause</div>
|
||||
<div class="no-sort" title="Edit audio">Edit</div>
|
||||
</div>
|
||||
<div id="voice-list" style="display:flex;flex-direction:column;gap:4px;flex:1;min-height:260px;overflow-y:auto;padding-right:2px"></div>
|
||||
<div class="library-insights" id="library-insights" aria-live="polite"></div>
|
||||
<div id="lib-audio-bar" style="display:none">
|
||||
<div class="lib-audio-label" id="lib-audio-label">-</div>
|
||||
<audio id="lib-audio" controls></audio>
|
||||
|
||||
</div><!-- /voices-list-pane -->
|
||||
|
||||
<!-- RIGHT: inspector / editor pane -->
|
||||
<div class="voices-inspector-pane" id="voices-inspector">
|
||||
<div class="inspector-placeholder">
|
||||
<span>🔊</span>
|
||||
<p>Pick a voice on the left<br>to edit it here</p>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /tab-library -->
|
||||
|
||||
</div><!-- /voices-workbench -->
|
||||
|
||||
<!-- Hidden scaffold — IDs required by app.js but not shown in new layout -->
|
||||
<div id="vl-scaffold" style="display:none" aria-hidden="true">
|
||||
|
||||
<select id="library-filter-sex"><option value="">All</option><option value="F">F</option><option value="M">M</option><option value="N">N</option></select>
|
||||
<select id="library-filter-rating"><option value="">Any</option><option value="5">5 stars</option><option value="4">4+ stars</option><option value="3">3+ stars</option><option value="1">Rated</option><option value="0">Unrated</option></select>
|
||||
<button id="library-clear-filters" type="button">Clear filters</button>
|
||||
<div id="library-filter-panel"></div>
|
||||
<div class="library-insights" id="library-insights"></div>
|
||||
<div class="disabled-info" id="disabled-info">
|
||||
<strong>About disabled voices:</strong> The Active toggle moves the voice package between
|
||||
<code>active_voices</code> and <code>hidden_voices</code>. Qwen3-TTS should scan only <code>active_voices</code>.
|
||||
</div>
|
||||
|
||||
<!-- Audio playback bar -->
|
||||
<div id="lib-audio-bar">
|
||||
<div class="lib-audio-label" id="lib-audio-label">-</div>
|
||||
<audio id="lib-audio" controls></audio>
|
||||
</div>
|
||||
|
||||
<!-- Benchmark config panel -->
|
||||
<div class="library-benchmark-panel">
|
||||
<textarea id="benchmark-sample-text" placeholder="Sample sentence for benchmark"></textarea>
|
||||
<select id="library-tts-backend-select"><option value="">Checking backends...</option></select>
|
||||
<input type="number" id="library-target-db" value="-20" min="-60" max="-1" step="0.5">
|
||||
<button id="normalize-volume-btn">Normalize volume</button>
|
||||
<button id="benchmark-reset-sample-btn">Reset sample</button>
|
||||
<button id="benchmark-use-preview-btn">Use preview text</button>
|
||||
</div>
|
||||
|
||||
<!-- Benchmark progress -->
|
||||
<div class="benchmark-progress" id="benchmark-progress" hidden aria-live="polite">
|
||||
<div class="benchmark-progress-head">
|
||||
<span id="benchmark-progress-label">Benchmarking voices...</span>
|
||||
<span id="benchmark-progress-count">0 / 0</span>
|
||||
</div>
|
||||
<div class="benchmark-progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
|
||||
<div id="benchmark-progress-bar"></div>
|
||||
</div>
|
||||
<div class="benchmark-live-stats" id="benchmark-live-stats">
|
||||
<span>Elapsed -</span><span>Avg -</span><span>ETA -</span><span>OK 0</span><span>Slow 0</span><span>Errors 0</span>
|
||||
</div>
|
||||
<div class="benchmark-live-last" id="benchmark-live-last"></div>
|
||||
</div>
|
||||
|
||||
<!-- Add new voice panel -->
|
||||
<div class="lib-add-panel" id="lib-add-panel">
|
||||
<div class="lib-add-stack">
|
||||
<div class="lib-add-section">
|
||||
<h2 style="margin-bottom:10px">1 Load or record source</h2>
|
||||
<div class="lib-add-input-grid">
|
||||
<div class="lib-add-import-box">
|
||||
<input type="file" id="lib-add-file" accept="audio/*,video/*" style="display:none">
|
||||
<div class="lib-add-drop" id="lib-add-drop">
|
||||
<strong>Drop an audio / video file here</strong>
|
||||
<span>WAV · MP3 · OGG · FLAC · M4A · MP4 · MKV · WEBM</span>
|
||||
<span>or click to browse</span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="note" for="lib-add-url">Audio or video URL</label>
|
||||
<div class="url-row">
|
||||
<input type="text" id="lib-add-url" placeholder="Paste a direct MP3/WAV, YouTube, or Aiartes URL">
|
||||
<button class="btn-primary" id="lib-add-url-btn">Download URL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-add-source-box lib-add-record-box">
|
||||
<div class="record-head">
|
||||
<span class="note">Record a fresh sample</span>
|
||||
<div class="record-head-actions">
|
||||
<button class="btn-secondary" id="lib-add-mic-help-btn">How to unlock mic</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-add-recorder-tools">
|
||||
<div class="lib-add-mic-row">
|
||||
<div class="mic-buttons">
|
||||
<button class="btn-secondary" id="lib-add-monitor-btn">Check level</button>
|
||||
<button class="btn-secondary" id="lib-add-monitor-stop" disabled>Stop monitor</button>
|
||||
<button class="btn-red" id="lib-add-rec-start">● Record</button>
|
||||
<button class="btn-secondary" id="lib-add-rec-stop" disabled>■ Stop</button>
|
||||
<span class="mic-timer" id="lib-add-rec-time">0:00</span>
|
||||
</div>
|
||||
<div class="mic-monitor-box">
|
||||
<div class="mic-monitor-head">
|
||||
<span>Input level</span>
|
||||
<span class="meter-readout" id="lib-add-db-readout">-∞ dB</span>
|
||||
</div>
|
||||
<div class="mic-meter" id="lib-add-mic-meter" aria-hidden="true"></div>
|
||||
<div class="mic-gain-row">
|
||||
<label for="lib-add-mic-gain">Mic gain</label>
|
||||
<input id="lib-add-mic-gain" type="range" min="0" max="2" step="0.05" value="1">
|
||||
<span class="mic-gain-value" id="lib-add-mic-gain-value">1.00x</span>
|
||||
</div>
|
||||
<div class="note" style="margin-top:5px">Best peaks: -18 to -9 dB, never red.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sample-read-box">
|
||||
<div class="sample-head">
|
||||
<label for="lib-add-sample-lang">Read sample</label>
|
||||
<select id="lib-add-sample-lang">
|
||||
<option value="EN">English</option><option value="DE">Deutsch</option>
|
||||
<option value="IT">Italiano</option><option value="ES">Español</option>
|
||||
<option value="FR">Français</option><option value="PT">Português</option>
|
||||
<option value="NL">Nederlands</option><option value="PL">Polski</option>
|
||||
</select>
|
||||
</div>
|
||||
<textarea class="sample-sentence" id="lib-add-sample-text" spellcheck="true"></textarea>
|
||||
<div class="sample-actions">
|
||||
<button class="btn-secondary" id="lib-add-use-sample">Use as transcript</button>
|
||||
<button class="btn-secondary" id="lib-add-reset-sample">Reset sentence</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mic-help-panel" id="lib-add-mic-help">
|
||||
<strong>If the microphone is blocked:</strong>
|
||||
<ul>
|
||||
<li><strong>Chrome/Brave/Edge:</strong> click the lock icon in the address bar → Microphone → Allow, then reload.</li>
|
||||
<li><strong>Firefox:</strong> click the lock icon → remove Blocked → Allow, then reload.</li>
|
||||
<li><strong>Safari:</strong> Settings → Websites → Microphone → allow this site.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="opt-status" id="lib-add-status">Load a file, paste a URL, or record. Then trim, name, and save.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-add-audio-row">
|
||||
<audio id="lib-add-audio" controls style="display:none"></audio>
|
||||
<div class="btn-row">
|
||||
<button class="btn-secondary" id="lib-add-auto-trim">Auto trim</button>
|
||||
<button class="btn-primary" id="lib-add-save-crop">Crop selection</button>
|
||||
<button class="btn-secondary" id="lib-add-play">Play crop</button>
|
||||
</div>
|
||||
</div>
|
||||
<canvas class="opt-wave" id="lib-add-wave" style="display:none;margin-top:10px"></canvas>
|
||||
<div class="opt-controls">
|
||||
<div class="opt-field"><label>Start</label><input id="lib-add-start" type="number" step="0.01" value="0"></div>
|
||||
<div class="opt-field"><label>End</label><input id="lib-add-end" type="number" step="0.01" value="0"></div>
|
||||
<button class="btn-primary" id="lib-add-save-crop-bottom">Crop selection</button>
|
||||
<span class="crop-duration-hint" id="lib-add-crop-hint">Select 3-20 seconds for best cloning.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-add-section">
|
||||
<h2 style="margin-bottom:10px">2 Name and save voice</h2>
|
||||
<div class="lib-add-name-grid">
|
||||
<div class="opt-field"><label>Language</label><select id="lib-add-lang">
|
||||
<option value="EN">EN</option><option value="DE">DE</option><option value="IT">IT</option>
|
||||
<option value="ES">ES</option><option value="FR">FR</option><option value="PT">PT</option>
|
||||
<option value="NL">NL</option><option value="PL">PL</option>
|
||||
</select></div>
|
||||
<div class="opt-field"><label>Gender</label><input id="lib-add-gender" value="N"></div>
|
||||
<div class="opt-field"><label>Voice ID</label><input id="lib-add-voice-id" placeholder="EN_N_NewVoice"></div>
|
||||
</div>
|
||||
<textarea id="lib-add-transcript" placeholder="Reference transcript" style="margin-top:10px"></textarea>
|
||||
<div class="btn-row" style="margin-top:10px">
|
||||
<button class="btn-secondary" id="lib-add-recognize">Recognise text</button>
|
||||
<button class="btn-green" id="lib-add-save">Save voice</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /vl-scaffold -->
|
||||
|
||||
744
static/style.css
744
static/style.css
@ -1,4 +1,3 @@
|
||||
<style>
|
||||
/* ── Theme tokens (warm cream light theme) ──────────────────────────────── */
|
||||
:root {
|
||||
--bg: #F5F3EE;
|
||||
@ -55,6 +54,28 @@ body {
|
||||
font-size: 10px; font-weight: 800; color: var(--subtext);
|
||||
text-transform: uppercase; letter-spacing: 0.09em;
|
||||
}
|
||||
/* ── Sidebar tree (My Voices) ────────────────────────────────────────────── */
|
||||
.nav-tree-head { position: relative; }
|
||||
.nav-label { flex: 1; }
|
||||
.nav-badge {
|
||||
font-size: 10.5px; font-weight: 700; color: var(--subtext);
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 1px 6px; min-width: 18px; text-align: center;
|
||||
}
|
||||
.nav-chevron { font-size: 11px; color: var(--subtext); transition: transform .2s; margin-left: 2px; }
|
||||
.nav-chevron.open { transform: rotate(180deg); }
|
||||
.nav-tree { display: none; flex-direction: column; padding: 2px 0 6px; }
|
||||
.nav-tree.open { display: flex; }
|
||||
.nav-tree-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 5px 16px 5px 38px; font-size: 12.5px; color: var(--subtext);
|
||||
cursor: pointer; transition: background .1s, color .1s;
|
||||
}
|
||||
.nav-tree-item:hover { background: var(--panel); color: var(--text); }
|
||||
.nav-tree-item.is-active { color: var(--accent); font-weight: 700; }
|
||||
.ntc { font-size: 11px; color: var(--subtext); font-weight: 500; }
|
||||
.nav-tree-item.is-active .ntc { color: var(--accent); }
|
||||
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 9px 16px; cursor: pointer; color: var(--subtext);
|
||||
@ -84,11 +105,12 @@ body {
|
||||
/* ── Main content ────────────────────────────────────────────────────────── */
|
||||
#main-content {
|
||||
flex: 1; overflow-y: auto; overflow-x: hidden;
|
||||
padding: 0 28px 80px; min-width: 0;
|
||||
padding: 0 28px 0; min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Page sections ───────────────────────────────────────────────────────── */
|
||||
.page-section { scroll-margin-top: 12px; padding-top: 28px; }
|
||||
.page-section { padding-top: 7px; display: none; }
|
||||
.page-section.is-active { display: block; }
|
||||
.section-head {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding-bottom: 14px; border-bottom: 2px solid var(--border);
|
||||
@ -102,12 +124,25 @@ body {
|
||||
.section-title p {
|
||||
font-size: 13px; color: var(--subtext); margin: 3px 0 0; line-height: 1.4;
|
||||
}
|
||||
/* Section head right-side actions (search + button) */
|
||||
.section-head-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-shrink: 0; }
|
||||
.section-search {
|
||||
width: 220px; padding: 6px 12px; font-size: 13px; font-family: var(--font);
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
color: var(--text); transition: border-color .15s;
|
||||
}
|
||||
.section-search:focus, .section-search:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,.12); }
|
||||
.section-new-voice-btn { padding: 6px 14px; font-size: 13px; white-space: nowrap; }
|
||||
|
||||
/* ── Force all tab-content visible ──────────────────────────────────────── */
|
||||
.tab-content {
|
||||
display: flex !important; flex-direction: column; gap: 18px; padding: 0;
|
||||
}
|
||||
#tab-library { min-height: 0; }
|
||||
/* Hide noisy sub-panels we don't need in this layout */
|
||||
.library-benchmark-panel { display: none !important; }
|
||||
#library-filter-panel { display: none; }
|
||||
#disabled-info { display: none; }
|
||||
|
||||
/* ── Cards ──────────────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
@ -336,7 +371,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.skeleton-stack { display: flex; flex-direction: column; gap: 8px; }
|
||||
.skeleton-row { height: 38px; border-radius: 6px; border: 1px solid var(--border); background: linear-gradient(90deg, var(--panel), rgba(37,99,235,.06), var(--panel)); background-size: 220% 100%; animation: skeletonSweep 1.15s ease-in-out infinite; }
|
||||
@keyframes skeletonSweep { 0% { background-position: 120% 0; } 100% { background-position: -120% 0; } }
|
||||
.library-benchmark-panel { margin-top: 12px; display: grid; grid-template-columns: minmax(260px,1fr) 210px auto; gap: 10px; align-items: end; }
|
||||
.library-benchmark-panel { display: none !important; }
|
||||
.library-benchmark-panel .field { min-width: 0; }
|
||||
#benchmark-sample-text { min-height: 62px; resize: vertical; background: var(--panel); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: 10px 14px; font-size: 14px; font-family: inherit; width: 100%; }
|
||||
.benchmark-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
@ -387,36 +422,33 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.disabled-info strong { color: var(--text); }
|
||||
|
||||
/* Voice list rows - Card grid layout */
|
||||
.vl-grid { display: grid; grid-template-columns: repeat(2, minmax(300px, 1fr)); gap: 14px; }
|
||||
.vl-grid { display: grid; grid-template-columns: repeat(2, minmax(320px, 1fr)); gap: 16px; padding: 12px 0; }
|
||||
.vl-header { display: none; }
|
||||
.vl-row { position: relative; padding: 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); transition: border-color .15s, background .15s, box-shadow .15s; display: flex; flex-direction: column; gap: 10px; }
|
||||
.vl-row:hover { border-color: var(--accent); background: rgba(37,99,235,.02); box-shadow: 0 4px 12px rgba(37,99,235,.12); }
|
||||
.vl-row { position: relative; padding: 16px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); transition: all .2s; display: flex; flex-direction: column; gap: 12px; }
|
||||
.vl-row:hover { border-color: var(--accent); background: rgba(37,99,235,.02); box-shadow: 0 8px 24px rgba(37,99,235,.15); transform: translateY(-2px); }
|
||||
.vl-row.vr-disabled { opacity: .42; }
|
||||
.vl-row.edit-open { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(37,99,235,.15); }
|
||||
.vr-main-row { display: flex; flex-direction: column; gap: 8px; width: 100%; }
|
||||
.vr-photo { width: 100%; height: 160px; border-radius: 8px; overflow: hidden; background: var(--border); position: relative; }
|
||||
.vr-main-row { display: flex; flex-direction: column; gap: 12px; width: 100%; }
|
||||
.vr-photo { width: 100%; height: 200px; border-radius: 12px; overflow: hidden; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); position: relative; display: flex; align-items: center; justify-content: center; }
|
||||
.vr-photo img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.vr-photo .ph-icon { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 40px; color: var(--subtext); }
|
||||
.vr-photo .ph-icon { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 64px; color: white; font-weight: 700; }
|
||||
.vr-photo:hover::after { content: ''; }
|
||||
.vr-identity { display: flex; gap: 10px; align-items: center; min-width: 0; }
|
||||
.vr-flag { cursor: pointer; user-select: none; display: flex; flex-direction: column; align-items: center; gap: 1px; flex: 0 0 auto; }
|
||||
.vr-identity { display: flex; gap: 10px; align-items: center; min-width: 0; width: 100%; }
|
||||
.vr-flag { display: none; }
|
||||
.vr-flag .flag-emoji { font-size: 22px; line-height: 1; transition: transform .15s; }
|
||||
.vr-flag .flag-code { font-size: 10px; font-family: monospace; font-weight: 700; color: var(--subtext); letter-spacing: .04em; }
|
||||
.vr-flag:hover .flag-emoji { transform: scale(1.18); }
|
||||
.flag-picker { position: absolute; top: 100%; left: 0; z-index: 50; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px; display: none; flex-wrap: wrap; gap: 4px; width: max-content; max-width: 320px; max-height: 260px; overflow-y: auto; box-shadow: var(--shadow); }
|
||||
.flag-picker.open { display: flex; }
|
||||
.flag-opt { display: flex; flex-direction: column; align-items: center; gap: 2px; cursor: pointer; padding: 5px 7px; border-radius: 6px; font-size: 20px; transition: background .1s; }
|
||||
.flag-opt .fo-code { font-size: 10px; font-family: monospace; font-weight: 700; color: var(--subtext); }
|
||||
.flag-opt:hover { background: var(--border); }
|
||||
.flag-opt.active { background: rgba(37,99,235,.15); }
|
||||
.vr-gender { text-align: center; cursor: pointer; user-select: none; line-height: 1; display: flex; align-items: center; justify-content: center; flex: 0 0 auto; }
|
||||
.vr-gender span { width: 34px; height: 34px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: rgba(37,99,235,.08); border: 1px solid rgba(37,99,235,.22); font-size: 28px; font-weight: 800; transition: transform .15s, background .15s, border-color .15s; }
|
||||
.vr-gender span:hover { transform: scale(1.12); background: rgba(37,99,235,.15); border-color: var(--accent); }
|
||||
.flag-picker { display: none; }
|
||||
.vr-gender { display: none; }
|
||||
.gender-badge { display: inline-flex; align-items: center; gap: 5px; border-radius: 6px; padding: 4px 10px; background: rgba(37,99,235,.08); border: 1px solid rgba(37,99,235,.18); cursor: pointer; transition: border-color .15s, background .15s; font-size: 13px; font-weight: 600; }
|
||||
.gender-badge:hover { background: rgba(37,99,235,.14); border-color: var(--accent); }
|
||||
.gender-sym { font-size: 14px; line-height: 1; }
|
||||
.gender-txt { font-size: 12px; font-weight: 600; letter-spacing: .01em; }
|
||||
.g-f { color: #e91e8c; }
|
||||
.g-m { color: #0ea5e9; }
|
||||
.g-n { color: #8b5cf6; }
|
||||
.vr-name { display: flex; align-items: center; gap: 4px; min-width: 0; flex: 1; }
|
||||
.vr-name-text { font-weight: 700; color: var(--accent); font-family: monospace; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; cursor: text; }
|
||||
.vr-name-text { font-weight: 800; color: var(--text); font-family: inherit; font-size: 15px; overflow: hidden; text-overflow: ellipsis; flex: 1; cursor: text; white-space: nowrap; }
|
||||
.vr-name-input { flex: 1; background: var(--bg); border: 1px solid var(--accent); color: var(--text); border-radius: 4px; padding: 4px 8px; font-size: 13px; font-family: monospace; min-width: 0; }
|
||||
.icon-btn { background: none; border: none; padding: 3px 5px; font-size: 14px; color: var(--subtext); border-radius: 4px; cursor: pointer; flex-shrink: 0; }
|
||||
.icon-btn:hover { background: var(--border); color: var(--text); }
|
||||
@ -473,10 +505,10 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.vr-note input:focus { border-bottom-color: var(--accent); }
|
||||
.vr-note input::placeholder { color: var(--border); }
|
||||
.vr-row-subtle { color: var(--subtext); font-size: 11px; font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vr-optimizer { grid-column: auto; display: none; padding: 12px; border-top: 1px solid var(--border); margin: 6px 0 0; background: rgba(37,99,235,.04); border-radius: 6px; width: 100%; box-sizing: border-box; }
|
||||
.vr-optimizer { grid-column: auto; display: none; margin: 6px 0 0; width: 100%; box-sizing: border-box; }
|
||||
.vl-row.edit-open .vr-optimizer { display: block; }
|
||||
.optimizer-grid { display: grid; grid-template-columns: minmax(280px,1.2fr) minmax(280px,1fr); gap: 12px; align-items: start; }
|
||||
.opt-group { border: 1px solid var(--border); border-radius: 6px; background: var(--surface); padding: 10px; min-width: 0; }
|
||||
.opt-group { margin-top: 20px; margin-right: 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); padding: 10px; min-width: 0; }
|
||||
.opt-group-title { font-size: 11px; font-weight: 800; color: var(--accent); text-transform: uppercase; letter-spacing: .08em; margin-bottom: 8px; }
|
||||
.opt-group-note { font-size: 12px; color: var(--subtext); line-height: 1.4; margin-top: 7px; }
|
||||
.opt-compare-panel { grid-column: 1 / -1; }
|
||||
@ -493,6 +525,11 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.backend-tag { border: 1px solid var(--border); border-radius: 4px; padding: 2px 6px; background: var(--panel); color: var(--subtext); font-size: 12px; }
|
||||
.backend-tag.good { color: var(--green); border-color: rgba(22,163,74,.35); }
|
||||
.backend-tag.warn { color: var(--yellow); border-color: rgba(217,119,6,.35); }
|
||||
.style-backend-warn {
|
||||
margin-top: 6px; padding: 7px 10px; border-radius: 6px;
|
||||
background: rgba(220,38,38,.07); border: 1px solid rgba(220,38,38,.25);
|
||||
color: var(--red); font-size: 12px; line-height: 1.45;
|
||||
}
|
||||
.opt-wave { cursor: crosshair; touch-action: none; width: 100%; height: 86px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; display: block; }
|
||||
.opt-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: end; margin-top: 8px; }
|
||||
.crop-duration-hint { align-self: center; color: var(--subtext); font-size: 12px; padding: 7px 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); }
|
||||
@ -680,7 +717,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
#sidebar { display: none; }
|
||||
#main-content { padding: 0 12px 60px; }
|
||||
#main-content { padding: 0 12px 0; }
|
||||
.vl-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
@ -689,3 +726,660 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.settings-cluster-grid { grid-template-columns: 1fr; }
|
||||
.lib-add-input-grid, .lib-add-mic-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── Voices empty state ──────────────────────────────────────────────────── */
|
||||
.voices-empty-state { padding: 32px 8px; }
|
||||
.voices-empty-cases {
|
||||
display: flex; align-items: stretch; gap: 0;
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--surface); overflow: hidden;
|
||||
}
|
||||
.voices-empty-case {
|
||||
flex: 1; padding: 28px 24px; display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.voices-empty-case:first-child { border-right: 1px solid var(--border); }
|
||||
.voices-empty-case-icon { font-size: 32px; line-height: 1; }
|
||||
.voices-empty-case h4 { font-size: 15px; font-weight: 700; color: var(--text); margin: 0; }
|
||||
.voices-empty-case p { font-size: 13px; line-height: 1.6; color: var(--subtext); margin: 0; }
|
||||
.voices-empty-case code {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 4px; padding: 1px 5px; font-size: 12px;
|
||||
}
|
||||
.voices-empty-divider {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 40px; flex-shrink: 0;
|
||||
font-size: 11px; font-weight: 700; color: var(--subtext);
|
||||
text-transform: uppercase; letter-spacing: .08em;
|
||||
background: var(--panel); border-left: 1px solid var(--border); border-right: 1px solid var(--border);
|
||||
}
|
||||
.voices-folder-row { display: flex; gap: 8px; margin-top: 4px; }
|
||||
.voices-folder-input {
|
||||
flex: 1; min-width: 0; background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 8px 10px; font-size: 13px;
|
||||
color: var(--text); font-family: monospace;
|
||||
}
|
||||
.voices-folder-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,.12); }
|
||||
.voices-empty-hint { font-size: 11.5px; color: var(--subtext); opacity: .75; line-height: 1.7; margin-top: 2px; }
|
||||
.voices-empty-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
|
||||
.voices-empty-action-btn { width: 100%; }
|
||||
|
||||
/* ── Voices workbench ────────────────────────────────────────────────────── */
|
||||
.voices-workbench {
|
||||
display: flex; height: calc(100vh - 148px); min-height: 420px;
|
||||
gap: 10px; background: transparent; overflow: visible;
|
||||
}
|
||||
|
||||
/* List pane */
|
||||
.voices-list-pane {
|
||||
width: 380px; flex-shrink: 0;
|
||||
display: flex; flex-direction: column;
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--surface); overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.vl-toolbar {
|
||||
display: flex; flex-wrap: wrap; gap: 4px;
|
||||
padding: 8px 10px 7px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.vl-tb-btn { padding: 3px 8px !important; font-size: 11px !important; flex: 0 0 auto; }
|
||||
.vl-filters {
|
||||
display: flex; align-items: center; gap: 5px; flex-wrap: wrap;
|
||||
padding: 7px 10px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.vl-search {
|
||||
flex: 1; min-width: 60px; padding: 5px 8px; font-size: 12.5px;
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--bg); color: var(--text);
|
||||
}
|
||||
.vl-search:focus { outline: none; border-color: var(--accent); }
|
||||
.vl-filters select {
|
||||
padding: 4px 5px; font-size: 11.5px; border: 1px solid var(--border);
|
||||
border-radius: var(--radius); background: var(--bg); color: var(--text); max-width: 80px;
|
||||
}
|
||||
.vl-disabled-label { font-size: 11.5px; color: var(--subtext); white-space: nowrap; cursor: pointer; gap: 3px; display: flex; align-items: center; }
|
||||
#voice-list { flex: 1; overflow-y: auto; min-height: 0; }
|
||||
.vl-footer {
|
||||
padding: 7px 10px; border-top: 1px solid var(--border);
|
||||
font-size: 11.5px; color: var(--subtext);
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 6px;
|
||||
}
|
||||
.vl-add-btn { padding: 4px 9px !important; font-size: 11.5px !important; }
|
||||
|
||||
/* Compact voice row — override original card-style .vl-row */
|
||||
.voices-list-pane .vl-row {
|
||||
display: flex !important; flex-direction: row !important; align-items: center !important;
|
||||
gap: 9px !important; padding: 7px 10px !important; cursor: pointer;
|
||||
border: none !important; border-left: 3px solid transparent !important;
|
||||
border-radius: 0 !important; box-shadow: none !important;
|
||||
transform: none !important; transition: background .1s; user-select: none;
|
||||
background: var(--surface) !important;
|
||||
}
|
||||
.voices-list-pane .vl-row:hover { background: var(--panel) !important; }
|
||||
.voices-list-pane .vl-row.vr-selected { background: rgba(37,99,235,.07) !important; border-left-color: var(--accent) !important; }
|
||||
.voices-list-pane .vl-row.vr-disabled { opacity: .5; }
|
||||
/* Hide everything except the compact div */
|
||||
.voices-list-pane .vl-row .vr-main-row,
|
||||
.voices-list-pane .vl-row .vr-detail-row,
|
||||
.voices-list-pane .vl-row .vr-optimizer { display: none !important; }
|
||||
|
||||
/* Compact content block */
|
||||
.vl-compact { display: flex; align-items: center; gap: 9px; width: 100%; min-width: 0; }
|
||||
.vl-compact .vr-play-group { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.vl-compact .vr-play-group button {
|
||||
width: 26px; height: 26px; border-radius: 50%;
|
||||
border: none; font-size: 9px; display: flex; align-items: center; justify-content: center;
|
||||
padding: 0; cursor: pointer; transition: filter .15s;
|
||||
}
|
||||
.vl-compact .play-btn-orig { background: var(--accent); color: #fff; }
|
||||
.vl-compact .play-btn-synth { background: var(--teal); color: #fff; }
|
||||
.vl-compact .vr-play-group button:hover { filter: brightness(1.12); }
|
||||
|
||||
/* Avatar: rounded square, supports photo / flag-icon / initial */
|
||||
.vl-avatar {
|
||||
width: 42px; height: 42px; border-radius: var(--radius); flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vl-avatar-photo { padding: 0; }
|
||||
.vl-avatar-img { width: 100%; height: 100%; object-fit: cover; border-radius: var(--radius); }
|
||||
.vl-avatar-flag { background: transparent; }
|
||||
.vl-avatar-flag .fi { display: block; width: 100%; height: 100%; background-size: cover; background-position: center; border-radius: var(--radius); }
|
||||
.vl-avatar-initial { }
|
||||
.vl-avatar-letter { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; font-size: 17px; font-weight: 800; border-radius: var(--radius); }
|
||||
|
||||
/* Voice info block (name + type badge + gender chip) */
|
||||
.vl-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.vl-name { font-size: 12.5px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vl-row.vr-selected .vl-name { color: var(--accent); }
|
||||
.vl-meta { display: flex; align-items: center; gap: 6px; }
|
||||
/* Type label — no background, just colored text */
|
||||
.vl-type-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.vl-type-clone { color: var(--accent); }
|
||||
.vl-type-design { color: var(--mauve); }
|
||||
/* Separator dot between type and gender */
|
||||
.vl-type-label + .vl-gender-label::before { content: '·'; color: var(--border); margin-right: 6px; font-weight: 400; }
|
||||
/* Gender label — no background, just colored symbol + text */
|
||||
.vl-gender-label { font-size: 10px; font-weight: 600; }
|
||||
.vl-gender-label.g-f { color: #e91e8c; }
|
||||
.vl-gender-label.g-m { color: #0ea5e9; }
|
||||
.vl-gender-label.g-n { color: #8b5cf6; }
|
||||
.vl-flag { font-size: 11px; color: var(--subtext); }
|
||||
.vl-dbfs { display: none; }
|
||||
|
||||
/* Synth mode panel (segmented control + preview input) */
|
||||
.vl-synth-panel { background: var(--surface); border-bottom: 1px solid var(--border); }
|
||||
.vl-synth-mode-row { display: flex; align-items: center; gap: 7px; padding: 5px 8px 5px; }
|
||||
.vl-synth-icon { font-size: 9px; color: var(--teal); flex-shrink: 0; }
|
||||
.vl-synth-label { font-size: 10px; font-weight: 700; color: var(--subtext); white-space: nowrap; flex-shrink: 0; }
|
||||
.vl-synth-seg { display: flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; flex-shrink: 0; }
|
||||
.vl-synth-seg-btn {
|
||||
background: var(--panel); border: none; padding: 3px 9px;
|
||||
font-size: 10.5px; font-weight: 600; color: var(--subtext);
|
||||
cursor: pointer; transition: background .12s, color .12s; white-space: nowrap;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.vl-synth-seg-btn:last-child { border-right: none; }
|
||||
.vl-synth-seg-btn.active { background: var(--teal); color: #fff; }
|
||||
.vl-synth-seg-btn:not(.active):hover { background: var(--border); color: var(--text); }
|
||||
.vl-preview-text-wrap { padding: 0 8px 6px; }
|
||||
.vl-preview-input { width: 100%; font-size: 11px; background: var(--bg); border: 1px solid var(--border); border-radius: 5px; color: var(--text); padding: 3px 7px; font-family: var(--font); }
|
||||
.vl-preview-input:focus { outline: none; border-color: var(--accent); }
|
||||
.vl-preview-text-wrap.hidden { display: none; }
|
||||
|
||||
/* Inspector pane */
|
||||
.voices-inspector-pane {
|
||||
flex: 1; min-width: 0;
|
||||
background: var(--bg);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.inspector-placeholder {
|
||||
height: 100%; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 10px;
|
||||
color: var(--subtext); text-align: center; padding: 24px;
|
||||
}
|
||||
.inspector-placeholder span { font-size: 40px; opacity: .4; }
|
||||
.inspector-placeholder p { font-size: 13px; line-height: 1.6; opacity: .7; }
|
||||
|
||||
/* ── Inspector header ────────────────────────────────────────────────────── */
|
||||
.inspector-header {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
border-top-left-radius: var(--radius);
|
||||
border-top-right-radius: var(--radius);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
margin-right: 30px; /* cause of the scrollbar */
|
||||
}
|
||||
|
||||
/* Header top: [avatar 72px] + [title-stack flex:1] */
|
||||
.insp-hd-top {
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
padding: 12px 14px 8px;
|
||||
}
|
||||
|
||||
/* Avatar — compact square */
|
||||
.inspector-avatar {
|
||||
width: 120px; height: 120px; border-radius: var(--radius); flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 26px; font-weight: 700; color: #fff; overflow: hidden;
|
||||
}
|
||||
.insp-avatar-photo { padding: 0; }
|
||||
.insp-avatar-img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.inspector-avatar .fi { display: block; width: 100%; height: 100%; background-size: cover; background-position: center; }
|
||||
.insp-avatar-clickable { cursor: pointer; transition: opacity .15s; }
|
||||
.insp-avatar-clickable:hover { opacity: .75; }
|
||||
|
||||
/* Title stack — all right-side rows stacked vertically */
|
||||
.insp-title-stack { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; padding-top: 1px; }
|
||||
|
||||
/* Row 1: name + save button */
|
||||
.insp-hd-row1 { display: flex; align-items: center; gap: 8px; }
|
||||
.insp-disp-name {
|
||||
flex: 1; min-width: 0;
|
||||
font-size: 16px; font-weight: 600; color: var(--text); margin: 0; line-height: 1.3;
|
||||
cursor: default; user-select: text; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.insp-disp-name:hover { color: var(--accent); }
|
||||
.insp-name-edit {
|
||||
flex: 1; min-width: 0;
|
||||
font-size: 16px; font-weight: 600; color: var(--text);
|
||||
border: none; border-bottom: 2px solid var(--accent); background: transparent;
|
||||
outline: none; padding: 0; line-height: 1.3;
|
||||
}
|
||||
.insp-actions-save { flex-shrink: 0; }
|
||||
|
||||
/* Row 2: voice ID + copy + active toggle */
|
||||
.insp-hd-row2 { display: flex; align-items: center; gap: 5px; }
|
||||
.insp-id-block { display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0; }
|
||||
.insp-full-id {
|
||||
font-size: 10px; color: var(--subtext); font-family: monospace; font-weight: 400;
|
||||
cursor: default; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0;
|
||||
}
|
||||
.insp-full-id:hover { color: var(--accent); }
|
||||
.insp-copy-id-btn {
|
||||
background: none; border: 1px solid var(--border); color: var(--subtext);
|
||||
font-size: 9px; cursor: pointer; padding: 1px 4px; border-radius: 3px;
|
||||
transition: color .15s, border-color .15s; flex-shrink: 0; white-space: nowrap;
|
||||
}
|
||||
.insp-copy-id-btn:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.insp-actions-active { display: flex; align-items: center; flex-shrink: 0; }
|
||||
.insp-actions-active .vr-detail-active { display: flex; align-items: center; gap: 5px; }
|
||||
.insp-actions-active .vr-detail-active > label:first-child { font-size: 10px; font-weight: 500; color: var(--subtext); }
|
||||
.insp-actions-active .toggle { width: 32px; height: 18px; }
|
||||
.insp-actions-active .t-slider::before { height: 12px; width: 12px; left: 3px; bottom: 3px; }
|
||||
.insp-actions-active .toggle input:checked + .t-slider::before { transform: translateX(14px); }
|
||||
|
||||
/* Divider between ID row and subtitle */
|
||||
.insp-hd-divider { height: 1px; background: var(--border); margin: 2px 0; }
|
||||
|
||||
/* Note + delete bottom row */
|
||||
.insp-note-delete-row { display: flex; align-items: center; min-width: 0; }
|
||||
.insp-note-slot { flex: 1; min-width: 0; display: flex; }
|
||||
.insp-note-slot .vr-note { display: flex; flex: 1; }
|
||||
.insp-note-slot .vr-note label { display: none; }
|
||||
.insp-note-slot .vr-note .vr-inline { display: flex; flex: 1; }
|
||||
.insp-note-slot .vr-note .vr-inline input {
|
||||
width: 100%; padding: 2px 0; font-size: 11px; font-style: italic; font-family: inherit;
|
||||
border: none; background: transparent; color: var(--text); outline: none; transition: color .15s;
|
||||
}
|
||||
.insp-note-slot .vr-note .vr-inline input::placeholder { color: var(--subtext); opacity: .6; }
|
||||
.insp-note-slot .vr-note .vr-inline input:focus { font-style: normal; }
|
||||
.insp-note-slot .ref-transcribe-btn { display: none !important; }
|
||||
.insp-actions-delete { flex-shrink: 0; display: flex; align-items: center; padding-left: 8px; }
|
||||
.insp-actions-delete .vr-detail-delete { display: flex; align-items: center; }
|
||||
.insp-actions-delete .delete-btn {
|
||||
background: none; border: none; color: var(--subtext); font-size: 10.5px;
|
||||
cursor: pointer; padding: 0; text-decoration: underline dotted;
|
||||
text-underline-offset: 2px; transition: color .15s; white-space: nowrap;
|
||||
}
|
||||
.insp-actions-delete .delete-btn:hover { color: var(--red); }
|
||||
.insp-actions-delete .delete-confirm {
|
||||
position: static; min-width: auto; max-width: none; box-shadow: none;
|
||||
display: none; align-items: center; gap: 4px; font-size: 10.5px;
|
||||
}
|
||||
.insp-actions-delete .vr-detail-delete.delete-pending .delete-confirm { display: flex; }
|
||||
.insp-actions-delete .vr-detail-delete.delete-pending .delete-btn { display: none; }
|
||||
|
||||
/* Subtitle: pipe-separated info bar */
|
||||
.insp-subtitle {
|
||||
display: flex; align-items: center; gap: 0; overflow: hidden;
|
||||
}
|
||||
.insp-subtitle > * {
|
||||
padding: 0 6px; flex-shrink: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
white-space: nowrap; line-height: 1;
|
||||
}
|
||||
.insp-subtitle > *:first-child { padding-left: 0; }
|
||||
.insp-subtitle > *:last-child { border-right: none; flex: 1; flex-shrink: 1; min-width: 0; }
|
||||
|
||||
/* Flag (small inline icon, dblclick → picker) */
|
||||
.insp-flag { display: flex; align-items: center; cursor: pointer; gap: 3px; }
|
||||
.insp-flag-icon { display: flex; align-items: center; line-height: 1; }
|
||||
.insp-flag-icon .fi { width: 22px; height: 16px; background-size: cover; background-position: center; border-radius: 2px; display: inline-block; flex-shrink: 0; }
|
||||
.insp-flag-sel {
|
||||
font-size: 11px; padding: 1px 4px; border: 1px solid var(--accent);
|
||||
border-radius: 4px; background: var(--surface); color: var(--text); outline: none;
|
||||
}
|
||||
/* Lang wrap — dblclick to edit language */
|
||||
.insp-lang-wrap { display: flex; align-items: center; cursor: default; }
|
||||
.insp-lang-code { font-size: 11.5px; font-weight: 600; color: var(--subtext); }
|
||||
.insp-lang-sel {
|
||||
font-size: 11px; padding: 1px 4px; border: 1px solid var(--accent);
|
||||
border-radius: 4px; background: var(--surface); color: var(--text); outline: none;
|
||||
}
|
||||
|
||||
/* Gender label — click to cycle */
|
||||
.insp-gender-label {
|
||||
font-size: 11.5px; color: var(--subtext); cursor: pointer; user-select: none;
|
||||
}
|
||||
.insp-gender-label:hover { color: var(--text); }
|
||||
|
||||
/* Stars */
|
||||
.insp-stars-wrap { display: flex; align-items: center; gap: 2px; cursor: pointer; }
|
||||
.insp-stars { display: flex; gap: 0; }
|
||||
.insp-star { font-size: 12px; color: var(--border); cursor: pointer; transition: color .1s; line-height: 1; }
|
||||
.insp-star.on { color: #f59e0b; }
|
||||
.insp-rating-label { font-size: 10px; color: var(--subtext); font-weight: 600; opacity: .75; }
|
||||
|
||||
/* Tag input — inline last item in subtitle */
|
||||
.insp-tag-input {
|
||||
border: none; background: transparent; color: var(--subtext);
|
||||
font-size: 11.5px; font-family: inherit; outline: none;
|
||||
padding: 0 6px; min-width: 50px;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.insp-tag-input::placeholder { color: var(--subtext); opacity: .5; font-style: italic; }
|
||||
.insp-tag-input:hover { color: var(--text); }
|
||||
.insp-tag-input:focus { background: var(--panel); color: var(--text); }
|
||||
|
||||
.insp-save-btn { white-space: nowrap; font-size: 12px; padding: 4px 10px; }
|
||||
|
||||
|
||||
/* ── Searchable picker (flag / language) ─────────────────────────────────── */
|
||||
.insp-picker {
|
||||
position: fixed; z-index: 9999;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); box-shadow: 0 4px 20px rgba(0,0,0,0.14);
|
||||
width: 244px; max-height: 290px;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.insp-picker-search {
|
||||
padding: 8px 10px; border: none; border-bottom: 1px solid var(--border);
|
||||
font-size: 12px; font-family: inherit; background: var(--panel);
|
||||
color: var(--text); outline: none; flex-shrink: 0;
|
||||
}
|
||||
.insp-picker-search::placeholder { color: var(--subtext); opacity: .65; }
|
||||
.insp-picker-list { flex: 1; overflow-y: auto; padding: 3px 0; }
|
||||
.insp-picker-item {
|
||||
display: flex; align-items: center; width: 100%; gap: 8px;
|
||||
padding: 5px 10px; background: none; border: none; border-radius: 0;
|
||||
font-size: 12px; font-family: inherit; color: var(--text);
|
||||
cursor: pointer; text-align: left; white-space: nowrap;
|
||||
transition: background .1s;
|
||||
}
|
||||
.insp-picker-item:hover { background: var(--panel); filter: none; }
|
||||
.insp-picker-item .ipi-code {
|
||||
font-family: monospace; font-weight: 700; font-size: 11px;
|
||||
color: var(--accent); min-width: 28px; flex-shrink: 0;
|
||||
}
|
||||
.insp-picker-empty { padding: 10px; font-size: 12px; color: var(--subtext); text-align: center; }
|
||||
|
||||
/* ── Inspector body ───────────────────────────────────────────────────────── */
|
||||
.inspector-body { display: flex; flex-direction: column; gap: 10px; flex: 1; overflow-y: auto;}
|
||||
|
||||
/* Optimizer: grid becomes transparent so opt-groups become flex children */
|
||||
.inspector-body .vr-optimizer { display: block !important; }
|
||||
.inspector-body .optimizer-grid { display: contents; }
|
||||
.inspector-body .opt-status { display: none !important; }
|
||||
|
||||
/* Each opt-group as a clean card */
|
||||
.inspector-body .opt-group {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
|
||||
}
|
||||
.inspector-body .opt-group-title {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 9px 14px; font-size: 10.5px; font-weight: 700; letter-spacing: .05em;
|
||||
text-transform: uppercase; color: var(--subtext);
|
||||
border-bottom: 1px solid var(--border); background: var(--panel);
|
||||
}
|
||||
.inspector-body .opt-group canvas.opt-wave { display: block; width: 100%; height: 72px; background: var(--bg); }
|
||||
.inspector-body .opt-group > .opt-controls { padding: 10px 14px; display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
|
||||
.inspector-body .opt-group > textarea {
|
||||
display: block; width: 100%; box-sizing: border-box; padding: 10px 14px; margin: 0;
|
||||
background: transparent; border: none; outline: none; resize: vertical;
|
||||
font-size: 13px; color: var(--text); font-family: inherit; min-height: 80px;
|
||||
}
|
||||
|
||||
/* Section order */
|
||||
.inspector-body .opt-trim-panel { order: 10; }
|
||||
.inspector-body .opt-maintenance { order: 20; }
|
||||
.inspector-body .opt-text-panel { order: 30; }
|
||||
.inspector-body .opt-compare-panel { order: 40; }
|
||||
.inspector-body .opt-style-panel { order: 50; }
|
||||
|
||||
/* vr-main-row in inspector — metadata grid (lang, gender, rating, rename) */
|
||||
.inspector-body .vr-main-row {
|
||||
order: 55;
|
||||
display: flex !important; flex-wrap: wrap; gap: 8px; align-items: center;
|
||||
padding: 10px 14px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
}
|
||||
.inspector-body .vr-main-row .vr-photo,
|
||||
.inspector-body .vr-main-row .vr-type,
|
||||
.inspector-body .vr-main-row .vr-length,
|
||||
.inspector-body .vr-main-row .vr-db,
|
||||
.inspector-body .vr-main-row .vr-bench,
|
||||
.inspector-body .vr-main-row .vr-edit,
|
||||
.inspector-body .vr-main-row .vr-play-group { display: none !important; }
|
||||
.inspector-body .vr-main-row .vr-identity { display: flex; gap: 8px; align-items: center; flex: 1; min-width: 0; }
|
||||
.inspector-body .vr-main-row .vr-rating { display: flex; gap: 2px; align-items: center; }
|
||||
|
||||
/* vr-detail-row in inspector — Note only (transcript is in opt-text-panel) */
|
||||
.inspector-body .vr-detail-row {
|
||||
order: 60;
|
||||
display: flex !important; flex-direction: column; gap: 0;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
|
||||
}
|
||||
.inspector-body .vr-detail-row .vr-detail-active,
|
||||
.inspector-body .vr-detail-row .vr-detail-delete { display: none !important; }
|
||||
.inspector-body .vr-detail-row .vr-ref { display: none !important; }
|
||||
.inspector-body .vr-note { padding: 10px 14px; display: flex; flex-direction: column; gap: 5px; }
|
||||
.inspector-body .vr-note label {
|
||||
font-size: 10px; font-weight: 700; color: var(--subtext);
|
||||
text-transform: uppercase; letter-spacing: .07em;
|
||||
}
|
||||
.inspector-body .vr-note input {
|
||||
background: transparent; border: none; outline: none;
|
||||
font-size: 13.5px; color: var(--text); width: 100%; padding: 0;
|
||||
}
|
||||
.inspector-body .vr-inline { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.opt-group-meta {
|
||||
font-size: 11px; font-weight: 600; color: var(--accent);
|
||||
text-transform: none; letter-spacing: 0; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* vr-main-row in inspector: always hidden (metadata shown in header grid) */
|
||||
.inspector-body .vr-main-row { display: none !important; }
|
||||
|
||||
/* ── AI Backends / LLMs section ──────────────────────────────────────────── */
|
||||
|
||||
/* Category pill tabs */
|
||||
.llm-cat-tabs {
|
||||
display: flex; gap: 6px; margin-bottom: 22px; flex-wrap: wrap;
|
||||
}
|
||||
.llm-cat-tab {
|
||||
padding: 6px 18px; font-size: 13px; font-weight: 600;
|
||||
border: 1.5px solid var(--border); border-radius: 20px;
|
||||
background: var(--surface); color: var(--subtext);
|
||||
cursor: pointer; transition: all .15s; line-height: 1.4;
|
||||
}
|
||||
.llm-cat-tab:hover { border-color: var(--accent); color: var(--accent); filter: none; }
|
||||
.llm-cat-tab.active {
|
||||
background: var(--accent); color: var(--on-accent);
|
||||
border-color: var(--accent); box-shadow: 0 2px 10px rgba(37,99,235,.28);
|
||||
}
|
||||
|
||||
/* Animated section reveal */
|
||||
.llm-section { animation: llm-fadein .18s ease; }
|
||||
@keyframes llm-fadein { from { opacity:0; transform:translateY(5px); } to { opacity:1; transform:none; } }
|
||||
|
||||
/* Section header row */
|
||||
.llm-sec-header {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: 16px; margin-bottom: 18px; flex-wrap: wrap;
|
||||
}
|
||||
.llm-sec-title { font-size: 15px; font-weight: 700; color: var(--text); margin: 0 0 4px; }
|
||||
.llm-sec-note { font-size: 13px; color: var(--subtext); margin: 0; line-height: 1.5; max-width: 640px; }
|
||||
.llm-free-badge {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: .02em;
|
||||
color: var(--green); background: rgba(22,163,74,.10); border: 1px solid rgba(22,163,74,.22);
|
||||
border-radius: 12px; padding: 3px 12px; white-space: nowrap; flex-shrink: 0; align-self: flex-start;
|
||||
}
|
||||
.llm-free-local { color: var(--teal); background: rgba(13,148,136,.10); border-color: rgba(13,148,136,.22); }
|
||||
|
||||
/* ── Cloud service cards grid ─────────────────────────── */
|
||||
.llm-service-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); gap: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.llm-card {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 16px 18px; display: flex; flex-direction: column; gap: 11px;
|
||||
transition: box-shadow .2s, border-color .2s, transform .15s;
|
||||
}
|
||||
.llm-card:hover { box-shadow: 0 4px 18px rgba(37,99,235,.11); border-color: rgba(37,99,235,.28); transform: translateY(-1px); }
|
||||
.llm-card-highlight {
|
||||
border-color: var(--accent); border-width: 1.5px;
|
||||
background: linear-gradient(135deg, rgba(37,99,235,.03) 0%, var(--surface) 100%);
|
||||
}
|
||||
|
||||
/* Card head: icon bubble + name block + tier badge */
|
||||
.llm-card-head { display: flex; align-items: center; gap: 11px; }
|
||||
.llm-card-icon {
|
||||
font-size: 20px; flex-shrink: 0; line-height: 1;
|
||||
width: 42px; height: 42px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
}
|
||||
.llm-card-name { font-size: 14px; font-weight: 700; color: var(--text); line-height: 1.2; }
|
||||
.llm-card-sub { font-size: 11px; color: var(--subtext); margin-top: 2px; line-height: 1.35; }
|
||||
.llm-tier-badge {
|
||||
margin-left: auto; flex-shrink: 0;
|
||||
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
|
||||
padding: 3px 9px; border-radius: 10px;
|
||||
}
|
||||
.llm-tier-free { color: var(--green); background: rgba(22,163,74,.10); border: 1px solid rgba(22,163,74,.22); }
|
||||
.llm-tier-demo { color: var(--yellow); background: rgba(217,119,6,.10); border: 1px solid rgba(217,119,6,.22); }
|
||||
.llm-tier-paid { color: var(--mauve); background: rgba(124,58,237,.10); border: 1px solid rgba(124,58,237,.22); }
|
||||
|
||||
/* Quick-stat chips */
|
||||
.llm-card-stats { display: flex; gap: 5px; flex-wrap: wrap; }
|
||||
.llm-card-stats span {
|
||||
font-size: 11px; color: var(--subtext);
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
padding: 2px 9px; border-radius: 10px; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* API key row */
|
||||
.llm-field-row { display: flex; align-items: center; gap: 7px; }
|
||||
.llm-label { font-size: 11px; font-weight: 600; color: var(--subtext); white-space: nowrap; flex-shrink: 0; }
|
||||
.llm-input {
|
||||
flex: 1; min-width: 0; font-size: 12px; font-family: monospace;
|
||||
padding: 5px 10px; border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--panel); color: var(--text); outline: none; transition: border-color .15s, background .15s;
|
||||
}
|
||||
.llm-input:focus { border-color: var(--accent); background: var(--surface); box-shadow: 0 0 0 3px rgba(37,99,235,.10); }
|
||||
.llm-link {
|
||||
font-size: 11px; color: var(--accent); text-decoration: none; white-space: nowrap;
|
||||
flex-shrink: 0; font-weight: 600; transition: opacity .15s;
|
||||
}
|
||||
.llm-link:hover { opacity: .75; }
|
||||
|
||||
/* Endpoint display */
|
||||
.llm-endpoint {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 5px 10px; background: var(--panel); border-radius: 6px;
|
||||
border: 1px solid var(--border); font-size: 11px; overflow: hidden;
|
||||
}
|
||||
.llm-endpoint-label { color: var(--subtext); font-weight: 600; white-space: nowrap; flex-shrink: 0; }
|
||||
.llm-endpoint code {
|
||||
font-family: monospace; font-size: 11px; color: var(--teal);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0;
|
||||
}
|
||||
|
||||
/* Model tags */
|
||||
.llm-models { display: flex; gap: 5px; flex-wrap: wrap; }
|
||||
.llm-model-tag {
|
||||
font-size: 10.5px; font-family: monospace; color: var(--accent);
|
||||
background: rgba(37,99,235,.07); border: 1px solid rgba(37,99,235,.18);
|
||||
padding: 2px 8px; border-radius: 4px; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Info / tip box */
|
||||
.llm-info-box {
|
||||
font-size: 12px; color: var(--subtext); line-height: 1.55;
|
||||
background: var(--panel); border-radius: 7px; padding: 10px 13px;
|
||||
border-left: 3px solid var(--yellow);
|
||||
}
|
||||
.llm-link-inline { color: var(--accent); text-decoration: none; font-weight: 600; }
|
||||
.llm-link-inline:hover { text-decoration: underline; }
|
||||
.llm-info-note { font-size: 11px; color: var(--subtext); font-style: italic; }
|
||||
.llm-info-note code { font-family: monospace; color: var(--teal); font-style: normal; }
|
||||
|
||||
/* ── Local services ────────────────────────────────────── */
|
||||
.llm-local-cat {
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .07em;
|
||||
color: var(--subtext); margin: 0 0 10px;
|
||||
padding-bottom: 7px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.llm-local-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.llm-local-card {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 14px 16px; display: flex; flex-direction: column; gap: 9px;
|
||||
transition: box-shadow .2s, border-color .2s;
|
||||
}
|
||||
.llm-local-card:hover { box-shadow: 0 4px 16px rgba(13,148,136,.12); border-color: rgba(13,148,136,.35); }
|
||||
.llm-local-card-running { border-color: var(--teal); background: rgba(13,148,136,.025); }
|
||||
|
||||
.llm-local-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.llm-local-name { font-size: 13.5px; font-weight: 700; color: var(--text); }
|
||||
.llm-local-compat {
|
||||
font-size: 10px; font-weight: 600; color: var(--subtext);
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
padding: 2px 8px; border-radius: 10px; white-space: nowrap;
|
||||
}
|
||||
.llm-compat-detected {
|
||||
color: var(--teal); background: rgba(13,148,136,.10); border-color: rgba(13,148,136,.28);
|
||||
}
|
||||
.llm-local-desc { font-size: 12px; color: var(--subtext); line-height: 1.5; margin: 0; }
|
||||
|
||||
.llm-local-url {
|
||||
display: flex; align-items: center; gap: 7px; flex-wrap: wrap;
|
||||
font-size: 11px; font-weight: 600; color: var(--subtext);
|
||||
background: var(--panel); padding: 4px 9px; border-radius: 5px; border: 1px solid var(--border);
|
||||
}
|
||||
.llm-local-url code { font-family: monospace; font-size: 11px; color: var(--teal); }
|
||||
|
||||
/* Code snippet block with dark theme */
|
||||
.llm-local-snippet { background: #1b1f2e; border-radius: 8px; overflow: hidden; }
|
||||
.llm-snippet-bar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 5px 11px; background: rgba(255,255,255,.04); border-bottom: 1px solid rgba(255,255,255,.06);
|
||||
font-size: 10.5px; color: rgba(255,255,255,.4); font-weight: 600; letter-spacing: .02em;
|
||||
}
|
||||
.llm-copy-btn {
|
||||
font-size: 10px; padding: 2px 9px; border-radius: 4px;
|
||||
background: rgba(255,255,255,.10); border: 1px solid rgba(255,255,255,.18);
|
||||
color: rgba(255,255,255,.65); cursor: pointer; transition: background .15s; font-family: inherit;
|
||||
}
|
||||
.llm-copy-btn:hover { background: rgba(255,255,255,.2); filter: none; }
|
||||
.llm-local-snippet pre {
|
||||
margin: 0; padding: 10px 12px; color: #9ecfb0; font-family: monospace;
|
||||
font-size: 11px; line-height: 1.65; overflow-x: auto; white-space: pre;
|
||||
}
|
||||
.llm-local-link {
|
||||
font-size: 11.5px; color: var(--accent); text-decoration: none; font-weight: 600;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.llm-local-link:hover { text-decoration: underline; }
|
||||
|
||||
/* Password show/hide toggle */
|
||||
.llm-eye-btn {
|
||||
background: none; border: 1px solid var(--border); border-radius: 5px;
|
||||
padding: 3px 6px; font-size: 13px; cursor: pointer; color: var(--subtext);
|
||||
flex-shrink: 0; transition: border-color .15s, opacity .15s; line-height: 1; opacity: .55;
|
||||
}
|
||||
.llm-eye-btn:hover { border-color: var(--accent); opacity: 1; filter: none; }
|
||||
.llm-eye-btn.active { opacity: 1; border-color: var(--accent); }
|
||||
|
||||
/* "Key saved" indicator inside card head */
|
||||
.llm-saved-badge {
|
||||
margin-left: auto; font-size: 10px; font-weight: 700; letter-spacing: .02em;
|
||||
color: var(--green); background: rgba(22,163,74,.10); border: 1px solid rgba(22,163,74,.22);
|
||||
border-radius: 10px; padding: 2px 9px; white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
/* Override margin-left:auto on tier badge when saved badge is also present */
|
||||
.llm-card-head .llm-tier-badge { margin-left: auto; }
|
||||
.llm-card-head .llm-saved-badge { margin-left: 0; }
|
||||
|
||||
/* How-to tip panel */
|
||||
.llm-local-howto {
|
||||
display: flex; align-items: flex-start; gap: 14px; margin-top: 20px;
|
||||
background: var(--panel); border-radius: var(--radius); padding: 16px 18px;
|
||||
border-left: 4px solid var(--accent);
|
||||
}
|
||||
.llm-howto-icon { font-size: 22px; flex-shrink: 0; margin-top: 1px; }
|
||||
.llm-howto-body { font-size: 13px; line-height: 1.55; color: var(--text); }
|
||||
.llm-howto-body strong { display: block; margin-bottom: 5px; }
|
||||
.llm-howto-body p { margin: 0; color: var(--subtext); }
|
||||
.llm-howto-body em { font-style: normal; font-weight: 600; color: var(--text); }
|
||||
|
||||
/* Bottom action buttons */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user