perf: faster page loads, live-recording playback and seeking fixes
Server (web.py): - /api/analyze no longer returns the full per-window RMS array (~45x larger than the rms_display the UI actually renders); old caches are stripped on read - /api/files reads only the first 256 bytes of each analysis cache to get threshold/min_gap instead of parsing the whole JSON - durations cached by (mtime, size) instead of re-opening every audio header per request; stat() race with deleted files guarded - /api/storage no longer walks the recordings tree (used bytes now computed client-side from the file list) - HTTP/1.1 keep-alive enabled; short writes force-close the connection; client-disconnect tracebacks from aborted seeks silenced - all file copies bounded by the advertised Content-Length so files growing during a response cannot desync the connection Live recording playback: - /stream/ patches in-progress WAV headers to the current file size so browsers show real duration and can seek (on-disk header says 0 frames until the recorder closes the file) - active files served with Cache-Control: no-store - reopening the player for a recording file reloads the source to pick up newly captured audio UI loading: - analyses lazy-load only for expanded day groups; collapsed days defer fetching until opened, and auto-load only when cached parameters match the current controls (no surprise mass recompute) - client-side analysis cache shared by file rows and day highlights, so re-renders and filters never refetch - filename filter debounced (200 ms) - file list auto-refreshes when the active recording set changes, unless audio is playing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+73
-16
@@ -246,6 +246,10 @@ function togglePlayer(idx, filename) {
|
||||
audio.src = '/stream/' + encodeURIComponent(filename);
|
||||
audio.load();
|
||||
audio.setAttribute('data-src-set','1');
|
||||
} else if (!document.getElementById('rec-'+idx)?.hidden) {
|
||||
// Still recording: re-fetch so duration and seek range cover the audio
|
||||
// captured since the source was last loaded
|
||||
audio.load();
|
||||
}
|
||||
activePlayerIdx = idx;
|
||||
prow.hidden = false;
|
||||
@@ -317,7 +321,22 @@ function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
|
||||
}
|
||||
}
|
||||
|
||||
async function analyse(idx, filename, cell, btn) {
|
||||
// filename|threshold|gap -> analysis result, so re-renders (filtering,
|
||||
// refresh) never refetch what this session already has
|
||||
const analysisCache = new Map();
|
||||
|
||||
async function fetchAnalysis(filename, threshold, minGap, force = false) {
|
||||
const key = `${filename}|${threshold}|${minGap}`;
|
||||
if (!force && analysisCache.has(key)) return analysisCache.get(key);
|
||||
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)
|
||||
+'&threshold='+encodeURIComponent(threshold)
|
||||
+'&min_gap='+encodeURIComponent(minGap));
|
||||
const d = await r.json();
|
||||
if (!d.error) analysisCache.set(key, d);
|
||||
return d;
|
||||
}
|
||||
|
||||
async function analyse(idx, filename, cell, btn, force = false) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
|
||||
@@ -329,10 +348,7 @@ async function analyse(idx, filename, cell, btn) {
|
||||
if (!cell.contains(btn)) cell.appendChild(btn);
|
||||
};
|
||||
try {
|
||||
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)
|
||||
+'&threshold='+encodeURIComponent(threshold)
|
||||
+'&min_gap='+encodeURIComponent(minGap));
|
||||
const d = await r.json();
|
||||
const d = await fetchAnalysis(filename, threshold, minGap, force);
|
||||
if (d.error) {
|
||||
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`;
|
||||
restoreBtn(); return;
|
||||
@@ -369,7 +385,7 @@ async function analyse(idx, filename, cell, btn) {
|
||||
|
||||
const rebtn = document.createElement('button');
|
||||
rebtn.className='reanalyse-btn'; rebtn.textContent='Re-analyse';
|
||||
rebtn.onclick = () => analyse(idx, filename, cell, rebtn);
|
||||
rebtn.onclick = () => analyse(idx, filename, cell, rebtn, true);
|
||||
box.appendChild(rebtn);
|
||||
|
||||
cell.innerHTML=''; cell.appendChild(box);
|
||||
@@ -447,6 +463,8 @@ async function deleteFile(idx, filename) {
|
||||
if (r.ok) {
|
||||
allFiles = allFiles.filter(f => f._idx !== idx);
|
||||
recMap.delete(idx);
|
||||
for (const k of [...analysisCache.keys()])
|
||||
if (k.startsWith(filename + '|')) analysisCache.delete(k);
|
||||
applyFilters();
|
||||
updateStorage();
|
||||
} else {
|
||||
@@ -464,13 +482,31 @@ async function updateStorage() {
|
||||
try {
|
||||
const s = await (await fetch('/api/storage')).json();
|
||||
const el = document.getElementById('storage-info');
|
||||
let txt = fmtSize(s.used) + ' used';
|
||||
const used = allFiles.reduce((a, f) => a + f.size, 0);
|
||||
let txt = fmtSize(used) + ' used';
|
||||
if (s.disk_free != null) txt += ' · ' + fmtSize(s.disk_free) + ' free';
|
||||
if (s.disk_total != null) txt += ' of ' + fmtSize(s.disk_total);
|
||||
el.textContent = txt;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// Does a server-side cached analysis match the current control values?
|
||||
// Auto-loading on a mismatch would silently recompute every file.
|
||||
function cachedParamsMatch(ca) {
|
||||
return ca != null
|
||||
&& Number(ca.threshold) === parseFloat(document.getElementById('threshold-input').value)
|
||||
&& Number(ca.min_gap) === parseFloat(document.getElementById('min-gap-input').value);
|
||||
}
|
||||
|
||||
// Run the deferred analyses of a freshly expanded day
|
||||
function autoloadDayAnalyses(dayId) {
|
||||
document.querySelectorAll('#daytbl-' + dayId + ' td[data-autoload="1"]').forEach(cell => {
|
||||
cell.removeAttribute('data-autoload');
|
||||
const btn = cell.querySelector('button') || document.createElement('button');
|
||||
analyse(Number(cell.dataset.idx), cell.dataset.fname, cell, btn);
|
||||
});
|
||||
}
|
||||
|
||||
function _attachFileRowHandlers(f, isRec, expanded, dayId) {
|
||||
const i = f._idx;
|
||||
const ext = f.ext;
|
||||
@@ -485,10 +521,19 @@ function _attachFileRowHandlers(f, isRec, expanded, dayId) {
|
||||
abtn.disabled = true;
|
||||
abtn.title = 'Recording in progress — analyse after recording stops';
|
||||
cell.appendChild(abtn);
|
||||
} else if (f.cached_analysis) {
|
||||
abtn.textContent = 'Re-analyse';
|
||||
cell.innerHTML = '<div class="spin">Loading…</div>';
|
||||
analyse(i, f.name, cell, abtn);
|
||||
} else if (cachedParamsMatch(f.cached_analysis)) {
|
||||
if (expanded) {
|
||||
abtn.textContent = 'Re-analyse';
|
||||
analyse(i, f.name, cell, abtn);
|
||||
} else {
|
||||
// Collapsed day: defer the fetch until the day is opened
|
||||
cell.setAttribute('data-autoload', '1');
|
||||
cell.dataset.idx = i;
|
||||
cell.dataset.fname = f.name;
|
||||
abtn.textContent = 'Analyse';
|
||||
abtn.onclick = () => analyse(i, f.name, cell, abtn);
|
||||
cell.appendChild(abtn);
|
||||
}
|
||||
} else {
|
||||
abtn.textContent = 'Analyse';
|
||||
abtn.onclick = () => analyse(i, f.name, cell, abtn);
|
||||
@@ -683,6 +728,7 @@ function renderFiles(files) {
|
||||
tgl.querySelector('.day-arrow').textContent = nowExp ? '▼' : '▶';
|
||||
headBar.classList.toggle('open', nowExp);
|
||||
document.getElementById('daytbl-' + dayId).hidden = !nowExp;
|
||||
if (nowExp) autoloadDayAnalyses(dayId);
|
||||
if (!nowExp) {
|
||||
dayFiles.forEach(f => closePlayer(f._idx));
|
||||
document.getElementById('dayhl-' + dayId).hidden = true;
|
||||
@@ -733,10 +779,7 @@ async function dayHighlights(dayId, analyzableFiles) {
|
||||
progFile.textContent = `${i + 1} / ${n} — ${f.name}`;
|
||||
progFill.style.width = `${(i / n) * 100}%`;
|
||||
try {
|
||||
const r = await fetch('/api/analyze?file=' + encodeURIComponent(f.name)
|
||||
+ '&threshold=' + encodeURIComponent(threshold)
|
||||
+ '&min_gap=' + encodeURIComponent(minGap));
|
||||
const d = await r.json();
|
||||
const d = await fetchAnalysis(f.name, threshold, minGap);
|
||||
if (!d.error) { results.push({ f, data: d }); d.cached ? nCached++ : nLive++; }
|
||||
} catch(e) {}
|
||||
}
|
||||
@@ -929,6 +972,7 @@ async function load() {
|
||||
}
|
||||
|
||||
// Poll recording status every 5 s to update REC badges
|
||||
let lastActiveKey = null;
|
||||
async function pollStatus() {
|
||||
try {
|
||||
const s = await (await fetch('/api/status')).json();
|
||||
@@ -937,12 +981,25 @@ async function pollStatus() {
|
||||
const badge = document.getElementById('rec-'+idx);
|
||||
if (badge) badge.hidden = !active.has(filename);
|
||||
});
|
||||
// Active set changed (recording started/stopped or rolled over to a new
|
||||
// split file): refresh the list so new files appear — but never yank the
|
||||
// DOM out from under an in-progress playback.
|
||||
const key = [...active].sort().join('|');
|
||||
if (lastActiveKey === null) { lastActiveKey = key; return; }
|
||||
if (key !== lastActiveKey) {
|
||||
const playing = [...document.querySelectorAll('audio')].some(a => !a.paused && !a.ended);
|
||||
if (!playing) { lastActiveKey = key; load(); }
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
document.getElementById('refresh-btn').addEventListener('click', load);
|
||||
|
||||
document.getElementById('filter-name').addEventListener('input', applyFilters);
|
||||
let filterDebounce;
|
||||
document.getElementById('filter-name').addEventListener('input', () => {
|
||||
clearTimeout(filterDebounce);
|
||||
filterDebounce = setTimeout(applyFilters, 200);
|
||||
});
|
||||
document.getElementById('filter-from').addEventListener('change', applyFilters);
|
||||
document.getElementById('filter-to').addEventListener('change', applyFilters);
|
||||
document.getElementById('filter-clear').addEventListener('click', () => {
|
||||
|
||||
Reference in New Issue
Block a user