feat: add WPM column + info tooltips to voice table (v1.12.10)

WPM (words per minute) is derived from the benchmark audio duration and
sentence word count, revealing how fast a voice speaks — independent of
GPU speed. 130–180 wpm is comfortable for audiobooks. Column is sortable.
Info (ⓘ) icons on Speed and WPM headers explain both metrics on hover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-27 22:41:01 +02:00
parent 73d991c2f8
commit c5181b499a
5 changed files with 38 additions and 12 deletions

View File

@ -9,6 +9,14 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.12.10] — 2026-06-27
### Added
- **WPM column** in the voice table — shows how fast a voice speaks in words per minute, calculated from the benchmark audio duration and sentence word count. Sortable. Hover for a tooltip explaining the range (130180 wpm is natural for audiobooks).
- **Info (ⓘ) icons** on the Speed and WPM column headers with tooltips explaining what each metric measures.
---
## [1.12.9] — 2026-06-27 ## [1.12.9] — 2026-06-27
### Changed ### Changed

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

View File

@ -98,6 +98,7 @@ function getSortValue(v, field) {
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': { const b = voiceBenchmark(v); return b && b.speed != null ? -Number(b.speed) : 999; }
case 'wpm': return voiceWpm(v) ?? -1;
case 'transcript': return (v.transcript || '').toLowerCase(); case 'transcript': return (v.transcript || '').toLowerCase();
case 'note': return (v.note || '').toLowerCase(); case 'note': return (v.note || '').toLowerCase();
case 'source': return (_displaySource(v) || '').toLowerCase(); case 'source': return (_displaySource(v) || '').toLowerCase();
@ -376,6 +377,18 @@ function benchmarkClass(v) {
return elapsed != null && elapsed <= 4 ? 'bench-ok' : 'bench-warn'; return elapsed != null && elapsed <= 4 ? 'bench-ok' : 'bench-warn';
} }
function voiceWpm(v) {
const b = voiceBenchmark(v);
if (!b || !b.ok || !b.audio_sec || !b.text) return null;
const words = b.text.trim().split(/\s+/).length;
return Math.round(words / (b.audio_sec / 60));
}
function fmtWpm(v) {
const wpm = voiceWpm(v);
return wpm != null ? wpm + ' wpm' : '-';
}
function voiceFileUrl(v) { function voiceFileUrl(v) {
const version = v._audioVersion || v.updated_at || v.benchmarked_at || ''; const version = v._audioVersion || v.updated_at || v.benchmarked_at || '';
const bust = version || Date.now(); const bust = version || Date.now();
@ -1951,6 +1964,7 @@ function makeVoiceRow(v) {
<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-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-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-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-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">`

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>Type</div> <div>Type</div>
<div class="vl-th-sortable" data-sort="benchmark">Speed <span class="mdi mdi-sort"></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: seconds of audio produced per second of compute (higher = faster GPU). Shown as: render_time · multiplier×"></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 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="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="rating">Rating <span class="mdi mdi-sort"></span></div> <div class="vl-th-sortable" data-sort="rating">Rating <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="source">Source <span class="mdi mdi-sort"></span></div> <div class="vl-th-sortable" data-sort="source">Source <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="seed">Seed <span class="mdi mdi-sort"></span></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-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-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,7 +1524,7 @@ 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 60px 80px 70px 60px 90px 70px 60px minmax(120px, 1fr) minmax(80px, 1fr) 50px; grid-template-columns: 24px 32px 60px minmax(140px, 1.2fr) 60px 70px 60px 80px 70px 60px 55px 90px 70px 60px minmax(120px, 1fr) minmax(80px, 1fr) 50px;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
padding: 8px 10px; padding: 8px 10px;
@ -1538,10 +1538,12 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.vl-th-sortable:hover { color: var(--text); } .vl-th-sortable:hover { color: var(--text); }
.vl-th-sortable .mdi { font-size: 12px; opacity: .6; } .vl-th-sortable .mdi { font-size: 12px; opacity: .6; }
.vl-th-sortable:hover .mdi { opacity: 1; color: var(--accent); } .vl-th-sortable:hover .mdi { opacity: 1; color: var(--accent); }
.vl-th-info { font-size: 11px !important; opacity: .5; cursor: help; margin-left: 1px; }
.vl-th-info:hover { opacity: 1; color: var(--accent); }
.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 60px 80px 70px 60px 90px 70px 60px minmax(120px, 1fr) minmax(80px, 1fr) 50px; grid-template-columns: 24px 32px 60px minmax(140px, 1.2fr) 60px 70px 60px 80px 70px 60px 55px 90px 70px 60px minmax(120px, 1fr) minmax(80px, 1fr) 50px;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
} }
@ -1558,13 +1560,14 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.voices-workbench.table-view .vl-tbl-bench { display: block; order: 7; font-size: 12px; } .voices-workbench.table-view .vl-tbl-bench { display: block; order: 7; font-size: 12px; }
.voices-workbench.table-view .vl-tbl-dbfs { display: block; order: 8; font-size: 12px; font-family: monospace; } .voices-workbench.table-view .vl-tbl-dbfs { display: block; order: 8; font-size: 12px; font-family: monospace; }
.voices-workbench.table-view .vl-tbl-len { display: block; order: 9; font-size: 12px; font-family: monospace; } .voices-workbench.table-view .vl-tbl-len { display: block; order: 9; font-size: 12px; font-family: monospace; }
.voices-workbench.table-view .vl-tbl-rating { display: flex; order: 10; font-size: 14px; color: var(--subtext); } .voices-workbench.table-view .vl-tbl-wpm { display: block; order: 10; font-size: 12px; font-family: monospace; }
.voices-workbench.table-view .vl-tbl-rating { display: flex; order: 11; 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-source { display: block; order: 11; font-size: 12px; overflow: hidden; text-overflow: ellipsis; } .voices-workbench.table-view .vl-tbl-source { display: block; order: 12; font-size: 12px; overflow: hidden; text-overflow: ellipsis; }
.voices-workbench.table-view .vl-tbl-seed { display: block; order: 12; font-size: 12px; font-family: monospace; } .voices-workbench.table-view .vl-tbl-seed { display: block; order: 13; font-size: 12px; font-family: monospace; }
.voices-workbench.table-view .vl-tbl-note { display: block; order: 13; 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: 14; font-size: 12px; color: var(--subtext); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-style: italic; }
.voices-workbench.table-view .vl-tbl-tag { display: block; order: 14; 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: 15; font-size: 12px; color: var(--accent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.voices-workbench.table-view .vl-tbl-active { display: block; order: 15; font-size: 16px; text-align: center; } .voices-workbench.table-view .vl-tbl-active { display: block; order: 16; font-size: 16px; text-align: center; }
.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);