f3716d3ff1
A single 100 ms RMS window above the noise floor used to become its own section, so isolated pops (clicks, single raindrops) flooded a day with thousands of sub-second clips like "21:18 to 21:18". Sections shorter than min_duration (measured after min_gap merging, so a cluster of blips spanning longer still flags) are now discarded. Wired through all coupled places: CLI flag, /api/config, controls-bar input, /api/analyze query param, and the analysis-cache head keys (old two-key caches no longer match and are recomputed on next analyse). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1115 lines
47 KiB
HTML
1115 lines
47 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>ISR Archive</title>
|
||
<style>
|
||
:root{--bg:#0f1117;--surf:#1a1d27;--brd:#272a38;--txt:#e2e8f0;--muted:#6b7491;
|
||
--accent:#4f9cf9;--orange:#f97316;--green:#22c55e;--red:#ef4444;}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{background:var(--bg);color:var(--txt);font:14px/1.5 system-ui,sans-serif}
|
||
/* skip link */
|
||
.skip{position:absolute;left:-999px;top:0;padding:6px 14px;background:var(--accent);
|
||
color:#000;border-radius:0 0 4px 4px;text-decoration:none;font-size:13px;font-weight:600}
|
||
.skip:focus{left:0}
|
||
/* sr-only */
|
||
.sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;
|
||
overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
||
header{padding:16px 28px;border-bottom:1px solid var(--brd);
|
||
display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||
header h1{font-size:18px;font-weight:600}
|
||
#subtitle{color:var(--muted);font-size:13px}
|
||
#storage-info{color:var(--muted);font-size:12px;margin-right:auto}
|
||
.controls-bar{display:flex;align-items:center;gap:10px;padding:10px 28px;
|
||
border-bottom:1px solid var(--brd);background:var(--surf);flex-wrap:wrap}
|
||
.controls-bar label{font-size:13px;color:var(--muted);white-space:nowrap}
|
||
.controls-bar input[type=number]{width:70px;background:var(--bg);border:1px solid var(--brd);
|
||
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px}
|
||
.controls-bar input[type=number]:focus{outline:2px solid var(--accent);outline-offset:1px}
|
||
.controls-hint{font-size:11px;color:var(--muted)}
|
||
.wrap{padding:20px 28px}
|
||
table{width:100%;border-collapse:collapse}
|
||
th{text-align:left;padding:9px 10px;color:var(--muted);font-weight:500;font-size:12px;
|
||
text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--brd);
|
||
white-space:nowrap}
|
||
td{padding:9px 10px;border-bottom:1px solid var(--brd);vertical-align:middle}
|
||
tr.data-row:hover td{background:var(--surf)}
|
||
.fn{font-family:ui-monospace,monospace;font-size:12px}
|
||
.badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;
|
||
font-weight:700;text-transform:uppercase;margin-right:4px;border:1px solid}
|
||
.badge-wav{color:var(--green);border-color:#166534;background:#052e16}
|
||
.badge-mp3{color:var(--accent);border-color:#1e40af;background:#0c1a40}
|
||
.badge-ogg{color:#c084fc;border-color:#6b21a8;background:#2d1157}
|
||
.badge-flac{color:#fb923c;border-color:#7c2d12;background:#2c0e04}
|
||
.badge-aac,.badge-opus{color:var(--muted);border-color:var(--brd);background:var(--surf)}
|
||
.badge-rec{display:inline-flex;align-items:center;gap:2px;padding:1px 6px;border-radius:3px;
|
||
font-size:10px;font-weight:700;text-transform:uppercase;margin-right:4px;
|
||
color:var(--red);border:1px solid #7f1d1d;background:#2d0808;
|
||
animation:pulse 1.5s ease-in-out infinite}
|
||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
|
||
@media (prefers-reduced-motion:reduce){.badge-rec{animation:none}}
|
||
.muted{color:var(--muted)}
|
||
button{cursor:pointer;border:1px solid var(--brd);background:var(--surf);
|
||
color:var(--txt);padding:4px 10px;border-radius:5px;font-size:12px;white-space:nowrap}
|
||
button:hover{background:var(--brd)}
|
||
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||
button:disabled{opacity:.5;cursor:default}
|
||
a.dl{color:var(--accent);text-decoration:none;font-size:13px}
|
||
a.dl:hover{text-decoration:underline}
|
||
a.dl:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px}
|
||
.actions{display:flex;gap:6px;align-items:center}
|
||
button.del{color:var(--red);border-color:#7f1d1d}
|
||
button.del:hover:not(:disabled){background:#2d0808}
|
||
/* waveform */
|
||
.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:10px 12px}
|
||
svg.wave{display:block;width:100%;height:56px}
|
||
.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
|
||
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px;
|
||
padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace}
|
||
button.chip{cursor:pointer}
|
||
button.chip:hover{background:#6c1f08;border-color:#9a3412}
|
||
button.chip:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||
.quiet{color:var(--muted);font-size:12px;margin-top:6px}
|
||
.spin{color:var(--muted);font-style:italic;font-size:12px;padding:6px 0}
|
||
.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-file{font-size:12px;color:var(--muted);font-style:italic;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
|
||
.analysis-meta{font-size:10px;color:var(--muted);margin-top:5px;font-style:italic}
|
||
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)}
|
||
/* player row */
|
||
.player-row td{padding:0 10px 10px;background:var(--bg);border-bottom:1px solid var(--brd)}
|
||
audio{width:100%;height:36px;border-radius:4px;display:block;
|
||
color-scheme:dark;accent-color:var(--accent)}
|
||
/* cut panel */
|
||
.cut-panel{display:flex;align-items:center;gap:8px;margin-top:8px;flex-wrap:wrap;
|
||
padding-top:8px;border-top:1px solid var(--brd)}
|
||
.cut-label{font-size:12px;color:var(--muted);white-space:nowrap}
|
||
.cut-field{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--muted)}
|
||
.cut-time{width:90px;background:var(--bg);border:1px solid var(--brd);color:var(--txt);
|
||
padding:3px 6px;border-radius:4px;font-size:12px;font-family:ui-monospace,monospace}
|
||
.cut-time:focus{outline:2px solid var(--accent);outline-offset:1px}
|
||
button.cut{color:var(--accent);border-color:#1e40af;background:#0c1a40}
|
||
button.cut:hover:not(:disabled){background:#1e3a8a}
|
||
/* filter bar */
|
||
.filter-bar{display:flex;align-items:center;gap:10px;padding:8px 28px;
|
||
border-bottom:1px solid var(--brd);background:var(--surf);flex-wrap:wrap}
|
||
.filter-bar label{font-size:13px;color:var(--muted);white-space:nowrap}
|
||
.filter-bar input[type=text]{width:180px;background:var(--bg);border:1px solid var(--brd);
|
||
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px}
|
||
.filter-bar input[type=date]{background:var(--bg);border:1px solid var(--brd);
|
||
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px;color-scheme:dark}
|
||
.filter-bar input:focus{outline:2px solid var(--accent);outline-offset:1px}
|
||
/* day sections */
|
||
.day-section{margin-bottom:18px}
|
||
.day-heading-bar{background:var(--surf);padding:7px 10px;border:1px solid var(--brd);
|
||
border-radius:6px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||
.day-heading-bar.open{border-radius:6px 6px 0 0}
|
||
.day-toggle{background:none;border:none;color:var(--txt);font-size:13px;font-weight:600;
|
||
cursor:pointer;padding:2px 0;display:inline-flex;align-items:center;gap:8px;flex:1 1 auto}
|
||
.day-toggle:hover{color:var(--accent)}
|
||
.day-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px}
|
||
.day-meta{color:var(--muted);font-size:12px;font-weight:400}
|
||
button.day-hl{color:var(--green);border-color:#166534;background:#052e16;font-size:11px}
|
||
button.day-hl:hover:not(:disabled){background:#0a3d1f}
|
||
button.day-hl:disabled{opacity:.5;cursor:default}
|
||
h2.day-heading{margin:0;font-size:inherit;font-weight:inherit;line-height:inherit;flex:1 1 auto}
|
||
.day-hl-container{background:var(--bg);border:1px solid var(--brd);border-top:none;padding:8px 12px 12px}
|
||
table.day-table{width:100%;border-collapse:collapse;border:1px solid var(--brd);border-top:none}
|
||
svg.day-timeline{display:block;width:100%;height:22px}
|
||
.day-tl-labels{display:flex;justify-content:space-between;font-size:10px;
|
||
color:var(--muted);font-family:ui-monospace,monospace;margin-top:2px}
|
||
/* clip player bar */
|
||
#clip-bar{position:fixed;bottom:0;left:0;right:0;z-index:20;background:var(--surf);
|
||
border-top:1px solid var(--brd);padding:8px 28px;display:flex;align-items:center;
|
||
gap:10px;flex-wrap:wrap}
|
||
#clip-bar audio{flex:1 1 240px;min-width:180px;height:32px}
|
||
#clip-label{font-size:12px;color:var(--muted);font-family:ui-monospace,monospace;
|
||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:38%}
|
||
#clip-auto-label{font-size:12px;color:var(--muted);display:flex;align-items:center;
|
||
gap:4px;white-space:nowrap;cursor:pointer}
|
||
body.clip-open{padding-bottom:70px}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="sr-announce" aria-live="polite" aria-atomic="true" class="sr"></div>
|
||
<a href="#main" class="skip">Skip to content</a>
|
||
<header>
|
||
<h1>ISR Archive</h1>
|
||
<span id="subtitle" aria-live="polite" aria-atomic="true">Loading…</span>
|
||
<span id="storage-info" aria-live="polite"></span>
|
||
<button id="refresh-btn" aria-label="Refresh file list">↻ Refresh</button>
|
||
</header>
|
||
<div class="controls-bar">
|
||
<label for="margin-input">Loudness margin:</label>
|
||
<input type="number" id="margin-input" min="1" max="60" step="1" value="12"
|
||
aria-describedby="margin-hint">
|
||
<span id="margin-hint" class="controls-hint">dB above background noise — sections that rise this far above the rolling noise floor are marked loud</span>
|
||
<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"
|
||
aria-describedby="preroll-hint">
|
||
<span id="preroll-hint" class="controls-hint">seconds to rewind before section start</span>
|
||
<label for="min-gap-input" style="margin-left:16px">Grace period:</label>
|
||
<input type="number" id="min-gap-input" min="0" max="300" step="0.5" value="2"
|
||
aria-describedby="min-gap-hint">
|
||
<span id="min-gap-hint" class="controls-hint">seconds — merge loud sections closer than this</span>
|
||
<label for="min-duration-input" style="margin-left:16px">Min duration:</label>
|
||
<input type="number" id="min-duration-input" min="0" max="60" step="0.1" value="0.5"
|
||
aria-describedby="min-duration-hint">
|
||
<span id="min-duration-hint" class="controls-hint">seconds — ignore loud sections shorter than this</span>
|
||
</div>
|
||
<div class="filter-bar" role="search" aria-label="Filter recordings">
|
||
<label for="filter-name">Search:</label>
|
||
<input type="text" id="filter-name" placeholder="filename…" aria-label="Filter by filename">
|
||
<label for="filter-from">From:</label>
|
||
<input type="date" id="filter-from" aria-label="From date">
|
||
<label for="filter-to">To:</label>
|
||
<input type="date" id="filter-to" aria-label="To date">
|
||
<button id="filter-clear" aria-label="Clear all filters">✕ Clear</button>
|
||
</div>
|
||
<div class="wrap" id="main">
|
||
<div id="tbody" role="region" aria-label="Recordings archive"></div>
|
||
<div id="empty" class="empty" style="display:none" role="status">No recordings found.</div>
|
||
</div>
|
||
<div id="clip-bar" hidden role="region" aria-label="Section clip player">
|
||
<button id="clip-prev" aria-label="Previous section (J)">⏮</button>
|
||
<button id="clip-next" aria-label="Next section (K)">⏭</button>
|
||
<span id="clip-label"></span>
|
||
<audio id="clip-audio" controls preload="auto" aria-label="Section clip playback"></audio>
|
||
<label id="clip-auto-label"><input type="checkbox" id="clip-auto" checked> Auto-advance</label>
|
||
<button id="clip-context" title="Open the full recording at this position">⤴ Open in file</button>
|
||
<button id="clip-close" aria-label="Close clip player">✕</button>
|
||
</div>
|
||
<script>
|
||
const esc = s => String(s)
|
||
.replace(/&/g,'&').replace(/</g,'<')
|
||
.replace(/>/g,'>').replace(/"/g,'"');
|
||
|
||
const fmtDur = s => {
|
||
if (s == null) return '—';
|
||
const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=Math.floor(s%60);
|
||
return h?`${h}:${pad(m)}:${pad(sec)}`:`${m}:${pad(sec)}`;
|
||
};
|
||
const fmtSize = b => {
|
||
if (b<1024) return b+' B';
|
||
if (b<1<<20) return (b/1024).toFixed(0)+' KB';
|
||
if (b<1<<30) return (b/(1<<20)).toFixed(1)+' MB';
|
||
return (b/(1<<30)).toFixed(2)+' GB';
|
||
};
|
||
const pad = n => String(n).padStart(2,'0');
|
||
|
||
function announce(msg) {
|
||
const el = document.getElementById('sr-announce');
|
||
if (!el) return;
|
||
el.textContent = ''; // clear first so same text re-triggers
|
||
setTimeout(() => { el.textContent = msg; }, 50);
|
||
}
|
||
const getPreroll = () => {
|
||
const v = parseFloat(document.getElementById('preroll-input').value);
|
||
return isNaN(v) || v < 0 ? 0 : v;
|
||
};
|
||
function setCutFields(idx, startSec, endSec) {
|
||
const startEl = document.getElementById('cut-start-'+idx);
|
||
const endEl = document.getElementById('cut-end-'+idx);
|
||
if (startEl) startEl.value = fmtDur(startSec);
|
||
if (endEl && endSec != null) endEl.value = fmtDur(endSec);
|
||
}
|
||
|
||
// idx -> filename, for live-status polling
|
||
const recMap = new Map();
|
||
// idx -> [{start,end}], populated after analysis
|
||
const sectionMap = new Map();
|
||
let activePlayerIdx = null;
|
||
// full file list from server, annotated with stable _idx
|
||
let allFiles = [];
|
||
// dayId -> boolean, persists expanded state across re-renders
|
||
const dayExpanded = new Map();
|
||
// cross-file day section list (populated by ★ Highlights)
|
||
let dayActiveSections = [];
|
||
let dayActiveId = null;
|
||
|
||
function groupByDay(files) {
|
||
const map = new Map();
|
||
files.forEach(f => {
|
||
const day = f.date.slice(0, 10);
|
||
if (!map.has(day)) map.set(day, []);
|
||
map.get(day).push(f);
|
||
});
|
||
return map;
|
||
}
|
||
|
||
function closePlayer(idx) {
|
||
const prow = document.getElementById('prow-'+idx);
|
||
if (!prow || prow.hidden) return;
|
||
document.getElementById('aud-'+idx)?.pause();
|
||
prow.hidden = true;
|
||
const btn = document.getElementById('pbtn-'+idx);
|
||
if (btn) {
|
||
btn.setAttribute('aria-expanded','false');
|
||
btn.textContent = '▶ Play';
|
||
btn.setAttribute('aria-label','Play '+(recMap.get(idx) || ''));
|
||
}
|
||
}
|
||
|
||
function togglePlayer(idx, filename) {
|
||
const prow = document.getElementById('prow-'+idx);
|
||
const btn = document.getElementById('pbtn-'+idx);
|
||
const audio = document.getElementById('aud-'+idx);
|
||
const open = btn.getAttribute('aria-expanded') === 'true';
|
||
|
||
if (open) {
|
||
closePlayer(idx);
|
||
return;
|
||
}
|
||
if (!audio.getAttribute('data-src-set')) {
|
||
audio.preload = 'auto';
|
||
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;
|
||
btn.setAttribute('aria-expanded','true');
|
||
btn.textContent = '⏹ Hide';
|
||
btn.setAttribute('aria-label','Hide player for '+filename);
|
||
audio.focus();
|
||
}
|
||
|
||
function drawWave(rms, sections, duration, filename) {
|
||
const ns = 'http://www.w3.org/2000/svg';
|
||
const svg = document.createElementNS(ns,'svg');
|
||
svg.setAttribute('class','wave');
|
||
svg.setAttribute('viewBox',`0 0 ${rms.length} 1`);
|
||
svg.setAttribute('preserveAspectRatio','none');
|
||
svg.setAttribute('role','img');
|
||
const nSec = sections ? sections.length : 0;
|
||
svg.setAttribute('aria-label',
|
||
`Waveform for ${filename}: duration ${fmtDur(duration)}, ${nSec} loud section${nSec!==1?'s':''}`);
|
||
|
||
if (duration > 0 && sections) {
|
||
sections.forEach(s => {
|
||
const r = document.createElementNS(ns,'rect');
|
||
r.setAttribute('x', (s.start/duration)*rms.length);
|
||
r.setAttribute('y', 0);
|
||
r.setAttribute('width', ((s.end-s.start)/duration)*rms.length);
|
||
r.setAttribute('height', 1);
|
||
r.setAttribute('fill','rgba(249,115,22,0.22)');
|
||
r.setAttribute('aria-hidden','true');
|
||
svg.appendChild(r);
|
||
});
|
||
}
|
||
const maxV = Math.max(...rms, 0.001);
|
||
rms.forEach((v,i) => {
|
||
const h = v/maxV;
|
||
const r = document.createElementNS(ns,'rect');
|
||
r.setAttribute('x',i); r.setAttribute('y',1-h);
|
||
r.setAttribute('width',1); r.setAttribute('height',h);
|
||
r.setAttribute('fill','#4f9cf9');
|
||
r.setAttribute('aria-hidden','true');
|
||
svg.appendChild(r);
|
||
});
|
||
return svg;
|
||
}
|
||
|
||
function parseTime(s) {
|
||
if (!s || !s.trim()) return null;
|
||
const parts = s.trim().split(':').map(v => parseFloat(v));
|
||
if (parts.some(isNaN) || parts.length < 1 || parts.length > 3) return null;
|
||
if (parts.length === 3) return parts[0]*3600 + parts[1]*60 + parts[2];
|
||
if (parts.length === 2) return parts[0]*60 + parts[1];
|
||
return parts[0];
|
||
}
|
||
|
||
function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
|
||
const pbtn = document.getElementById('pbtn-'+idx);
|
||
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename);
|
||
activePlayerIdx = idx;
|
||
const audio = document.getElementById('aud-'+idx);
|
||
audio.preload = 'auto';
|
||
const seekTo = Math.max(0, startSec - getPreroll());
|
||
const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); };
|
||
if (audio.readyState >= 1) doSeek();
|
||
else audio.addEventListener('loadedmetadata', doSeek, {once: true});
|
||
setCutFields(idx, startSec, endSec);
|
||
if (sectionIdx != null) {
|
||
const total = (sectionMap.get(idx) || []).length;
|
||
announce(`Section ${sectionIdx + 1} of ${total}: ${fmtDur(startSec)} to ${fmtDur(endSec)}`);
|
||
}
|
||
}
|
||
|
||
// --- clip player -----------------------------------------------------------
|
||
// Sections play as small server-rendered WAV clips (/api/clip) in the bottom
|
||
// bar instead of seeking the full recording, which is slow for big FLACs.
|
||
// clipQueue holds the active review list (one file's sections, or a whole
|
||
// day's); J/K and ⏮/⏭ step through it.
|
||
let clipQueue = [];
|
||
let clipCursor = -1;
|
||
|
||
function playClip(i) {
|
||
if (i < 0 || i >= clipQueue.length) return;
|
||
clipCursor = i;
|
||
const c = clipQueue[i];
|
||
const cs = Math.max(0, c.start - getPreroll());
|
||
const ce = c.end + 1.5; // small tail after the section
|
||
const a = document.getElementById('clip-audio');
|
||
a.src = '/api/clip?file=' + encodeURIComponent(c.filename)
|
||
+ '&start=' + cs.toFixed(1) + '&end=' + ce.toFixed(1);
|
||
a.play().catch(() => {});
|
||
document.getElementById('clip-label').textContent =
|
||
`${i + 1}/${clipQueue.length} · ${c.filename} @ ${fmtDur(c.start)}–${fmtDur(c.end)}`
|
||
+ (c.score != null ? ` · +${Math.round(c.score)} dB` : '');
|
||
document.getElementById('clip-bar').hidden = false;
|
||
document.body.classList.add('clip-open');
|
||
setCutFields(c.fileIdx, c.start, c.end);
|
||
announce(`Clip ${i + 1} of ${clipQueue.length}: ${fmtDur(c.start)} to ${fmtDur(c.end)} in ${c.filename}`);
|
||
}
|
||
|
||
function hideClipBar() {
|
||
const a = document.getElementById('clip-audio');
|
||
a.pause();
|
||
a.removeAttribute('src');
|
||
document.getElementById('clip-bar').hidden = true;
|
||
document.body.classList.remove('clip-open');
|
||
clipQueue = [];
|
||
clipCursor = -1;
|
||
}
|
||
|
||
function playFileSection(idx, filename, si) {
|
||
const secs = sectionMap.get(idx) || [];
|
||
clipQueue = secs.map(s => ({fileIdx: idx, filename, start: s.start, end: s.end, score: s.score}));
|
||
playClip(si);
|
||
}
|
||
|
||
document.getElementById('clip-prev').addEventListener('click', () => playClip(clipCursor - 1));
|
||
document.getElementById('clip-next').addEventListener('click', () => playClip(clipCursor + 1));
|
||
document.getElementById('clip-close').addEventListener('click', hideClipBar);
|
||
document.getElementById('clip-audio').addEventListener('ended', () => {
|
||
if (document.getElementById('clip-auto').checked && clipCursor + 1 < clipQueue.length)
|
||
playClip(clipCursor + 1);
|
||
});
|
||
document.getElementById('clip-context').addEventListener('click', () => {
|
||
const c = clipQueue[clipCursor];
|
||
if (!c) return;
|
||
document.getElementById('clip-audio').pause();
|
||
const row = document.getElementById('row-' + c.fileIdx);
|
||
if (row) {
|
||
const tbl = row.closest('table');
|
||
if (tbl && tbl.hidden) // file's day is collapsed: expand it
|
||
document.getElementById('daytgl-' + tbl.id.slice('daytbl-'.length))?.click();
|
||
row.scrollIntoView({block: 'center'});
|
||
}
|
||
seekToSection(c.fileIdx, c.filename, c.start, c.end, null);
|
||
});
|
||
|
||
// filename|margin|gap|minDur -> analysis result, so re-renders (filtering,
|
||
// refresh) never refetch what this session already has
|
||
const analysisCache = new Map();
|
||
|
||
async function fetchAnalysis(filename, margin, minGap, minDur, force = false) {
|
||
const key = `${filename}|${margin}|${minGap}|${minDur}`;
|
||
if (!force && analysisCache.has(key)) return analysisCache.get(key);
|
||
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)
|
||
+'&margin='+encodeURIComponent(margin)
|
||
+'&min_gap='+encodeURIComponent(minGap)
|
||
+'&min_duration='+encodeURIComponent(minDur));
|
||
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>';
|
||
const margin = document.getElementById('margin-input').value || '12';
|
||
const minGap = document.getElementById('min-gap-input').value || '2';
|
||
const minDur = document.getElementById('min-duration-input').value || '0.5';
|
||
const restoreBtn = () => {
|
||
btn.textContent = 'Analyse'; btn.disabled = false;
|
||
btn.onclick = () => analyse(idx, filename, cell, btn);
|
||
if (!cell.contains(btn)) cell.appendChild(btn);
|
||
};
|
||
try {
|
||
const d = await fetchAnalysis(filename, margin, minGap, minDur, force);
|
||
if (d.error) {
|
||
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`;
|
||
restoreBtn(); return;
|
||
}
|
||
const box = document.createElement('div'); box.className='wbox';
|
||
box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0, filename));
|
||
|
||
const meta = document.createElement('div'); meta.className='analysis-meta';
|
||
meta.textContent = `margin: ${margin} dB · gap: ${minGap}s · min: ${minDur}s${d.cached ? ' · cached' : ''}`;
|
||
box.appendChild(meta);
|
||
|
||
const chips = document.createElement('div');
|
||
chips.className='chips';
|
||
chips.setAttribute('role','group');
|
||
chips.setAttribute('aria-label','Loud sections — click to jump, J/K to step');
|
||
if (d.sections && d.sections.length) {
|
||
sectionMap.set(idx, d.sections);
|
||
d.sections.forEach((s, si) => {
|
||
const c = document.createElement('button');
|
||
c.className='chip';
|
||
c.title = 'Play this section (or use J/K keys)';
|
||
c.textContent = `${fmtDur(s.start)} – ${fmtDur(s.end)}`
|
||
+ (s.score != null ? ` · +${Math.round(s.score)} dB` : '');
|
||
c.addEventListener('click', () => playFileSection(idx, filename, si));
|
||
chips.appendChild(c);
|
||
});
|
||
} else {
|
||
sectionMap.delete(idx);
|
||
const q = document.createElement('span');
|
||
q.className='quiet';
|
||
q.textContent='No loud sections found';
|
||
chips.appendChild(q);
|
||
}
|
||
box.appendChild(chips);
|
||
|
||
const rebtn = document.createElement('button');
|
||
rebtn.className='reanalyse-btn'; rebtn.textContent='Re-analyse';
|
||
rebtn.onclick = () => analyse(idx, filename, cell, rebtn, true);
|
||
box.appendChild(rebtn);
|
||
|
||
cell.innerHTML=''; cell.appendChild(box);
|
||
} catch(e) {
|
||
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(e.message)}</div>`;
|
||
restoreBtn();
|
||
}
|
||
}
|
||
|
||
// J = previous section, K = next section (only when focus is not in an input)
|
||
document.addEventListener('keydown', e => {
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||
if (e.key !== 'j' && e.key !== 'J' && e.key !== 'k' && e.key !== 'K') return;
|
||
e.preventDefault();
|
||
|
||
// Clip queue navigation (a chip was clicked or day highlights are loaded)
|
||
if (clipQueue.length) {
|
||
if (e.key === 'j' || e.key === 'J') {
|
||
if (clipCursor > 0) playClip(clipCursor - 1);
|
||
else announce('Beginning of sections');
|
||
} else {
|
||
if (clipCursor + 1 < clipQueue.length) playClip(clipCursor + 1);
|
||
else announce('End of sections');
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Per-file in-player navigation (full-file listening, no clip queue)
|
||
if (activePlayerIdx === null) return;
|
||
const sections = sectionMap.get(activePlayerIdx) || [];
|
||
if (!sections.length) return;
|
||
const audio = document.getElementById('aud-'+activePlayerIdx);
|
||
if (!audio) return;
|
||
const preroll = getPreroll();
|
||
|
||
if (e.key === 'j' || e.key === 'J') {
|
||
const cur = audio.currentTime;
|
||
let targetIdx = -1;
|
||
for (let i = sections.length - 1; i >= 0; i--) {
|
||
if (sections[i].start < cur - 1) { targetIdx = i; break; }
|
||
}
|
||
if (targetIdx >= 0) {
|
||
const s = sections[targetIdx];
|
||
audio.currentTime = Math.max(0, s.start - preroll);
|
||
setCutFields(activePlayerIdx, s.start, s.end);
|
||
announce(`Section ${targetIdx + 1} of ${sections.length}: ${fmtDur(s.start)} to ${fmtDur(s.end)}`);
|
||
} else {
|
||
announce('Beginning of sections');
|
||
}
|
||
} else {
|
||
const cur = audio.currentTime;
|
||
let jumped = false;
|
||
for (let i = 0; i < sections.length; i++) {
|
||
if (sections[i].start > cur + preroll) {
|
||
const s = sections[i];
|
||
audio.currentTime = Math.max(0, s.start - preroll);
|
||
setCutFields(activePlayerIdx, s.start, s.end);
|
||
announce(`Section ${i + 1} of ${sections.length}: ${fmtDur(s.start)} to ${fmtDur(s.end)}`);
|
||
jumped = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!jumped) announce('End of sections');
|
||
}
|
||
});
|
||
|
||
async function deleteFile(idx, filename) {
|
||
if (!confirm(`Delete "${filename}"?\nThis cannot be undone.`)) return;
|
||
const btn = document.getElementById('delbtn-'+idx);
|
||
if (btn) { btn.disabled = true; btn.textContent = '…'; }
|
||
try {
|
||
const r = await fetch('/api/files/'+encodeURIComponent(filename), {method:'DELETE'});
|
||
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 {
|
||
const d = await r.json().catch(()=>({}));
|
||
alert('Delete failed: '+(d.error||r.statusText));
|
||
if (btn) { btn.disabled = false; btn.textContent = '✕ Delete'; }
|
||
}
|
||
} catch(e) {
|
||
alert('Delete failed: '+e.message);
|
||
if (btn) { btn.disabled = false; btn.textContent = '✕ Delete'; }
|
||
}
|
||
}
|
||
|
||
async function updateStorage() {
|
||
try {
|
||
const s = await (await fetch('/api/storage')).json();
|
||
const el = document.getElementById('storage-info');
|
||
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.margin) === parseFloat(document.getElementById('margin-input').value)
|
||
&& Number(ca.min_gap) === parseFloat(document.getElementById('min-gap-input').value)
|
||
&& Number(ca.min_duration) === parseFloat(document.getElementById('min-duration-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;
|
||
const canAnalyse = ext === 'wav' || ext === 'flac';
|
||
|
||
if (canAnalyse) {
|
||
const cell = document.getElementById('wave-'+i);
|
||
const abtn = document.createElement('button');
|
||
abtn.setAttribute('aria-label', `Analyse loudness of ${f.name}`);
|
||
if (isRec) {
|
||
abtn.textContent = 'Analyse';
|
||
abtn.disabled = true;
|
||
abtn.title = 'Recording in progress — analyse after recording stops';
|
||
cell.appendChild(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);
|
||
cell.appendChild(abtn);
|
||
}
|
||
}
|
||
|
||
document.getElementById('pbtn-'+i)
|
||
.addEventListener('click', () => togglePlayer(i, f.name));
|
||
|
||
if (!isRec) {
|
||
document.getElementById('cutbtn-'+i).addEventListener('click', () => {
|
||
const pbtn = document.getElementById('pbtn-'+i);
|
||
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(i, f.name);
|
||
document.getElementById('cut-start-'+i)?.focus();
|
||
});
|
||
document.getElementById('cut-dl-'+i).addEventListener('click', () => {
|
||
const startStr = document.getElementById('cut-start-'+i).value;
|
||
const endStr = document.getElementById('cut-end-'+i).value;
|
||
const start = parseTime(startStr);
|
||
const end = parseTime(endStr);
|
||
if (start === null || end === null) {
|
||
alert('Enter start and end times, e.g. 1:30 or 0:01:30');
|
||
return;
|
||
}
|
||
if (start >= end) { alert('Start must be before end'); return; }
|
||
window.location.href =
|
||
'/api/cut?file=' + encodeURIComponent(f.name) +
|
||
'&start=' + start + '&end=' + end;
|
||
});
|
||
document.getElementById('delbtn-'+i)
|
||
.addEventListener('click', () => deleteFile(i, f.name));
|
||
}
|
||
|
||
recMap.set(i, f.name);
|
||
}
|
||
|
||
function renderFiles(files) {
|
||
const container = document.getElementById('tbody');
|
||
container.innerHTML = '';
|
||
recMap.clear();
|
||
sectionMap.clear();
|
||
|
||
const total = allFiles.length;
|
||
const visible = files.length;
|
||
document.getElementById('subtitle').textContent = total === visible
|
||
? `${total} recording${total!==1?'s':''} found`
|
||
: `${visible} of ${total} recording${total!==1?'s':''} shown`;
|
||
document.getElementById('empty').style.display = visible ? 'none' : '';
|
||
|
||
const days = groupByDay(files);
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
|
||
days.forEach((dayFiles, day) => {
|
||
const dayId = 'day-' + day.replace(/-/g, '');
|
||
if (!dayExpanded.has(dayId)) dayExpanded.set(dayId, day === today);
|
||
const expanded = dayExpanded.get(dayId);
|
||
|
||
const totalSize = dayFiles.reduce((a, f) => a + f.size, 0);
|
||
const totalDur = dayFiles.reduce((a, f) => a + (f.duration || 0), 0);
|
||
const canHl = dayFiles.some(f => (f.ext === 'wav' || f.ext === 'flac') && !f.recording);
|
||
const durStr = totalDur > 0 ? ' · ' + fmtDur(Math.round(totalDur)) : '';
|
||
const sizeStr = ' · ' + fmtSize(totalSize);
|
||
const fileStr = `${dayFiles.length} file${dayFiles.length !== 1 ? 's' : ''}`;
|
||
|
||
// Day section wrapper
|
||
const section = document.createElement('div');
|
||
section.className = 'day-section';
|
||
section.id = 'daysec-' + dayId;
|
||
|
||
// Heading bar
|
||
const headBar = document.createElement('div');
|
||
headBar.className = 'day-heading-bar' + (expanded ? ' open' : '');
|
||
headBar.id = 'dayhead-' + dayId;
|
||
headBar.innerHTML = `
|
||
<h2 class="day-heading">
|
||
<button class="day-toggle" id="daytgl-${dayId}"
|
||
aria-expanded="${expanded}"
|
||
aria-controls="daytbl-${dayId}">
|
||
<span class="day-arrow" aria-hidden="true">${expanded ? '▼' : '▶'}</span>
|
||
${esc(day)}
|
||
<span class="day-meta">${fileStr}${durStr}${sizeStr}</span>
|
||
</button>
|
||
</h2>
|
||
${canHl ? `<button class="day-hl" id="dayhln-${dayId}"
|
||
aria-label="Show day highlights for ${esc(day)}">★ Highlights</button>` : ''}`;
|
||
section.appendChild(headBar);
|
||
|
||
// Highlights panel (hidden until button clicked)
|
||
const hlDiv = document.createElement('div');
|
||
hlDiv.className = 'day-hl-container';
|
||
hlDiv.id = 'dayhl-' + dayId;
|
||
hlDiv.hidden = true;
|
||
hlDiv.innerHTML = `<div id="dayhlc-${dayId}"></div>`;
|
||
section.appendChild(hlDiv);
|
||
|
||
// Per-day table
|
||
const table = document.createElement('table');
|
||
table.className = 'day-table';
|
||
table.id = 'daytbl-' + dayId;
|
||
table.setAttribute('aria-label', `Recordings for ${day}`);
|
||
if (!expanded) table.hidden = true;
|
||
table.innerHTML = `<thead><tr>
|
||
<th scope="col">File</th>
|
||
<th scope="col">Date</th>
|
||
<th scope="col">Duration</th>
|
||
<th scope="col">Size</th>
|
||
<th scope="col">Loudness</th>
|
||
<th scope="col"><span class="sr">Actions</span></th>
|
||
</tr></thead>`;
|
||
const dayTbody = document.createElement('tbody');
|
||
table.appendChild(dayTbody);
|
||
section.appendChild(table);
|
||
container.appendChild(section);
|
||
|
||
// File rows
|
||
dayFiles.forEach(f => {
|
||
const i = f._idx;
|
||
const ext = f.ext;
|
||
const canAnalyse = ext === 'wav' || ext === 'flac';
|
||
const isRec = !!f.recording;
|
||
|
||
const tr = document.createElement('tr');
|
||
tr.className = 'data-row';
|
||
tr.id = 'row-'+i;
|
||
|
||
const recBadge = `<span id="rec-${i}" class="badge-rec"${isRec?'':' hidden'}
|
||
aria-label="Currently recording">
|
||
<span aria-hidden="true">●</span> REC</span>`;
|
||
|
||
tr.innerHTML = `
|
||
<td>
|
||
<span class="badge badge-${esc(ext)}" aria-label="${esc(ext.toUpperCase())} format">${esc(ext)}</span>${recBadge}<span class="fn">${esc(f.name)}</span>
|
||
</td>
|
||
<td class="muted" style="white-space:nowrap">${esc(f.date)}</td>
|
||
<td style="white-space:nowrap">${fmtDur(f.duration)}</td>
|
||
<td class="muted" style="white-space:nowrap">${fmtSize(f.size)}</td>
|
||
<td id="wave-${i}">${canAnalyse ? '' :
|
||
'<span class="muted" style="font-size:12px" aria-label="Loudness analysis unavailable for this format">—</span>'}</td>
|
||
<td>
|
||
<div class="actions">
|
||
<button id="pbtn-${i}"
|
||
aria-expanded="false"
|
||
aria-controls="prow-${i}"
|
||
aria-label="Play ${esc(f.name)}">▶ Play</button>
|
||
<a class="dl" href="/download/${encodeURIComponent(f.name)}"
|
||
aria-label="Download ${esc(f.name)}">↓ Download</a>
|
||
<button id="cutbtn-${i}" class="cut"
|
||
aria-label="Cut ${esc(f.name)}"
|
||
${isRec ? 'disabled title="Cannot cut while recording"' : ''}>✂ Cut</button>
|
||
<button id="delbtn-${i}" class="del"
|
||
aria-label="Delete ${esc(f.name)}"
|
||
${isRec ? 'disabled title="Cannot delete while recording"' : ''}>✕ Delete</button>
|
||
</div>
|
||
</td>`;
|
||
dayTbody.appendChild(tr);
|
||
|
||
const prow = document.createElement('tr');
|
||
prow.className = 'player-row';
|
||
prow.id = 'prow-'+i;
|
||
prow.hidden = true;
|
||
const durLabel = f.duration != null
|
||
? `<div class="muted" style="font-size:11px;margin-top:3px">Duration: ${fmtDur(f.duration)}</div>`
|
||
: '';
|
||
prow.innerHTML = `<td colspan="6">
|
||
<audio id="aud-${i}" controls preload="none"
|
||
aria-label="Playback: ${esc(f.name)}"></audio>${durLabel}
|
||
<div class="cut-panel">
|
||
<span class="cut-label">✂ Cut:</span>
|
||
<label class="cut-field">Start
|
||
<input type="text" id="cut-start-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
|
||
</label>
|
||
<label class="cut-field">End
|
||
<input type="text" id="cut-end-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
|
||
</label>
|
||
<button id="cut-dl-${i}" class="cut"
|
||
${isRec ? 'disabled title="Cannot cut while recording"' : ''}
|
||
aria-label="Download cut of ${esc(f.name)}">↓ Download cut</button>
|
||
</div>
|
||
</td>`;
|
||
dayTbody.appendChild(prow);
|
||
|
||
_attachFileRowHandlers(f, isRec, expanded, dayId);
|
||
});
|
||
|
||
// Day toggle handler
|
||
document.getElementById('daytgl-' + dayId).addEventListener('click', () => {
|
||
const nowExp = !dayExpanded.get(dayId);
|
||
dayExpanded.set(dayId, nowExp);
|
||
const tgl = document.getElementById('daytgl-' + dayId);
|
||
tgl.setAttribute('aria-expanded', nowExp);
|
||
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;
|
||
if (dayActiveId === dayId) {
|
||
dayActiveSections = [];
|
||
dayActiveId = null;
|
||
hideClipBar();
|
||
}
|
||
}
|
||
});
|
||
|
||
// Highlights button handler
|
||
if (canHl) {
|
||
document.getElementById('dayhln-' + dayId)?.addEventListener('click', () => {
|
||
const hlFiles = dayFiles.filter(f => (f.ext === 'wav' || f.ext === 'flac') && !f.recording);
|
||
dayHighlights(dayId, hlFiles);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
async function dayHighlights(dayId, analyzableFiles) {
|
||
const hlRow = document.getElementById('dayhl-' + dayId);
|
||
const contentEl = document.getElementById('dayhlc-' + dayId);
|
||
const btn = document.getElementById('dayhln-' + dayId);
|
||
|
||
hlRow.hidden = false;
|
||
const n = analyzableFiles.length;
|
||
if (btn) btn.disabled = true;
|
||
|
||
// Build progress UI
|
||
const progWrap = document.createElement('div');
|
||
progWrap.setAttribute('aria-live', 'polite'); progWrap.setAttribute('aria-busy', 'true');
|
||
const progBar = document.createElement('div'); progBar.className = 'prog-bar';
|
||
const progFill = document.createElement('div'); progFill.className = 'prog-fill'; progFill.style.width = '0%';
|
||
progBar.appendChild(progFill);
|
||
const progFile = document.createElement('div'); progFile.className = 'prog-file';
|
||
progWrap.appendChild(progBar); progWrap.appendChild(progFile);
|
||
contentEl.innerHTML = ''; contentEl.appendChild(progWrap);
|
||
|
||
const margin = document.getElementById('margin-input').value || '12';
|
||
const minGap = document.getElementById('min-gap-input').value || '2';
|
||
const minDur = document.getElementById('min-duration-input').value || '0.5';
|
||
|
||
const results = [];
|
||
let nCached = 0, nLive = 0;
|
||
for (let i = 0; i < analyzableFiles.length; i++) {
|
||
const f = analyzableFiles[i];
|
||
progFile.textContent = `${i + 1} / ${n} — ${f.name}`;
|
||
progFill.style.width = `${(i / n) * 100}%`;
|
||
try {
|
||
const d = await fetchAnalysis(f.name, margin, minGap, minDur);
|
||
if (!d.error) { results.push({ f, data: d }); d.cached ? nCached++ : nLive++; }
|
||
} catch(e) {}
|
||
}
|
||
progFill.style.width = '100%';
|
||
const doneExtra = nCached ? ` (${nCached} from cache)` : '';
|
||
progFile.textContent = `Done — ${n} file${n!==1?'s':''}${doneExtra}`;
|
||
|
||
if (!results.length) {
|
||
contentEl.innerHTML = '<div class="quiet">No analysable results.</div>';
|
||
if (btn) btn.disabled = false;
|
||
return;
|
||
}
|
||
|
||
// Map files onto the day timeline using mtime as file-end, duration for start
|
||
const positioned = results.map(({ f, data }) => {
|
||
const fileEnd = f.mtime;
|
||
const fileDur = data.duration || f.duration || 0;
|
||
const fileStart = fileEnd - fileDur;
|
||
return { f, data, fileStart, fileEnd, fileDur };
|
||
}).filter(r => r.fileDur > 0);
|
||
|
||
if (!positioned.length) {
|
||
contentEl.innerHTML = '<div class="quiet">Could not determine file time positions.</div>';
|
||
if (btn) btn.disabled = false;
|
||
return;
|
||
}
|
||
|
||
const minT = Math.min(...positioned.map(r => r.fileStart));
|
||
const maxT = Math.max(...positioned.map(r => r.fileEnd));
|
||
const spanT = maxT - minT || 1;
|
||
const W = 800, H = 22;
|
||
const ns = 'http://www.w3.org/2000/svg';
|
||
|
||
const svg = document.createElementNS(ns, 'svg');
|
||
svg.setAttribute('class', 'wave day-timeline');
|
||
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||
svg.setAttribute('preserveAspectRatio', 'none');
|
||
svg.setAttribute('role', 'img');
|
||
const totalSecs = positioned.reduce((a, r) => a + r.data.sections.length, 0);
|
||
svg.setAttribute('aria-label', `Day activity: ${results.length} file${results.length!==1?'s':''}, ${totalSecs} loud section${totalSecs!==1?'s':''}`);
|
||
|
||
// Background track
|
||
const bgR = document.createElementNS(ns, 'rect');
|
||
bgR.setAttribute('x', 0); bgR.setAttribute('y', 7);
|
||
bgR.setAttribute('width', W); bgR.setAttribute('height', 8);
|
||
bgR.setAttribute('fill', '#1e2535');
|
||
svg.appendChild(bgR);
|
||
|
||
positioned.forEach(({ f, data, fileStart, fileEnd, fileDur }) => {
|
||
const fx = ((fileStart - minT) / spanT) * W;
|
||
const fw = Math.max(1, ((fileEnd - fileStart) / spanT) * W);
|
||
|
||
// File span (dim blue)
|
||
const fr = document.createElementNS(ns, 'rect');
|
||
fr.setAttribute('x', fx); fr.setAttribute('y', 8);
|
||
fr.setAttribute('width', fw); fr.setAttribute('height', 6);
|
||
fr.setAttribute('fill', '#1e3a5f');
|
||
fr.setAttribute('aria-hidden', 'true');
|
||
svg.appendChild(fr);
|
||
|
||
// Loud sections (orange)
|
||
(data.sections || []).forEach(s => {
|
||
const sx = fx + (s.start / fileDur) * fw;
|
||
const sw = Math.max(1, ((s.end - s.start) / fileDur) * fw);
|
||
const sr = document.createElementNS(ns, 'rect');
|
||
sr.setAttribute('x', sx); sr.setAttribute('y', 4);
|
||
sr.setAttribute('width', sw); sr.setAttribute('height', 14);
|
||
sr.setAttribute('fill', '#f97316');
|
||
sr.setAttribute('rx', '1');
|
||
sr.setAttribute('aria-hidden', 'true');
|
||
svg.appendChild(sr);
|
||
});
|
||
});
|
||
|
||
const fmtHM = ts => {
|
||
const d = new Date(ts * 1000);
|
||
return d.getHours().toString().padStart(2,'0') + ':' + d.getMinutes().toString().padStart(2,'0');
|
||
};
|
||
|
||
// Build cross-file section list for J/K navigation and chips
|
||
dayActiveSections = [];
|
||
positioned.forEach(({ f, data, fileStart }) => {
|
||
(data.sections || []).forEach(s => {
|
||
dayActiveSections.push({
|
||
fileIdx: f._idx,
|
||
filename: f.name,
|
||
start: s.start,
|
||
end: s.end,
|
||
score: s.score,
|
||
absStart: fileStart + s.start,
|
||
});
|
||
});
|
||
});
|
||
dayActiveSections.sort((a, b) => a.absStart - b.absStart);
|
||
dayActiveId = dayId;
|
||
// Arm the clip queue so J/K steps through the day immediately
|
||
clipQueue = dayActiveSections;
|
||
clipCursor = -1;
|
||
|
||
const box = document.createElement('div');
|
||
box.className = 'wbox';
|
||
box.style.marginBottom = '4px';
|
||
box.appendChild(svg);
|
||
|
||
const labels = document.createElement('div');
|
||
labels.className = 'day-tl-labels';
|
||
labels.innerHTML = `<span>${esc(fmtHM(minT))}</span><span>${esc(fmtHM((minT+maxT)/2))}</span><span>${esc(fmtHM(maxT))}</span>`;
|
||
box.appendChild(labels);
|
||
|
||
if (dayActiveSections.length) {
|
||
const MAX_DAY_CHIPS = 50;
|
||
// When there are too many sections to show them all, show the ones most
|
||
// worth reviewing: the top MAX_DAY_CHIPS by score, loudest first.
|
||
let chipList = dayActiveSections.map((sec, si) => ({sec, si}));
|
||
const truncated = chipList.length > MAX_DAY_CHIPS;
|
||
if (truncated) {
|
||
chipList = chipList
|
||
.sort((a, b) => (b.sec.score || 0) - (a.sec.score || 0))
|
||
.slice(0, MAX_DAY_CHIPS);
|
||
const note = document.createElement('p');
|
||
note.className = 'quiet';
|
||
note.style.marginTop = '6px';
|
||
note.textContent = `${dayActiveSections.length} sections — chips show the top ${MAX_DAY_CHIPS} by loudness; J / K steps through all in time order`;
|
||
box.appendChild(note);
|
||
}
|
||
const chips = document.createElement('div');
|
||
chips.className = 'chips';
|
||
chips.setAttribute('role', 'group');
|
||
chips.setAttribute('aria-label', 'Day loud sections — click to jump, J/K to step across files');
|
||
chipList.forEach(({sec, si}) => {
|
||
const c = document.createElement('button');
|
||
c.className = 'chip';
|
||
c.title = sec.filename + ' @ ' + fmtDur(sec.start);
|
||
const d = new Date(sec.absStart * 1000);
|
||
const hms = d.getHours().toString().padStart(2,'0') + ':'
|
||
+ d.getMinutes().toString().padStart(2,'0') + ':'
|
||
+ d.getSeconds().toString().padStart(2,'0');
|
||
c.textContent = hms + (sec.score != null ? ` · +${Math.round(sec.score)} dB` : '');
|
||
c.addEventListener('click', () => jumpToDaySection(si));
|
||
chips.appendChild(c);
|
||
});
|
||
box.appendChild(chips);
|
||
}
|
||
|
||
const summary = document.createElement('div');
|
||
summary.className = 'quiet';
|
||
summary.style.marginTop = '4px';
|
||
summary.textContent = `${results.length} file${results.length!==1?'s':''} analysed · ${totalSecs} loud section${totalSecs!==1?'s':''}`;
|
||
box.appendChild(summary);
|
||
|
||
contentEl.innerHTML = '';
|
||
contentEl.appendChild(box);
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
|
||
function jumpToDaySection(si) {
|
||
if (si < 0 || si >= dayActiveSections.length) return;
|
||
clipQueue = dayActiveSections;
|
||
playClip(si);
|
||
}
|
||
|
||
function applyFilters() {
|
||
const nameQ = document.getElementById('filter-name').value.toLowerCase().trim();
|
||
const fromD = document.getElementById('filter-from').value;
|
||
const toD = document.getElementById('filter-to').value;
|
||
const filtered = allFiles.filter(f => {
|
||
if (nameQ && !f.name.toLowerCase().includes(nameQ)) return false;
|
||
if (fromD && f.date < fromD + ' 00:00:00') return false;
|
||
if (toD && f.date > toD + ' 23:59:59') return false;
|
||
return true;
|
||
});
|
||
renderFiles(filtered);
|
||
}
|
||
|
||
async function load() {
|
||
const refreshBtn = document.getElementById('refresh-btn');
|
||
refreshBtn.disabled = true;
|
||
document.getElementById('subtitle').textContent = 'Loading…';
|
||
|
||
let files;
|
||
try {
|
||
files = await (await fetch('/api/files')).json();
|
||
} catch(e) {
|
||
document.getElementById('subtitle').textContent = 'Error loading files';
|
||
refreshBtn.disabled = false;
|
||
return;
|
||
}
|
||
|
||
allFiles = files.map((f, i) => ({...f, _idx: i}));
|
||
updateStorage();
|
||
applyFilters();
|
||
refreshBtn.disabled = false;
|
||
}
|
||
|
||
// 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();
|
||
const active = new Set(s.active || []);
|
||
recMap.forEach((filename, idx) => {
|
||
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);
|
||
|
||
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', () => {
|
||
document.getElementById('filter-name').value = '';
|
||
document.getElementById('filter-from').value = '';
|
||
document.getElementById('filter-to').value = '';
|
||
applyFilters();
|
||
});
|
||
|
||
// Seed margin and min_gap from server config, then start
|
||
fetch('/api/config').then(r => r.json()).then(cfg => {
|
||
if (cfg.margin != null)
|
||
document.getElementById('margin-input').value = cfg.margin;
|
||
if (cfg.min_gap != null)
|
||
document.getElementById('min-gap-input').value = cfg.min_gap;
|
||
if (cfg.min_duration != null)
|
||
document.getElementById('min-duration-input').value = cfg.min_duration;
|
||
}).catch(() => {}).finally(() => load().then(() => setInterval(pollStatus, 5000)));
|
||
</script>
|
||
</body>
|
||
</html> |