Add batch benchmark to Benchmark page
Batch card pre-populates from active My Voices (checked by default) with an option to reload from the backend. Select all / deselect all buttons. Runs each selected voice N times sequentially with a live progress bar and stop button. Results table updates after every voice and sorts by avg RTF fastest-first; each row shows a trend badge (faster/slower/stable) vs the previous session for that voice. All runs are saved to History. renderPerfHistory hoisted to module level so both single-voice and batch IIFEs can refresh the History card after saving new entries. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e0ff837947
commit
e434f10d9b
300
static/app.js
300
static/app.js
@ -5891,6 +5891,46 @@ function perfSparklineSvg(rtfValues) {
|
||||
return `<svg viewBox="0 0 ${W} ${H}" class="perf-sparkline" aria-hidden="true">${bars}</svg>`;
|
||||
}
|
||||
|
||||
function renderPerfHistory() {
|
||||
const histList = $('perf-history-list');
|
||||
if (!histList) return;
|
||||
const filterEl = $('perf-history-filter-current');
|
||||
const filterOn = filterEl?.checked;
|
||||
const curBack = $('perf-backend-select')?.value;
|
||||
const curVoice = $('perf-voice-select')?.value;
|
||||
let entries = perfHistoryLoad().slice().reverse();
|
||||
if (filterOn && curBack) entries = entries.filter(e => e.backend === curBack && e.voice === curVoice);
|
||||
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>';
|
||||
return;
|
||||
}
|
||||
const head = `<div class="perf-history-row perf-history-head">
|
||||
<span>Date / Time</span><span>Backend</span><span>Voice</span>
|
||||
<span>Avg latency</span><span>Min</span><span>Avg RTF</span><span></span>
|
||||
</div>`;
|
||||
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';
|
||||
return `<div class="perf-history-row">
|
||||
<span class="perf-history-ts">${escHtml(dt)}</span>
|
||||
<span>${escHtml(e.backend)}</span>
|
||||
<span>${escHtml(e.voice)}</span>
|
||||
<span>${Math.round(e.avgLatencyMs)} ms</span>
|
||||
<span>${Math.round(e.minLatencyMs)} ms</span>
|
||||
<span class="${rtfCls}">${e.avgRtf.toFixed(2)}</span>
|
||||
<span class="perf-history-del" data-ts="${e.ts}" title="Remove"><span class="mdi mdi-close"></span></span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
histList.innerHTML = head + rows;
|
||||
histList.querySelectorAll('.perf-history-del').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const ts = Number(btn.dataset.ts);
|
||||
perfHistorySave(perfHistoryLoad().filter(e => e.ts !== ts));
|
||||
renderPerfHistory();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
(function initPerfBenchmark() {
|
||||
const perfBackendSel = $('perf-backend-select');
|
||||
const perfVoiceSel = $('perf-voice-select');
|
||||
@ -5952,48 +5992,6 @@ function perfSparklineSvg(rtfValues) {
|
||||
trendRow.style.display = '';
|
||||
}
|
||||
|
||||
function renderPerfHistory() {
|
||||
const histList = $('perf-history-list');
|
||||
if (!histList) return;
|
||||
const filterEl = $('perf-history-filter-current');
|
||||
const filterOn = filterEl?.checked;
|
||||
const curBack = perfBackendSel?.value;
|
||||
const curVoice = perfVoiceSel?.value;
|
||||
let entries = perfHistoryLoad().slice().reverse();
|
||||
if (filterOn && curBack) entries = entries.filter(e => e.backend === curBack && e.voice === curVoice);
|
||||
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>';
|
||||
return;
|
||||
}
|
||||
const head = `<div class="perf-history-row perf-history-head">
|
||||
<span>Date / Time</span><span>Backend</span><span>Voice</span>
|
||||
<span>Avg latency</span><span>Min</span><span>Avg RTF</span><span></span>
|
||||
</div>`;
|
||||
const rows = entries.map((e, i) => {
|
||||
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';
|
||||
return `<div class="perf-history-row" data-idx="${i}">
|
||||
<span class="perf-history-ts">${escHtml(dt)}</span>
|
||||
<span>${escHtml(e.backend)}</span>
|
||||
<span>${escHtml(e.voice)}</span>
|
||||
<span>${Math.round(e.avgLatencyMs)} ms</span>
|
||||
<span>${Math.round(e.minLatencyMs)} ms</span>
|
||||
<span class="${rtfCls}">${e.avgRtf.toFixed(2)}</span>
|
||||
<span class="perf-history-del" data-ts="${e.ts}" title="Remove this entry"><span class="mdi mdi-close"></span></span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
histList.innerHTML = head + rows;
|
||||
|
||||
histList.querySelectorAll('.perf-history-del').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const ts = Number(btn.dataset.ts);
|
||||
const updated = perfHistoryLoad().filter(e => e.ts !== ts);
|
||||
perfHistorySave(updated);
|
||||
renderPerfHistory();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderPerfTable(sessionDone = false) {
|
||||
if (!perfRows.length) { perfResultsCard.style.display='none'; return; }
|
||||
perfResultsCard.style.display = '';
|
||||
@ -6097,6 +6095,224 @@ function perfSparklineSvg(rtfValues) {
|
||||
renderPerfHistory();
|
||||
})();
|
||||
|
||||
// ── Batch benchmark ───────────────────────────────────────────────────────
|
||||
|
||||
(function initBatchBenchmark() {
|
||||
const batchBackendSel = $('batch-backend-select');
|
||||
const batchRunsSel = $('batch-runs');
|
||||
const batchLoadBtn = $('batch-load-voices-btn');
|
||||
const batchSelectAllBtn = $('batch-select-all-btn');
|
||||
const batchSelectNoneBtn= $('batch-select-none-btn');
|
||||
const batchVoiceList = $('batch-voice-list');
|
||||
const batchSelCount = $('batch-selected-count');
|
||||
const batchRunBtn = $('batch-run-btn');
|
||||
const batchStopBtn = $('batch-stop-btn');
|
||||
const batchProgress = $('batch-progress');
|
||||
const batchProgLabel = $('batch-progress-label');
|
||||
const batchProgCount = $('batch-progress-count');
|
||||
const batchProgBar = $('batch-progress-bar');
|
||||
const batchResultsCard = $('batch-results-card');
|
||||
const batchResultsLabel = $('batch-results-label');
|
||||
const batchTbody = $('batch-tbody');
|
||||
if (!batchRunBtn) return;
|
||||
|
||||
let batchStopped = false;
|
||||
let batchResults = [];
|
||||
|
||||
function populateBatchBackends() {
|
||||
if (!batchBackendSel) return;
|
||||
const cur = batchBackendSel.value;
|
||||
batchBackendSel.innerHTML = availableTtsBackends().map(b =>
|
||||
`<option value="${escHtml(b.id)}"${b.id===cur?' selected':''}>${escHtml(b.label)}</option>`
|
||||
).join('') || '<option value="">No backends available</option>';
|
||||
}
|
||||
populateBatchBackends();
|
||||
|
||||
function updateSelCount() {
|
||||
if (!batchVoiceList || !batchSelCount) return;
|
||||
const total = batchVoiceList.querySelectorAll('.batch-voice-cb').length;
|
||||
const checked = batchVoiceList.querySelectorAll('.batch-voice-cb:checked').length;
|
||||
batchSelCount.textContent = total ? `${checked} of ${total} selected` : '';
|
||||
batchRunBtn.disabled = checked === 0;
|
||||
}
|
||||
|
||||
function buildVoiceList(voices) {
|
||||
const activeIds = new Set(activeVoiceIds());
|
||||
if (!voices.length) {
|
||||
batchVoiceList.innerHTML = '<div class="perf-history-empty">No voices found.</div>';
|
||||
updateSelCount();
|
||||
return;
|
||||
}
|
||||
batchVoiceList.innerHTML = voices.map(v => {
|
||||
const isActive = activeIds.has(v.id);
|
||||
return `<label class="batch-voice-item${isActive ? ' is-active' : ''}">
|
||||
<input type="checkbox" class="batch-voice-cb" value="${escHtml(v.id)}"${isActive ? ' checked' : ''}>
|
||||
<span class="batch-voice-name">${escHtml(v.label)}</span>
|
||||
${isActive ? '<span class="batch-voice-tag">active</span>' : ''}
|
||||
</label>`;
|
||||
}).join('');
|
||||
batchVoiceList.querySelectorAll('.batch-voice-cb').forEach(cb => cb.addEventListener('change', updateSelCount));
|
||||
updateSelCount();
|
||||
}
|
||||
|
||||
// Pre-populate from My Voices library on init
|
||||
function populateFromLibrary() {
|
||||
const voices = (_voices || [])
|
||||
.filter(v => v.enabled !== false)
|
||||
.map(v => ({ id: v.id, label: v.display_name || v.name || v.id }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
buildVoiceList(voices);
|
||||
}
|
||||
populateFromLibrary();
|
||||
|
||||
batchLoadBtn.addEventListener('click', async () => {
|
||||
const backend = batchBackendSel.value;
|
||||
if (!backend) { toast('Select a backend first', 'error'); return; }
|
||||
batchLoadBtn.disabled = true;
|
||||
batchVoiceList.innerHTML = '<div class="perf-history-empty">Loading from backend…</div>';
|
||||
try {
|
||||
const raw = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
|
||||
const voices = raw.map(v => ({ id: backendVoiceId(v), label: backendVoiceId(v) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
buildVoiceList(voices);
|
||||
} catch(e) {
|
||||
batchVoiceList.innerHTML = `<div class="perf-history-empty">Failed: ${escHtml(e.message)}</div>`;
|
||||
}
|
||||
finally { batchLoadBtn.disabled = false; }
|
||||
});
|
||||
|
||||
batchSelectAllBtn.addEventListener('click', () => {
|
||||
batchVoiceList.querySelectorAll('.batch-voice-cb').forEach(cb => cb.checked = true);
|
||||
updateSelCount();
|
||||
});
|
||||
batchSelectNoneBtn.addEventListener('click', () => {
|
||||
batchVoiceList.querySelectorAll('.batch-voice-cb').forEach(cb => cb.checked = false);
|
||||
updateSelCount();
|
||||
});
|
||||
|
||||
function renderBatchResults() {
|
||||
if (!batchResults.length) { batchResultsCard.style.display = 'none'; return; }
|
||||
batchResultsCard.style.display = '';
|
||||
const sorted = batchResults.slice().sort((a, b) => {
|
||||
if (a.ok && !b.ok) return -1;
|
||||
if (!a.ok && b.ok) return 1;
|
||||
return (a.avgRtf || 999) - (b.avgRtf || 999);
|
||||
});
|
||||
const okCount = batchResults.filter(r => r.ok).length;
|
||||
if (batchResultsLabel) batchResultsLabel.textContent = `${okCount} / ${batchResults.length} voices — ${batchBackendSel.value}`;
|
||||
batchTbody.innerHTML = sorted.map(r => {
|
||||
const rtfCls = r.ok && r.avgRtf < 1 ? 'perf-good' : r.ok ? 'perf-slow' : '';
|
||||
const trend = r.trend ? `<span class="perf-trend-badge ${r.trend.cls}" style="font-size:11px;padding:1px 6px">${r.trend.label}</span>` : '';
|
||||
return `<tr class="${r.ok ? '' : 'perf-row-error'}">
|
||||
<td>${escHtml(r.voice)}</td>
|
||||
<td>${r.ok ? Math.round(r.avgLatency) + ' ms' : '—'}</td>
|
||||
<td>${r.ok ? r.minLatency + ' ms' : '—'}</td>
|
||||
<td>${r.ok && r.avgAudio > 0 ? r.avgAudio.toFixed(2) : '—'}</td>
|
||||
<td class="${rtfCls}">${r.ok && r.avgRtf ? r.avgRtf.toFixed(2) : '—'}${trend}</td>
|
||||
<td>${r.ok ? '<span class="perf-ok">OK</span>' : `<span class="perf-err">${escHtml(r.error || 'Failed')}</span>`}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
batchRunBtn.addEventListener('click', async () => {
|
||||
const backend = batchBackendSel.value;
|
||||
const text = $('perf-text')?.value.trim();
|
||||
const runs = parseInt(batchRunsSel.value) || 1;
|
||||
const selected = [...(batchVoiceList?.querySelectorAll('.batch-voice-cb:checked') || [])].map(cb => cb.value);
|
||||
if (!backend) { toast('Select a backend first', 'error'); return; }
|
||||
if (!text) { toast('Enter sample text in the single-voice form above', 'error'); return; }
|
||||
if (!selected.length) { toast('Select at least one voice', 'error'); return; }
|
||||
|
||||
batchStopped = false;
|
||||
batchResults = [];
|
||||
batchRunBtn.disabled = true;
|
||||
batchStopBtn.disabled = false;
|
||||
batchProgress.style.display = '';
|
||||
batchResultsCard.style.display = 'none';
|
||||
|
||||
for (let vi = 0; vi < selected.length; vi++) {
|
||||
if (batchStopped) break;
|
||||
const voice = selected[vi];
|
||||
batchProgLabel.textContent = `${voice} (${vi + 1} / ${selected.length})`;
|
||||
batchProgCount.textContent = `${vi + 1} / ${selected.length}`;
|
||||
batchProgBar.style.width = `${Math.round((vi / selected.length) * 100)}%`;
|
||||
|
||||
const entry = { voice, ok: false, avgLatency: 0, minLatency: 0, avgRtf: 0, avgAudio: 0, error: '' };
|
||||
const rowRunResults = [];
|
||||
|
||||
for (let ri = 0; ri < runs; ri++) {
|
||||
if (batchStopped) break;
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
const blob = await fetchTtsPreviewBlob(voice, text, 'wav', '', backend);
|
||||
const lat = Math.round(performance.now() - t0);
|
||||
let dur = 0;
|
||||
try {
|
||||
const ac = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const buf = await ac.decodeAudioData(await blob.arrayBuffer());
|
||||
dur = buf.duration; ac.close();
|
||||
} catch(_) {}
|
||||
rowRunResults.push({ lat, dur });
|
||||
} catch(e) { entry.error = e.message; break; }
|
||||
}
|
||||
|
||||
if (rowRunResults.length) {
|
||||
entry.ok = true;
|
||||
entry.avgLatency = rowRunResults.reduce((s, r) => s + r.lat, 0) / rowRunResults.length;
|
||||
entry.minLatency = Math.min(...rowRunResults.map(r => r.lat));
|
||||
const durRows = rowRunResults.filter(r => r.dur > 0);
|
||||
entry.avgAudio = durRows.length ? durRows.reduce((s, r) => s + r.dur, 0) / durRows.length : 0;
|
||||
const rtfArr = durRows.map(r => r.lat / 1000 / r.dur);
|
||||
entry.avgRtf = rtfArr.length ? rtfArr.reduce((s, v) => s + v, 0) / rtfArr.length : 0;
|
||||
|
||||
// compute trend vs previous session for this voice
|
||||
if (entry.avgRtf > 0) {
|
||||
const prev = perfHistoryLoad().filter(e => e.backend === backend && e.voice === voice && typeof e.avgRtf === 'number');
|
||||
if (prev.length) {
|
||||
const prevRtf = prev[prev.length - 1].avgRtf;
|
||||
const delta = entry.avgRtf - prevRtf;
|
||||
const pct = Math.abs(delta / Math.max(prevRtf, 0.01)) * 100;
|
||||
if (pct < 5) entry.trend = { cls: 'perf-trend-stable', label: '→ stable' };
|
||||
else if (delta < 0) entry.trend = { cls: 'perf-trend-better', label: `↓ ${pct.toFixed(0)}% faster` };
|
||||
else entry.trend = { cls: 'perf-trend-worse', label: `↑ ${pct.toFixed(0)}% slower` };
|
||||
}
|
||||
perfHistoryAdd({
|
||||
ts: Date.now(), backend, voice, textLen: text.length,
|
||||
avgLatencyMs: entry.avgLatency, minLatencyMs: entry.minLatency,
|
||||
maxLatencyMs: Math.max(...rowRunResults.map(r => r.lat)),
|
||||
avgRtf: entry.avgRtf, runCount: rowRunResults.length, allOk: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
batchResults.push(entry);
|
||||
renderBatchResults();
|
||||
}
|
||||
|
||||
batchProgBar.style.width = '100%';
|
||||
batchProgLabel.textContent = batchStopped
|
||||
? `Stopped after ${batchResults.length} voice${batchResults.length !== 1 ? 's' : ''}.`
|
||||
: `Done — ${batchResults.length} voice${batchResults.length !== 1 ? 's' : ''} benchmarked.`;
|
||||
batchStopBtn.disabled = true;
|
||||
batchRunBtn.disabled = false;
|
||||
renderPerfHistory();
|
||||
|
||||
const ok = batchResults.filter(r => r.ok);
|
||||
const best = ok.slice().sort((a, b) => a.avgRtf - b.avgRtf)[0];
|
||||
toast(
|
||||
`Batch done: ${ok.length}/${batchResults.length} OK` +
|
||||
(best ? `, best RTF ${best.avgRtf.toFixed(2)} (${best.voice})` : ''),
|
||||
ok.length < batchResults.length ? 'error' : 'success'
|
||||
);
|
||||
});
|
||||
|
||||
batchStopBtn.addEventListener('click', () => {
|
||||
batchStopped = true;
|
||||
batchStopBtn.disabled = true;
|
||||
batchProgLabel.textContent = 'Stopping after current voice…';
|
||||
});
|
||||
})();
|
||||
|
||||
// ── STT -> TTS ───────────────────────────────────────────────────────────
|
||||
|
||||
let sttTtsSourceId = null;
|
||||
|
||||
@ -68,6 +68,63 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Batch benchmark -->
|
||||
<div class="card">
|
||||
<h2>Batch benchmark</h2>
|
||||
<p class="card-subtitle">Benchmark multiple voices in one run. Pre-populated from your active My Voices — or reload from the backend. Results are sorted fastest first and saved to History.</p>
|
||||
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
|
||||
<div class="field">
|
||||
<label>Backend</label>
|
||||
<select id="batch-backend-select"><option value="">Checking backends…</option></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Runs per voice</label>
|
||||
<select id="batch-runs">
|
||||
<option value="1" selected>1 run</option>
|
||||
<option value="3">3 runs</option>
|
||||
<option value="5">5 runs</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="note" style="margin:4px 0 8px">Uses the sample text from the single-voice form above.</p>
|
||||
<div class="batch-voice-toolbar">
|
||||
<button class="btn-secondary btn-sm" id="batch-load-voices-btn" type="button"><span class="mdi mdi-reload"></span> Reload from backend</button>
|
||||
<button class="btn-secondary btn-sm" id="batch-select-all-btn" type="button">Select all</button>
|
||||
<button class="btn-secondary btn-sm" id="batch-select-none-btn" type="button">Deselect all</button>
|
||||
<span id="batch-selected-count" class="note"></span>
|
||||
</div>
|
||||
<div id="batch-voice-list" class="batch-voice-list"></div>
|
||||
<div class="btn-row" style="margin-top:12px">
|
||||
<button class="btn-primary" id="batch-run-btn" type="button" disabled><span class="mdi mdi-play"></span> Run batch</button>
|
||||
<button class="btn-secondary" id="batch-stop-btn" type="button" disabled><span class="mdi mdi-stop"></span> Stop</button>
|
||||
</div>
|
||||
<div id="batch-progress" class="batch-progress" style="display:none">
|
||||
<div class="batch-progress-head">
|
||||
<span id="batch-progress-label">Benchmarking…</span>
|
||||
<span id="batch-progress-count"></span>
|
||||
</div>
|
||||
<div class="batch-progress-track" role="progressbar">
|
||||
<div id="batch-progress-bar" class="batch-progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Batch results -->
|
||||
<div class="card" id="batch-results-card" style="display:none">
|
||||
<h2>Batch results <span id="batch-results-label" class="s-label-note"></span></h2>
|
||||
<p class="card-subtitle">Sorted fastest RTF first. Green = real-time capable (<1.0). Trend compares to the previous session for each voice.</p>
|
||||
<div class="perf-table-wrap">
|
||||
<table class="perf-table" id="batch-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Voice</th><th>Avg latency</th><th>Best</th><th>Audio (s)</th><th>Avg RTF</th><th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="batch-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History -->
|
||||
<div class="card" id="perf-history-card">
|
||||
<h2>History</h2>
|
||||
|
||||
@ -1734,6 +1734,18 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.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); }
|
||||
.batch-voice-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.batch-voice-list { display: flex; flex-direction: column; gap: 0; max-height: 260px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); }
|
||||
.batch-voice-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; cursor: pointer; font-size: 13px; border-bottom: 1px solid var(--border); transition: background .1s; }
|
||||
.batch-voice-item:last-child { border-bottom: none; }
|
||||
.batch-voice-item:hover { background: var(--hover); }
|
||||
.batch-voice-item.is-active .batch-voice-name { font-weight: 500; }
|
||||
.batch-voice-name { flex: 1; color: var(--text); }
|
||||
.batch-voice-tag { font-size: 10px; padding: 1px 6px; border-radius: 10px; background: rgba(var(--accent-rgb,99,102,241),.15); color: var(--accent); font-weight: 600; }
|
||||
.batch-progress { margin-top: 12px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.batch-progress-head { display: flex; justify-content: space-between; font-size: 12px; color: var(--subtext); }
|
||||
.batch-progress-track { height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; }
|
||||
.batch-progress-bar { height: 100%; border-radius: 3px; background: var(--accent); transition: width .3s; width: 0%; }
|
||||
|
||||
/* ── Chunked TTS toggle ──────────────────────────────────────────────────── */
|
||||
.chunk-toggle-label {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user