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:
2026-06-10 12:29:13 +02:00
parent c445eb3e04
commit 8e496ec2c4
4 changed files with 214 additions and 46 deletions
+73 -16
View File
@@ -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', () => {