Enhance benchmark history metadata

This commit is contained in:
mARTin-B78 2026-06-07 15:41:04 +02:00
parent 52d3dcd135
commit c3b1cab75b
5 changed files with 309 additions and 8 deletions

View File

@ -3390,6 +3390,7 @@ async function loadVoiceLibrary() {
if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices); if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices);
renderVoiceList(); renderVoiceList();
updatePreviewVoiceMatchPanel(); updatePreviewVoiceMatchPanel();
if (typeof renderPerfHistory === 'function') renderPerfHistory();
status(`Loaded ${_voices.length} voices`); status(`Loaded ${_voices.length} voices`);
} catch(e) { } catch(e) {
if (list) list.innerHTML = '<div style="color:var(--red);padding:8px">Failed to load voices</div>'; if (list) list.innerHTML = '<div style="color:var(--red);padding:8px">Failed to load voices</div>';
@ -5899,6 +5900,7 @@ $('save-preview-btn').addEventListener('click', () => {
const PERF_HISTORY_KEY = 'vcf-perf-history'; const PERF_HISTORY_KEY = 'vcf-perf-history';
const PERF_HISTORY_MAX = 50; const PERF_HISTORY_MAX = 50;
const PERF_HISTORY_SORT = { key: 'ts', dir: 'desc' };
function perfHistoryLoad() { function perfHistoryLoad() {
try { return JSON.parse(localStorage.getItem(PERF_HISTORY_KEY) || '[]'); } catch(_) { return []; } try { return JSON.parse(localStorage.getItem(PERF_HISTORY_KEY) || '[]'); } catch(_) { return []; }
@ -5926,6 +5928,114 @@ function perfSparklineSvg(rtfValues) {
return `<svg viewBox="0 0 ${W} ${H}" class="perf-sparkline" aria-hidden="true">${bars}</svg>`; return `<svg viewBox="0 0 ${W} ${H}" class="perf-sparkline" aria-hidden="true">${bars}</svg>`;
} }
function perfHistoryVoiceLookup(voiceId) {
const list = Array.isArray(window._voices) && window._voices.length
? window._voices
: (typeof _voices !== 'undefined' && Array.isArray(_voices) ? _voices : []);
return list.find(v => v && (v.id === voiceId || v.name === voiceId || v.voice_id === voiceId)) || null;
}
function perfHistoryVoiceMeta(entry) {
const voice = entry?.voice || '';
const saved = entry?.voiceMeta || {};
const lib = perfHistoryVoiceLookup(voice) || {};
const language = saved.lang || saved.language || entry?.lang || entry?.language || lib.lang || lib.language || '';
const gender = String(saved.gender || entry?.gender || lib.gender || '').trim().toUpperCase().charAt(0);
return {
id: voice,
label: saved.label || saved.display_name || entry?.voiceLabel || lib.display_name || lib.name || voice,
lang: language,
gender: ['F', 'M', 'N'].includes(gender) ? gender : '',
flag: saved.flag || entry?.flag || lib.flag || '',
avatar: saved.avatar || entry?.avatar || lib.avatar || '',
hasPicture: Boolean(saved.has_picture ?? saved.hasPicture ?? entry?.hasPicture ?? lib.has_picture),
};
}
function perfHistoryExtraFields(voice, backend) {
const lib = perfHistoryVoiceLookup(voice) || {};
return {
voiceMeta: {
label: lib.display_name || lib.name || voice,
lang: lib.lang || lib.language || '',
gender: lib.gender || '',
flag: lib.flag || '',
avatar: lib.avatar || '',
has_picture: !!lib.has_picture,
},
device: typeof backendComputeDevice === 'function' ? backendComputeDevice(backend) : '',
};
}
function perfHistoryGenderLabel(gender) {
return ({F:'Female', M:'Male', N:'Diverse'}[gender] || '');
}
function perfHistoryDeviceLabel(entry) {
return entry?.device || (typeof backendComputeDevice === 'function' ? backendComputeDevice(entry?.backend || '') : '') || 'Unknown';
}
function perfHistoryDeviceHtml(entry) {
const label = perfHistoryDeviceLabel(entry);
const cls = typeof backendComputeDeviceClass === 'function'
? backendComputeDeviceClass(entry?.backend || '')
: (label.toLowerCase().includes('gpu') ? 'gpu' : label.toLowerCase().includes('cpu') ? 'cpu' : '');
return `<span class="bench-device ${escHtml(cls)}">${escHtml(label)}</span>`;
}
function perfHistoryAvatarHtml(entry) {
const meta = perfHistoryVoiceMeta(entry);
const title = meta.label || meta.id || 'Voice';
if (meta.hasPicture) {
return `<span class="perf-history-avatar" title="${escHtml(title)}"><img src="/api/voice/picture/${encodeURIComponent(meta.id)}" alt=""></span>`;
}
const icon = window.voiceAvatarIcon ? window.voiceAvatarIcon(meta.avatar, 24) : null;
if (icon) return `<span class="perf-history-avatar" title="${escHtml(title)}">${icon.replace(/vp-avatar/g, 'perf-history-avatar-icon')}</span>`;
const color = typeof avatarColor === 'function' ? avatarColor(meta.lang || meta.id || title) : '#6b7280';
const init = (title || '?').trim()[0]?.toUpperCase() || '?';
return `<span class="perf-history-avatar perf-history-avatar-init" style="background:${color}" title="${escHtml(title)}">${escHtml(init)}</span>`;
}
function perfHistorySortValue(entry, key) {
switch (key) {
case 'backend': return String(entry.backend || '').toLowerCase();
case 'language': return String(perfHistoryVoiceMeta(entry).lang || '').toLowerCase();
case 'gender': return String(perfHistoryGenderLabel(perfHistoryVoiceMeta(entry).gender) || '').toLowerCase();
case 'voice': return String(entry.voice || '').toLowerCase();
case 'device': return String(perfHistoryDeviceLabel(entry) || '').toLowerCase();
case 'avgLatencyMs': return Number(entry.avgLatencyMs);
case 'minLatencyMs': return Number(entry.minLatencyMs);
case 'avgRtf': return Number(entry.avgRtf);
case 'ts':
default: return Number(entry.ts);
}
}
function perfHistoryCompare(a, b) {
const av = perfHistorySortValue(a, PERF_HISTORY_SORT.key);
const bv = perfHistorySortValue(b, PERF_HISTORY_SORT.key);
let result = 0;
if (typeof av === 'string' || typeof bv === 'string') {
result = String(av).localeCompare(String(bv), undefined, { numeric: true, sensitivity: 'base' });
} else {
const an = Number.isFinite(av) ? av : -Infinity;
const bn = Number.isFinite(bv) ? bv : -Infinity;
result = an === bn ? 0 : an - bn;
}
return PERF_HISTORY_SORT.dir === 'asc' ? result : -result;
}
function perfHistoryHeadButton(key, label) {
const active = PERF_HISTORY_SORT.key === key;
const icon = active
? (PERF_HISTORY_SORT.dir === 'asc' ? 'mdi-arrow-up' : 'mdi-arrow-down')
: 'mdi-swap-vertical';
return `<button class="perf-history-sort${active ? ' active' : ''}" type="button" data-history-sort="${escHtml(key)}" aria-label="Sort by ${escHtml(label)}">
<span>${escHtml(label)}</span><span class="mdi ${icon}" aria-hidden="true"></span>
</button>`;
}
function renderPerfHistory() { function renderPerfHistory() {
const histList = $('perf-history-list'); const histList = $('perf-history-list');
if (!histList) return; if (!histList) return;
@ -5933,23 +6043,38 @@ function renderPerfHistory() {
const filterOn = filterEl?.checked; const filterOn = filterEl?.checked;
const curBack = $('perf-backend-select')?.value; const curBack = $('perf-backend-select')?.value;
const curVoice = $('perf-voice-select')?.value; const curVoice = $('perf-voice-select')?.value;
let entries = perfHistoryLoad().slice().reverse(); let entries = perfHistoryLoad().slice();
if (filterOn && curBack) entries = entries.filter(e => e.backend === curBack && e.voice === curVoice); if (filterOn && curBack) entries = entries.filter(e => e.backend === curBack && e.voice === curVoice);
if (!entries.length) { if (!entries.length) {
histList.innerHTML = '<div class="perf-history-empty">' + (filterOn ? 'No history for this backend/voice yet.' : 'No benchmark history yet. Run a benchmark above to start tracking.') + '</div>'; histList.innerHTML = '<div class="perf-history-empty">' + (filterOn ? 'No history for this backend/voice yet.' : 'No benchmark history yet. Run a benchmark above to start tracking.') + '</div>';
return; return;
} }
entries.sort((a, b) => perfHistoryCompare(a, b) || (Number(b.ts) - Number(a.ts)));
const head = `<div class="perf-history-row perf-history-head"> const head = `<div class="perf-history-row perf-history-head">
<span>Date / Time</span><span>Backend</span><span>Voice</span> <span>${perfHistoryHeadButton('ts', 'Date / Time')}</span>
<span>Avg latency</span><span>Min</span><span>Avg RTF</span><span></span> <span>${perfHistoryHeadButton('backend', 'Backend')}</span>
<span>Profile</span>
<span>${perfHistoryHeadButton('language', 'Language')}</span>
<span>${perfHistoryHeadButton('gender', 'Gender')}</span>
<span>${perfHistoryHeadButton('voice', 'Voice')}</span>
<span>${perfHistoryHeadButton('device', 'CPU / GPU')}</span>
<span>${perfHistoryHeadButton('avgLatencyMs', 'Avg latency')}</span>
<span>${perfHistoryHeadButton('minLatencyMs', 'Min')}</span>
<span>${perfHistoryHeadButton('avgRtf', 'Avg RTF')}</span>
<span></span>
</div>`; </div>`;
const rows = entries.map(e => { const rows = entries.map(e => {
const dt = new Date(e.ts).toLocaleString([], {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}); const dt = new Date(e.ts).toLocaleString([], {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'});
const rtfCls = e.avgRtf < 1 ? 'perf-good' : 'perf-slow'; const rtfCls = e.avgRtf < 1 ? 'perf-good' : 'perf-slow';
const meta = perfHistoryVoiceMeta(e);
return `<div class="perf-history-row"> return `<div class="perf-history-row">
<span class="perf-history-ts">${escHtml(dt)}</span> <span class="perf-history-ts">${escHtml(dt)}</span>
<span>${escHtml(e.backend)}</span> <span>${escHtml(e.backend)}</span>
<span>${perfHistoryAvatarHtml(e)}</span>
<span>${escHtml(meta.lang || '-')}</span>
<span>${escHtml(perfHistoryGenderLabel(meta.gender) || '-')}</span>
<span>${escHtml(e.voice)}</span> <span>${escHtml(e.voice)}</span>
<span>${perfHistoryDeviceHtml(e)}</span>
<span>${Math.round(e.avgLatencyMs)} ms</span> <span>${Math.round(e.avgLatencyMs)} ms</span>
<span>${Math.round(e.minLatencyMs)} ms</span> <span>${Math.round(e.minLatencyMs)} ms</span>
<span class="${rtfCls}">${e.avgRtf.toFixed(2)}</span> <span class="${rtfCls}">${e.avgRtf.toFixed(2)}</span>
@ -5957,6 +6082,18 @@ function renderPerfHistory() {
</div>`; </div>`;
}).join(''); }).join('');
histList.innerHTML = head + rows; histList.innerHTML = head + rows;
histList.querySelectorAll('.perf-history-sort').forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.dataset.historySort || 'ts';
if (PERF_HISTORY_SORT.key === key) {
PERF_HISTORY_SORT.dir = PERF_HISTORY_SORT.dir === 'asc' ? 'desc' : 'asc';
} else {
PERF_HISTORY_SORT.key = key;
PERF_HISTORY_SORT.dir = key === 'backend' || key === 'voice' ? 'asc' : 'desc';
}
renderPerfHistory();
});
});
histList.querySelectorAll('.perf-history-del').forEach(btn => { histList.querySelectorAll('.perf-history-del').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const ts = Number(btn.dataset.ts); const ts = Number(btn.dataset.ts);
@ -6065,6 +6202,7 @@ function renderPerfHistory() {
ts: Date.now(), ts: Date.now(),
backend: perfBackendSel.value, backend: perfBackendSel.value,
voice: perfVoiceSel.value, voice: perfVoiceSel.value,
...perfHistoryExtraFields(perfVoiceSel.value, perfBackendSel.value),
textLen: perfText.value.trim().length, textLen: perfText.value.trim().length,
avgLatencyMs: avg, avgLatencyMs: avg,
minLatencyMs: minL, minLatencyMs: minL,
@ -6313,6 +6451,7 @@ function renderPerfHistory() {
} }
perfHistoryAdd({ perfHistoryAdd({
ts: Date.now(), backend, voice, textLen: text.length, ts: Date.now(), backend, voice, textLen: text.length,
...perfHistoryExtraFields(voice, backend),
avgLatencyMs: entry.avgLatency, minLatencyMs: entry.minLatency, avgLatencyMs: entry.avgLatency, minLatencyMs: entry.minLatency,
maxLatencyMs: Math.max(...rowRunResults.map(r => r.lat)), maxLatencyMs: Math.max(...rowRunResults.map(r => r.lat)),
avgRtf: entry.avgRtf, runCount: rowRunResults.length, allOk: true, avgRtf: entry.avgRtf, runCount: rowRunResults.length, allOk: true,

View File

@ -78,6 +78,22 @@
return `<span class="batch-voice-avatar batch-voice-avatar-init" style="background:${batchColor(meta.lang || v.id)}">${escHtml(init)}</span>`; return `<span class="batch-voice-avatar batch-voice-avatar-init" style="background:${batchColor(meta.lang || v.id)}">${escHtml(init)}</span>`;
} }
function batchHistoryExtraFields(voice, backend) {
const item = batchVoices.find(v => v.id === voice) || {};
const meta = item.meta || libraryVoice(voice) || {};
return {
voiceMeta: {
label: meta.display_name || meta.name || item.label || voice,
lang: meta.lang || meta.language || '',
gender: meta.gender || '',
flag: meta.flag || '',
avatar: meta.avatar || '',
has_picture: !!meta.has_picture,
},
device: typeof backendComputeDevice === 'function' ? backendComputeDevice(backend) : '',
};
}
function batchSearchText(v) { function batchSearchText(v) {
const meta = v.meta || {}; const meta = v.meta || {};
return [v.id, v.label, meta.display_name, meta.name, meta.lang, meta.language, Array.isArray(meta.tags) ? meta.tags.join(' ') : meta.tags, batchVoiceStats(v)] return [v.id, v.label, meta.display_name, meta.name, meta.lang, meta.language, Array.isArray(meta.tags) ? meta.tags.join(' ') : meta.tags, batchVoiceStats(v)]
@ -305,6 +321,7 @@
} }
perfHistoryAdd({ perfHistoryAdd({
ts: Date.now(), backend, voice, textLen: text.length, ts: Date.now(), backend, voice, textLen: text.length,
...batchHistoryExtraFields(voice, backend),
avgLatencyMs: entry.avgLatency, minLatencyMs: entry.minLatency, avgLatencyMs: entry.avgLatency, minLatencyMs: entry.minLatency,
maxLatencyMs: Math.max(...rowRunResults.map(r => r.lat)), maxLatencyMs: Math.max(...rowRunResults.map(r => r.lat)),
avgRtf: entry.avgRtf, runCount: rowRunResults.length, allOk: true, avgRtf: entry.avgRtf, runCount: rowRunResults.length, allOk: true,

View File

@ -299,6 +299,7 @@ $('save-preview-btn').addEventListener('click', () => {
const PERF_HISTORY_KEY = 'vcf-perf-history'; const PERF_HISTORY_KEY = 'vcf-perf-history';
const PERF_HISTORY_MAX = 50; const PERF_HISTORY_MAX = 50;
const PERF_HISTORY_SORT = { key: 'ts', dir: 'desc' };
function perfHistoryLoad() { function perfHistoryLoad() {
try { return JSON.parse(localStorage.getItem(PERF_HISTORY_KEY) || '[]'); } catch(_) { return []; } try { return JSON.parse(localStorage.getItem(PERF_HISTORY_KEY) || '[]'); } catch(_) { return []; }
@ -326,6 +327,114 @@ function perfSparklineSvg(rtfValues) {
return `<svg viewBox="0 0 ${W} ${H}" class="perf-sparkline" aria-hidden="true">${bars}</svg>`; return `<svg viewBox="0 0 ${W} ${H}" class="perf-sparkline" aria-hidden="true">${bars}</svg>`;
} }
function perfHistoryVoiceLookup(voiceId) {
const list = Array.isArray(window._voices) && window._voices.length
? window._voices
: (typeof _voices !== 'undefined' && Array.isArray(_voices) ? _voices : []);
return list.find(v => v && (v.id === voiceId || v.name === voiceId || v.voice_id === voiceId)) || null;
}
function perfHistoryVoiceMeta(entry) {
const voice = entry?.voice || '';
const saved = entry?.voiceMeta || {};
const lib = perfHistoryVoiceLookup(voice) || {};
const language = saved.lang || saved.language || entry?.lang || entry?.language || lib.lang || lib.language || '';
const gender = String(saved.gender || entry?.gender || lib.gender || '').trim().toUpperCase().charAt(0);
return {
id: voice,
label: saved.label || saved.display_name || entry?.voiceLabel || lib.display_name || lib.name || voice,
lang: language,
gender: ['F', 'M', 'N'].includes(gender) ? gender : '',
flag: saved.flag || entry?.flag || lib.flag || '',
avatar: saved.avatar || entry?.avatar || lib.avatar || '',
hasPicture: Boolean(saved.has_picture ?? saved.hasPicture ?? entry?.hasPicture ?? lib.has_picture),
};
}
function perfHistoryExtraFields(voice, backend) {
const lib = perfHistoryVoiceLookup(voice) || {};
return {
voiceMeta: {
label: lib.display_name || lib.name || voice,
lang: lib.lang || lib.language || '',
gender: lib.gender || '',
flag: lib.flag || '',
avatar: lib.avatar || '',
has_picture: !!lib.has_picture,
},
device: typeof backendComputeDevice === 'function' ? backendComputeDevice(backend) : '',
};
}
function perfHistoryGenderLabel(gender) {
return ({F:'Female', M:'Male', N:'Diverse'}[gender] || '');
}
function perfHistoryDeviceLabel(entry) {
return entry?.device || (typeof backendComputeDevice === 'function' ? backendComputeDevice(entry?.backend || '') : '') || 'Unknown';
}
function perfHistoryDeviceHtml(entry) {
const label = perfHistoryDeviceLabel(entry);
const cls = typeof backendComputeDeviceClass === 'function'
? backendComputeDeviceClass(entry?.backend || '')
: (label.toLowerCase().includes('gpu') ? 'gpu' : label.toLowerCase().includes('cpu') ? 'cpu' : '');
return `<span class="bench-device ${escHtml(cls)}">${escHtml(label)}</span>`;
}
function perfHistoryAvatarHtml(entry) {
const meta = perfHistoryVoiceMeta(entry);
const title = meta.label || meta.id || 'Voice';
if (meta.hasPicture) {
return `<span class="perf-history-avatar" title="${escHtml(title)}"><img src="/api/voice/picture/${encodeURIComponent(meta.id)}" alt=""></span>`;
}
const icon = window.voiceAvatarIcon ? window.voiceAvatarIcon(meta.avatar, 24) : null;
if (icon) return `<span class="perf-history-avatar" title="${escHtml(title)}">${icon.replace(/vp-avatar/g, 'perf-history-avatar-icon')}</span>`;
const color = typeof avatarColor === 'function' ? avatarColor(meta.lang || meta.id || title) : '#6b7280';
const init = (title || '?').trim()[0]?.toUpperCase() || '?';
return `<span class="perf-history-avatar perf-history-avatar-init" style="background:${color}" title="${escHtml(title)}">${escHtml(init)}</span>`;
}
function perfHistorySortValue(entry, key) {
switch (key) {
case 'backend': return String(entry.backend || '').toLowerCase();
case 'language': return String(perfHistoryVoiceMeta(entry).lang || '').toLowerCase();
case 'gender': return String(perfHistoryGenderLabel(perfHistoryVoiceMeta(entry).gender) || '').toLowerCase();
case 'voice': return String(entry.voice || '').toLowerCase();
case 'device': return String(perfHistoryDeviceLabel(entry) || '').toLowerCase();
case 'avgLatencyMs': return Number(entry.avgLatencyMs);
case 'minLatencyMs': return Number(entry.minLatencyMs);
case 'avgRtf': return Number(entry.avgRtf);
case 'ts':
default: return Number(entry.ts);
}
}
function perfHistoryCompare(a, b) {
const av = perfHistorySortValue(a, PERF_HISTORY_SORT.key);
const bv = perfHistorySortValue(b, PERF_HISTORY_SORT.key);
let result = 0;
if (typeof av === 'string' || typeof bv === 'string') {
result = String(av).localeCompare(String(bv), undefined, { numeric: true, sensitivity: 'base' });
} else {
const an = Number.isFinite(av) ? av : -Infinity;
const bn = Number.isFinite(bv) ? bv : -Infinity;
result = an === bn ? 0 : an - bn;
}
return PERF_HISTORY_SORT.dir === 'asc' ? result : -result;
}
function perfHistoryHeadButton(key, label) {
const active = PERF_HISTORY_SORT.key === key;
const icon = active
? (PERF_HISTORY_SORT.dir === 'asc' ? 'mdi-arrow-up' : 'mdi-arrow-down')
: 'mdi-swap-vertical';
return `<button class="perf-history-sort${active ? ' active' : ''}" type="button" data-history-sort="${escHtml(key)}" aria-label="Sort by ${escHtml(label)}">
<span>${escHtml(label)}</span><span class="mdi ${icon}" aria-hidden="true"></span>
</button>`;
}
function renderPerfHistory() { function renderPerfHistory() {
const histList = $('perf-history-list'); const histList = $('perf-history-list');
if (!histList) return; if (!histList) return;
@ -333,23 +442,38 @@ function renderPerfHistory() {
const filterOn = filterEl?.checked; const filterOn = filterEl?.checked;
const curBack = $('perf-backend-select')?.value; const curBack = $('perf-backend-select')?.value;
const curVoice = $('perf-voice-select')?.value; const curVoice = $('perf-voice-select')?.value;
let entries = perfHistoryLoad().slice().reverse(); let entries = perfHistoryLoad().slice();
if (filterOn && curBack) entries = entries.filter(e => e.backend === curBack && e.voice === curVoice); if (filterOn && curBack) entries = entries.filter(e => e.backend === curBack && e.voice === curVoice);
if (!entries.length) { if (!entries.length) {
histList.innerHTML = '<div class="perf-history-empty">' + (filterOn ? 'No history for this backend/voice yet.' : 'No benchmark history yet. Run a benchmark above to start tracking.') + '</div>'; histList.innerHTML = '<div class="perf-history-empty">' + (filterOn ? 'No history for this backend/voice yet.' : 'No benchmark history yet. Run a benchmark above to start tracking.') + '</div>';
return; return;
} }
entries.sort((a, b) => perfHistoryCompare(a, b) || (Number(b.ts) - Number(a.ts)));
const head = `<div class="perf-history-row perf-history-head"> const head = `<div class="perf-history-row perf-history-head">
<span>Date / Time</span><span>Backend</span><span>Voice</span> <span>${perfHistoryHeadButton('ts', 'Date / Time')}</span>
<span>Avg latency</span><span>Min</span><span>Avg RTF</span><span></span> <span>${perfHistoryHeadButton('backend', 'Backend')}</span>
<span>Profile</span>
<span>${perfHistoryHeadButton('language', 'Language')}</span>
<span>${perfHistoryHeadButton('gender', 'Gender')}</span>
<span>${perfHistoryHeadButton('voice', 'Voice')}</span>
<span>${perfHistoryHeadButton('device', 'CPU / GPU')}</span>
<span>${perfHistoryHeadButton('avgLatencyMs', 'Avg latency')}</span>
<span>${perfHistoryHeadButton('minLatencyMs', 'Min')}</span>
<span>${perfHistoryHeadButton('avgRtf', 'Avg RTF')}</span>
<span></span>
</div>`; </div>`;
const rows = entries.map(e => { const rows = entries.map(e => {
const dt = new Date(e.ts).toLocaleString([], {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}); const dt = new Date(e.ts).toLocaleString([], {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'});
const rtfCls = e.avgRtf < 1 ? 'perf-good' : 'perf-slow'; const rtfCls = e.avgRtf < 1 ? 'perf-good' : 'perf-slow';
const meta = perfHistoryVoiceMeta(e);
return `<div class="perf-history-row"> return `<div class="perf-history-row">
<span class="perf-history-ts">${escHtml(dt)}</span> <span class="perf-history-ts">${escHtml(dt)}</span>
<span>${escHtml(e.backend)}</span> <span>${escHtml(e.backend)}</span>
<span>${perfHistoryAvatarHtml(e)}</span>
<span>${escHtml(meta.lang || '-')}</span>
<span>${escHtml(perfHistoryGenderLabel(meta.gender) || '-')}</span>
<span>${escHtml(e.voice)}</span> <span>${escHtml(e.voice)}</span>
<span>${perfHistoryDeviceHtml(e)}</span>
<span>${Math.round(e.avgLatencyMs)} ms</span> <span>${Math.round(e.avgLatencyMs)} ms</span>
<span>${Math.round(e.minLatencyMs)} ms</span> <span>${Math.round(e.minLatencyMs)} ms</span>
<span class="${rtfCls}">${e.avgRtf.toFixed(2)}</span> <span class="${rtfCls}">${e.avgRtf.toFixed(2)}</span>
@ -357,6 +481,18 @@ function renderPerfHistory() {
</div>`; </div>`;
}).join(''); }).join('');
histList.innerHTML = head + rows; histList.innerHTML = head + rows;
histList.querySelectorAll('.perf-history-sort').forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.dataset.historySort || 'ts';
if (PERF_HISTORY_SORT.key === key) {
PERF_HISTORY_SORT.dir = PERF_HISTORY_SORT.dir === 'asc' ? 'desc' : 'asc';
} else {
PERF_HISTORY_SORT.key = key;
PERF_HISTORY_SORT.dir = key === 'backend' || key === 'voice' ? 'asc' : 'desc';
}
renderPerfHistory();
});
});
histList.querySelectorAll('.perf-history-del').forEach(btn => { histList.querySelectorAll('.perf-history-del').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const ts = Number(btn.dataset.ts); const ts = Number(btn.dataset.ts);
@ -474,6 +610,7 @@ function renderPerfHistory() {
ts: Date.now(), ts: Date.now(),
backend: perfBackendSel.value, backend: perfBackendSel.value,
voice: perfVoiceSel.value, voice: perfVoiceSel.value,
...perfHistoryExtraFields(perfVoiceSel.value, perfBackendSel.value),
textLen: perfText.value.trim().length, textLen: perfText.value.trim().length,
avgLatencyMs: avg, avgLatencyMs: avg,
minLatencyMs: minL, minLatencyMs: minL,

View File

@ -475,6 +475,7 @@ async function loadVoiceLibrary() {
if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices); if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices);
renderVoiceList(); renderVoiceList();
updatePreviewVoiceMatchPanel(); updatePreviewVoiceMatchPanel();
if (typeof renderPerfHistory === 'function') renderPerfHistory();
status(`Loaded ${_voices.length} voices`); status(`Loaded ${_voices.length} voices`);
} catch(e) { } catch(e) {
if (list) list.innerHTML = '<div style="color:var(--red);padding:8px">Failed to load voices</div>'; if (list) list.innerHTML = '<div style="color:var(--red);padding:8px">Failed to load voices</div>';

View File

@ -2366,11 +2366,18 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.perf-sparkline { width: 120px; height: 32px; display: block; } .perf-sparkline { width: 120px; height: 32px; display: block; }
.bench-history-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; } .bench-history-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
.bench-history-filter { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--subtext); cursor: pointer; } .bench-history-filter { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--subtext); cursor: pointer; }
.perf-history-list { display: flex; flex-direction: column; gap: 0; } .perf-history-list { display: flex; flex-direction: column; gap: 0; overflow-x: auto; }
.perf-history-empty { font-size: 13px; color: var(--subtext); padding: 16px 0; } .perf-history-empty { font-size: 13px; color: var(--subtext); padding: 16px 0; }
.perf-history-row { display: grid; grid-template-columns: 120px 1fr 1fr 80px 70px 70px 80px; gap: 0 10px; padding: 7px 4px; border-bottom: 1px solid var(--border); font-size: 12px; align-items: center; } .perf-history-row { display: grid; grid-template-columns: 118px minmax(96px,.8fr) 34px 72px 72px minmax(190px,1.4fr) 92px 82px 68px 70px 24px; min-width: 1010px; gap: 0 10px; padding: 7px 4px; border-bottom: 1px solid var(--border); font-size: 12px; align-items: center; }
.perf-history-row:last-child { border-bottom: none; } .perf-history-row:last-child { border-bottom: none; }
.perf-history-head { font-size: 11px; color: var(--subtext); text-transform: uppercase; letter-spacing: .05em; font-weight: 600; } .perf-history-head { font-size: 11px; color: var(--subtext); text-transform: uppercase; letter-spacing: .05em; font-weight: 600; }
.perf-history-sort { display: inline-flex; align-items: center; gap: 3px; max-width: 100%; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; text-transform: inherit; letter-spacing: inherit; cursor: pointer; text-align: left; }
.perf-history-sort:hover, .perf-history-sort.active { color: var(--accent); }
.perf-history-sort .mdi { font-size: 13px; line-height: 1; }
.perf-history-avatar { width: 24px; height: 24px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; overflow: hidden; color: #fff; font-size: 11px; font-weight: 700; }
.perf-history-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.perf-history-avatar-icon { width: 24px !important; height: 24px !important; }
.perf-history-avatar-init { flex-shrink: 0; }
.perf-history-ts { color: var(--subtext); font-size: 11px; } .perf-history-ts { color: var(--subtext); font-size: 11px; }
.perf-history-del { cursor: pointer; color: var(--subtext); font-size: 11px; text-align: right; } .perf-history-del { cursor: pointer; color: var(--subtext); font-size: 11px; text-align: right; }
.perf-history-del:hover { color: var(--red); } .perf-history-del:hover { color: var(--red); }