diff --git a/static/app.js b/static/app.js
index 4e29128..b9bc01e 100644
--- a/static/app.js
+++ b/static/app.js
@@ -3390,6 +3390,7 @@ async function loadVoiceLibrary() {
if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices);
renderVoiceList();
updatePreviewVoiceMatchPanel();
+ if (typeof renderPerfHistory === 'function') renderPerfHistory();
status(`Loaded ${_voices.length} voices`);
} catch(e) {
if (list) list.innerHTML = '
Failed to load voices
';
@@ -5899,6 +5900,7 @@ $('save-preview-btn').addEventListener('click', () => {
const PERF_HISTORY_KEY = 'vcf-perf-history';
const PERF_HISTORY_MAX = 50;
+const PERF_HISTORY_SORT = { key: 'ts', dir: 'desc' };
function perfHistoryLoad() {
try { return JSON.parse(localStorage.getItem(PERF_HISTORY_KEY) || '[]'); } catch(_) { return []; }
@@ -5926,6 +5928,114 @@ function perfSparklineSvg(rtfValues) {
return ``;
}
+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 `${escHtml(label)}`;
+}
+
+function perfHistoryAvatarHtml(entry) {
+ const meta = perfHistoryVoiceMeta(entry);
+ const title = meta.label || meta.id || 'Voice';
+ if (meta.hasPicture) {
+ return `
`;
+ }
+ const icon = window.voiceAvatarIcon ? window.voiceAvatarIcon(meta.avatar, 24) : null;
+ if (icon) return `${icon.replace(/vp-avatar/g, 'perf-history-avatar-icon')}`;
+ const color = typeof avatarColor === 'function' ? avatarColor(meta.lang || meta.id || title) : '#6b7280';
+ const init = (title || '?').trim()[0]?.toUpperCase() || '?';
+ return `${escHtml(init)}`;
+}
+
+
+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 ``;
+}
+
function renderPerfHistory() {
const histList = $('perf-history-list');
if (!histList) return;
@@ -5933,23 +6043,38 @@ function renderPerfHistory() {
const filterOn = filterEl?.checked;
const curBack = $('perf-backend-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 (!entries.length) {
histList.innerHTML = '' + (filterOn ? 'No history for this backend/voice yet.' : 'No benchmark history yet. Run a benchmark above to start tracking.') + '
';
return;
}
+ entries.sort((a, b) => perfHistoryCompare(a, b) || (Number(b.ts) - Number(a.ts)));
const head = `
- Date / TimeBackendVoice
- Avg latencyMinAvg RTF
+ ${perfHistoryHeadButton('ts', 'Date / Time')}
+ ${perfHistoryHeadButton('backend', 'Backend')}
+ Profile
+ ${perfHistoryHeadButton('language', 'Language')}
+ ${perfHistoryHeadButton('gender', 'Gender')}
+ ${perfHistoryHeadButton('voice', 'Voice')}
+ ${perfHistoryHeadButton('device', 'CPU / GPU')}
+ ${perfHistoryHeadButton('avgLatencyMs', 'Avg latency')}
+ ${perfHistoryHeadButton('minLatencyMs', 'Min')}
+ ${perfHistoryHeadButton('avgRtf', 'Avg RTF')}
+
`;
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 rtfCls = e.avgRtf < 1 ? 'perf-good' : 'perf-slow';
+ const meta = perfHistoryVoiceMeta(e);
return `
${escHtml(dt)}
${escHtml(e.backend)}
+ ${perfHistoryAvatarHtml(e)}
+ ${escHtml(meta.lang || '-')}
+ ${escHtml(perfHistoryGenderLabel(meta.gender) || '-')}
${escHtml(e.voice)}
+ ${perfHistoryDeviceHtml(e)}
${Math.round(e.avgLatencyMs)} ms
${Math.round(e.minLatencyMs)} ms
${e.avgRtf.toFixed(2)}
@@ -5957,6 +6082,18 @@ function renderPerfHistory() {
`;
}).join('');
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 => {
btn.addEventListener('click', () => {
const ts = Number(btn.dataset.ts);
@@ -6065,6 +6202,7 @@ function renderPerfHistory() {
ts: Date.now(),
backend: perfBackendSel.value,
voice: perfVoiceSel.value,
+ ...perfHistoryExtraFields(perfVoiceSel.value, perfBackendSel.value),
textLen: perfText.value.trim().length,
avgLatencyMs: avg,
minLatencyMs: minL,
@@ -6313,6 +6451,7 @@ function renderPerfHistory() {
}
perfHistoryAdd({
ts: Date.now(), backend, voice, textLen: text.length,
+ ...perfHistoryExtraFields(voice, backend),
avgLatencyMs: entry.avgLatency, minLatencyMs: entry.minLatency,
maxLatencyMs: Math.max(...rowRunResults.map(r => r.lat)),
avgRtf: entry.avgRtf, runCount: rowRunResults.length, allOk: true,
diff --git a/static/js/benchmark.js b/static/js/benchmark.js
index 0b42709..a39bd9c 100644
--- a/static/js/benchmark.js
+++ b/static/js/benchmark.js
@@ -78,6 +78,22 @@
return `${escHtml(init)}`;
}
+ 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) {
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)]
@@ -305,6 +321,7 @@
}
perfHistoryAdd({
ts: Date.now(), backend, voice, textLen: text.length,
+ ...batchHistoryExtraFields(voice, backend),
avgLatencyMs: entry.avgLatency, minLatencyMs: entry.minLatency,
maxLatencyMs: Math.max(...rowRunResults.map(r => r.lat)),
avgRtf: entry.avgRtf, runCount: rowRunResults.length, allOk: true,
diff --git a/static/js/tts-preview.js b/static/js/tts-preview.js
index 88192f0..5070e7f 100644
--- a/static/js/tts-preview.js
+++ b/static/js/tts-preview.js
@@ -299,6 +299,7 @@ $('save-preview-btn').addEventListener('click', () => {
const PERF_HISTORY_KEY = 'vcf-perf-history';
const PERF_HISTORY_MAX = 50;
+const PERF_HISTORY_SORT = { key: 'ts', dir: 'desc' };
function perfHistoryLoad() {
try { return JSON.parse(localStorage.getItem(PERF_HISTORY_KEY) || '[]'); } catch(_) { return []; }
@@ -326,6 +327,114 @@ function perfSparklineSvg(rtfValues) {
return ``;
}
+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 `${escHtml(label)}`;
+}
+
+function perfHistoryAvatarHtml(entry) {
+ const meta = perfHistoryVoiceMeta(entry);
+ const title = meta.label || meta.id || 'Voice';
+ if (meta.hasPicture) {
+ return `
`;
+ }
+ const icon = window.voiceAvatarIcon ? window.voiceAvatarIcon(meta.avatar, 24) : null;
+ if (icon) return `${icon.replace(/vp-avatar/g, 'perf-history-avatar-icon')}`;
+ const color = typeof avatarColor === 'function' ? avatarColor(meta.lang || meta.id || title) : '#6b7280';
+ const init = (title || '?').trim()[0]?.toUpperCase() || '?';
+ return `${escHtml(init)}`;
+}
+
+
+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 ``;
+}
+
function renderPerfHistory() {
const histList = $('perf-history-list');
if (!histList) return;
@@ -333,23 +442,38 @@ function renderPerfHistory() {
const filterOn = filterEl?.checked;
const curBack = $('perf-backend-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 (!entries.length) {
histList.innerHTML = '' + (filterOn ? 'No history for this backend/voice yet.' : 'No benchmark history yet. Run a benchmark above to start tracking.') + '
';
return;
}
+ entries.sort((a, b) => perfHistoryCompare(a, b) || (Number(b.ts) - Number(a.ts)));
const head = `
- Date / TimeBackendVoice
- Avg latencyMinAvg RTF
+ ${perfHistoryHeadButton('ts', 'Date / Time')}
+ ${perfHistoryHeadButton('backend', 'Backend')}
+ Profile
+ ${perfHistoryHeadButton('language', 'Language')}
+ ${perfHistoryHeadButton('gender', 'Gender')}
+ ${perfHistoryHeadButton('voice', 'Voice')}
+ ${perfHistoryHeadButton('device', 'CPU / GPU')}
+ ${perfHistoryHeadButton('avgLatencyMs', 'Avg latency')}
+ ${perfHistoryHeadButton('minLatencyMs', 'Min')}
+ ${perfHistoryHeadButton('avgRtf', 'Avg RTF')}
+
`;
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 rtfCls = e.avgRtf < 1 ? 'perf-good' : 'perf-slow';
+ const meta = perfHistoryVoiceMeta(e);
return `
${escHtml(dt)}
${escHtml(e.backend)}
+ ${perfHistoryAvatarHtml(e)}
+ ${escHtml(meta.lang || '-')}
+ ${escHtml(perfHistoryGenderLabel(meta.gender) || '-')}
${escHtml(e.voice)}
+ ${perfHistoryDeviceHtml(e)}
${Math.round(e.avgLatencyMs)} ms
${Math.round(e.minLatencyMs)} ms
${e.avgRtf.toFixed(2)}
@@ -357,6 +481,18 @@ function renderPerfHistory() {
`;
}).join('');
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 => {
btn.addEventListener('click', () => {
const ts = Number(btn.dataset.ts);
@@ -474,6 +610,7 @@ function renderPerfHistory() {
ts: Date.now(),
backend: perfBackendSel.value,
voice: perfVoiceSel.value,
+ ...perfHistoryExtraFields(perfVoiceSel.value, perfBackendSel.value),
textLen: perfText.value.trim().length,
avgLatencyMs: avg,
minLatencyMs: minL,
diff --git a/static/js/voice-library.js b/static/js/voice-library.js
index b62eac0..f202c08 100644
--- a/static/js/voice-library.js
+++ b/static/js/voice-library.js
@@ -475,6 +475,7 @@ async function loadVoiceLibrary() {
if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices);
renderVoiceList();
updatePreviewVoiceMatchPanel();
+ if (typeof renderPerfHistory === 'function') renderPerfHistory();
status(`Loaded ${_voices.length} voices`);
} catch(e) {
if (list) list.innerHTML = 'Failed to load voices
';
diff --git a/static/style.css b/static/style.css
index 10acda2..75d762a 100644
--- a/static/style.css
+++ b/static/style.css
@@ -2366,11 +2366,18 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.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-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-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-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-del { cursor: pointer; color: var(--subtext); font-size: 11px; text-align: right; }
.perf-history-del:hover { color: var(--red); }