feat: cleaner day highlights panel

- Day timeline axis now labels round wall-clock hours with tick marks
  on the bar (start/end fallback when the span has <2 round hours),
  replacing the arbitrary start/midpoint/end labels.
- Long chip lists (>12) collapse behind an aria-expanded toggle button
  so the panel is not a wall of 50 buttons; J/K/U/I are unaffected.
- Chips group aria-labels shortened to "Day loud sections" / "Loud
  sections" - the key-binding explanation lives only in the visible
  note, not repeated in the group name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 11:39:00 +02:00
parent 98d2d7085d
commit 653084e90b
3 changed files with 61 additions and 9 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC
`webui.html` (one `<script>` block): `webui.html` (one `<script>` block):
- Clip review: `clipQueue`/`clipCursor` globals, `playClip()`, `playFileSection()`, `hideClipBar()`; markup is the `#clip-bar` div. The clip label shows the wall-clock occurrence time + queue position (`03:46:20 to 03:46:22 (73 / 187)`): queue entries carry `absStart` (epoch s), derived from `fileStartEpoch(f.date)` — the filename clock — with in-file offsets as fallback for non-standard names; filename/score live in the label tooltip. - Clip review: `clipQueue`/`clipCursor` globals, `playClip()`, `playFileSection()`, `hideClipBar()`; markup is the `#clip-bar` div. The clip label shows the wall-clock occurrence time + queue position (`03:46:20 to 03:46:22 (73 / 187)`): queue entries carry `absStart` (epoch s), derived from `fileStartEpoch(f.date)` — the filename clock — with in-file offsets as fallback for non-standard names; filename/score live in the label tooltip.
- Day review: `dayHighlights()` builds `dayActiveSections` (chronological); `jumpToDaySection()` arms the queue. The day timeline positions files by `fileStartEpoch(f.date)` (filename clock), mtimeduration only as fallback. The Highlights button is a collapse/expand toggle (`setHlExpanded()` keeps arrow + `aria-expanded` in sync, also from the day-collapse path): a built panel is kept and re-armed from `dayHlSections` instead of recomputing, keyed by `hlRow.dataset.loaded = hlParams()` (margin|gap|minDur string) so changed params force a re-run. The `#dayhls-<dayId>` "· analysed" suffix appears when every file's `cached_analysis` passes `cachedParamsMatch()`; `fetchAnalysis()` updates `f.cached_analysis` client-side so the marker survives re-renders without refetching `/api/files`. - Day review: `dayHighlights()` builds `dayActiveSections` (chronological); `jumpToDaySection()` arms the queue. The day timeline positions files by `fileStartEpoch(f.date)` (filename clock), mtimeduration only as fallback. Axis labels are round wall-clock hours (absolutely-positioned spans in `.day-tl-labels`, tick rects in the SVG; falls back to start/end when <2 round hours fit) — not start/mid/end, which read as arbitrary. Chip lists longer than 12 are collapsed behind an `aria-expanded` toggle button (the `.chips[hidden]{display:none}` rule is required — the author-level `display:flex` on `.chips` would otherwise override the UA `[hidden]` rule). Group `aria-label`s stay short ("Day loud sections") — the J/K/U/I key explanation lives only in the visible note, per user feedback against repeating info text in labels. The Highlights button is a collapse/expand toggle (`setHlExpanded()` keeps arrow + `aria-expanded` in sync, also from the day-collapse path): a built panel is kept and re-armed from `dayHlSections` instead of recomputing, keyed by `hlRow.dataset.loaded = hlParams()` (margin|gap|minDur string) so changed params force a re-run. The `#dayhls-<dayId>` "· analysed" suffix appears when every file's `cached_analysis` passes `cachedParamsMatch()`; `fetchAnalysis()` updates `f.cached_analysis` client-side so the marker survives re-renders without refetching `/api/files`.
- J/K/U/I/O: single document-level `keydown` listener — clip queue takes priority, in-player `currentTime` stepping is the fallback when no queue is armed; O calls `openClipInFile()` (shared with the "Open in file" button). J/K (and Prev/Next) always step in time order; U/I walk the loudest-first ranking from `scoreOrder()` — no top-N cutoff (the `#clip-top` input and `#clip-hl-only` checkbox were removed deliberately; J/K must never be affected by an auto-advance/highlights setting). Auto-advance is the `input[name="clip-adv"]` radio (off / next in time / next by loudness), read by `advanceMode()`; `stepClip(dir, byScore)` is the shared queue-stepping path. In-player U/I anchor the ranking on the section under the playhead, else start at the loudest. - J/K/U/I/O: single document-level `keydown` listener — clip queue takes priority, in-player `currentTime` stepping is the fallback when no queue is armed; O calls `openClipInFile()` (shared with the "Open in file" button). J/K (and Prev/Next) always step in time order; U/I walk the loudest-first ranking from `scoreOrder()` — no top-N cutoff (the `#clip-top` input and `#clip-hl-only` checkbox were removed deliberately; J/K must never be affected by an auto-advance/highlights setting). Auto-advance is the `input[name="clip-adv"]` radio (off / next in time / next by loudness), read by `advanceMode()`; `stepClip(dir, byScore)` is the shared queue-stepping path. In-player U/I anchor the ranking on the section under the playhead, else start at the loudest.
- Analysis: `fetchAnalysis()` (session `analysisCache`), `analyse()` (per-row render), `cachedParamsMatch()` (autoload guard). - Analysis: `fetchAnalysis()` (session `analysisCache`), `analyse()` (per-row render), `cachedParamsMatch()` (autoload guard).
+1 -1
View File
@@ -154,7 +154,7 @@ The browser UI (HTML/CSS/JS) lives in `webui.html`, which `web.py` loads at star
Shows recordings grouped by day with collapsible sections. Features: Shows recordings grouped by day with collapsible sections. Features:
- **Day groups** — recordings are grouped under a collapsible day heading showing date, file count, total duration, and total size. The most recent day is expanded by default; older days start collapsed. Expanded state is preserved across filter changes. - **Day groups** — recordings are grouped under a collapsible day heading showing date, file count, total duration, and total size. The most recent day is expanded by default; older days start collapsed. Expanded state is preserved across filter changes.
- **Day highlights** — click **Highlights** on any day heading to run loudness analysis across all WAV/FLAC files in that day and display a combined activity timeline SVG. The button is a toggle: clicking again collapses the panel, and re-expanding it reuses the already-computed results (they are only recomputed when the analysis parameters change). A **· analysed** suffix on the button marks days where every file already has a cached analysis for the current parameters, i.e. highlights open instantly. Orange segments show when loud sections occurred relative to the day's time span; blue shows the file extents. The labels under the timeline are the wall-clock start of the first recording, the timeline midpoint, and the end of the last recording. When a day has more sections than fit as chips, the chips show the top 50 by score (loudest-above-background first) so the most promising events are reviewed first; J/K still steps through all sections in time order, and U/I steps through them by loudness. - **Day highlights** — click **Highlights** on any day heading to run loudness analysis across all WAV/FLAC files in that day and display a combined activity timeline SVG. The button is a toggle: clicking again collapses the panel, and re-expanding it reuses the already-computed results (they are only recomputed when the analysis parameters change). A **· analysed** suffix on the button marks days where every file already has a cached analysis for the current parameters, i.e. highlights open instantly. Orange segments show when loud sections occurred relative to the day's time span; blue shows the file extents. The timeline axis is labelled at round wall-clock hours, with matching tick marks on the bar. When a day has more sections than fit as chips, the chips are the top 50 by score (loudest-above-background first) so the most promising events are reviewed first; long chip lists are collapsed behind a toggle button so the panel stays compact. J/K still steps through all sections in time order, and U/I steps through them by loudness.
- **Inline playback** — collapsible `Play` button per row; audio loads lazily via a seekable `/stream/` endpoint with HTTP Range support. Metadata is fetched immediately so the duration is visible without pressing play. - **Inline playback** — collapsible `Play` button per row; audio loads lazily via a seekable `/stream/` endpoint with HTTP Range support. Metadata is fetched immediately so the duration is visible without pressing play.
- **Waveform analysis** — on demand per file; computes RMS per 100 ms window and marks sections that stand out above the background. Detection is **adaptive**: a rolling noise floor (20th percentile per 30 s block) is estimated across the file, and a section is flagged when the level rises at least *margin* dB (default 12) above that floor. Slow ambience changes — rain setting in, day/night traffic hum — move the floor instead of producing false positives. Each section gets a **score** used to rank it: its peak dB above the floor, capped by the sharpest rise within 0.5 s. Abrupt events — voices, impacts, barks — rise fast, so their score is their full prominence; a gradual swell (a gust, a distant approaching car) that drifts up faster than the floor can track still gets flagged, but scores near zero and sinks to the bottom of the highlight ranking. Supported for WAV and FLAC (FLAC requires `numpy` + `soundfile`). Pure-Python fallback for WAV when numpy is absent. Results are cached in `recordings/analyses/<filename>.analysis.json`; subsequent requests at the same margin, min-gap, and min-duration settings return instantly without re-reading the audio. The cache file is deleted automatically when the audio file is deleted. Orphaned cache files (audio deleted outside the UI) are pruned on startup. - **Waveform analysis** — on demand per file; computes RMS per 100 ms window and marks sections that stand out above the background. Detection is **adaptive**: a rolling noise floor (20th percentile per 30 s block) is estimated across the file, and a section is flagged when the level rises at least *margin* dB (default 12) above that floor. Slow ambience changes — rain setting in, day/night traffic hum — move the floor instead of producing false positives. Each section gets a **score** used to rank it: its peak dB above the floor, capped by the sharpest rise within 0.5 s. Abrupt events — voices, impacts, barks — rise fast, so their score is their full prominence; a gradual swell (a gust, a distant approaching car) that drifts up faster than the floor can track still gets flagged, but scores near zero and sinks to the bottom of the highlight ranking. Supported for WAV and FLAC (FLAC requires `numpy` + `soundfile`). Pure-Python fallback for WAV when numpy is absent. Results are cached in `recordings/analyses/<filename>.analysis.json`; subsequent requests at the same margin, min-gap, and min-duration settings return instantly without re-reading the audio. The cache file is deleted automatically when the audio file is deleted. Orphaned cache files (audio deleted outside the UI) are pruned on startup.
- **Grace period** — configurable in the controls bar (default 2 s). Loud sections separated by less than this gap are merged into one. Raise this (e.g. to 1530 s) when a single event generates many timestamps due to brief quiet gaps within it. - **Grace period** — configurable in the controls bar (default 2 s). Loud sections separated by less than this gap are merged into one. Raise this (e.g. to 1530 s) when a single event generates many timestamps due to brief quiet gaps within it.
+59 -7
View File
@@ -65,6 +65,7 @@ button.del:hover:not(:disabled){background:#2d0808}
.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:10px 12px} .wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:10px 12px}
svg.wave{display:block;width:100%;height:56px} svg.wave{display:block;width:100%;height:56px}
.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px} .chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
.chips[hidden]{display:none}
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px; .chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px;
padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace} padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace}
button.chip{cursor:pointer} button.chip{cursor:pointer}
@@ -121,8 +122,9 @@ h2.day-heading{margin:0;font-size:inherit;font-weight:inherit;line-height:inheri
.day-hl-container{background:var(--bg);border:1px solid var(--brd);border-top:none;padding:8px 12px 12px} .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} 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} svg.day-timeline{display:block;width:100%;height:22px}
.day-tl-labels{display:flex;justify-content:space-between;font-size:10px; .day-tl-labels{position:relative;height:13px;font-size:10px;
color:var(--muted);font-family:ui-monospace,monospace;margin-top:2px} color:var(--muted);font-family:ui-monospace,monospace;margin-top:2px}
.day-tl-labels span{position:absolute;top:0;white-space:nowrap}
/* clip player bar */ /* clip player bar */
#clip-bar{position:fixed;bottom:0;left:0;right:0;z-index:20;background:var(--surf); #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; border-top:1px solid var(--brd);padding:8px 28px;display:flex;align-items:center;
@@ -539,7 +541,7 @@ async function analyse(idx, filename, cell, btn, force = false) {
const chips = document.createElement('div'); const chips = document.createElement('div');
chips.className='chips'; chips.className='chips';
chips.setAttribute('role','group'); chips.setAttribute('role','group');
chips.setAttribute('aria-label','Loud sections — click to jump, J/K to step in time order, U/I by loudness'); chips.setAttribute('aria-label','Loud sections');
if (d.sections && d.sections.length) { if (d.sections && d.sections.length) {
sectionMap.set(idx, d.sections); sectionMap.set(idx, d.sections);
d.sections.forEach((s, si) => { d.sections.forEach((s, si) => {
@@ -1017,6 +1019,17 @@ async function dayHighlights(dayId, analyzableFiles) {
const minT = Math.min(...positioned.map(r => r.fileStart)); const minT = Math.min(...positioned.map(r => r.fileStart));
const maxT = Math.max(...positioned.map(r => r.fileEnd)); const maxT = Math.max(...positioned.map(r => r.fileEnd));
const spanT = maxT - minT || 1; const spanT = maxT - minT || 1;
// Axis ticks at round hours (aligned to the day, widest step that yields
// at most ~7 labels) instead of the arbitrary start/midpoint/end times
const stepH = [1, 2, 3, 6, 12].find(h => spanT / (h * 3600) <= 7) || 24;
const step = stepH * 3600;
const d0 = new Date(minT * 1000);
const dayStart = new Date(d0.getFullYear(), d0.getMonth(), d0.getDate()).getTime() / 1000;
const ticks = [];
for (let t = dayStart + Math.ceil((minT - dayStart) / step) * step; t <= maxT; t += step)
ticks.push(t);
const W = 800, H = 22; const W = 800, H = 22;
const ns = 'http://www.w3.org/2000/svg'; const ns = 'http://www.w3.org/2000/svg';
@@ -1035,6 +1048,16 @@ async function dayHighlights(dayId, analyzableFiles) {
bgR.setAttribute('fill', '#1e2535'); bgR.setAttribute('fill', '#1e2535');
svg.appendChild(bgR); svg.appendChild(bgR);
// Hour tick marks (drawn under the file spans and sections)
ticks.forEach(t => {
const tl = document.createElementNS(ns, 'rect');
tl.setAttribute('x', ((t - minT) / spanT) * W); tl.setAttribute('y', 4);
tl.setAttribute('width', 1); tl.setAttribute('height', 14);
tl.setAttribute('fill', '#2e3950');
tl.setAttribute('aria-hidden', 'true');
svg.appendChild(tl);
});
positioned.forEach(({ f, data, fileStart, fileEnd, fileDur }) => { positioned.forEach(({ f, data, fileStart, fileEnd, fileDur }) => {
const fx = ((fileStart - minT) / spanT) * W; const fx = ((fileStart - minT) / spanT) * W;
const fw = Math.max(1, ((fileEnd - fileStart) / spanT) * W); const fw = Math.max(1, ((fileEnd - fileStart) / spanT) * W);
@@ -1093,9 +1116,16 @@ async function dayHighlights(dayId, analyzableFiles) {
const labels = document.createElement('div'); const labels = document.createElement('div');
labels.className = 'day-tl-labels'; labels.className = 'day-tl-labels';
labels.innerHTML = `<span title="First recording starts">${esc(fmtHM(minT))}</span>` // Fewer than two round hours in the span: fall back to start/end times
+ `<span title="Timeline midpoint">${esc(fmtHM((minT+maxT)/2))}</span>` (ticks.length >= 2 ? ticks : [minT, maxT]).forEach(t => {
+ `<span title="Last recording ends">${esc(fmtHM(maxT))}</span>`; const s = document.createElement('span');
s.textContent = fmtHM(t);
const pct = ((t - minT) / spanT) * 100;
if (pct < 4) s.style.left = '0';
else if (pct > 96) s.style.right = '0';
else { s.style.left = pct + '%'; s.style.transform = 'translateX(-50%)'; }
labels.appendChild(s);
});
box.appendChild(labels); box.appendChild(labels);
if (dayActiveSections.length) { if (dayActiveSections.length) {
@@ -1111,13 +1141,13 @@ async function dayHighlights(dayId, analyzableFiles) {
const note = document.createElement('p'); const note = document.createElement('p');
note.className = 'quiet'; note.className = 'quiet';
note.style.marginTop = '6px'; 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, U / I by loudness (loudest first)`; note.textContent = `${dayActiveSections.length} sections — J / K steps through all in time order, U / I by loudness (loudest first)`;
box.appendChild(note); box.appendChild(note);
} }
const chips = document.createElement('div'); const chips = document.createElement('div');
chips.className = 'chips'; chips.className = 'chips';
chips.setAttribute('role', 'group'); chips.setAttribute('role', 'group');
chips.setAttribute('aria-label', 'Day loud sections — click to jump, J/K to step across files in time order, U/I by loudness'); chips.setAttribute('aria-label', 'Day loud sections');
chipList.forEach(({sec, si}) => { chipList.forEach(({sec, si}) => {
const c = document.createElement('button'); const c = document.createElement('button');
c.className = 'chip'; c.className = 'chip';
@@ -1126,6 +1156,28 @@ async function dayHighlights(dayId, analyzableFiles) {
c.addEventListener('click', () => jumpToDaySection(si)); c.addEventListener('click', () => jumpToDaySection(si));
chips.appendChild(c); chips.appendChild(c);
}); });
// A long chip list is a wall of buttons: collapse it behind a toggle
if (chipList.length > 12) {
chips.hidden = true;
const tog = document.createElement('button');
tog.style.marginTop = '8px';
tog.setAttribute('aria-expanded', 'false');
const arrow = document.createElement('span');
arrow.className = 'day-arrow';
arrow.setAttribute('aria-hidden', 'true');
arrow.textContent = '▸';
tog.appendChild(arrow);
tog.appendChild(document.createTextNode(truncated
? ` Top ${chipList.length} sections by loudness`
: ` ${chipList.length} sections`));
tog.addEventListener('click', () => {
const exp = chips.hidden;
chips.hidden = !exp;
tog.setAttribute('aria-expanded', exp);
arrow.textContent = exp ? '▾' : '▸';
});
box.appendChild(tog);
}
box.appendChild(chips); box.appendChild(chips);
} }