feat: split Speed into Factor + Time columns, add colour coding (v1.12.13)

The old combined `21.8s · 1.30x` Speed cell is replaced by two separate
sortable columns:
- Factor (x.xx): audio÷render multiplier, green/amber/red colour-coded
- Time (Xs): total render time

Column order: Length · Duration · Factor · Time · WPM · Seed · dBFS · ...

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-27 23:19:33 +02:00
parent 0606db101a
commit 90a481f8d8
5 changed files with 79 additions and 65 deletions

View File

@ -9,6 +9,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.12.13] — 2026-06-27
### Changed
- **Speed column split into Factor + Time**: the old `21.8s · 1.30x` cell is now two separate sortable columns — **Factor** (`1.30x`, audio÷render, colour-coded green/amber/red) and **Time** (`21.8s`, total render time). Column order: Length · Duration · Factor · Time · WPM · Seed · dBFS · Type · Source · Rating · Tags · Note · Active.
---
## [1.12.12] — 2026-06-27 ## [1.12.12] — 2026-06-27
### Added ### Added

View File

@ -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.12"> <link rel="stylesheet" href="/static/style.css?v=1.12.13">
<!-- ── 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.12"></script> <script src="/static/loader.js?v=1.12.13"></script>
</body> </body>
</html> </html>

View File

