Improve benchmark voice selection and device reporting
This commit is contained in:
parent
7b855394a5
commit
1f4784653c
@ -79,6 +79,7 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||||
|
|
||||||
<!-- Hidden tabs nav — required for JS backend availability detection -->
|
<!-- Hidden tabs nav — required for JS backend availability detection -->
|
||||||
<nav class="tabs" style="display:none" aria-hidden="true">
|
<nav class="tabs" style="display:none" aria-hidden="true">
|
||||||
@ -186,7 +187,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main content — sections injected by loader.js -->
|
<!-- Main content — sections injected by loader.js -->
|
||||||
<main id="main-content">
|
<main id="main-content" tabindex="-1">
|
||||||
|
|
||||||
<!-- ── Skeleton loading view ─────────────────────────────────────────── -->
|
<!-- ── Skeleton loading view ─────────────────────────────────────────── -->
|
||||||
<!-- Visible immediately; loader.js fades it out when real content is ready -->
|
<!-- Visible immediately; loader.js fades it out when real content is ready -->
|
||||||
|
|||||||
@ -4,12 +4,14 @@
|
|||||||
const batchBackendSel = $('batch-backend-select');
|
const batchBackendSel = $('batch-backend-select');
|
||||||
const batchRunsSel = $('batch-runs');
|
const batchRunsSel = $('batch-runs');
|
||||||
const batchLoadBtn = $('batch-load-voices-btn');
|
const batchLoadBtn = $('batch-load-voices-btn');
|
||||||
|
const batchSearchInput = $('batch-voice-search');
|
||||||
const batchSelectAllBtn = $('batch-select-all-btn');
|
const batchSelectAllBtn = $('batch-select-all-btn');
|
||||||
const batchSelectNoneBtn = $('batch-select-none-btn');
|
const batchSelectNoneBtn = $('batch-select-none-btn');
|
||||||
const batchVoiceList = $('batch-voice-list');
|
const batchVoiceList = $('batch-voice-list');
|
||||||
const batchSelCount = $('batch-selected-count');
|
const batchSelCount = $('batch-selected-count');
|
||||||
const batchRunBtn = $('batch-run-btn');
|
const batchRunBtn = $('batch-run-btn');
|
||||||
const batchStopBtn = $('batch-stop-btn');
|
const batchStopBtn = $('batch-stop-btn');
|
||||||
|
const perfRunSelectedBtn = $('perf-run-selected-btn');
|
||||||
const batchProgress = $('batch-progress');
|
const batchProgress = $('batch-progress');
|
||||||
const batchProgLabel = $('batch-progress-label');
|
const batchProgLabel = $('batch-progress-label');
|
||||||
const batchProgCount = $('batch-progress-count');
|
const batchProgCount = $('batch-progress-count');
|
||||||
@ -21,6 +23,8 @@
|
|||||||
|
|
||||||
let batchStopped = false;
|
let batchStopped = false;
|
||||||
let batchResults = [];
|
let batchResults = [];
|
||||||
|
let batchVoices = [];
|
||||||
|
let batchSelected = new Set();
|
||||||
|
|
||||||
function populateBatchBackends() {
|
function populateBatchBackends() {
|
||||||
if (!batchBackendSel) return;
|
if (!batchBackendSel) return;
|
||||||
@ -31,66 +35,172 @@
|
|||||||
}
|
}
|
||||||
populateBatchBackends();
|
populateBatchBackends();
|
||||||
|
|
||||||
function updateSelCount() {
|
function libraryVoice(id) {
|
||||||
if (!batchVoiceList || !batchSelCount) return;
|
return (window._voices || []).find(v => v && v.id === id) || null;
|
||||||
const total = batchVoiceList.querySelectorAll('.batch-voice-cb').length;
|
|
||||||
const checked = batchVoiceList.querySelectorAll('.batch-voice-cb:checked').length;
|
|
||||||
batchSelCount.textContent = total ? `${checked} of ${total} selected` : '';
|
|
||||||
batchRunBtn.disabled = checked === 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildVoiceList(voices) {
|
function normalizeBatchVoice(value) {
|
||||||
const activeIds = new Set(activeVoiceIds());
|
const id = typeof value === 'string' ? value : (value?.id || value?.voice || value?.name || value?.voice_id || '');
|
||||||
if (!voices.length) {
|
const meta = typeof value === 'object' ? (value.meta || libraryVoice(id) || value) : (libraryVoice(id) || null);
|
||||||
batchVoiceList.innerHTML = '<div class="perf-history-empty">No voices found.</div>';
|
return id ? {
|
||||||
|
id,
|
||||||
|
label: meta?.display_name || value?.label || meta?.name || value?.name || id,
|
||||||
|
meta,
|
||||||
|
} : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchVoiceStats(v) {
|
||||||
|
const meta = v?.meta || libraryVoice(v?.id) || {};
|
||||||
|
const b = meta.benchmark || {};
|
||||||
|
const parts = [];
|
||||||
|
const dur = Number(meta.duration);
|
||||||
|
const speed = Number(b.speed ?? b.avg_speed);
|
||||||
|
const rtf = Number(b.rtf ?? b.avg_rtf);
|
||||||
|
if (Number.isFinite(dur) && dur > 0) parts.push(`${dur.toFixed(1)}s`);
|
||||||
|
if (Number.isFinite(speed) && speed > 0) parts.push(`${speed.toFixed(2)}x`);
|
||||||
|
else if (Number.isFinite(rtf) && rtf > 0) parts.push(`RTF ${rtf.toFixed(2)}`);
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchColor(id) {
|
||||||
|
const palette = ['#3b82f6', '#10b981', '#8b5cf6', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4', '#84cc16'];
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < String(id || '').length; i++) h = (h * 31 + String(id).charCodeAt(i)) >>> 0;
|
||||||
|
return palette[h % palette.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchAvatar(v) {
|
||||||
|
const meta = v?.meta || libraryVoice(v?.id) || {};
|
||||||
|
if (meta.has_picture) return `<img class="batch-voice-avatar" src="/api/voice/picture/${encodeURIComponent(v.id)}" alt="">`;
|
||||||
|
const icon = window.voiceAvatarIcon ? window.voiceAvatarIcon(meta.avatar, 28) : null;
|
||||||
|
if (icon) return icon.replace(/vp-avatar/g, 'batch-voice-avatar');
|
||||||
|
const init = (v.label || v.id || '?')[0].toUpperCase();
|
||||||
|
return `<span class="batch-voice-avatar batch-voice-avatar-init" style="background:${batchColor(meta.lang || v.id)}">${escHtml(init)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchSearchText(v) {
|
||||||
|
const meta = v.meta || {};
|
||||||
|
return [v.id, v.label, meta.display_name, meta.name, meta.lang, meta.language, Array.isArray(meta.tags) ? meta.tags.join(' ') : meta.tags, batchVoiceStats(v)]
|
||||||
|
.filter(Boolean).join(' ').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncRunSelectedLabel() {
|
||||||
|
const count = batchSelected.size;
|
||||||
|
if (perfRunSelectedBtn) {
|
||||||
|
perfRunSelectedBtn.disabled = count === 0;
|
||||||
|
perfRunSelectedBtn.innerHTML = `<span class="mdi mdi-playlist-play"></span> ${count ? `Run ${count} selected voice${count === 1 ? '' : 's'}` : 'Run selected voices'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelCount() {
|
||||||
|
const total = batchVoices.length;
|
||||||
|
const checked = batchSelected.size;
|
||||||
|
const visible = filteredBatchVoices().length;
|
||||||
|
if (batchSelCount) batchSelCount.textContent = total ? `${checked} of ${total} selected${visible !== total ? ` · ${visible} shown` : ''}` : '';
|
||||||
|
batchRunBtn.disabled = checked === 0;
|
||||||
|
syncRunSelectedLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredBatchVoices() {
|
||||||
|
const q = (batchSearchInput?.value || '').trim().toLowerCase();
|
||||||
|
return q ? batchVoices.filter(v => batchSearchText(v).includes(q)) : batchVoices;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBatchVoiceList() {
|
||||||
|
if (!batchVoiceList) return;
|
||||||
|
const visible = filteredBatchVoices();
|
||||||
|
if (!batchVoices.length) {
|
||||||
|
batchVoiceList.innerHTML = '<div class="perf-history-empty">No voices found. Fetch voices above or reload from backend.</div>';
|
||||||
updateSelCount();
|
updateSelCount();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
batchVoiceList.innerHTML = voices.map(v => {
|
if (!visible.length) {
|
||||||
|
batchVoiceList.innerHTML = '<div class="perf-history-empty">No matching voices.</div>';
|
||||||
|
updateSelCount();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const activeIds = new Set(activeVoiceIds());
|
||||||
|
batchVoiceList.innerHTML = visible.map(v => {
|
||||||
|
const checked = batchSelected.has(v.id);
|
||||||
const isActive = activeIds.has(v.id);
|
const isActive = activeIds.has(v.id);
|
||||||
return `<label class="batch-voice-item${isActive ? ' is-active' : ''}">
|
const stats = batchVoiceStats(v);
|
||||||
<input type="checkbox" class="batch-voice-cb" value="${escHtml(v.id)}"${isActive ? ' checked' : ''}>
|
return `<label class="batch-voice-item${isActive ? ' is-active' : ''}${checked ? ' is-selected' : ''}">
|
||||||
<span class="batch-voice-name">${escHtml(v.label)}</span>
|
<input type="checkbox" class="batch-voice-cb" value="${escHtml(v.id)}" aria-label="Benchmark ${escHtml(v.label || v.id)}"${checked ? ' checked' : ''}>
|
||||||
|
${batchAvatar(v)}
|
||||||
|
<span class="batch-voice-main"><span class="batch-voice-name">${escHtml(v.label || v.id)}</span><span class="batch-voice-id">${escHtml(v.id)}</span></span>
|
||||||
|
${stats ? `<span class="batch-voice-stats">${escHtml(stats)}</span>` : ''}
|
||||||
${isActive ? '<span class="batch-voice-tag">active</span>' : ''}
|
${isActive ? '<span class="batch-voice-tag">active</span>' : ''}
|
||||||
</label>`;
|
</label>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
batchVoiceList.querySelectorAll('.batch-voice-cb').forEach(cb => cb.addEventListener('change', updateSelCount));
|
batchVoiceList.querySelectorAll('.batch-voice-cb').forEach(cb => cb.addEventListener('change', () => {
|
||||||
|
if (cb.checked) batchSelected.add(cb.value);
|
||||||
|
else batchSelected.delete(cb.value);
|
||||||
|
renderBatchVoiceList();
|
||||||
|
}));
|
||||||
updateSelCount();
|
updateSelCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-populate from My Voices library on init
|
function buildVoiceList(voices, opts = {}) {
|
||||||
function populateFromLibrary() {
|
const previous = opts.preserve ? new Set(batchSelected) : null;
|
||||||
const voices = (_voices || [])
|
const byId = new Map();
|
||||||
.filter(v => v.enabled !== false)
|
(voices || []).map(normalizeBatchVoice).filter(Boolean).forEach(v => byId.set(v.id, v));
|
||||||
.map(v => ({ id: v.id, label: v.display_name || v.name || v.id }))
|
batchVoices = Array.from(byId.values()).sort((a, b) => (a.label || a.id).localeCompare(b.label || b.id));
|
||||||
.sort((a, b) => a.label.localeCompare(b.label));
|
if (previous) {
|
||||||
|
batchSelected = new Set(batchVoices.filter(v => previous.has(v.id)).map(v => v.id));
|
||||||
|
} else if (opts.keepSelection) {
|
||||||
|
batchSelected = new Set(Array.from(batchSelected).filter(id => byId.has(id)));
|
||||||
|
} else {
|
||||||
|
const activeIds = new Set(activeVoiceIds());
|
||||||
|
batchSelected = new Set(batchVoices.filter(v => activeIds.has(v.id)).map(v => v.id));
|
||||||
|
}
|
||||||
|
renderBatchVoiceList();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function populateFromLibrary() {
|
||||||
|
try {
|
||||||
|
if ((!window._voices || !window._voices.length) && typeof loadVoiceLibrary === 'function') await loadVoiceLibrary();
|
||||||
|
const voices = (window._voices || []).filter(v => v.enabled !== false).map(v => ({ id: v.id, label: v.display_name || v.name || v.id, meta: v }));
|
||||||
buildVoiceList(voices);
|
buildVoiceList(voices);
|
||||||
|
} catch (e) {
|
||||||
|
if (batchVoiceList) batchVoiceList.innerHTML = `<div class="perf-history-empty">Voice library unavailable: ${escHtml(e.message)}</div>`;
|
||||||
|
updateSelCount();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
populateFromLibrary();
|
populateFromLibrary();
|
||||||
|
|
||||||
batchLoadBtn.addEventListener('click', async () => {
|
async function loadBatchVoicesFromBackend() {
|
||||||
const backend = batchBackendSel.value;
|
const backend = batchBackendSel.value;
|
||||||
if (!backend) { toast('Select a backend first', 'error'); return; }
|
if (!backend) { toast('Select a backend first', 'error'); return; }
|
||||||
batchLoadBtn.disabled = true;
|
batchLoadBtn.disabled = true;
|
||||||
batchVoiceList.innerHTML = '<div class="perf-history-empty">Loading from backend…</div>';
|
batchVoiceList.innerHTML = '<div class="perf-history-empty">Loading from backend...</div>';
|
||||||
try {
|
try {
|
||||||
const raw = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
|
const raw = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
|
||||||
const voices = raw.map(v => ({ id: backendVoiceId(v), label: backendVoiceId(v) }))
|
const voices = raw.map(v => {
|
||||||
.sort((a, b) => a.label.localeCompare(b.label));
|
const id = backendVoiceId(v);
|
||||||
|
return { id, label: id, meta: libraryVoice(id) || (typeof v === 'object' ? v : null) };
|
||||||
|
}).filter(v => v.id);
|
||||||
buildVoiceList(voices);
|
buildVoiceList(voices);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
batchVoiceList.innerHTML = `<div class="perf-history-empty">Failed: ${escHtml(e.message)}</div>`;
|
batchVoiceList.innerHTML = `<div class="perf-history-empty">Failed: ${escHtml(e.message)}</div>`;
|
||||||
|
updateSelCount();
|
||||||
}
|
}
|
||||||
finally { batchLoadBtn.disabled = false; }
|
finally { batchLoadBtn.disabled = false; }
|
||||||
});
|
}
|
||||||
|
|
||||||
|
batchLoadBtn.addEventListener('click', loadBatchVoicesFromBackend);
|
||||||
|
batchSearchInput?.addEventListener('input', renderBatchVoiceList);
|
||||||
batchSelectAllBtn.addEventListener('click', () => {
|
batchSelectAllBtn.addEventListener('click', () => {
|
||||||
batchVoiceList.querySelectorAll('.batch-voice-cb').forEach(cb => cb.checked = true);
|
batchVoices.forEach(v => batchSelected.add(v.id));
|
||||||
updateSelCount();
|
renderBatchVoiceList();
|
||||||
});
|
});
|
||||||
batchSelectNoneBtn.addEventListener('click', () => {
|
batchSelectNoneBtn.addEventListener('click', () => {
|
||||||
batchVoiceList.querySelectorAll('.batch-voice-cb').forEach(cb => cb.checked = false);
|
batchSelected.clear();
|
||||||
updateSelCount();
|
renderBatchVoiceList();
|
||||||
|
});
|
||||||
|
window.addEventListener('benchmark:tts-voices-fetched', e => {
|
||||||
|
const detail = e.detail || {};
|
||||||
|
if (detail.backend && batchBackendSel) batchBackendSel.value = detail.backend;
|
||||||
|
buildVoiceList(detail.voices || []);
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderBatchResults() {
|
function renderBatchResults() {
|
||||||
@ -106,8 +216,11 @@
|
|||||||
batchTbody.innerHTML = sorted.map(r => {
|
batchTbody.innerHTML = sorted.map(r => {
|
||||||
const rtfCls = r.ok && r.avgRtf < 1 ? 'perf-good' : r.ok ? 'perf-slow' : '';
|
const rtfCls = r.ok && r.avgRtf < 1 ? 'perf-good' : r.ok ? 'perf-slow' : '';
|
||||||
const trend = r.trend ? `<span class="perf-trend-badge ${r.trend.cls}" style="font-size:11px;padding:1px 6px">${r.trend.label}</span>` : '';
|
const trend = r.trend ? `<span class="perf-trend-badge ${r.trend.cls}" style="font-size:11px;padding:1px 6px">${r.trend.label}</span>` : '';
|
||||||
|
const device = typeof backendComputeDevice === 'function' ? backendComputeDevice(r.backend || batchBackendSel.value) : 'Unknown';
|
||||||
|
const deviceCls = typeof backendComputeDeviceClass === 'function' ? backendComputeDeviceClass(r.backend || batchBackendSel.value) : '';
|
||||||
return `<tr class="${r.ok ? '' : 'perf-row-error'}">
|
return `<tr class="${r.ok ? '' : 'perf-row-error'}">
|
||||||
<td>${escHtml(r.voice)}</td>
|
<td>${escHtml(r.voice)}</td>
|
||||||
|
<td><span class="bench-device ${deviceCls}" title="Inferred from the selected TTS backend metadata">${escHtml(device)}</span></td>
|
||||||
<td>${r.ok ? Math.round(r.avgLatency) + ' ms' : '—'}</td>
|
<td>${r.ok ? Math.round(r.avgLatency) + ' ms' : '—'}</td>
|
||||||
<td>${r.ok ? r.minLatency + ' ms' : '—'}</td>
|
<td>${r.ok ? r.minLatency + ' ms' : '—'}</td>
|
||||||
<td>${r.ok && r.avgAudio > 0 ? r.avgAudio.toFixed(2) : '—'}</td>
|
<td>${r.ok && r.avgAudio > 0 ? r.avgAudio.toFixed(2) : '—'}</td>
|
||||||
@ -117,18 +230,30 @@
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
batchRunBtn.addEventListener('click', async () => {
|
async function runBatchBenchmark() {
|
||||||
|
const topBackend = $('perf-backend-select')?.value || '';
|
||||||
|
if (topBackend && batchBackendSel && batchBackendSel.value !== topBackend) batchBackendSel.value = topBackend;
|
||||||
|
const topRuns = $('perf-runs')?.value || '';
|
||||||
|
if (topRuns && batchRunsSel && Array.from(batchRunsSel.options).some(o => o.value === topRuns)) batchRunsSel.value = topRuns;
|
||||||
|
const singleVoice = $('perf-voice-select')?.value || '';
|
||||||
|
if (!batchSelected.size && singleVoice && batchVoices.some(v => v.id === singleVoice)) {
|
||||||
|
batchSelected.add(singleVoice);
|
||||||
|
renderBatchVoiceList();
|
||||||
|
}
|
||||||
|
|
||||||
const backend = batchBackendSel.value;
|
const backend = batchBackendSel.value;
|
||||||
const text = $('perf-text')?.value.trim();
|
const text = $('perf-text')?.value.trim();
|
||||||
const runs = parseInt(batchRunsSel.value) || 1;
|
const runs = parseInt(batchRunsSel.value) || 1;
|
||||||
const selected = [...(batchVoiceList?.querySelectorAll('.batch-voice-cb:checked') || [])].map(cb => cb.value);
|
const validIds = new Set(batchVoices.map(v => v.id));
|
||||||
|
const selected = Array.from(batchSelected).filter(id => validIds.has(id));
|
||||||
if (!backend) { toast('Select a backend first', 'error'); return; }
|
if (!backend) { toast('Select a backend first', 'error'); return; }
|
||||||
if (!text) { toast('Enter sample text in the single-voice form above', 'error'); return; }
|
if (!text) { toast('Enter sample text in the single-voice form above', 'error'); return; }
|
||||||
if (!selected.length) { toast('Select at least one voice', 'error'); return; }
|
if (!selected.length) { toast('Select one or more voices first', 'error'); return; }
|
||||||
|
|
||||||
batchStopped = false;
|
batchStopped = false;
|
||||||
batchResults = [];
|
batchResults = [];
|
||||||
batchRunBtn.disabled = true;
|
batchRunBtn.disabled = true;
|
||||||
|
if (perfRunSelectedBtn) perfRunSelectedBtn.disabled = true;
|
||||||
batchStopBtn.disabled = false;
|
batchStopBtn.disabled = false;
|
||||||
batchProgress.style.display = '';
|
batchProgress.style.display = '';
|
||||||
batchResultsCard.style.display = 'none';
|
batchResultsCard.style.display = 'none';
|
||||||
@ -140,7 +265,7 @@
|
|||||||
batchProgCount.textContent = `${vi + 1} / ${selected.length}`;
|
batchProgCount.textContent = `${vi + 1} / ${selected.length}`;
|
||||||
batchProgBar.style.width = `${Math.round((vi / selected.length) * 100)}%`;
|
batchProgBar.style.width = `${Math.round((vi / selected.length) * 100)}%`;
|
||||||
|
|
||||||
const entry = { voice, ok: false, avgLatency: 0, minLatency: 0, avgRtf: 0, avgAudio: 0, error: '' };
|
const entry = { voice, backend, ok: false, avgLatency: 0, minLatency: 0, avgRtf: 0, avgAudio: 0, error: '' };
|
||||||
const rowRunResults = [];
|
const rowRunResults = [];
|
||||||
|
|
||||||
for (let ri = 0; ri < runs; ri++) {
|
for (let ri = 0; ri < runs; ri++) {
|
||||||
@ -161,23 +286,22 @@
|
|||||||
|
|
||||||
if (rowRunResults.length) {
|
if (rowRunResults.length) {
|
||||||
entry.ok = true;
|
entry.ok = true;
|
||||||
entry.avgLatency = rowRunResults.reduce((s, r) => s + r.lat, 0) / rowRunResults.length;
|
entry.avgLatency = rowRunResults.reduce((sum, r) => sum + r.lat, 0) / rowRunResults.length;
|
||||||
entry.minLatency = Math.min(...rowRunResults.map(r => r.lat));
|
entry.minLatency = Math.min(...rowRunResults.map(r => r.lat));
|
||||||
const durRows = rowRunResults.filter(r => r.dur > 0);
|
const durRows = rowRunResults.filter(r => r.dur > 0);
|
||||||
entry.avgAudio = durRows.length ? durRows.reduce((s, r) => s + r.dur, 0) / durRows.length : 0;
|
entry.avgAudio = durRows.length ? durRows.reduce((sum, r) => sum + r.dur, 0) / durRows.length : 0;
|
||||||
const rtfArr = durRows.map(r => r.lat / 1000 / r.dur);
|
const rtfArr = durRows.map(r => r.lat / 1000 / r.dur);
|
||||||
entry.avgRtf = rtfArr.length ? rtfArr.reduce((s, v) => s + v, 0) / rtfArr.length : 0;
|
entry.avgRtf = rtfArr.length ? rtfArr.reduce((sum, value) => sum + value, 0) / rtfArr.length : 0;
|
||||||
|
|
||||||
// compute trend vs previous session for this voice
|
|
||||||
if (entry.avgRtf > 0) {
|
if (entry.avgRtf > 0) {
|
||||||
const prev = perfHistoryLoad().filter(e => e.backend === backend && e.voice === voice && typeof e.avgRtf === 'number');
|
const prev = perfHistoryLoad().filter(e => e.backend === backend && e.voice === voice && typeof e.avgRtf === 'number');
|
||||||
if (prev.length) {
|
if (prev.length) {
|
||||||
const prevRtf = prev[prev.length - 1].avgRtf;
|
const prevRtf = prev[prev.length - 1].avgRtf;
|
||||||
const delta = entry.avgRtf - prevRtf;
|
const delta = entry.avgRtf - prevRtf;
|
||||||
const pct = Math.abs(delta / Math.max(prevRtf, 0.01)) * 100;
|
const pct = Math.abs(delta / Math.max(prevRtf, 0.01)) * 100;
|
||||||
if (pct < 5) entry.trend = { cls: 'perf-trend-stable', label: '→ stable' };
|
if (pct < 5) entry.trend = { cls: 'perf-trend-stable', label: 'stable' };
|
||||||
else if (delta < 0) entry.trend = { cls: 'perf-trend-better', label: `↓ ${pct.toFixed(0)}% faster` };
|
else if (delta < 0) entry.trend = { cls: 'perf-trend-better', label: `${pct.toFixed(0)}% faster` };
|
||||||
else entry.trend = { cls: 'perf-trend-worse', label: `↑ ${pct.toFixed(0)}% slower` };
|
else entry.trend = { cls: 'perf-trend-worse', label: `${pct.toFixed(0)}% slower` };
|
||||||
}
|
}
|
||||||
perfHistoryAdd({
|
perfHistoryAdd({
|
||||||
ts: Date.now(), backend, voice, textLen: text.length,
|
ts: Date.now(), backend, voice, textLen: text.length,
|
||||||
@ -195,9 +319,10 @@
|
|||||||
batchProgBar.style.width = '100%';
|
batchProgBar.style.width = '100%';
|
||||||
batchProgLabel.textContent = batchStopped
|
batchProgLabel.textContent = batchStopped
|
||||||
? `Stopped after ${batchResults.length} voice${batchResults.length !== 1 ? 's' : ''}.`
|
? `Stopped after ${batchResults.length} voice${batchResults.length !== 1 ? 's' : ''}.`
|
||||||
: `Done — ${batchResults.length} voice${batchResults.length !== 1 ? 's' : ''} benchmarked.`;
|
: `Done - ${batchResults.length} voice${batchResults.length !== 1 ? 's' : ''} benchmarked.`;
|
||||||
batchStopBtn.disabled = true;
|
batchStopBtn.disabled = true;
|
||||||
batchRunBtn.disabled = false;
|
batchRunBtn.disabled = false;
|
||||||
|
syncRunSelectedLabel();
|
||||||
renderPerfHistory();
|
renderPerfHistory();
|
||||||
|
|
||||||
const ok = batchResults.filter(r => r.ok);
|
const ok = batchResults.filter(r => r.ok);
|
||||||
@ -207,12 +332,14 @@
|
|||||||
(best ? `, best RTF ${best.avgRtf.toFixed(2)} (${best.voice})` : ''),
|
(best ? `, best RTF ${best.avgRtf.toFixed(2)} (${best.voice})` : ''),
|
||||||
ok.length < batchResults.length ? 'error' : 'success'
|
ok.length < batchResults.length ? 'error' : 'success'
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
batchRunBtn.addEventListener('click', runBatchBenchmark);
|
||||||
|
perfRunSelectedBtn?.addEventListener('click', runBatchBenchmark);
|
||||||
batchStopBtn.addEventListener('click', () => {
|
batchStopBtn.addEventListener('click', () => {
|
||||||
batchStopped = true;
|
batchStopped = true;
|
||||||
batchStopBtn.disabled = true;
|
batchStopBtn.disabled = true;
|
||||||
batchProgLabel.textContent = 'Stopping after current voice…';
|
batchProgLabel.textContent = 'Stopping after current voice...';
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,24 @@ function backendById(id) {
|
|||||||
return availableTtsBackends().find(b => b.id === id) || availableTtsBackends()[0] || null;
|
return availableTtsBackends().find(b => b.id === id) || availableTtsBackends()[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function backendComputeDevice(id) {
|
||||||
|
const b = backendById(id);
|
||||||
|
const text = [id, b?.label, b?.speed, b?.latency, b?.quality, b?.ram, b?.purpose, b?.best_for]
|
||||||
|
.filter(Boolean).join(' ').toLowerCase();
|
||||||
|
if (/(cloud|api|elevenlabs|groq)/.test(text)) return 'Cloud';
|
||||||
|
if (/(cpu|metal)/.test(text) && !/(cuda|gpu|vram|rtx|dgx)/.test(text)) return 'CPU';
|
||||||
|
if (/(cuda|gpu|vram|rtx|dgx)/.test(text)) return 'CUDA/GPU';
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function backendComputeDeviceClass(id) {
|
||||||
|
const label = backendComputeDevice(id).toLowerCase();
|
||||||
|
if (label.includes('cuda') || label.includes('gpu')) return 'gpu';
|
||||||
|
if (label.includes('cpu')) return 'cpu';
|
||||||
|
if (label.includes('cloud')) return 'cloud';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function backendHelpHtml(b, compact = false) {
|
function backendHelpHtml(b, compact = false) {
|
||||||
if (!b) return '<strong>No TTS backend is reachable.</strong><div>Start at least one TTS service or check Settings URLs.</div>';
|
if (!b) return '<strong>No TTS backend is reachable.</strong><div>Start at least one TTS service or check Settings URLs.</div>';
|
||||||
const tags = [
|
const tags = [
|
||||||
|
|||||||
@ -409,6 +409,7 @@ function renderPerfHistory() {
|
|||||||
} else {
|
} else {
|
||||||
perfVoiceSel.innerHTML = items.map(v => `<option value="${escHtml(v.id)}"${v.id===cur?' selected':''}>${escHtml(v.id)}</option>`).join('') || '<option value="">No voices</option>';
|
perfVoiceSel.innerHTML = items.map(v => `<option value="${escHtml(v.id)}"${v.id===cur?' selected':''}>${escHtml(v.id)}</option>`).join('') || '<option value="">No voices</option>';
|
||||||
}
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('benchmark:tts-voices-fetched', { detail: { backend, voices: items } }));
|
||||||
} catch(e) { toast('Fetch voices failed: '+e.message, 'error'); }
|
} catch(e) { toast('Fetch voices failed: '+e.message, 'error'); }
|
||||||
finally { perfFetchBtn.disabled = false; }
|
finally { perfFetchBtn.disabled = false; }
|
||||||
});
|
});
|
||||||
@ -443,6 +444,7 @@ function renderPerfHistory() {
|
|||||||
return `<tr class="${ok?'':'perf-row-error'}">
|
return `<tr class="${ok?'':'perf-row-error'}">
|
||||||
<td>${i+1}</td>
|
<td>${i+1}</td>
|
||||||
<td>${escHtml(r.backend)}</td>
|
<td>${escHtml(r.backend)}</td>
|
||||||
|
<td><span class="bench-device ${typeof backendComputeDeviceClass === 'function' ? backendComputeDeviceClass(r.backend) : ''}" title="Inferred from the selected TTS backend metadata">${escHtml(typeof backendComputeDevice === 'function' ? backendComputeDevice(r.backend) : 'Unknown')}</span></td>
|
||||||
<td>${escHtml(r.voice)}</td>
|
<td>${escHtml(r.voice)}</td>
|
||||||
<td>${ok ? r.latencyMs : '—'}</td>
|
<td>${ok ? r.latencyMs : '—'}</td>
|
||||||
<td>${ok && r.audioDuration > 0 ? r.audioDuration.toFixed(2) : '—'}</td>
|
<td>${ok && r.audioDuration > 0 ? r.audioDuration.toFixed(2) : '—'}</td>
|
||||||
|
|||||||
@ -103,6 +103,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn-primary" id="perf-run-btn" type="button"><span class="mdi mdi-play"></span> Run benchmark</button>
|
<button class="btn-primary" id="perf-run-btn" type="button"><span class="mdi mdi-play"></span> Run benchmark</button>
|
||||||
|
<button class="btn-secondary" id="perf-run-selected-btn" type="button" disabled title="Benchmark every checked voice in the Batch benchmark list below" aria-describedby="batch-benchmark-help"><span class="mdi mdi-playlist-play"></span> Run selected voices</button>
|
||||||
<button class="btn-secondary" id="perf-clear-btn" type="button">Clear results</button>
|
<button class="btn-secondary" id="perf-clear-btn" type="button">Clear results</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="perf-progress" class="note" style="display:none"></div>
|
<div id="perf-progress" class="note" style="display:none"></div>
|
||||||
@ -124,7 +125,7 @@
|
|||||||
<table class="perf-table" id="perf-table">
|
<table class="perf-table" id="perf-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th><th>Backend</th><th>Voice</th>
|
<th>#</th><th>Backend</th><th>Device</th><th>Voice</th>
|
||||||
<th>Latency (ms)</th><th>Audio (s)</th><th>RTF</th><th>Status</th>
|
<th>Latency (ms)</th><th>Audio (s)</th><th>RTF</th><th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -148,17 +149,19 @@
|
|||||||
<option value="1" selected>1 run</option>
|
<option value="1" selected>1 run</option>
|
||||||
<option value="3">3 runs</option>
|
<option value="3">3 runs</option>
|
||||||
<option value="5">5 runs</option>
|
<option value="5">5 runs</option>
|
||||||
|
<option value="10">10 runs</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="note" style="margin:4px 0 8px">Uses the sample text from the single-voice form above.</p>
|
<p class="note" id="batch-benchmark-help" style="margin:4px 0 8px">Uses the sample text and Runs value from the single-voice form above. Check one voice, a few voices, or Select all, then run them together.</p>
|
||||||
<div class="batch-voice-toolbar">
|
<div class="batch-voice-toolbar">
|
||||||
<button class="btn-secondary btn-sm" id="batch-load-voices-btn" type="button"><span class="mdi mdi-reload"></span> Reload from backend</button>
|
<button class="btn-secondary btn-sm" id="batch-load-voices-btn" type="button" title="Load all voices exposed by the selected backend"><span class="mdi mdi-reload"></span> Reload from backend</button>
|
||||||
<button class="btn-secondary btn-sm" id="batch-select-all-btn" type="button">Select all</button>
|
<div class="batch-search-wrap"><span class="mdi mdi-magnify" aria-hidden="true"></span><input id="batch-voice-search" type="search" placeholder="Search voices..." autocomplete="off" spellcheck="false" aria-label="Search voices to benchmark"></div>
|
||||||
<button class="btn-secondary btn-sm" id="batch-select-none-btn" type="button">Deselect all</button>
|
<button class="btn-secondary btn-sm" id="batch-select-all-btn" type="button" title="Select every loaded voice for the batch benchmark">Select all</button>
|
||||||
<span id="batch-selected-count" class="note"></span>
|
<button class="btn-secondary btn-sm" id="batch-select-none-btn" type="button" title="Clear the batch benchmark voice selection">Deselect all</button>
|
||||||
|
<span id="batch-selected-count" class="note" aria-live="polite"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="batch-voice-list" class="batch-voice-list"></div>
|
<div id="batch-voice-list" class="batch-voice-list" role="group" aria-label="Voices selected for batch benchmark" aria-describedby="batch-benchmark-help"></div>
|
||||||
<div class="btn-row" style="margin-top:12px">
|
<div class="btn-row" style="margin-top:12px">
|
||||||
<button class="btn-primary" id="batch-run-btn" type="button" disabled><span class="mdi mdi-play"></span> Run batch</button>
|
<button class="btn-primary" id="batch-run-btn" type="button" disabled><span class="mdi mdi-play"></span> Run batch</button>
|
||||||
<button class="btn-secondary" id="batch-stop-btn" type="button" disabled><span class="mdi mdi-stop"></span> Stop</button>
|
<button class="btn-secondary" id="batch-stop-btn" type="button" disabled><span class="mdi mdi-stop"></span> Stop</button>
|
||||||
@ -182,7 +185,7 @@
|
|||||||
<table class="perf-table" id="batch-table">
|
<table class="perf-table" id="batch-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Voice</th><th>Avg latency</th><th>Best</th><th>Audio (s)</th><th>Avg RTF</th><th>Status</th>
|
<th>Voice</th><th>Device</th><th>Avg latency</th><th>Best</th><th>Audio (s)</th><th>Avg RTF</th><th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="batch-tbody"></tbody>
|
<tbody id="batch-tbody"></tbody>
|
||||||
|
|||||||
@ -487,6 +487,13 @@ audio { width: 100%; }
|
|||||||
@media (max-width:1100px) { .settings-grid.compact { grid-template-columns:1fr; } .settings-behavior-grid { grid-template-columns:1fr; } }
|
@media (max-width:1100px) { .settings-grid.compact { grid-template-columns:1fr; } .settings-behavior-grid { grid-template-columns:1fr; } }
|
||||||
@media (max-width:900px) { .stt-settings-grid { grid-template-columns:1fr; } }
|
@media (max-width:900px) { .stt-settings-grid { grid-template-columns:1fr; } }
|
||||||
|
|
||||||
|
|
||||||
|
/* Accessibility helpers */
|
||||||
|
.sr-only { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||||
|
.skip-link { position: fixed; left: 12px; top: 12px; z-index: 6000; transform: translateY(-160%); background: var(--accent); color: #fff; padding: 9px 13px; border-radius: 7px; font-weight: 800; box-shadow: var(--shadow); }
|
||||||
|
.skip-link:focus { transform: translateY(0); outline: 3px solid #fff; outline-offset: 2px; }
|
||||||
|
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible, .nav-item:focus-visible, .nav-tree-item:focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 78%, white); outline-offset: 2px; }
|
||||||
|
|
||||||
/* ── Toast ──────────────────────────────────────────────────────────────── */
|
/* ── Toast ──────────────────────────────────────────────────────────────── */
|
||||||
#toast { position: fixed; top: 50%; left: 50%; background: var(--surface); border: 2px solid var(--accent); border-left-width: 8px; color: var(--text); padding: 16px 20px; border-radius: 8px; font-size: 15px; font-weight: 700; opacity: 0; transform: translate(-50%, calc(-50% + 12px)) scale(.98); transition: opacity .18s, transform .18s; pointer-events: none; z-index: 5000; max-width: min(520px, calc(100vw - 40px)); min-width: min(360px, calc(100vw - 40px)); box-shadow: 0 20px 70px rgba(0,0,0,.35); text-align: center; }
|
#toast { position: fixed; top: 50%; left: 50%; background: var(--surface); border: 2px solid var(--accent); border-left-width: 8px; color: var(--text); padding: 16px 20px; border-radius: 8px; font-size: 15px; font-weight: 700; opacity: 0; transform: translate(-50%, calc(-50% + 12px)) scale(.98); transition: opacity .18s, transform .18s; pointer-events: none; z-index: 5000; max-width: min(520px, calc(100vw - 40px)); min-width: min(360px, calc(100vw - 40px)); box-shadow: 0 20px 70px rgba(0,0,0,.35); text-align: center; }
|
||||||
#toast.show { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
#toast.show { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||||
@ -2349,13 +2356,22 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
.perf-history-del { cursor: pointer; color: var(--subtext); font-size: 11px; text-align: right; }
|
.perf-history-del { cursor: pointer; color: var(--subtext); font-size: 11px; text-align: right; }
|
||||||
.perf-history-del:hover { color: var(--red); }
|
.perf-history-del:hover { color: var(--red); }
|
||||||
.batch-voice-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
|
.batch-voice-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||||
.batch-voice-list { display: flex; flex-direction: column; gap: 0; max-height: 260px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); }
|
.batch-search-wrap { display: flex; align-items: center; gap: 7px; min-width: min(300px, 100%); flex: 1 1 260px; height: 34px; padding: 0 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); color: var(--subtext); }
|
||||||
.batch-voice-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; cursor: pointer; font-size: 13px; border-bottom: 1px solid var(--border); transition: background .1s; }
|
.batch-search-wrap input { flex: 1; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--text); font-size: 13px; }
|
||||||
|
.batch-voice-list { display: flex; flex-direction: column; gap: 0; max-height: 320px; overflow-y: auto; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); }
|
||||||
|
.batch-voice-item { display: grid; grid-template-columns: auto auto minmax(0,1fr) auto auto; align-items: center; gap: 9px; padding: 7px 10px; cursor: pointer; font-size: 13px; border-bottom: 1px solid var(--border); transition: background .1s; }
|
||||||
.batch-voice-item:last-child { border-bottom: none; }
|
.batch-voice-item:last-child { border-bottom: none; }
|
||||||
.batch-voice-item:hover { background: var(--hover); }
|
.batch-voice-item:hover { background: var(--hover); }
|
||||||
.batch-voice-item.is-active .batch-voice-name { font-weight: 500; }
|
.batch-voice-item.is-selected { background: rgba(37,99,235,.08); }
|
||||||
.batch-voice-name { flex: 1; color: var(--text); }
|
.batch-voice-item.is-active .batch-voice-name { font-weight: 700; }
|
||||||
.batch-voice-tag { font-size: 10px; padding: 1px 6px; border-radius: 10px; background: rgba(var(--accent-rgb,99,102,241),.15); color: var(--accent); font-weight: 600; }
|
.batch-voice-avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; color: #fff; font-size: 12px; font-weight: 700; }
|
||||||
|
.batch-voice-main { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
.batch-voice-name, .batch-voice-id { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.batch-voice-name { color: var(--text); font-weight: 600; }
|
||||||
|
.batch-voice-id { color: var(--subtext); font-size: 11px; font-family: monospace; }
|
||||||
|
.batch-voice-stats { font-size: 11px; color: var(--subtext); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.batch-voice-tag { font-size: 10px; padding: 1px 6px; border-radius: 10px; background: rgba(var(--accent-rgb,99,102,241),.15); color: var(--accent); font-weight: 600; white-space: nowrap; }
|
||||||
|
@media (max-width: 700px) { .batch-voice-item { grid-template-columns: auto auto minmax(0,1fr); } .batch-voice-stats, .batch-voice-tag { grid-column: 3; justify-self: start; } }
|
||||||
.batch-progress { margin-top: 12px; display: flex; flex-direction: column; gap: 6px; }
|
.batch-progress { margin-top: 12px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
.batch-progress-head { display: flex; justify-content: space-between; font-size: 12px; color: var(--subtext); }
|
.batch-progress-head { display: flex; justify-content: space-between; font-size: 12px; color: var(--subtext); }
|
||||||
.batch-progress-track { height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; }
|
.batch-progress-track { height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; }
|
||||||
@ -2475,6 +2491,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
.bench-device { display: inline-flex; align-items: center; padding: 2px 7px; border-radius: 999px; background: var(--panel); border: 1px solid var(--border); color: var(--subtext); font-size: 11px; font-weight: 700; }
|
.bench-device { display: inline-flex; align-items: center; padding: 2px 7px; border-radius: 999px; background: var(--panel); border: 1px solid var(--border); color: var(--subtext); font-size: 11px; font-weight: 700; }
|
||||||
.bench-device.gpu { color: var(--green); background: rgba(22,163,74,.09); border-color: rgba(22,163,74,.28); }
|
.bench-device.gpu { color: var(--green); background: rgba(22,163,74,.09); border-color: rgba(22,163,74,.28); }
|
||||||
.bench-device.cpu { color: var(--yellow); background: rgba(217,119,6,.09); border-color: rgba(217,119,6,.28); }
|
.bench-device.cpu { color: var(--yellow); background: rgba(217,119,6,.09); border-color: rgba(217,119,6,.28); }
|
||||||
|
.bench-device.cloud { color: var(--accent); background: rgba(37,99,235,.08); border-color: rgba(37,99,235,.24); }
|
||||||
.bench-inline-controls { display: flex; gap: 6px; min-width: min(620px, 100%); }
|
.bench-inline-controls { display: flex; gap: 6px; min-width: min(620px, 100%); }
|
||||||
.bench-inline-controls input, .bench-inline-controls select { min-width: 0; }
|
.bench-inline-controls input, .bench-inline-controls select { min-width: 0; }
|
||||||
.bench-turn-main .bench-turn-log { min-height: 440px; overflow-y: auto; padding: 16px; }
|
.bench-turn-main .bench-turn-log { min-height: 440px; overflow-y: auto; padding: 16px; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user