Compare commits

...

2 Commits

Author SHA1 Message Date
admin 98d2d7085d feat: auto-advance radio, J/K decoupled from highlights, U/I ranked stepping
- J/K and Prev/Next now always step the clip queue in time order; the old
  "Highlights only" checkbox could silently change what J/K did.
- Auto-advance is a three-way radio: don't auto-advance / auto-advance
  (next in time) / auto-advance highlights only (next by loudness). It
  only affects what plays when a clip ends.
- The "Top" highlight-count input is gone. U/I (and highlights-only
  auto-advance) walk the full loudest-first ranking from scoreOrder()
  with no top-N cutoff - review simply stops when the user stops.
- In-player U/I (full-file playback) step the same ranking, anchored on
  the section under the playhead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:35:31 +02:00
admin 2b0403d05d feat: wall-clock clip labels, collapsible day Highlights with analysed marker
- Clip bar label is now the wall-clock time of occurrence plus queue
  position ("03:46:20 to 03:46:22 (73 / 187)"); filename and score moved
  to the hover tooltip. Works for both per-file and day queues via an
  absStart epoch on every queue entry, derived from the filename clock
  (listing date), falling back to in-file offsets for non-standard names.
- Day Highlights button toggles the panel; re-expanding reuses the
  already-built results (re-armed J/K queue from dayHlSections) and only
  recomputes when margin/gap/min-duration changed. A "analysed" suffix
  marks days where every file has a cached analysis for the current
  params; fetchAnalysis keeps f.cached_analysis fresh client-side.
- Day timeline now positions files by the filename clock instead of
  mtime-duration, and the three axis labels (span start / midpoint /
  end) carry explanatory tooltips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:28:23 +02:00
