feat: reorder columns, add Duration column, benchmark sentence presets (v1.12.12)

Column order now: Img·Play·Name·Lang·Gender·Length·Duration·WPM·Speed·
Seed·dBFS·Type·Source·Rating·Tags·Note·Active.
Duration shows synthesised audio length (b.audio_sec), sortable.
Preset dropdown offers 4 benchmark sentences (DE/EN narrative + DE/EN
tongue-twister) and a reset option.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-27 22:54:54 +02:00
parent ca3ce3adcb
commit 0606db101a
5 changed files with 88 additions and 25 deletions

View File

@ -9,6 +9,17 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
## [1.12.12] — 2026-06-27
### Added
- **Duration column** in the voice table — shows the length of the synthesised benchmark audio (e.g. `11.4s`), sortable. Distinct from Length (original clip) and WPM (speaking rate).
- **Benchmark sentence presets** dropdown — four ready-made sentences (DE narrative, EN narrative, DE pangram, EN tongue-twister) plus a Reset option. Selecting a preset loads it instantly without overwriting anything else.
### Changed
- **Column order** rearranged to: Img · Play · Name · Lang · Gender · Length · Duration · WPM · Speed · Seed · dBFS · Type · Source · Rating · Tags · Note · Active.
---
## [1.12.11] — 2026-06-27
### Fixed

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

View File

