feat: U/I keys and "Highlights only" mode to review top-scored sections

J/K still steps through every queued section in time order; U/I steps
through only the highlights, defined as the top-N sections by score
(new "Top" input in the clip bar, default 50, matching the day chips).
A "Highlights only" checkbox makes J/K, Prev/Next, and Auto-advance
skip non-highlights too, so a day with thousands of detections plays
as a short reel of just the loudest events. Both key pairs also work
during full-file playback, and modified keypresses (Ctrl+K, Ctrl+U)
are no longer hijacked from the browser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 14:38:56 +02:00
parent 5e7620627b
commit f52eb62215
3 changed files with 69 additions and 47 deletions
+1 -1
View File
@@ -40,7 +40,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. - Clip review: `clipQueue`/`clipCursor` globals, `playClip()`, `playFileSection()`, `hideClipBar()`; markup is the `#clip-bar` div.
- Day review: `dayHighlights()` builds `dayActiveSections` (chronological); `jumpToDaySection()` arms the queue. - Day review: `dayHighlights()` builds `dayActiveSections` (chronological); `jumpToDaySection()` arms the queue.
- J/K: single document-level `keydown` listener — clip queue takes priority, in-player `currentTime` stepping is the fallback when no queue is armed. - J/K/U/I: single document-level `keydown` listener — clip queue takes priority, in-player `currentTime` stepping is the fallback when no queue is armed. U/I (and the `#clip-hl-only` checkbox, which also affects J/K, Prev/Next, and auto-advance) restrict stepping to highlights: the top `#clip-top` (default 50) sections by score, computed on demand by `topScoreSet()`; `stepClip()` is the shared queue-stepping path.
- Analysis: `fetchAnalysis()` (session `analysisCache`), `analyse()` (per-row render), `cachedParamsMatch()` (autoload guard). - Analysis: `fetchAnalysis()` (session `analysisCache`), `analyse()` (per-row render), `cachedParamsMatch()` (autoload guard).
## Verifying changes ## Verifying changes
+2 -2
View File
@@ -154,12 +154,12 @@ 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. Orange segments show when loud sections occurred relative to the day's time span; blue shows the file extents. Labels show the start, midpoint, and end times. 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. - **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. Orange segments show when loud sections occurred relative to the day's time span; blue shows the file extents. Labels show the start, midpoint, and end times. 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 only the top-scored highlights.
- **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** (its peak dB above the floor) used to rank sections by how much they stand out. 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** (its peak dB above the floor) used to rank sections by how much they stand out. 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.
- **Min duration** — configurable in the controls bar (default 0.5 s). Loud sections shorter than this (after grace-period merging) are discarded, so isolated sub-second pops — a click, a single raindrop — don't flood a day with thousands of near-zero-length sections. Set to 0 to disable. - **Min duration** — configurable in the controls bar (default 0.5 s). Loud sections shorter than this (after grace-period merging) are discarded, so isolated sub-second pops — a click, a single raindrop — don't flood a day with thousands of near-zero-length sections. Set to 0 to disable.
- **Clip playback** — clicking a loud-section chip plays a short server-rendered WAV clip (`/api/clip`, pre-roll included) in a player bar at the bottom of the page. Playback starts instantly even for sections deep inside multi-hundred-MB FLACs, because the browser never has to seek the full file. **J** / **K** (or the **Prev** / **Next** buttons) step through the queued sections — one file's, or a whole day's after **Highlights** — and **Auto-advance** plays the next section when one ends, turning a day's detections into a continuous review reel. **Open in file** switches to the full recording at the same position for context; each chip click also pre-fills the cut panel. - **Clip playback** — clicking a loud-section chip plays a short server-rendered WAV clip (`/api/clip`, pre-roll included) in a player bar at the bottom of the page. Playback starts instantly even for sections deep inside multi-hundred-MB FLACs, because the browser never has to seek the full file. **J** / **K** (or the **Prev** / **Next** buttons) step through the queued sections — one file's, or a whole day's after **Highlights** — and **Auto-advance** plays the next section when one ends, turning a day's detections into a continuous review reel. **U** / **I** step through *highlights only*: the top-scored sections of the queue (count set by the **Top** input in the player bar, default 50). Ticking **Highlights only** makes J/K, Prev/Next, and Auto-advance skip non-highlights too, so a day with thousands of detections can be reviewed as a short reel of just the loudest events. The same keys work during full-file playback, seeking the open recording between (highlight) sections. **Open in file** switches to the full recording at the same position for context; each chip click also pre-fills the cut panel.
- **Cut & download** — `Cut` button opens the player row and reveals a cut panel. Enter start and end times in `m:ss` or `h:mm:ss` format and click **Download cut** to receive an ffmpeg-trimmed copy without re-encoding. Requires ffmpeg (included in the Docker image). The cut is named with the real wall-clock span it covers — `<YYYYMMDD>_<HH-MM-SS>_<HH-MM-SS>.<ext>`, e.g. a 22:31:30→22:32:30 slice of a recording started at 22:00:00 becomes `20260523_22-31-30_22-32-30.flac`. - **Cut & download** — `Cut` button opens the player row and reveals a cut panel. Enter start and end times in `m:ss` or `h:mm:ss` format and click **Download cut** to receive an ffmpeg-trimmed copy without re-encoding. Requires ffmpeg (included in the Docker image). The cut is named with the real wall-clock span it covers — `<YYYYMMDD>_<HH-MM-SS>_<HH-MM-SS>.<ext>`, e.g. a 22:31:30→22:32:30 slice of a recording started at 22:00:00 becomes `20260523_22-31-30_22-32-30.flac`.
- **Filters** — live filename search and from/to date pickers above the table; applied client-side with no additional requests. Shows `N of M shown` when a filter is active. - **Filters** — live filename search and from/to date pickers above the table; applied client-side with no additional requests. Shows `N of M shown` when a filter is active.
- **Delete** — `Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/<name>` and re-renders the table. - **Delete** — `Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/<name>` and re-renders the table.
+66 -44
View File
@@ -128,8 +128,9 @@ svg.day-timeline{display:block;width:100%;height:22px}
#clip-bar audio{flex:1 1 240px;min-width:180px;height:32px} #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; #clip-label{font-size:12px;color:var(--muted);font-family:ui-monospace,monospace;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:38%} 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; #clip-auto-label,#clip-hl-label,#clip-top-label{font-size:12px;color:var(--muted);
gap:4px;white-space:nowrap;cursor:pointer} display:flex;align-items:center;gap:4px;white-space:nowrap;cursor:pointer}
#clip-top{width:52px}
body.clip-open{padding-bottom:70px} body.clip-open{padding-bottom:70px}
</style> </style>
</head> </head>
@@ -179,6 +180,8 @@ body.clip-open{padding-bottom:70px}
<span id="clip-label"></span> <span id="clip-label"></span>
<audio id="clip-audio" controls preload="auto" aria-label="Section clip playback"></audio> <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> <label id="clip-auto-label"><input type="checkbox" id="clip-auto" checked> Auto-advance</label>
<label id="clip-hl-label" title="J/K, Prev/Next and auto-advance skip everything outside the top sections"><input type="checkbox" id="clip-hl-only"> Highlights only</label>
<label id="clip-top-label" title="How many top-scored sections count as highlights">Top <input type="number" id="clip-top" min="1" step="1" value="50" aria-label="Number of top-scored sections treated as highlights"></label>
<button id="clip-context" title="Open the full recording at this position">Open in file</button> <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">&times;</button> <button id="clip-close" aria-label="Close clip player">&times;</button>
</div> </div>
@@ -347,7 +350,8 @@ function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
// Sections play as small server-rendered WAV clips (/api/clip) in the bottom // 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. // 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 // clipQueue holds the active review list (one file's sections, or a whole
// day's); J/K and the Prev/Next buttons step through it. // day's); J/K and the Prev/Next buttons step through it, U/I (or the
// "Highlights only" toggle) restrict stepping to the top-N entries by score.
let clipQueue = []; let clipQueue = [];
let clipCursor = -1; let clipCursor = -1;
@@ -386,12 +390,37 @@ function playFileSection(idx, filename, si) {
playClip(si); playClip(si);
} }
document.getElementById('clip-prev').addEventListener('click', () => playClip(clipCursor - 1)); // Highlights = the top-N entries of a section list by score (N from the
document.getElementById('clip-next').addEventListener('click', () => playClip(clipCursor + 1)); // clip-bar "Top" input). Returns the indices into the original list.
function topScoreSet(secs) {
const n = Math.max(1, parseInt(document.getElementById('clip-top').value, 10) || 50);
return new Set(secs.map((s, i) => ({i, score: s.score || 0}))
.sort((a, b) => b.score - a.score)
.slice(0, n)
.map(r => r.i));
}
function highlightsOnly() {
return document.getElementById('clip-hl-only').checked;
}
// Step the clip queue by dir (±1); with hlOnly, skip non-highlight entries.
function stepClip(dir, hlOnly) {
if (!clipQueue.length) return;
const hs = hlOnly ? topScoreSet(clipQueue) : null;
const word = hlOnly ? 'highlights' : 'sections';
for (let i = clipCursor + dir; i >= 0 && i < clipQueue.length; i += dir) {
if (!hs || hs.has(i)) { playClip(i); return; }
}
announce(dir < 0 ? `Beginning of ${word}` : `End of ${word}`);
}
document.getElementById('clip-prev').addEventListener('click', () => stepClip(-1, highlightsOnly()));
document.getElementById('clip-next').addEventListener('click', () => stepClip(1, highlightsOnly()));
document.getElementById('clip-close').addEventListener('click', hideClipBar); document.getElementById('clip-close').addEventListener('click', hideClipBar);
document.getElementById('clip-audio').addEventListener('ended', () => { document.getElementById('clip-audio').addEventListener('ended', () => {
if (document.getElementById('clip-auto').checked && clipCursor + 1 < clipQueue.length) if (document.getElementById('clip-auto').checked)
playClip(clipCursor + 1); stepClip(1, highlightsOnly());
}); });
document.getElementById('clip-context').addEventListener('click', () => { document.getElementById('clip-context').addEventListener('click', () => {
const c = clipQueue[clipCursor]; const c = clipQueue[clipCursor];
@@ -451,7 +480,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'); chips.setAttribute('aria-label','Loud sections — click to jump, J/K to step, U/I for highlights only');
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) => {
@@ -484,60 +513,53 @@ async function analyse(idx, filename, cell, btn, force = false) {
} }
} }
// J = previous section, K = next section (only when focus is not in an input) // J/K = previous/next section, U/I = previous/next highlight (top-N by score).
// With "Highlights only" checked, J/K behave like U/I.
// Only when focus is not in an input.
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key !== 'j' && e.key !== 'J' && e.key !== 'k' && e.key !== 'K') return; if (e.ctrlKey || e.metaKey || e.altKey) return;
const key = e.key.toLowerCase();
if (key !== 'j' && key !== 'k' && key !== 'u' && key !== 'i') return;
e.preventDefault(); e.preventDefault();
const dir = (key === 'j' || key === 'u') ? -1 : 1;
const hlOnly = key === 'u' || key === 'i' || highlightsOnly();
// Clip queue navigation (a chip was clicked or day highlights are loaded) // Clip queue navigation (a chip was clicked or day highlights are loaded)
if (clipQueue.length) { if (clipQueue.length) {
if (e.key === 'j' || e.key === 'J') { stepClip(dir, hlOnly);
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; return;
} }
// Per-file in-player navigation (full-file listening, no clip queue) // Per-file in-player navigation (full-file listening, no clip queue)
if (activePlayerIdx === null) return; if (activePlayerIdx === null) return;
const sections = sectionMap.get(activePlayerIdx) || []; const all = sectionMap.get(activePlayerIdx) || [];
if (!sections.length) return; if (!all.length) return;
const audio = document.getElementById('aud-'+activePlayerIdx); const audio = document.getElementById('aud-'+activePlayerIdx);
if (!audio) return; if (!audio) return;
const hs = hlOnly ? topScoreSet(all) : null;
const sections = all.filter((s, i) => !hs || hs.has(i));
if (!sections.length) return;
const preroll = getPreroll(); const preroll = getPreroll();
const cur = audio.currentTime;
const word = hlOnly ? 'Highlight' : 'Section';
if (e.key === 'j' || e.key === 'J') { const jumpTo = (s, i) => {
const cur = audio.currentTime; audio.currentTime = Math.max(0, s.start - preroll);
let targetIdx = -1; setCutFields(activePlayerIdx, s.start, s.end);
announce(`${word} ${i + 1} of ${sections.length}: ${fmtDur(s.start)} to ${fmtDur(s.end)}`);
};
if (dir < 0) {
for (let i = sections.length - 1; i >= 0; i--) { for (let i = sections.length - 1; i >= 0; i--) {
if (sections[i].start < cur - 1) { targetIdx = i; break; } if (sections[i].start < cur - 1) { jumpTo(sections[i], i); return; }
}
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');
} }
announce(`Beginning of ${word.toLowerCase()}s`);
} else { } else {
const cur = audio.currentTime;
let jumped = false;
for (let i = 0; i < sections.length; i++) { for (let i = 0; i < sections.length; i++) {
if (sections[i].start > cur + preroll) { if (sections[i].start > cur + preroll) { jumpTo(sections[i], i); return; }
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'); announce(`End of ${word.toLowerCase()}s`);
} }
}); });
@@ -991,13 +1013,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`; note.textContent = `${dayActiveSections.length} sections — chips show the top ${MAX_DAY_CHIPS} by loudness; J / K steps through all in time order, U / I through highlights only`;
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'); chips.setAttribute('aria-label', 'Day loud sections — click to jump, J/K to step across files, U/I for highlights only');
chipList.forEach(({sec, si}) => { chipList.forEach(({sec, si}) => {
const c = document.createElement('button'); const c = document.createElement('button');
c.className = 'chip'; c.className = 'chip';