feat: lang/gender in batch benchmark list+results; sortable results table (v1.12.15)
Voice selection list now shows flag + gender symbol on each row. Batch results table adds Lang and Gender columns. All columns are sortable by clicking the header (↑↓ indicator); defaults to Factor descending. Sort logic handles strings (locale) and numbers uniformly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5db93d55d1
commit
ef5321cb18
@ -9,6 +9,15 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.12.15] — 2026-06-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Lang + Gender in Benchmark batch results** — two new sortable columns after Voice.
|
||||||
|
- **Lang + Gender in voice selection list** — flag emoji and gender symbol shown on each voice row.
|
||||||
|
- **Sortable batch results table** — click any column header to sort; arrow indicator shows active sort direction. Defaults to Factor descending (fastest first).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.12.14] — 2026-06-27
|
## [1.12.14] — 2026-06-27
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||||
<link rel="stylesheet" href="/static/style.css?v=1.12.14">
|
<link rel="stylesheet" href="/static/style.css?v=1.12.15">
|
||||||
|
|
||||||
|
|
||||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||||
@ -336,7 +336,7 @@
|
|||||||
<script src="/static/vendor/wavesurfer-regions.min.js"></script>
|
<script src="/static/vendor/wavesurfer-regions.min.js"></script>
|
||||||
|
|
||||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||||
<script src="/static/loader.js?v=1.12.14"></script>
|
<script src="/static/loader.js?v=1.12.15"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -25,6 +25,8 @@
|
|||||||
let batchResults = [];
|
let batchResults = [];
|
||||||
let batchVoices = [];
|
let batchVoices = [];
|
||||||
let batchSelected = new Set();
|
let batchSelected = new Set();
|
||||||
|
let batchSortField = 'factor';
|
||||||
|
let batchSortDir = -1; // -1 = desc (fastest first)
|
||||||
|
|
||||||
function populateBatchBackends() {
|
function populateBatchBackends() {
|
||||||
if (!batchBackendSel) return;
|
if (!batchBackendSel) return;
|
||||||
@ -140,10 +142,17 @@
|
|||||||
const checked = batchSelected.has(v.id);
|
const checked = batchSelected.has(v.id);
|
||||||
const isActive = activeIds.has(v.id);
|
const isActive = activeIds.has(v.id);
|
||||||
const stats = batchVoiceStats(v);
|
const stats = batchVoiceStats(v);
|
||||||
|
const meta = v.meta || libraryVoice(v.id) || {};
|
||||||
|
const flag = meta.flag || '';
|
||||||
|
const lang = meta.lang || meta.language || '';
|
||||||
|
const genderMap = { M: '♂', F: '♀', N: '⚬' };
|
||||||
|
const gender = genderMap[meta.gender] || '';
|
||||||
|
const langGender = [flag || lang, gender].filter(Boolean).join(' ');
|
||||||
return `<label class="batch-voice-item${isActive ? ' is-active' : ''}${checked ? ' is-selected' : ''}">
|
return `<label class="batch-voice-item${isActive ? ' is-active' : ''}${checked ? ' is-selected' : ''}">
|
||||||
<input type="checkbox" class="batch-voice-cb" value="${escHtml(v.id)}" aria-label="Benchmark ${escHtml(v.label || v.id)}"${checked ? ' checked' : ''}>
|
<input type="checkbox" class="batch-voice-cb" value="${escHtml(v.id)}" aria-label="Benchmark ${escHtml(v.label || v.id)}"${checked ? ' checked' : ''}>
|
||||||
${batchAvatar(v)}
|
${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>
|
<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>
|
||||||
|
${langGender ? `<span class="batch-voice-meta">${escHtml(langGender)}</span>` : ''}
|
||||||
${stats ? `<span class="batch-voice-stats">${escHtml(stats)}</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>`;
|
||||||
@ -219,27 +228,54 @@
|
|||||||
buildVoiceList(detail.voices || []);
|
buildVoiceList(detail.voices || []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function batchSortValue(r, field) {
|
||||||
|
const factor = r.ok && r.avgRtf > 0 ? 1 / r.avgRtf : null;
|
||||||
|
switch (field) {
|
||||||
|
case 'voice': return (r.voice || '').toLowerCase();
|
||||||
|
case 'lang': return (r.flag || r.lang || '').toLowerCase();
|
||||||
|
case 'gender': return (r.gender || '').toLowerCase();
|
||||||
|
case 'device': return typeof backendComputeDevice === 'function' ? backendComputeDevice(r.backend || '').toLowerCase() : '';
|
||||||
|
case 'latency': return r.ok ? r.avgLatency : 999999;
|
||||||
|
case 'best': return r.ok ? r.minLatency : 999999;
|
||||||
|
case 'duration': return r.ok && r.avgAudio > 0 ? r.avgAudio : 999999;
|
||||||
|
case 'factor': return factor != null ? factor : -1;
|
||||||
|
case 'time': return r.ok ? r.avgLatency / 1000 : 999999;
|
||||||
|
case 'wpm': return r.ok && r.avgAudio > 0 && r.wordCount > 0 ? Math.round(r.wordCount / (r.avgAudio / 60)) : -1;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderBatchResults() {
|
function renderBatchResults() {
|
||||||
if (!batchResults.length) { batchResultsCard.style.display = 'none'; return; }
|
if (!batchResults.length) { batchResultsCard.style.display = 'none'; return; }
|
||||||
batchResultsCard.style.display = '';
|
batchResultsCard.style.display = '';
|
||||||
const sorted = batchResults.slice().sort((a, b) => {
|
const sorted = batchResults.slice().sort((a, b) => {
|
||||||
if (a.ok && !b.ok) return -1;
|
if (a.ok && !b.ok) return -1;
|
||||||
if (!a.ok && b.ok) return 1;
|
if (!a.ok && b.ok) return 1;
|
||||||
// sort by Factor descending (higher = faster); Factor = 1/avgRtf
|
const av = batchSortValue(a, batchSortField), bv = batchSortValue(b, batchSortField);
|
||||||
return (b.avgRtf > 0 ? 1/b.avgRtf : 0) - (a.avgRtf > 0 ? 1/a.avgRtf : 0);
|
return typeof av === 'string' ? av.localeCompare(bv) * batchSortDir : (av - bv) * batchSortDir;
|
||||||
});
|
});
|
||||||
const okCount = batchResults.filter(r => r.ok).length;
|
const okCount = batchResults.filter(r => r.ok).length;
|
||||||
if (batchResultsLabel) batchResultsLabel.textContent = `${okCount} / ${batchResults.length} voices — ${batchBackendSel.value}`;
|
if (batchResultsLabel) batchResultsLabel.textContent = `${okCount} / ${batchResults.length} voices — ${batchBackendSel.value}`;
|
||||||
|
|
||||||
|
// Update header sort indicators
|
||||||
|
document.querySelectorAll('#batch-table th[data-sort]').forEach(th => {
|
||||||
|
th.classList.remove('perf-sort-asc', 'perf-sort-desc');
|
||||||
|
if (th.dataset.sort === batchSortField) th.classList.add(batchSortDir === 1 ? 'perf-sort-asc' : 'perf-sort-desc');
|
||||||
|
});
|
||||||
|
|
||||||
batchTbody.innerHTML = sorted.map(r => {
|
batchTbody.innerHTML = sorted.map(r => {
|
||||||
const factor = r.ok && r.avgRtf > 0 ? 1 / r.avgRtf : null;
|
const factor = r.ok && r.avgRtf > 0 ? 1 / r.avgRtf : null;
|
||||||
const timeSec = r.ok && r.avgLatency > 0 ? r.avgLatency / 1000 : null;
|
const timeSec = r.ok && r.avgLatency > 0 ? r.avgLatency / 1000 : null;
|
||||||
const wpm = r.ok && r.avgAudio > 0 && r.wordCount > 0 ? Math.round(r.wordCount / (r.avgAudio / 60)) : null;
|
const wpm = r.ok && r.avgAudio > 0 && r.wordCount > 0 ? Math.round(r.wordCount / (r.avgAudio / 60)) : null;
|
||||||
const factorCls = factor == null ? '' : factor >= 1 ? 'perf-good' : 'perf-slow';
|
const factorCls = factor == null ? '' : factor >= 1 ? 'perf-good' : '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 device = typeof backendComputeDevice === 'function' ? backendComputeDevice(r.backend || batchBackendSel.value) : 'Unknown';
|
||||||
const deviceCls = typeof backendComputeDeviceClass === 'function' ? backendComputeDeviceClass(r.backend || batchBackendSel.value) : '';
|
const deviceCls = typeof backendComputeDeviceClass === 'function' ? backendComputeDeviceClass(r.backend || batchBackendSel.value) : '';
|
||||||
|
const genderMap = { M: '♂', F: '♀', N: '⚬' };
|
||||||
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>${escHtml(r.flag || r.lang || '—')}</td>
|
||||||
|
<td>${escHtml(genderMap[r.gender] || '—')}</td>
|
||||||
<td><span class="bench-device ${deviceCls}" title="Inferred from the selected TTS backend metadata">${escHtml(device)}</span></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>
|
||||||
@ -252,6 +288,16 @@
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wire sortable headers
|
||||||
|
document.querySelector('#batch-table')?.addEventListener('click', e => {
|
||||||
|
const th = e.target.closest('th[data-sort]');
|
||||||
|
if (!th) return;
|
||||||
|
const field = th.dataset.sort;
|
||||||
|
if (field === batchSortField) batchSortDir *= -1;
|
||||||
|
else { batchSortField = field; batchSortDir = field === 'voice' || field === 'lang' || field === 'gender' ? 1 : -1; }
|
||||||
|
renderBatchResults();
|
||||||
|
});
|
||||||
|
|
||||||
async function runBatchBenchmark() {
|
async function runBatchBenchmark() {
|
||||||
const topBackend = $('perf-backend-select')?.value || '';
|
const topBackend = $('perf-backend-select')?.value || '';
|
||||||
if (topBackend && batchBackendSel && batchBackendSel.value !== topBackend) batchBackendSel.value = topBackend;
|
if (topBackend && batchBackendSel && batchBackendSel.value !== topBackend) batchBackendSel.value = topBackend;
|
||||||
@ -287,7 +333,9 @@
|
|||||||
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, backend, ok: false, avgLatency: 0, minLatency: 0, avgRtf: 0, avgAudio: 0, wordCount: text ? text.trim().split(/\s+/).length : 0, error: '' };
|
const _vm = batchVoices.find(bv => bv.id === voice);
|
||||||
|
const _meta = _vm?.meta || libraryVoice(voice) || {};
|
||||||
|
const entry = { voice, backend, ok: false, avgLatency: 0, minLatency: 0, avgRtf: 0, avgAudio: 0, wordCount: text ? text.trim().split(/\s+/).length : 0, lang: _meta.lang || _meta.language || '', flag: _meta.flag || '', gender: _meta.gender || '', error: '' };
|
||||||
const rowRunResults = [];
|
const rowRunResults = [];
|
||||||
|
|
||||||
for (let ri = 0; ri < runs; ri++) {
|
for (let ri = 0; ri < runs; ri++) {
|
||||||
|
|||||||
@ -188,7 +188,17 @@
|
|||||||
<table class="perf-table" id="batch-table">
|
<table class="perf-table" id="batch-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Voice</th><th>Device</th><th title="Avg time to first audio chunk (ms)">Latency</th><th title="Best (fastest) run latency">Best</th><th title="Average synthesised audio duration in seconds">Duration</th><th title="audio ÷ render — higher is better; above 1.0× = faster than real-time">Factor</th><th title="Average total render time in seconds">Time</th><th title="Words per minute the voice speaks">WPM</th><th>Status</th>
|
<th data-sort="voice" class="perf-th-sort">Voice</th>
|
||||||
|
<th data-sort="lang" class="perf-th-sort">Lang</th>
|
||||||
|
<th data-sort="gender" class="perf-th-sort">Gender</th>
|
||||||
|
<th data-sort="device" class="perf-th-sort">Device</th>
|
||||||
|
<th data-sort="latency" class="perf-th-sort" title="Avg total render time (ms)">Latency</th>
|
||||||
|
<th data-sort="best" class="perf-th-sort" title="Fastest single run">Best</th>
|
||||||
|
<th data-sort="duration" class="perf-th-sort" title="Average synthesised audio duration">Duration</th>
|
||||||
|
<th data-sort="factor" class="perf-th-sort perf-sort-desc" title="audio ÷ render — higher is better; above 1.0× = faster than real-time">Factor</th>
|
||||||
|
<th data-sort="time" class="perf-th-sort" title="Average total render time in seconds">Time</th>
|
||||||
|
<th data-sort="wpm" class="perf-th-sort" title="Words per minute the voice speaks">WPM</th>
|
||||||
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="batch-tbody"></tbody>
|
<tbody id="batch-tbody"></tbody>
|
||||||
|
|||||||
@ -2608,6 +2608,11 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
.perf-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
.perf-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
.perf-table th, .perf-table td { padding: 7px 10px; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
|
.perf-table th, .perf-table td { padding: 7px 10px; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
|
||||||
.perf-table th { color: var(--subtext); font-size: 11px; text-transform: uppercase; letter-spacing: .05em; }
|
.perf-table th { color: var(--subtext); font-size: 11px; text-transform: uppercase; letter-spacing: .05em; }
|
||||||
|
.perf-th-sort { cursor: pointer; user-select: none; }
|
||||||
|
.perf-th-sort:hover { color: var(--text); }
|
||||||
|
.perf-th-sort::after { content: ' ⇅'; opacity: .35; font-size: 10px; }
|
||||||
|
.perf-th-sort.perf-sort-asc::after { content: ' ↑'; opacity: 1; color: var(--accent); }
|
||||||
|
.perf-th-sort.perf-sort-desc::after { content: ' ↓'; opacity: 1; color: var(--accent); }
|
||||||
.perf-row-error td { color: var(--subtext); }
|
.perf-row-error td { color: var(--subtext); }
|
||||||
.perf-ok { color: var(--green); font-weight: 600; }
|
.perf-ok { color: var(--green); font-weight: 600; }
|
||||||
.perf-err { color: var(--red); }
|
.perf-err { color: var(--red); }
|
||||||
@ -2644,7 +2649,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
.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-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-search-wrap input { flex: 1; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--text); font-size: 13px; }
|
.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-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 { display: grid; grid-template-columns: auto auto minmax(0,1fr) auto 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-selected { background: rgba(37,99,235,.08); }
|
.batch-voice-item.is-selected { background: rgba(37,99,235,.08); }
|
||||||
@ -2654,6 +2659,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
.batch-voice-name, .batch-voice-id { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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-name { color: var(--text); font-weight: 600; }
|
||||||
.batch-voice-id { color: var(--subtext); font-size: 11px; font-family: monospace; }
|
.batch-voice-id { color: var(--subtext); font-size: 11px; font-family: monospace; }
|
||||||
|
.batch-voice-meta { font-size: 11px; color: var(--subtext); white-space: nowrap; }
|
||||||
.batch-voice-stats { font-size: 11px; color: var(--subtext); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
.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; }
|
.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; } }
|
@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; } }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user