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:
mARTin-B78 2026-06-28 00:15:23 +02:00
parent 5db93d55d1
commit ef5321cb18
5 changed files with 85 additions and 12 deletions

View File

@ -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
### Changed

View File

@ -26,7 +26,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<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) ── -->
@ -336,7 +336,7 @@
<script src="/static/vendor/wavesurfer-regions.min.js"></script>
<!-- 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>
</html>

View File

@ -25,6 +25,8 @@
let batchResults = [];
let batchVoices = [];
let batchSelected = new Set();
let batchSortField = 'factor';
let batchSortDir = -1; // -1 = desc (fastest first)
function populateBatchBackends() {
if (!batchBackendSel) return;
@ -140,10 +142,17 @@
const checked = batchSelected.has(v.id);
const isActive = activeIds.has(v.id);
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' : ''}">
<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>
${langGender ? `<span class="batch-voice-meta">${escHtml(langGender)}</span>` : ''}
${stats ? `<span class="batch-voice-stats">${escHtml(stats)}</span>` : ''}
${isActive ? '<span class="batch-voice-tag">active</span>' : ''}
</label>`;
@ -219,17 +228,41 @@
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() {
if (!batchResults.length) { batchResultsCard.style.display = 'none'; return; }
batchResultsCard.style.display = '';
const sorted = batchResults.slice().sort((a, b) => {
if (a.ok && !b.ok) return -1;
if (!a.ok && b.ok) return 1;
// sort by Factor descending (higher = faster); Factor = 1/avgRtf
return (b.avgRtf > 0 ? 1/b.avgRtf : 0) - (a.avgRtf > 0 ? 1/a.avgRtf : 0);
const av = batchSortValue(a, batchSortField), bv = batchSortValue(b, batchSortField);
return typeof av === 'string' ? av.localeCompare(bv) * batchSortDir : (av - bv) * batchSortDir;
});
const okCount = batchResults.filter(r => r.ok).length;
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 => {
const factor = r.ok && r.avgRtf > 0 ? 1 / r.avgRtf : null;
const timeSec = r.ok && r.avgLatency > 0 ? r.avgLatency / 1000 : null;
@ -238,8 +271,11 @@
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) : '';
const genderMap = { M: '♂', F: '♀', N: '⚬' };
return `<tr class="${r.ok ? '' : 'perf-row-error'}">
<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>${r.ok ? Math.round(r.avgLatency) + ' ms' : '—'}</td>
<td>${r.ok ? r.minLatency + ' ms' : '—'}</td>
@ -252,6 +288,16 @@
}).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() {
const topBackend = $('perf-backend-select')?.value || '';
if (topBackend && batchBackendSel && batchBackendSel.value !== topBackend) batchBackendSel.value = topBackend;
@ -287,7 +333,9 @@
batchProgCount.textContent = `${vi + 1} / ${selected.length}`;
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 = [];
for (let ri = 0; ri < runs; ri++) {

View File

@ -188,7 +188,17 @@
<table class="perf-table" id="batch-table">
<thead>
<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>
</thead>
<tbody id="batch-tbody"></tbody>

View File

@ -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 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-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-ok { color: var(--green); font-weight: 600; }
.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 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 { 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:hover { background: var(--hover); }
.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 { color: var(--text); font-weight: 600; }
.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-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; } }