@ -45,6 +45,14 @@ const BENCHMARK_SAMPLE_STORAGE_KEY = 'vcf-benchmark-sample-text';
const _libraryFilters = {text:'', lang:'', sex:'', type:'', rating:''};
const DEFAULT_BENCHMARK_SAMPLE_TEXT = 'Hello, how are you today? Please read this sample clearly for a fair voice benchmark.';
const BENCHMARK_PRESETS = {
de: 'Die Welt ist voller Geschichten, die darauf warten, erzählt zu werden — von mutigen Helden und stillen Träumern.',
en: 'The old lighthouse stood firm against the crashing waves, its beam sweeping silently across the dark and restless sea.',
de2: 'Victor jagt zwölf Boxkämpfer quer über den großen Sylter Deich. Im Winter ist es kalt und die Tage sind kurz.',
en2: 'She sells seashells by the seashore. Peter Piper picked a peck of pickled peppers on a perfectly pleasant afternoon.',
reset: DEFAULT_BENCHMARK_SAMPLE_TEXT,
};
function benchmarkSampleText() {
const el = $('benchmark-sample-text');
return (el && el.value.trim()) || DEFAULT_BENCHMARK_SAMPLE_TEXT;
@ -63,6 +71,17 @@ function initBenchmarkSampleControls() {
localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, sample.value);
status('Benchmark sample sentence reset');
});
const presetSel = $('benchmark-preset-select');
if (presetSel) {
presetSel.addEventListener('change', () => {
const key = presetSel.value;
if (!key || !BENCHMARK_PRESETS[key]) { presetSel.value = ''; return; }
sample.value = BENCHMARK_PRESETS[key];
localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, sample.value);
presetSel.value = '';
status('Benchmark sample sentence loaded');
});
}
$('benchmark-use-preview-btn')?.addEventListener('click', () => {
const text = $('preview-text-area')?.value.trim();
if (!text) { toast('Preview text is empty', 'error'); return; }
@ -98,6 +117,7 @@ function getSortValue(v, field) {
case 'duration': return v.duration || 0;
case 'dbfs': return voiceDbfs(v) ?? -999;
case 'benchmark': { const b = voiceBenchmark(v); return b && b.speed != null ? -Number(b.speed) : 999; }
case 'bench_audio': return voiceBenchmarkAudioSec(v) ?? 999;
case 'wpm': return voiceWpm(v) ?? -1;
case 'transcript': return (v.transcript || '').toLowerCase();
case 'note': return (v.note || '').toLowerCase();
@ -377,6 +397,16 @@ function benchmarkClass(v) {
return elapsed != null && elapsed <= 4 ? 'bench-ok' : 'bench-warn';
}
function voiceBenchmarkAudioSec(v) {
const b = voiceBenchmark(v);
return b && b.ok && b.audio_sec != null ? Number(b.audio_sec) : null;
}
function fmtBenchmarkAudio(v) {
const sec = voiceBenchmarkAudioSec(v);
return sec != null ? sec.toFixed(1) + 's' : '-';
}
function voiceWpm(v) {
const b = voiceBenchmark(v);
if (!b || !b.ok || !b.audio_sec || !b.text) return null;
@ -836,11 +866,17 @@ async function runVoiceBenchmarkBatch() {
// Live-update the row cells so results appear immediately
if (row) {
const benchCell = row.querySelector('.vl-tbl-bench');
const durCell = row.querySelector('.vl-tbl-dur');
const wpmCell = row.querySelector('.vl-tbl-wpm');
if (benchCell) {
const bt = fmtBenchmark(voice), bc = benchmarkClass(voice), btitle = benchmarkTitle(voice);
benchCell.innerHTML = bt !== '-' ? `<span class="vl-bench-chip ${bc}" title="${escHtml(btitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(bt)}</span>` : '-';
}
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);
@ -1978,6 +2014,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-dbfs" title="${dbTitle}">${dbfs} dB</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-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">
${window._voiceTableEditMode
@ -2945,11 +2982,17 @@ function makeVoiceRow(v) {
const tblRow = document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"]`);
if (tblRow) {
const tblBench = tblRow.querySelector('.vl-tbl-bench');
const tblDur = tblRow.querySelector('.vl-tbl-dur');
const tblWpm = tblRow.querySelector('.vl-tbl-wpm');
if (tblBench) {
const bt = fmtBenchmark(v), bc = benchmarkClass(v), btitle = benchmarkTitle(v);
tblBench.innerHTML = bt !== '-' ? `<span class="vl-bench-chip ${bc}" title="${escHtml(btitle)}"><span class="mdi mdi-timer-outline"></span> ${escHtml(bt)}</span>` : '-';
}
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);

View File

@ -108,16 +108,17 @@
<div class="vl-th-sortable" data-sort="id">Name <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>Type</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="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="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="source">Source <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: 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="note">Note <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 class="vl-th-sortable" data-sort="source">Source <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="tag">Tags <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="note">Note <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="enabled">Active <span class="mdi mdi-sort"></span></div>
</div>
<div id="voice-list" tabindex="0" role="region" aria-label="Voice library"></div>
@ -174,7 +175,14 @@
<select id="library-tts-backend-select"><option value="">Checking backends...</option></select>
<input type="number" id="library-target-db" value="-20" min="-60" max="-1" step="0.5">
<button id="normalize-volume-btn">Normalize volume</button>
<button id="benchmark-reset-sample-btn">Reset sample</button>
<select id="benchmark-preset-select" title="Load a benchmark sentence preset">
<option value="">Sentence presets…</option>
<option value="de">DE — Die Welt ist voller Geschichten…</option>
<option value="en">EN — The old lighthouse stood firm…</option>
<option value="de2">DE — Der schnelle braune Fuchs…</option>
<option value="en2">EN — She sells seashells…</option>
<option value="reset">↺ Reset to default</option>
</select>
<button id="benchmark-use-preview-btn">Use preview text</button>
</div>

View File

@ -1516,7 +1516,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.vl-dbfs { display: none; }
/* Table View mode */
.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-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-table-header { display: none; }
.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 {
display: grid;
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;
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;
gap: 10px;
align-items: center;
padding: 8px 10px;
@ -1543,7 +1543,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.voices-workbench.table-view .vl-compact {
display: grid;
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;
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;
gap: 10px;
align-items: center;
}
@ -1556,18 +1556,19 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.voices-workbench.table-view .vl-name-input { order: 3; display: block; font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
.voices-workbench.table-view .vl-tbl-lang { display: block; order: 4; font-size: 12px; }
.voices-workbench.table-view .vl-tbl-gender { display: block; order: 5; font-size: 12px; }
.voices-workbench.table-view .vl-tbl-type { display: block; order: 6; 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-len { display: block; order: 9; 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-rating { display: flex; order: 11; font-size: 14px; color: var(--subtext); }
.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-wpm { display: block; order: 8; font-size: 12px; font-family: monospace; }
.voices-workbench.table-view .vl-tbl-bench { display: block; order: 9; font-size: 12px; }
.voices-workbench.table-view .vl-tbl-seed { 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-type { display: block; order: 12; font-size: 12px; }
.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-rating { display: flex; order: 14; font-size: 14px; color: var(--subtext); }
.voices-workbench.table-view .vl-tbl-rating .star.on { color: #f59e0b; }
.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: 13; font-size: 12px; font-family: monospace; }
.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: 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: 16; font-size: 16px; text-align: center; }
.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-active { display: block; order: 17; font-size: 16px; text-align: center; }
.voices-workbench.table-edit-mode .vl-compact {
background: rgba(37,99,235,0.03);