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:
mARTin-B78 2026-05-26 11:23:27 +02:00
parent 6583a9fc80
commit 28f5ec2e25
7 changed files with 2218 additions and 311 deletions

View File

@ -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>&#128266;</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}">&#9733;</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">&#9888; 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">&#128193;</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 &amp; 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">&#127908;</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">&#9889; Clone a Voice</button>
<button class="btn-secondary voices-empty-action-btn" id="voices-goto-studio">&#127760; 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">&#9654;</button></div>
<div class="vr-play vr-play-synth"><button class="play-btn-synth" title="Generate and play TTS preview">&#9654;</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">&#128100;</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 &middot; 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();

View File

@ -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">&#128266;</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">&#128266;</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">&#8964;</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">&#127908;</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">&#129504;</span> LLMs
</div>
<div class="nav-item" data-nav-section="s-routing" onclick="navTo('s-routing')">
<span class="nav-icon">&#8652;</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 -->

View File

@ -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) {

View File

@ -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
View File

@ -0,0 +1,634 @@
<div class="section-head">
<span class="section-icon">&#129302;</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">&#127908; ASR &middot; STT</button>
<button class="llm-cat-tab" data-llm-cat="tts">&#128266; TTS</button>
<button class="llm-cat-tab" data-llm-cat="llm">&#129302; LLM</button>
<button class="llm-cat-tab" data-llm-cat="local">&#128187; 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 &middot; 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">&#9889;</span>
<div>
<div class="llm-card-name">Groq Whisper</div>
<div class="llm-card-sub">whisper-large-v3-turbo &middot; 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_&hellip;" data-llm-key="groq_stt">
<a class="llm-link" href="https://console.groq.com" target="_blank" rel="noopener">Get key &#8599;</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">&#129303;</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_&hellip;" data-llm-key="hf_stt">
<a class="llm-link" href="https://huggingface.co/settings/tokens" target="_blank" rel="noopener">Get key &#8599;</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">&#128203;</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&hellip;" data-llm-key="assemblyai">
<a class="llm-link" href="https://www.assemblyai.com" target="_blank" rel="noopener">Get key &#8599;</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">&#128137;</span>
<div>
<div class="llm-card-name">ElevenLabs</div>
<div class="llm-card-sub">High-quality voice cloning &amp; 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&hellip;" data-llm-key="elevenlabs">
<a class="llm-link" href="https://elevenlabs.io" target="_blank" rel="noopener">Get key &#8599;</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">&#127981;</span>
<div>
<div class="llm-card-name">Fish Audio</div>
<div class="llm-card-sub">Voice cloning &amp; 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&hellip;" data-llm-key="fish_audio">
<a class="llm-link" href="https://fish.audio" target="_blank" rel="noopener">Get key &#8599;</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">&#127925;</span>
<div>
<div class="llm-card-name">Kokoro TTS</div>
<div class="llm-card-sub">82M model &middot; 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>&lt;$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">&#8594; 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">&#9889;</span>
<div>
<div class="llm-card-name">Groq</div>
<div class="llm-card-sub">Ultra-fast inference &middot; 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_&hellip;" data-llm-key="groq_llm">
<a class="llm-link" href="https://console.groq.com" target="_blank" rel="noopener">Get key &#8599;</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">&#127760;</span>
<div>
<div class="llm-card-name">OpenRouter</div>
<div class="llm-card-sub">50+ free models &middot; 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-&hellip;" data-llm-key="openrouter">
<a class="llm-link" href="https://openrouter.ai" target="_blank" rel="noopener">Get key &#8599;</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">&#128142;</span>
<div>
<div class="llm-card-name">Google Gemini</div>
<div class="llm-card-sub">Gemini 2.5 Flash &middot; 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&hellip;" data-llm-key="gemini">
<a class="llm-link" href="https://ai.google.dev" target="_blank" rel="noopener">Get key &#8599;</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 &mdash; use model <code>gemini-2.5-flash</code></div>
</div>
<div class="llm-card">
<div class="llm-card-head">
<span class="llm-card-icon">&#127315;</span>
<div>
<div class="llm-card-name">Mistral AI</div>
<div class="llm-card-sub">OpenAI-compatible &middot; 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&hellip;" data-llm-key="mistral">
<a class="llm-link" href="https://mistral.ai" target="_blank" rel="noopener">Get key &#8599;</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 &mdash; 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&#10;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 &#8599;</a>
</div>
<div class="llm-local-card llm-local-card-running">
<div class="llm-local-head">
<span class="llm-local-name">vLLM &#9889;</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 &#8599;</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 &#8599;</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 &#8599;</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:&#10; whisper:&#10; image: fedirz/faster-whisper-server:latest-cuda&#10; ports: [&quot;8000:8000&quot;]&#10; environment:&#10; - WHISPER__MODEL=large-v3&#10; deploy:&#10; resources:&#10; reservations:&#10; 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 &#8599;</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 &amp; run</span>
<button class="llm-copy-btn" data-copy="git clone https://github.com/ggml-org/whisper.cpp&#10;cd whisper.cpp && cmake -B build && cmake --build build -j&#10;./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 &#8599;</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 &#8599;</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&rsquo;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 &#8599;</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&rsquo;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 &#8599;</a>
</div>
</div>
<!-- Generic how-to tip -->
<div class="llm-local-howto">
<div class="llm-howto-icon">&#128161;</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&rsquo;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(/&#10;/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>

View File

@ -1,250 +1,239 @@
<div class="section-head">
<span class="section-icon">&#128266;</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&#x2026;" 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">&#8635; 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 &middot; MP3 &middot; OGG &middot; FLAC &middot; M4A &middot; MP4 &middot; MKV &middot; 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">&#9679; Record</button>
<button class="btn-secondary" id="lib-add-rec-stop" disabled>&#9632; 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">-&#8734; 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&ntilde;ol</option>
<option value="FR">Fran&ccedil;ais</option>
<option value="PT">Portugu&ecirc;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&#x2026;" 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">&#9654;&#9654;</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&#x2019;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&#x2026;"
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">&#8635; 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>&#128266;</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 &middot; MP3 &middot; OGG &middot; FLAC &middot; M4A &middot; MP4 &middot; MKV &middot; 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">&#9679; Record</button>
<button class="btn-secondary" id="lib-add-rec-stop" disabled>&#9632; 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">-&#8734; 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&ntilde;ol</option>
<option value="FR">Fran&ccedil;ais</option><option value="PT">Portugu&ecirc;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 &rarr; Microphone &rarr; Allow, then reload.</li>
<li><strong>Firefox:</strong> click the lock icon &rarr; remove Blocked &rarr; Allow, then reload.</li>
<li><strong>Safari:</strong> Settings &rarr; Websites &rarr; Microphone &rarr; 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 -->

View File

@ -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 */