// Per-file loudness analysis: fetching (with a session cache) and the result // cell — a meta line, the section chips, a Re-analyse button. import {fmtDur, fmtClock, fileStartEpoch, announce, chipToggle} from './util.js'; import * as api from './api.js'; import {fileByName, fileByIdx, fileSpan, sectionMap} from './store.js'; import {analysisParams} from './settings.js'; import {playFrom} from './clips.js'; // filename|margin|gap|minDur -> analysis result, so re-renders (filtering, // refresh) never refetch what this session already has const analysisCache = new Map(); export async function fetchAnalysis(filename, params, force = false) { const {margin, minGap, minDur} = params; const key = `${filename}|${margin}|${minGap}|${minDur}`; if (!force && analysisCache.has(key)) return analysisCache.get(key); const d = await api.analyze(filename, params); if (!d.error) { analysisCache.set(key, d); // Keep the listing's cache info current so re-renders (filtering) still // know the file is analysed without refetching /api/files const f = fileByName(filename); if (f) f.cached_analysis = {margin, min_gap: minGap, min_duration: minDur}; } return d; } export function forgetAnalyses(filename) { for (const k of [...analysisCache.keys()]) if (k.startsWith(filename + '|')) analysisCache.delete(k); } export function playFileSection(idx, si) { const f = fileByIdx(idx); if (!f) return; const epoch = fileStartEpoch(f.date); const queue = (sectionMap.get(idx) || []).map(s => ({ filename: f.name, start: s.start, end: s.end, score: s.score, absStart: epoch != null ? epoch + s.start : null, })); playFrom(queue, si); } export function analyseButton(idx, cell, text) { const f = fileByIdx(idx); const b = document.createElement('button'); b.textContent = text; b.setAttribute('aria-label', `${text} ${f ? fileSpan(f) : ''}`); b.addEventListener('click', () => analyse(idx, cell, b, {force: text === 'Re-analyse', speak: true})); return b; } const para = (cls, text) => { const p = document.createElement('p'); p.className = cls; p.textContent = text; return p; }; // btn is the button that started it (optional). It stays in place and // focusable while busy — disabling or replacing a focused button drops the // screen reader at the top of the page. speak: the user asked for this one, so // say how it went; background autoloads stay quiet. export async function analyse(idx, cell, btn, {force = false, speak = false} = {}) { const f = fileByIdx(idx); if (!f || cell.dataset.busy) return; cell.dataset.busy = '1'; const span = fileSpan(f); if (btn) { btn.textContent = 'Analysing…'; btn.setAttribute('aria-label', `Analysing ${span}`); btn.setAttribute('aria-disabled', 'true'); } else { cell.replaceChildren(para('quiet', 'Analysing…')); } const params = analysisParams(); let d; try { d = await fetchAnalysis(f.name, params, force); } catch (e) { d = {error: e.message}; } delete cell.dataset.busy; // Focus inside the cell is about to be destroyed: move it to the new content const hadFocus = cell.contains(document.activeElement); if (d.error) { const b = analyseButton(idx, cell, 'Analyse'); cell.replaceChildren(para('quiet', `Error: ${d.error}`), b); if (hadFocus) b.focus(); if (speak) announce(`Analysis of ${span} failed: ${d.error}`); return; } const secs = d.sections || []; const n = secs.length; const box = document.createElement('div'); box.className = 'wbox'; box.append(para('analysis-meta', `${n} loud section${n !== 1 ? 's' : ''}` + ` · margin ${params.margin} dB · gap ${params.minGap} s · min ${params.minDur} s` + (d.cached ? ' · cached' : ''))); if (n) { sectionMap.set(idx, secs); const epoch = fileStartEpoch(f.date); const chips = document.createElement('div'); chips.className = 'chips'; chips.setAttribute('role', 'group'); chips.setAttribute('aria-label', 'Loud sections'); secs.forEach((s, si) => { const c = document.createElement('button'); c.className = 'chip'; // Wall-clock time like the day highlights and the clip label; the // in-file offset only matters for cutting and lives in the tooltip c.textContent = (epoch != null ? fmtClock(epoch + s.start) : `${fmtDur(s.start)} – ${fmtDur(s.end)}`) + (s.score != null ? ` · +${Math.round(s.score)} dB` : ''); c.title = `${fmtDur(s.start)}–${fmtDur(s.end)} into ${f.name}`; c.addEventListener('click', () => playFileSection(idx, si)); chips.appendChild(c); }); const tog = chipToggle(chips, `${n} sections`); if (tog) box.append(tog); box.append(chips); } else { sectionMap.delete(idx); } box.append(analyseButton(idx, cell, 'Re-analyse')); box.lastChild.classList.add('reanalyse-btn'); cell.replaceChildren(box); // First control in reading order: the first chip, the chip toggle when the // list is collapsed, or Re-analyse when there are no sections if (hadFocus) box.querySelector('button').focus(); if (speak) announce(n ? `${n} loud section${n !== 1 ? 's' : ''} in ${span}` : `No loud sections in ${span}`); } // Run the deferred analyses of a freshly expanded day export function autoloadDayAnalyses(dayEl) { dayEl.querySelectorAll('td[data-autoload]').forEach(cell => { cell.removeAttribute('data-autoload'); analyse(Number(cell.dataset.idx), cell, cell.querySelector('button')); }); }