feat: persist analyses on reload, add re-analyse button and metadata, trim highlights tally

- _api_files: read each file's analysis cache and return cached_analysis
  {threshold, min_gap} so the client knows upfront which files are cached
- On page load: files with a cached analysis auto-fetch and render their
  waveform immediately instead of showing an idle Analyse button
- After any analysis: show threshold/gap/cached metadata line below the
  waveform and a Re-analyse button inside the waveform box; old button
  no longer disappears on success
- Error path now always re-adds an actionable Analyse button to the cell
- dayHighlights progress: remove the redundant intermediate "N analysed"
  tally (16/22 counter already conveys progress); done message now says
  "N files (N from cache)" when relevant
- Threshold hint updated to show dBFS equivalent (0.05 ≈ −26 dBFS)
- CSS: remove unused .prog-tally and .cached-badge; add .analysis-meta
  and .reanalyse-btn

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 09:32:04 +02:00
parent 77e7e4ca9e
commit 4539ff78fa
+52 -26
View File
@@ -324,8 +324,24 @@ class _Handler(BaseHTTPRequestHandler):
self._send(200, _HTML.encode('utf-8'), 'text/html; charset=utf-8') self._send(200, _HTML.encode('utf-8'), 'text/html; charset=utf-8')
def _api_files(self): def _api_files(self):
data = json.dumps(list_files(self.recordings_dir)).encode('utf-8') files = list_files(self.recordings_dir)
self._send(200, data, 'application/json') recordings_base = Path(self.recordings_dir).resolve()
analyses_base = Path(self.analyses_dir).resolve()
for f in files:
if f.get('ext') in ('wav', 'flac') and not f.get('recording'):
cache_path = _analysis_cache_path(
analyses_base, recordings_base, recordings_base / f['name'])
try:
cached = json.loads(cache_path.read_text('utf-8'))
f['cached_analysis'] = {
'threshold': cached['threshold'],
'min_gap': cached['min_gap'],
}
except Exception:
f['cached_analysis'] = None
else:
f['cached_analysis'] = None
self._send(200, json.dumps(files).encode('utf-8'), 'application/json')
def _api_analyze(self, qs): def _api_analyze(self, qs):
filename = qs.get('file', [None])[0] filename = qs.get('file', [None])[0]
@@ -712,8 +728,9 @@ button.chip:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.prog-bar{height:3px;background:var(--brd);border-radius:2px;margin:6px 0 4px;overflow:hidden} .prog-bar{height:3px;background:var(--brd);border-radius:2px;margin:6px 0 4px;overflow:hidden}
.prog-fill{height:100%;background:var(--accent);border-radius:2px;transition:width 0.15s ease} .prog-fill{height:100%;background:var(--accent);border-radius:2px;transition:width 0.15s ease}
.prog-file{font-size:12px;color:var(--muted);font-style:italic;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%} .prog-file{font-size:12px;color:var(--muted);font-style:italic;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.prog-tally{font-size:11px;color:var(--muted);margin-top:3px} .analysis-meta{font-size:10px;color:var(--muted);margin-top:5px;font-style:italic}
.cached-badge{font-size:10px;color:var(--muted);background:var(--surf);border:1px solid var(--brd);border-radius:3px;padding:1px 5px;margin-left:6px;vertical-align:middle;font-style:normal} button.reanalyse-btn{font-size:10px;padding:2px 7px;margin-top:6px;color:var(--muted);border-color:var(--brd);background:transparent}
button.reanalyse-btn:hover:not(:disabled){background:var(--surf);color:var(--txt)}
.empty{text-align:center;padding:60px;color:var(--muted)} .empty{text-align:center;padding:60px;color:var(--muted)}
/* player row */ /* player row */
.player-row td{padding:0 10px 10px;background:var(--bg);border-bottom:1px solid var(--brd)} .player-row td{padding:0 10px 10px;background:var(--bg);border-bottom:1px solid var(--brd)}
@@ -772,7 +789,7 @@ svg.day-timeline{display:block;width:100%;height:22px}
<label for="threshold-input">Analysis threshold:</label> <label for="threshold-input">Analysis threshold:</label>
<input type="number" id="threshold-input" min="0" max="1" step="0.005" value="0.05" <input type="number" id="threshold-input" min="0" max="1" step="0.005" value="0.05"
aria-describedby="threshold-hint"> aria-describedby="threshold-hint">
<span id="threshold-hint" class="controls-hint">RMS 01 · sections above this value are marked loud</span> <span id="threshold-hint" class="controls-hint">RMS amplitude 01 (linear; 0.05 ≈ 26 dBFS) · sections above this are marked loud</span>
<label for="preroll-input" style="margin-left:16px">Pre-roll:</label> <label for="preroll-input" style="margin-left:16px">Pre-roll:</label>
<input type="number" id="preroll-input" min="0" max="30" step="0.5" value="3" <input type="number" id="preroll-input" min="0" max="30" step="0.5" value="3"
aria-describedby="preroll-hint"> aria-describedby="preroll-hint">
@@ -954,6 +971,11 @@ async function analyse(idx, filename, cell, btn) {
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>'; cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
const threshold = document.getElementById('threshold-input').value || '0.05'; const threshold = document.getElementById('threshold-input').value || '0.05';
const minGap = document.getElementById('min-gap-input').value || '2'; const minGap = document.getElementById('min-gap-input').value || '2';
const restoreBtn = () => {
btn.textContent = 'Analyse'; btn.disabled = false;
btn.onclick = () => analyse(idx, filename, cell, btn);
if (!cell.contains(btn)) cell.appendChild(btn);
};
try { try {
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename) const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)
+'&threshold='+encodeURIComponent(threshold) +'&threshold='+encodeURIComponent(threshold)
@@ -961,17 +983,14 @@ async function analyse(idx, filename, cell, btn) {
const d = await r.json(); const d = await r.json();
if (d.error) { if (d.error) {
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`; cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`;
btn.disabled = false; btn.textContent = 'Analyse'; restoreBtn(); return;
return;
} }
const box = document.createElement('div'); box.className='wbox'; const box = document.createElement('div'); box.className='wbox';
box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0, filename)); box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0, filename));
if (d.cached) {
const badge = document.createElement('span'); const meta = document.createElement('div'); meta.className='analysis-meta';
badge.className = 'cached-badge'; badge.textContent = 'cached'; meta.textContent = `threshold: ${threshold} · gap: ${minGap}s${d.cached ? ' · cached' : ''}`;
badge.title = 'Result loaded from cache — change threshold/gap and re-analyse to recompute'; box.appendChild(meta);
box.firstChild.after(badge);
}
const chips = document.createElement('div'); const chips = document.createElement('div');
chips.className='chips'; chips.className='chips';
@@ -995,10 +1014,16 @@ async function analyse(idx, filename, cell, btn) {
chips.appendChild(q); chips.appendChild(q);
} }
box.appendChild(chips); box.appendChild(chips);
const rebtn = document.createElement('button');
rebtn.className='reanalyse-btn'; rebtn.textContent='Re-analyse';
rebtn.onclick = () => analyse(idx, filename, cell, rebtn);
box.appendChild(rebtn);
cell.innerHTML=''; cell.appendChild(box); cell.innerHTML=''; cell.appendChild(box);
} catch(e) { } catch(e) {
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(e.message)}</div>`; cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(e.message)}</div>`;
btn.disabled = false; btn.textContent = 'Analyse'; restoreBtn();
} }
} }
@@ -1102,15 +1127,21 @@ function _attachFileRowHandlers(f, isRec, expanded, dayId) {
if (canAnalyse) { if (canAnalyse) {
const cell = document.getElementById('wave-'+i); const cell = document.getElementById('wave-'+i);
const abtn = document.createElement('button'); const abtn = document.createElement('button');
abtn.textContent = 'Analyse';
abtn.setAttribute('aria-label', `Analyse loudness of ${f.name}`); abtn.setAttribute('aria-label', `Analyse loudness of ${f.name}`);
if (isRec) { if (isRec) {
abtn.textContent = 'Analyse';
abtn.disabled = true; abtn.disabled = true;
abtn.title = 'Recording in progress — analyse after recording stops'; abtn.title = 'Recording in progress — analyse after recording stops';
} else {
abtn.addEventListener('click', () => analyse(i, f.name, cell, abtn));
}
cell.appendChild(abtn); 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 {
abtn.textContent = 'Analyse';
abtn.onclick = () => analyse(i, f.name, cell, abtn);
cell.appendChild(abtn);
}
} }
document.getElementById('pbtn-'+i) document.getElementById('pbtn-'+i)
@@ -1349,8 +1380,7 @@ async function dayHighlights(dayId, analyzableFiles) {
const progFill = document.createElement('div'); progFill.className = 'prog-fill'; progFill.style.width = '0%'; const progFill = document.createElement('div'); progFill.className = 'prog-fill'; progFill.style.width = '0%';
progBar.appendChild(progFill); progBar.appendChild(progFill);
const progFile = document.createElement('div'); progFile.className = 'prog-file'; const progFile = document.createElement('div'); progFile.className = 'prog-file';
const progTally = document.createElement('div'); progTally.className = 'prog-tally'; progWrap.appendChild(progBar); progWrap.appendChild(progFile);
progWrap.appendChild(progBar); progWrap.appendChild(progFile); progWrap.appendChild(progTally);
contentEl.innerHTML = ''; contentEl.appendChild(progWrap); contentEl.innerHTML = ''; contentEl.appendChild(progWrap);
const threshold = document.getElementById('threshold-input').value || '0.05'; const threshold = document.getElementById('threshold-input').value || '0.05';
@@ -1362,10 +1392,6 @@ async function dayHighlights(dayId, analyzableFiles) {
const f = analyzableFiles[i]; const f = analyzableFiles[i];
progFile.textContent = `${i + 1} / ${n} — ${f.name}`; progFile.textContent = `${i + 1} / ${n} — ${f.name}`;
progFill.style.width = `${(i / n) * 100}%`; progFill.style.width = `${(i / n) * 100}%`;
const tallyParts = [];
if (nCached) tallyParts.push(`${nCached} cached`);
if (nLive) tallyParts.push(`${nLive} analysed`);
progTally.textContent = tallyParts.join(' · ');
try { try {
const r = await fetch('/api/analyze?file=' + encodeURIComponent(f.name) const r = await fetch('/api/analyze?file=' + encodeURIComponent(f.name)
+ '&threshold=' + encodeURIComponent(threshold) + '&threshold=' + encodeURIComponent(threshold)
@@ -1375,8 +1401,8 @@ async function dayHighlights(dayId, analyzableFiles) {
} catch(e) {} } catch(e) {}
} }
progFill.style.width = '100%'; progFill.style.width = '100%';
progFile.textContent = `Done — ${n} file${n!==1?'s':''} (${nCached} cached, ${nLive} analysed)`; const doneExtra = nCached ? ` (${nCached} from cache)` : '';
progTally.textContent = ''; progFile.textContent = `Done — ${n} file${n!==1?'s':''}${doneExtra}`;
if (!results.length) { if (!results.length) {
contentEl.innerHTML = '<div class="quiet">No analysable results.</div>'; contentEl.innerHTML = '<div class="quiet">No analysable results.</div>';