@ -116,7 +116,9 @@ function getSortValue(v, field) {
case 'file_type': return voiceFileType(v); case 'file_type': return voiceFileType(v);
case 'duration': return v.duration || 0; case 'duration': return v.duration || 0;
case 'dbfs': return voiceDbfs(v) ?? -999; case 'dbfs': return voiceDbfs(v) ?? -999;
case 'benchmark': { const b = voiceBenchmark(v); return b && b.speed != null ? -Number(b.speed) : 999; } case 'benchmark':
case 'factor': return -(voiceFactor(v) ?? -999);
case 'elapsed': return voiceBenchmarkElapsed(v) ?? 999;
case 'bench_audio': return voiceBenchmarkAudioSec(v) ?? 999; case 'bench_audio': return voiceBenchmarkAudioSec(v) ?? 999;
case 'wpm': return voiceWpm(v) ?? -1; case 'wpm': return voiceWpm(v) ?? -1;
case 'transcript': return (v.transcript || '').toLowerCase(); case 'transcript': return (v.transcript || '').toLowerCase();
@ -379,14 +381,28 @@ function voiceBenchmarkElapsed(v) {
return value == null || Number.isNaN(Number(value)) ? null : Number(value); return value == null || Number.isNaN(Number(value)) ? null : Number(value);
} }
function fmtBenchmark(v) { function voiceFactor(v) {
const b = voiceBenchmark(v); const b = voiceBenchmark(v);
if (!b) return '-'; return b && b.ok && b.speed != null ? Number(b.speed) : null;
if (!b.ok) return 'ERR'; }
const elapsed = voiceBenchmarkElapsed(v);
if (elapsed == null) return '-'; function fmtFactor(v) {
const speed = b.speed != null ? ` · ${Number(b.speed).toFixed(2)}x` : ''; const f = voiceFactor(v);
return elapsed.toFixed(1) + 's' + speed; return f != null ? f.toFixed(2) + 'x' : '-';
}
function fmtElapsed(v) {
const e = voiceBenchmarkElapsed(v);
const b = voiceBenchmark(v);
if (!b || !b.ok) return b && !b.ok ? 'ERR' : '-';
return e != null ? e.toFixed(1) + 's' : '-';
}
function fmtBenchmark(v) {
const elapsed = fmtElapsed(v);
const factor = fmtFactor(v);
if (elapsed === '-' && factor === '-') return '-';
return [elapsed, factor].filter(x => x !== '-').join(' · ');
} }
function benchmarkClass(v) { function benchmarkClass(v) {
@ -865,23 +881,15 @@ async function runVoiceBenchmarkBatch() {
stats.last = `${voice.id}: ${Number(b.elapsed_sec || 0).toFixed(1)}s${b.speed != null ? ` · ${Number(b.speed).toFixed(2)}x` : ''}${b.realtime_ok === false ? ' · slow' : ''}`; stats.last = `${voice.id}: ${Number(b.elapsed_sec || 0).toFixed(1)}s${b.speed != null ? ` · ${Number(b.speed).toFixed(2)}x` : ''}${b.realtime_ok === false ? ' · slow' : ''}`;
// Live-update the row cells so results appear immediately // Live-update the row cells so results appear immediately
if (row) { if (row) {
const benchCell = row.querySelector('.vl-tbl-bench'); const bc = benchmarkClass(voice), btitle = benchmarkTitle(voice);
const durCell = row.querySelector('.vl-tbl-dur'); const durCell = row.querySelector('.vl-tbl-dur');
const factorCell = row.querySelector('.vl-tbl-factor');
const timeCell = row.querySelector('.vl-tbl-time');
const wpmCell = row.querySelector('.vl-tbl-wpm'); const wpmCell = row.querySelector('.vl-tbl-wpm');
if (benchCell) { if (durCell) { durCell.textContent = fmtBenchmarkAudio(voice); durCell.title = `${fmtBenchmarkAudio(voice)} — length of synthesised benchmark audio`; }
const bt = fmtBenchmark(voice), bc = benchmarkClass(voice), btitle = benchmarkTitle(voice); if (factorCell) { factorCell.textContent = fmtFactor(voice); factorCell.className = `vl-tbl-factor ${bc}`; factorCell.title = btitle; }
benchCell.innerHTML = bt !== '-' ? `<span class="vl-bench-chip ${bc}" title="${escHtml(btitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(bt)}</span>` : '-'; if (timeCell) { timeCell.textContent = fmtElapsed(voice); timeCell.className = `vl-tbl-time ${bc}`; timeCell.title = btitle; }
} if (wpmCell) { const wpm = voiceWpm(voice); wpmCell.textContent = fmtWpm(voice); wpmCell.title = wpm != null ? `${wpm} wpm — 130180 wpm is natural for long listening` : ''; }
if (durCell) {
const sec = voiceBenchmarkAudioSec(voice);
durCell.textContent = fmtBenchmarkAudio(voice);
durCell.title = sec != null ? `${fmtBenchmarkAudio(voice)} of synthesised audio for the benchmark sentence` : '';
}
if (wpmCell) {
const wpm = voiceWpm(voice);
wpmCell.textContent = fmtWpm(voice);
wpmCell.title = wpm != null ? `${wpm} wpm — 130180 wpm is natural for long listening` : '';
}
} }
} else { } else {
stats.errors++; stats.errors++;
@ -2010,12 +2018,13 @@ function makeVoiceRow(v) {
? `<select class="vl-inline-edit" data-field="gender" style="width:100%"><option value="N" ${gender==='N'?'selected':''}>N</option><option value="M" ${gender==='M'?'selected':''}>M</option><option value="F" ${gender==='F'?'selected':''}>F</option></select>` ? `<select class="vl-inline-edit" data-field="gender" style="width:100%"><option value="N" ${gender==='N'?'selected':''}>N</option><option value="M" ${gender==='M'?'selected':''}>M</option><option value="F" ${gender==='F'?'selected':''}>F</option></select>`
: `${genderMap[gender]||'?'} ${escHtml(genderLabel[gender]||'—')}`} : `${genderMap[gender]||'?'} ${escHtml(genderLabel[gender]||'—')}`}
</div> </div>
<div class="vl-tbl-type"><span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span></div>
<div class="vl-tbl-bench">${benchText !== '-' ? `<span class="vl-bench-chip ${benchCls}" title="${escHtml(benchTitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(benchText)}</span>` : '-'}</div>
<div class="vl-tbl-dbfs" title="${dbTitle}">${dbfs} dB</div>
<div class="vl-tbl-len">${fmtDuration(v.duration)}</div> <div class="vl-tbl-len">${fmtDuration(v.duration)}</div>
<div class="vl-tbl-dur" title="${escHtml(voiceBenchmarkAudioSec(v) != null ? `${fmtBenchmarkAudio(v)} of synthesised audio for the benchmark sentence` : 'Benchmark this voice to see output duration')}">${escHtml(fmtBenchmarkAudio(v))}</div> <div class="vl-tbl-dur" title="${escHtml(voiceBenchmarkAudioSec(v) != null ? `${fmtBenchmarkAudio(v)} — length of synthesised benchmark audio` : 'Benchmark this voice to see output duration')}">${escHtml(fmtBenchmarkAudio(v))}</div>
<div class="vl-tbl-factor ${benchCls}" title="${escHtml(benchTitle)}">${escHtml(fmtFactor(v))}</div>
<div class="vl-tbl-time ${benchCls}" title="${escHtml(benchTitle)}">${escHtml(fmtElapsed(v))}</div>
<div class="vl-tbl-wpm" title="${escHtml(voiceWpm(v) != null ? `${voiceWpm(v)} words per minute — how fast this voice speaks. 130180 wpm is natural for long listening.` : 'Benchmark this voice to see speaking rate')}">${escHtml(fmtWpm(v))}</div> <div class="vl-tbl-wpm" title="${escHtml(voiceWpm(v) != null ? `${voiceWpm(v)} words per minute — how fast this voice speaks. 130180 wpm is natural for long listening.` : 'Benchmark this voice to see speaking rate')}">${escHtml(fmtWpm(v))}</div>
<div class="vl-tbl-dbfs" title="${dbTitle}">${dbfs} dB</div>
<div class="vl-tbl-type"><span class="vl-type-label ${isClone ? 'vl-type-clone' : 'vl-type-design'}">${isClone ? 'Clone' : 'Design'}</span></div>
<div class="vl-tbl-rating"> <div class="vl-tbl-rating">
${window._voiceTableEditMode ${window._voiceTableEditMode
? `<input type="number" min="0" max="5" class="vl-inline-edit" data-field="rating" value="${v.rating||0}" style="width:40px">` ? `<input type="number" min="0" max="5" class="vl-inline-edit" data-field="rating" value="${v.rating||0}" style="width:40px">`
@ -2981,23 +2990,15 @@ function makeVoiceRow(v) {
// Also update the table row cells // Also update the table row cells
const tblRow = document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`); const tblRow = document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);
if (tblRow) { if (tblRow) {
const tblBench = tblRow.querySelector('.vl-tbl-bench'); const bc = benchmarkClass(v), btitle = benchmarkTitle(v);
const tblDur = tblRow.querySelector('.vl-tbl-dur'); const tblDur = tblRow.querySelector('.vl-tbl-dur');
const tblFactor = tblRow.querySelector('.vl-tbl-factor');
const tblTime = tblRow.querySelector('.vl-tbl-time');
const tblWpm = tblRow.querySelector('.vl-tbl-wpm'); const tblWpm = tblRow.querySelector('.vl-tbl-wpm');
if (tblBench) { if (tblDur) { tblDur.textContent = fmtBenchmarkAudio(v); tblDur.title = `${fmtBenchmarkAudio(v)} — length of synthesised benchmark audio`; }
const bt = fmtBenchmark(v), bc = benchmarkClass(v), btitle = benchmarkTitle(v); if (tblFactor) { tblFactor.textContent = fmtFactor(v); tblFactor.className = `vl-tbl-factor ${bc}`; tblFactor.title = btitle; }
tblBench.innerHTML = bt !== '-' ? `<span class="vl-bench-chip ${bc}" title="${escHtml(btitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(bt)}</span>` : '-'; if (tblTime) { tblTime.textContent = fmtElapsed(v); tblTime.className = `vl-tbl-time ${bc}`; tblTime.title = btitle; }
} if (tblWpm) { const wpm = voiceWpm(v); tblWpm.textContent = fmtWpm(v); tblWpm.title = wpm != null ? `${wpm} wpm — 130180 wpm is natural for long listening` : ''; }
if (tblDur) {
const sec = voiceBenchmarkAudioSec(v);
tblDur.textContent = fmtBenchmarkAudio(v);
tblDur.title = sec != null ? `${fmtBenchmarkAudio(v)} of synthesised audio for the benchmark sentence` : '';
}
if (tblWpm) {
const wpm = voiceWpm(v);
tblWpm.textContent = fmtWpm(v);
tblWpm.title = wpm != null ? `${wpm} wpm — 130180 wpm is natural for long listening` : '';
}
} }
setBenchmarkProgress(1, 1, `Finished ${v.id}`); setBenchmarkProgress(1, 1, `Finished ${v.id}`);
toast('Benchmarked ' + v.id, 'success'); toast('Benchmarked ' + v.id, 'success');

View File

@ -109,9 +109,10 @@
<div class="vl-th-sortable" data-sort="flag">Lang <span class="mdi mdi-sort"></span></div> <div class="vl-th-sortable" data-sort="flag">Lang <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="gender">Gender <span class="mdi mdi-sort"></span></div> <div class="vl-th-sortable" data-sort="gender">Gender <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="duration">Length <span class="mdi mdi-sort"></span></div> <div class="vl-th-sortable" data-sort="duration">Length <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="bench_audio">Duration <span class="mdi mdi-sort"></span><span class="vl-th-info mdi mdi-information-outline" title="Length of the synthesised benchmark audio — how many seconds of speech the engine produced for the test sentence."></span></div> <div class="vl-th-sortable" data-sort="bench_audio">Duration <span class="mdi mdi-sort"></span><span class="vl-th-info mdi mdi-information-outline" title="Duration of the synthesised benchmark audio — how many seconds of speech the engine produced for the test sentence."></span></div>
<div class="vl-th-sortable" data-sort="factor">Factor <span class="mdi mdi-sort"></span><span class="vl-th-info mdi mdi-information-outline" title="Speed multiplier: audio duration ÷ render time. Above 1.0× means the GPU is faster than real-time — higher is better."></span></div>
<div class="vl-th-sortable" data-sort="elapsed">Time <span class="mdi mdi-sort"></span><span class="vl-th-info mdi mdi-information-outline" title="Total render time: how many seconds the engine took to produce the benchmark audio from start to finish."></span></div>
<div class="vl-th-sortable" data-sort="wpm">WPM <span class="mdi mdi-sort"></span><span class="vl-th-info mdi mdi-information-outline" title="Words per minute the voice speaks. 130180 wpm is natural for audiobooks; lower = slower, more deliberate pace."></span></div> <div class="vl-th-sortable" data-sort="wpm">WPM <span class="mdi mdi-sort"></span><span class="vl-th-info mdi mdi-information-outline" title="Words per minute the voice speaks. 130180 wpm is natural for audiobooks; lower = slower, more deliberate pace."></span></div>
<div class="vl-th-sortable" data-sort="benchmark">Speed <span class="mdi mdi-sort"></span><span class="vl-th-info mdi mdi-information-outline" title="Synthesis speed: shown as render_time · multiplier×. The multiplier is seconds of audio produced per second of compute — higher means a faster GPU / engine."></span></div>
<div class="vl-th-sortable" data-sort="seed">Seed <span class="mdi mdi-sort"></span></div> <div class="vl-th-sortable" data-sort="seed">Seed <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="dbfs">dBFS <span class="mdi mdi-sort"></span></div> <div class="vl-th-sortable" data-sort="dbfs">dBFS <span class="mdi mdi-sort"></span></div>
<div>Type</div> <div>Type</div>

View File

@ -1516,7 +1516,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.vl-dbfs { display: none; } .vl-dbfs { display: none; }
/* Table View mode */ /* Table View mode */
.vl-tbl-lang, .vl-tbl-gender, .vl-tbl-type, .vl-tbl-bench, .vl-tbl-dbfs, .vl-tbl-len, .vl-tbl-dur, .vl-tbl-wpm, .vl-tbl-rating, .vl-tbl-source, .vl-tbl-seed, .vl-tbl-note, .vl-tbl-tag, .vl-tbl-active { display: none; } .vl-tbl-lang, .vl-tbl-gender, .vl-tbl-type, .vl-tbl-bench, .vl-tbl-dbfs, .vl-tbl-len, .vl-tbl-dur, .vl-tbl-factor, .vl-tbl-time, .vl-tbl-wpm, .vl-tbl-rating, .vl-tbl-source, .vl-tbl-seed, .vl-tbl-note, .vl-tbl-tag, .vl-tbl-active { display: none; }
.vl-table-header { display: none; } .vl-table-header { display: none; }
.voices-workbench.table-view .voices-list-pane { width: 100% !important; max-width: 100%; } .voices-workbench.table-view .voices-list-pane { width: 100% !important; max-width: 100%; }
@ -1524,8 +1524,8 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.voices-workbench.table-view .vl-table-header { .voices-workbench.table-view .vl-table-header {
display: grid; display: grid;
grid-template-columns: 24px 32px 60px minmax(140px, 1.2fr) 60px 70px 55px 55px 55px 80px 55px 70px 60px 90px 70px minmax(80px, 1fr) minmax(120px, 1fr) 50px; grid-template-columns: 24px 32px 60px minmax(140px, 1.2fr) 55px 65px 50px 55px 55px 55px 55px 50px 65px 55px 85px 65px minmax(80px, 1fr) minmax(120px, 1fr) 50px;
gap: 10px; gap: 8px;
align-items: center; align-items: center;
padding: 8px 10px; padding: 8px 10px;
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--subtext); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--subtext);
@ -1543,8 +1543,8 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.voices-workbench.table-view .vl-compact { .voices-workbench.table-view .vl-compact {
display: grid; display: grid;
grid-template-columns: 24px 32px 60px minmax(140px, 1.2fr) 60px 70px 55px 55px 55px 80px 55px 70px 60px 90px 70px minmax(80px, 1fr) minmax(120px, 1fr) 50px; grid-template-columns: 24px 32px 60px minmax(140px, 1.2fr) 55px 65px 50px 55px 55px 55px 55px 50px 65px 55px 85px 65px minmax(80px, 1fr) minmax(120px, 1fr) 50px;
gap: 10px; gap: 8px;
align-items: center; align-items: center;
} }
.voices-workbench.table-view .vl-bulk-cb { order: 0; margin: 0; justify-self: center; } .voices-workbench.table-view .vl-bulk-cb { order: 0; margin: 0; justify-self: center; }
@ -1558,17 +1558,22 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.voices-workbench.table-view .vl-tbl-gender { display: block; order: 5; font-size: 12px; } .voices-workbench.table-view .vl-tbl-gender { display: block; order: 5; font-size: 12px; }
.voices-workbench.table-view .vl-tbl-len { display: block; order: 6; font-size: 12px; font-family: monospace; } .voices-workbench.table-view .vl-tbl-len { display: block; order: 6; font-size: 12px; font-family: monospace; }
.voices-workbench.table-view .vl-tbl-dur { display: block; order: 7; font-size: 12px; font-family: monospace; } .voices-workbench.table-view .vl-tbl-dur { display: block; order: 7; font-size: 12px; font-family: monospace; }
.voices-workbench.table-view .vl-tbl-wpm { display: block; order: 8; font-size: 12px; font-family: monospace; } .voices-workbench.table-view .vl-tbl-factor { display: block; order: 8; font-size: 12px; font-family: monospace; font-weight: 600; }
.voices-workbench.table-view .vl-tbl-bench { display: block; order: 9; font-size: 12px; } .voices-workbench.table-view .vl-tbl-time { display: block; order: 9; font-size: 12px; font-family: monospace; }
.voices-workbench.table-view .vl-tbl-seed { display: block; order: 10; font-size: 12px; font-family: monospace; } .voices-workbench.table-view .vl-tbl-wpm { display: block; order: 10; font-size: 12px; font-family: monospace; }
.voices-workbench.table-view .vl-tbl-dbfs { display: block; order: 11; font-size: 12px; font-family: monospace; } .voices-workbench.table-view .vl-tbl-seed { display: block; order: 11; font-size: 12px; font-family: monospace; }
.voices-workbench.table-view .vl-tbl-type { display: block; order: 12; font-size: 12px; } .voices-workbench.table-view .vl-tbl-dbfs { display: block; order: 12; font-size: 12px; font-family: monospace; }
.voices-workbench.table-view .vl-tbl-source { display: block; order: 13; font-size: 12px; overflow: hidden; text-overflow: ellipsis; } .voices-workbench.table-view .vl-tbl-type { display: block; order: 13; font-size: 12px; }
.voices-workbench.table-view .vl-tbl-rating { display: flex; order: 14; font-size: 14px; color: var(--subtext); } .voices-workbench.table-view .vl-tbl-source { display: block; order: 14; font-size: 12px; overflow: hidden; text-overflow: ellipsis; }
.voices-workbench.table-view .vl-tbl-rating { display: flex; order: 15; font-size: 14px; color: var(--subtext); }
.voices-workbench.table-view .vl-tbl-rating .star.on { color: #f59e0b; } .voices-workbench.table-view .vl-tbl-rating .star.on { color: #f59e0b; }
.voices-workbench.table-view .vl-tbl-tag { display: block; order: 15; font-size: 12px; color: var(--accent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; } .voices-workbench.table-view .vl-tbl-tag { display: block; order: 16; font-size: 12px; color: var(--accent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.voices-workbench.table-view .vl-tbl-note { display: block; order: 16; font-size: 12px; color: var(--subtext); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-style: italic; } .voices-workbench.table-view .vl-tbl-note { display: block; order: 17; font-size: 12px; color: var(--subtext); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-style: italic; }
.voices-workbench.table-view .vl-tbl-active { display: block; order: 17; font-size: 16px; text-align: center; } .voices-workbench.table-view .vl-tbl-active { display: block; order: 18; font-size: 16px; text-align: center; }
.voices-workbench.table-view .vl-tbl-factor.bench-ok { color: var(--success, #16a34a); }
.voices-workbench.table-view .vl-tbl-factor.bench-warn { color: #d97706; }
.voices-workbench.table-view .vl-tbl-factor.bench-bad { color: var(--error, #dc2626); }
.voices-workbench.table-view .vl-tbl-time.bench-bad { color: var(--error, #dc2626); }
.voices-workbench.table-edit-mode .vl-compact { .voices-workbench.table-edit-mode .vl-compact {
background: rgba(37,99,235,0.03); background: rgba(37,99,235,0.03);