3 changed files with 166 additions and 75 deletions
+3 -3
View File
@@ -38,9 +38,9 @@ Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC
- Serving: `_stream()` (Range support), `_copy_to_response()`, `_safe_path()` (path traversal guard). - Serving: `_stream()` (Range support), `_copy_to_response()`, `_safe_path()` (path traversal guard).
`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. 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. - 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`.
- 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). 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. - 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).
## Verifying changes ## Verifying changes
+3 -3
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, and U/I steps through only the top-scored highlights. - **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.
- **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.
- **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. **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** (or the **O** key) 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. The player bar labels each clip with the wall-clock time it occurred (derived from the recording's filename) and its position in the queue, e.g. `03:46:20 to 03:46:22 (73 / 187)`; the filename and score are in the label's hover tooltip. **J** / **K** (or the **Prev** / **Next** buttons) always step through the queued sections in time order — one file's, or a whole day's after **Highlights**. **U** / **I** step through the same queue by loudness instead: **I** plays the next-loudest section, **U** goes back up the ranking, so a day with thousands of detections is reviewed loudest-first for as long as it stays interesting — there is no top-N cutoff, just stop when it gets boring. The auto-advance radio in the player bar picks what happens when a clip ends: **Don't auto-advance**, **Auto-advance** (next section in time), or **Auto-advance highlights only** (next section by loudness) — it never changes what J/K do. The same keys work during full-file playback, seeking the open recording to the next section in time (J/K) or by loudness (U/I). **Open in file** (or the **O** key) 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.
@@ -172,7 +172,7 @@ Shows recordings grouped by day with collapsible sections. Features:
The detector is purely energy-based: anything that rises *margin* dB above the rolling background floor for at least *min duration* seconds is flagged. In a quiet environment the floor sits very low, so even modest sounds — a gust, a distant car, rustling — clear the margin, and an outdoor day can easily produce over a thousand sections. What to adjust, in rough order of preference: The detector is purely energy-based: anything that rises *margin* dB above the rolling background floor for at least *min duration* seconds is flagged. In a quiet environment the floor sits very low, so even modest sounds — a gust, a distant car, rustling — clear the margin, and an outdoor day can easily produce over a thousand sections. What to adjust, in rough order of preference:
1. **Review by rank instead of thinning the list**tick **Highlights only** (or use **U**/**I**) to step through just the top-scored sections. Scores favour sharp onsets, so slow ambience swells sort themselves to the bottom automatically. Nothing is discarded — quieter events are still there if the top of the list comes up empty. 1. **Review by rank instead of thinning the list**use **U**/**I** (or **Auto-advance highlights only**) to step through the sections loudest-first. Scores favour sharp onsets, so slow ambience swells sort themselves to the bottom automatically. Nothing is discarded and there is no cutoff — just stop when the events stop being interesting.
2. **Raise the grace period** (2 → 1530 s) — merges clusters of related noise (a rain burst, one long conversation) into a single section. Cuts the count heavily without dropping any audio from review. 2. **Raise the grace period** (2 → 1530 s) — merges clusters of related noise (a rain burst, one long conversation) into a single section. Cuts the count heavily without dropping any audio from review.
3. **Raise the margin** (12 → 1518 dB) — demands more prominence above the background. The quietest events disappear first, so move in small steps. 3. **Raise the margin** (12 → 1518 dB) — demands more prominence above the background. The quietest events disappear first, so move in small steps.
4. **Raise min duration** (0.5 → 12 s) — drops short rustles and pops, but beware: single bangs or knocks are themselves short. 4. **Raise min duration** (0.5 → 12 s) — drops short rustles and pops, but beware: single bangs or knocks are themselves short.
+158 -67
View File
@@ -115,6 +115,8 @@ button.cut:hover:not(:disabled){background:#1e3a8a}
button.day-hl{color:var(--green);border-color:#166534;background:#052e16;font-size:11px} 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:hover:not(:disabled){background:#0a3d1f}
button.day-hl:disabled{opacity:.5;cursor:default} button.day-hl:disabled{opacity:.5;cursor:default}
button.day-hl .day-arrow{font-size:9px}
.day-hl-status{opacity:.75;font-weight:400}
h2.day-heading{margin:0;font-size:inherit;font-weight:inherit;line-height:inherit;flex:1 1 auto} 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} .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}
@@ -131,9 +133,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,#clip-hl-label,#clip-top-label{font-size:12px;color:var(--muted); #clip-adv{display:flex;align-items:center;gap:10px}
#clip-adv label{font-size:12px;color:var(--muted);
display:flex;align-items:center;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>
@@ -182,9 +184,11 @@ body.clip-open{padding-bottom:70px}
<button id="clip-next" aria-label="Next section (K)">Next</button> <button id="clip-next" aria-label="Next section (K)">Next</button>
<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> <span id="clip-adv" role="radiogroup" aria-label="Auto-advance mode">
<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><input type="radio" name="clip-adv" value="off"> Don't auto-advance</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> <label><input type="radio" name="clip-adv" value="all" checked> Auto-advance</label>
<label title="When a clip ends, play the next-loudest section instead of the next in time"><input type="radio" name="clip-adv" value="hl"> Auto-advance highlights only</label>
</span>
<button id="clip-context" title="Open the full recording at this position (O)" aria-label="Open in file (O)">Open in file</button> <button id="clip-context" title="Open the full recording at this position (O)" aria-label="Open in file (O)">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>
@@ -205,6 +209,17 @@ const fmtSize = b => {
return (b/(1<<30)).toFixed(2)+' GB'; return (b/(1<<30)).toFixed(2)+' GB';
}; };
const pad = n => String(n).padStart(2,'0'); const pad = n => String(n).padStart(2,'0');
// Epoch seconds -> local wall-clock "HH:MM:SS"
const fmtClock = ts => {
const d = new Date(ts * 1000);
return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
};
// Listing `date` ("YYYY-MM-DD HH:MM:SS", the recording start parsed out of
// the filename server-side) -> epoch seconds, or null for unparseable values
const fileStartEpoch = date => {
const t = Date.parse(String(date).replace(' ', 'T'));
return isNaN(t) ? null : t / 1000;
};
function announce(msg) { function announce(msg) {
const el = document.getElementById('sr-announce'); const el = document.getElementById('sr-announce');
@@ -235,6 +250,23 @@ const dayExpanded = new Map();
// cross-file day section list (populated by the day Highlights button) // cross-file day section list (populated by the day Highlights button)
let dayActiveSections = []; let dayActiveSections = [];
let dayActiveId = null; let dayActiveId = null;
// dayId -> section list of an already-built highlights panel, so collapsing
// and re-expanding it re-arms J/K without recomputing
const dayHlSections = new Map();
// Current analysis params as one string; a highlights panel built with other
// values is stale and gets recomputed on the next expand
const hlParams = () =>
['margin-input', 'min-gap-input', 'min-duration-input']
.map(id => document.getElementById(id).value).join('|');
function setHlExpanded(dayId, exp) {
const btn = document.getElementById('dayhln-' + dayId);
if (!btn) return;
btn.setAttribute('aria-expanded', exp);
const arrow = btn.querySelector('.day-arrow');
if (arrow) arrow.textContent = exp ? '▾' : '▸';
}
function groupByDay(files) { function groupByDay(files) {
const map = new Map(); const map = new Map();
@@ -353,8 +385,9 @@ 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, U/I (or the // day's); J/K and the Prev/Next buttons step through it in time order, U/I
// "Highlights only" toggle) restrict stepping to the top-N entries by score. // step through the loudest-first ranking. The auto-advance radio picks what
// plays when a clip ends: nothing, the next in time, or the next by loudness.
let clipQueue = []; let clipQueue = [];
let clipCursor = -1; let clipCursor = -1;
@@ -368,13 +401,19 @@ function playClip(i) {
a.src = '/api/clip?file=' + encodeURIComponent(c.filename) a.src = '/api/clip?file=' + encodeURIComponent(c.filename)
+ '&start=' + cs.toFixed(1) + '&end=' + ce.toFixed(1); + '&start=' + cs.toFixed(1) + '&end=' + ce.toFixed(1);
a.play().catch(() => {}); a.play().catch(() => {});
document.getElementById('clip-label').textContent = // Label = wall-clock time of occurrence (absStart from the filename clock);
`${i + 1}/${clipQueue.length} · ${c.filename} @ ${fmtDur(c.start)}${fmtDur(c.end)}` // falls back to in-file offsets for non-standard filenames.
const when = c.absStart != null
? `${fmtClock(c.absStart)} to ${fmtClock(c.absStart + (c.end - c.start))}`
: `${fmtDur(c.start)} to ${fmtDur(c.end)}`;
const label = document.getElementById('clip-label');
label.textContent = `${when} (${i + 1} / ${clipQueue.length})`;
label.title = `${c.filename} @ ${fmtDur(c.start)}${fmtDur(c.end)}`
+ (c.score != null ? ` · +${Math.round(c.score)} dB` : ''); + (c.score != null ? ` · +${Math.round(c.score)} dB` : '');
document.getElementById('clip-bar').hidden = false; document.getElementById('clip-bar').hidden = false;
document.body.classList.add('clip-open'); document.body.classList.add('clip-open');
setCutFields(c.fileIdx, c.start, c.end); setCutFields(c.fileIdx, c.start, c.end);
announce(`Clip ${i + 1} of ${clipQueue.length}: ${fmtDur(c.start)} to ${fmtDur(c.end)} in ${c.filename}`); announce(`Clip ${i + 1} of ${clipQueue.length}: ${when}`);
} }
function hideClipBar() { function hideClipBar() {
@@ -389,41 +428,49 @@ function hideClipBar() {
function playFileSection(idx, filename, si) { function playFileSection(idx, filename, si) {
const secs = sectionMap.get(idx) || []; const secs = sectionMap.get(idx) || [];
clipQueue = secs.map(s => ({fileIdx: idx, filename, start: s.start, end: s.end, score: s.score})); const f = allFiles.find(f => f._idx === idx);
const epoch = f ? fileStartEpoch(f.date) : null;
clipQueue = secs.map(s => ({fileIdx: idx, filename, start: s.start, end: s.end, score: s.score,
absStart: epoch != null ? epoch + s.start : null}));
playClip(si); playClip(si);
} }
// Highlights = the top-N entries of a section list by score (N from the // Highlight order = section indices sorted loudest-first. U/I (and
// clip-bar "Top" input). Returns the indices into the original list. // auto-advance in "highlights only" mode) walk this ranking, so a review
function topScoreSet(secs) { // runs from the most to the least interesting event until the user stops —
const n = Math.max(1, parseInt(document.getElementById('clip-top').value, 10) || 50); // no top-N cutoff.
return new Set(secs.map((s, i) => ({i, score: s.score || 0})) function scoreOrder(secs) {
return secs.map((s, i) => ({i, score: s.score || 0}))
.sort((a, b) => b.score - a.score) .sort((a, b) => b.score - a.score)
.slice(0, n) .map(r => r.i);
.map(r => r.i));
} }
function highlightsOnly() { const advanceMode = () =>
return document.getElementById('clip-hl-only').checked; document.querySelector('input[name="clip-adv"]:checked')?.value || 'off';
}
// Step the clip queue by dir (±1); with hlOnly, skip non-highlight entries. // Step the clip queue by dir (±1): in time order, or with byScore down/up
function stepClip(dir, hlOnly) { // the loudest-first ranking (next = next quieter).
function stepClip(dir, byScore) {
if (!clipQueue.length) return; if (!clipQueue.length) return;
const hs = hlOnly ? topScoreSet(clipQueue) : null; if (byScore) {
const word = hlOnly ? 'highlights' : 'sections'; const order = scoreOrder(clipQueue);
for (let i = clipCursor + dir; i >= 0 && i < clipQueue.length; i += dir) { const pos = order.indexOf(clipCursor); // -1: nothing played yet
if (!hs || hs.has(i)) { playClip(i); return; } const next = pos === -1 ? (dir > 0 ? 0 : -1) : pos + dir;
if (next >= 0 && next < order.length) playClip(order[next]);
else announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights');
return;
} }
announce(dir < 0 ? `Beginning of ${word}` : `End of ${word}`); const i = clipCursor + dir;
if (i >= 0 && i < clipQueue.length) playClip(i);
else announce(dir < 0 ? 'Beginning of sections' : 'End of sections');
} }
document.getElementById('clip-prev').addEventListener('click', () => stepClip(-1, highlightsOnly())); document.getElementById('clip-prev').addEventListener('click', () => stepClip(-1, false));
document.getElementById('clip-next').addEventListener('click', () => stepClip(1, highlightsOnly())); document.getElementById('clip-next').addEventListener('click', () => stepClip(1, false));
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) const mode = advanceMode();
stepClip(1, highlightsOnly()); if (mode !== 'off') stepClip(1, mode === 'hl');
}); });
// "Open in file": switch from the current clip to the full recording at the // "Open in file": switch from the current clip to the full recording at the
// same position. Bound to the clip-bar button and the O key. // same position. Bound to the clip-bar button and the O key.
@@ -454,7 +501,13 @@ async function fetchAnalysis(filename, margin, minGap, minDur, force = false) {
+'&min_gap='+encodeURIComponent(minGap) +'&min_gap='+encodeURIComponent(minGap)
+'&min_duration='+encodeURIComponent(minDur)); +'&min_duration='+encodeURIComponent(minDur));
const d = await r.json(); const d = await r.json();
if (!d.error) analysisCache.set(key, d); if (!d.error) {
analysisCache.set(key, d);
// Keep the listing's cache info current so re-renders (filtering) still
// know the file is analysed without refetching /api/files
const f = allFiles.find(f => f.name === filename);
if (f) f.cached_analysis = {margin, min_gap: minGap, min_duration: minDur};
}
return d; return d;
} }
@@ -486,7 +539,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, U/I for highlights only'); chips.setAttribute('aria-label','Loud sections — click to jump, J/K to step in time order, U/I by loudness');
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) => {
@@ -519,9 +572,8 @@ async function analyse(idx, filename, cell, btn, force = false) {
} }
} }
// J/K = previous/next section, U/I = previous/next highlight (top-N by score), // J/K = previous/next section in time order, U/I = up/down the loudest-first
// O = open the current clip in the full file. // ranking, O = open the current clip in the full file.
// With "Highlights only" checked, J/K behave like U/I.
// Only when focus is not in an input. // 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;
@@ -531,11 +583,11 @@ document.addEventListener('keydown', e => {
e.preventDefault(); e.preventDefault();
if (key === 'o') { openClipInFile(); return; } if (key === 'o') { openClipInFile(); return; }
const dir = (key === 'j' || key === 'u') ? -1 : 1; const dir = (key === 'j' || key === 'u') ? -1 : 1;
const hlOnly = key === 'u' || key === 'i' || highlightsOnly(); const byScore = key === 'u' || key === 'i';
// 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) {
stepClip(dir, hlOnly); stepClip(dir, byScore);
return; return;
} }
@@ -545,29 +597,39 @@ document.addEventListener('keydown', e => {
if (!all.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 cur = audio.currentTime;
const word = hlOnly ? 'Highlight' : 'Section';
const jumpTo = (s, i) => { const jumpTo = (s, i, n, word) => {
audio.currentTime = Math.max(0, s.start - preroll); audio.currentTime = Math.max(0, s.start - preroll);
setCutFields(activePlayerIdx, s.start, s.end); setCutFields(activePlayerIdx, s.start, s.end);
announce(`${word} ${i + 1} of ${sections.length}: ${fmtDur(s.start)} to ${fmtDur(s.end)}`); announce(`${word} ${i + 1} of ${n}: ${fmtDur(s.start)} to ${fmtDur(s.end)}`);
}; };
if (byScore) {
// U/I walk the ranking; the anchor is the section the playhead sits in
// (incl. its preroll lead-in). Anywhere else, I starts at the loudest.
const order = scoreOrder(all);
const curIdx = all.findIndex(s => cur >= s.start - preroll - 0.5 && cur <= s.end + 0.5);
const pos = curIdx === -1 ? -1 : order.indexOf(curIdx);
const next = pos === -1 ? (dir > 0 ? 0 : -1) : pos + dir;
if (next >= 0 && next < order.length)
jumpTo(all[order[next]], next, order.length, 'Highlight');
else
announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights');
return;
}
if (dir < 0) { if (dir < 0) {
for (let i = sections.length - 1; i >= 0; i--) { for (let i = all.length - 1; i >= 0; i--) {
if (sections[i].start < cur - 1) { jumpTo(sections[i], i); return; } if (all[i].start < cur - 1) { jumpTo(all[i], i, all.length, 'Section'); return; }
} }
announce(`Beginning of ${word.toLowerCase()}s`); announce('Beginning of sections');
} else { } else {
for (let i = 0; i < sections.length; i++) { for (let i = 0; i < all.length; i++) {
if (sections[i].start > cur + preroll) { jumpTo(sections[i], i); return; } if (all[i].start > cur + preroll) { jumpTo(all[i], i, all.length, 'Section'); return; }
} }
announce(`End of ${word.toLowerCase()}s`); announce('End of sections');
} }
}); });
@@ -712,7 +774,11 @@ function renderFiles(files) {
const totalSize = dayFiles.reduce((a, f) => a + f.size, 0); const totalSize = dayFiles.reduce((a, f) => a + f.size, 0);
const totalDur = dayFiles.reduce((a, f) => a + (f.duration || 0), 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 hlFiles = dayFiles.filter(f => (f.ext === 'wav' || f.ext === 'flac') && !f.recording);
const canHl = hlFiles.length > 0;
// Every analysable file already has a cached analysis for the current
// params -> the day's highlights are available without recomputing
const analysed = canHl && hlFiles.every(f => cachedParamsMatch(f.cached_analysis));
const durStr = totalDur > 0 ? ' · ' + fmtDur(Math.round(totalDur)) : ''; const durStr = totalDur > 0 ? ' · ' + fmtDur(Math.round(totalDur)) : '';
const sizeStr = ' · ' + fmtSize(totalSize); const sizeStr = ' · ' + fmtSize(totalSize);
const fileStr = `${dayFiles.length} file${dayFiles.length !== 1 ? 's' : ''}`; const fileStr = `${dayFiles.length} file${dayFiles.length !== 1 ? 's' : ''}`;
@@ -737,7 +803,10 @@ function renderFiles(files) {
</button> </button>
</h2> </h2>
${canHl ? `<button class="day-hl" id="dayhln-${dayId}" ${canHl ? `<button class="day-hl" id="dayhln-${dayId}"
aria-label="Show day highlights for ${esc(day)}">Highlights</button>` : ''}`; aria-expanded="false" aria-controls="dayhl-${dayId}"
aria-label="Day highlights for ${esc(day)}">
<span class="day-arrow" aria-hidden="true">▸</span> Highlights<span
class="day-hl-status" id="dayhls-${dayId}"${analysed ? '' : ' hidden'}> · analysed</span></button>` : ''}`;
section.appendChild(headBar); section.appendChild(headBar);
// Highlights panel (hidden until button clicked) // Highlights panel (hidden until button clicked)
@@ -850,6 +919,7 @@ function renderFiles(files) {
if (!nowExp) { if (!nowExp) {
dayFiles.forEach(f => closePlayer(f._idx)); dayFiles.forEach(f => closePlayer(f._idx));
document.getElementById('dayhl-' + dayId).hidden = true; document.getElementById('dayhl-' + dayId).hidden = true;
setHlExpanded(dayId, false);
if (dayActiveId === dayId) { if (dayActiveId === dayId) {
dayActiveSections = []; dayActiveSections = [];
dayActiveId = null; dayActiveId = null;
@@ -858,10 +928,25 @@ function renderFiles(files) {
} }
}); });
// Highlights button handler // Highlights button: expand/collapse the panel; only (re)compute when it
// has not been built yet this session or the analysis params changed
if (canHl) { if (canHl) {
document.getElementById('dayhln-' + dayId)?.addEventListener('click', () => { document.getElementById('dayhln-' + dayId)?.addEventListener('click', () => {
const hlFiles = dayFiles.filter(f => (f.ext === 'wav' || f.ext === 'flac') && !f.recording); const hlRow = document.getElementById('dayhl-' + dayId);
if (!hlRow.hidden) { // collapse, keep the panel
hlRow.hidden = true;
setHlExpanded(dayId, false);
return;
}
if (hlRow.dataset.loaded === hlParams()) { // re-open and re-arm J/K
hlRow.hidden = false;
setHlExpanded(dayId, true);
dayActiveSections = dayHlSections.get(dayId) || [];
dayActiveId = dayId;
clipQueue = dayActiveSections;
clipCursor = -1;
return;
}
dayHighlights(dayId, hlFiles); dayHighlights(dayId, hlFiles);
}); });
} }
@@ -874,6 +959,7 @@ async function dayHighlights(dayId, analyzableFiles) {
const btn = document.getElementById('dayhln-' + dayId); const btn = document.getElementById('dayhln-' + dayId);
hlRow.hidden = false; hlRow.hidden = false;
setHlExpanded(dayId, true);
const n = analyzableFiles.length; const n = analyzableFiles.length;
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
@@ -912,12 +998,14 @@ async function dayHighlights(dayId, analyzableFiles) {
return; return;
} }
// Map files onto the day timeline using mtime as file-end, duration for start // Map files onto the day timeline. The filename is the clock: f.date is the
// recording start parsed server-side; mtime (≈ file end) is only a fallback
// for non-standard names.
const positioned = results.map(({ f, data }) => { const positioned = results.map(({ f, data }) => {
const fileEnd = f.mtime;
const fileDur = data.duration || f.duration || 0; const fileDur = data.duration || f.duration || 0;
const fileStart = fileEnd - fileDur; const startEpoch = fileStartEpoch(f.date);
return { f, data, fileStart, fileEnd, fileDur }; const fileStart = startEpoch != null ? startEpoch : f.mtime - fileDur;
return { f, data, fileStart, fileEnd: fileStart + fileDur, fileDur };
}).filter(r => r.fileDur > 0); }).filter(r => r.fileDur > 0);
if (!positioned.length) { if (!positioned.length) {
@@ -1005,7 +1093,9 @@ 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>${esc(fmtHM(minT))}</span><span>${esc(fmtHM((minT+maxT)/2))}</span><span>${esc(fmtHM(maxT))}</span>`; labels.innerHTML = `<span title="First recording starts">${esc(fmtHM(minT))}</span>`
+ `<span title="Timeline midpoint">${esc(fmtHM((minT+maxT)/2))}</span>`
+ `<span title="Last recording ends">${esc(fmtHM(maxT))}</span>`;
box.appendChild(labels); box.appendChild(labels);
if (dayActiveSections.length) { if (dayActiveSections.length) {
@@ -1021,22 +1111,18 @@ 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 through highlights only`; 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)`;
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, U/I for highlights only'); chips.setAttribute('aria-label', 'Day loud sections — click to jump, J/K to step across files in time order, U/I by loudness');
chipList.forEach(({sec, si}) => { chipList.forEach(({sec, si}) => {
const c = document.createElement('button'); const c = document.createElement('button');
c.className = 'chip'; c.className = 'chip';
c.title = sec.filename + ' @ ' + fmtDur(sec.start); c.title = sec.filename + ' @ ' + fmtDur(sec.start);
const d = new Date(sec.absStart * 1000); c.textContent = fmtClock(sec.absStart) + (sec.score != null ? ` · +${Math.round(sec.score)} dB` : '');
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)); c.addEventListener('click', () => jumpToDaySection(si));
chips.appendChild(c); chips.appendChild(c);
}); });
@@ -1051,6 +1137,11 @@ async function dayHighlights(dayId, analyzableFiles) {
contentEl.innerHTML = ''; contentEl.innerHTML = '';
contentEl.appendChild(box); contentEl.appendChild(box);
hlRow.dataset.loaded = hlParams();
dayHlSections.set(dayId, dayActiveSections);
// Every file is now cached with the current params
if (results.length === n)
document.getElementById('dayhls-' + dayId)?.removeAttribute('hidden');
if (btn) btn.disabled = false; if (btn) btn.disabled = false;
} }