Compare commits

..

6 Commits

Author SHA1 Message Date
admin 89c95a70a2 feat: loudness walk (U/I) keeps its own cursor, independent of J/K
The clip bar had one shared cursor, so pressing U/I re-derived the
loudness rank from wherever you currently were. A J/K detour to review
clips around an interesting spot therefore hijacked the loudness walk:
returning to U/I continued from the J/K position, not from where the
ranking left off.

Add a separate scoreCursor that only U/I (and "highlights only" auto-
advance) move; J/K never touches it. So: U/I to a loud moment, J/K to
review the time-adjacent clips, U/I again resumes the ranking exactly
where you left it. scoreCursor resets to -1 on every explicit jump /
queue re-arm (hideClipBar, chip clicks, day-highlights arm) so the next
U/I re-anchors on the selected section.

Also label the position count "by time" (J/K) or "by loudness" (U/I) so
the blind user can hear which dimension the count is in — the two looked
identical before and switched meaning silently when changing keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 10:28:52 +02:00
admin 46efbd6d75 docs: README clip-bar label and J/K vs U/I navigation accuracy
Bring the clip-playback section in line with the current UI:
- label example now shows the dB prominence and notes the visible label
  and screen-reader announcement are one identical string
- tooltip holds only the filename + in-file offset (score moved into
  the label)
- explain that the position count is the chronological index under J/K
  and the loudness rank under U/I, with the denominator always the full
  set (U/I has no top-N cutoff; only the chip display is capped)
- document that J/K and U/I share one queue and cursor, so U/I to find a
  spot then J/K to review the time-adjacent clips works as expected

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 08:17:42 +02:00
admin c5c63a76e8 fix: loudness navigation shows rank position, not time-order index
When stepping the clip queue by loudness (U/I, or "highlights only"
auto-advance), the position count showed the section's time-order index
(e.g. "30 / 426") instead of its place in the loudest-first ranking, so
walking highlights produced a count that jumped around. playClip now
takes a byScore flag: the displayed position is the rank in scoreOrder()
when navigating highlights ("1 / 426" = loudest), the time-order index
otherwise. The in-player U/I path already announced the rank.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 07:41:12 +02:00
admin 8a532fcf57 fix: clip label and screen-reader announcement use one identical string
The clip bar's visible label and its aria-live announcement were two
different formats ("17:09:56 to 17:09:57 (30 / 426)" vs "Clip 368 of
426: 21:25:44 to 21:25:50"). Build one string and use it for both, and
include the section's dB prominence (as the highlight chips do):

  17:09:56 to 17:09:57 · +30 dB (30 / 426)

The dB moves out of the hover tooltip into the spoken/visible label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 07:36:35 +02:00
admin 682b0522d3 feat: Shift+J/K/U/I jump to first/last/loudest/quietest section
Holding Shift with the section-navigation keys jumps straight to the
extreme in that direction instead of stepping one at a time:
- Shift+J / Shift+K -> first / last section in time order
- Shift+U / Shift+I -> loudest / quietest section

Works in both clip-queue navigation (stepClip gains a jump flag) and
in-player full-file navigation. Visible key-hint note and README updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 07:33:43 +02:00
admin 5a9518e262 fix: screen-reader fixes for day highlights button and chips toggle
The Highlights button's aria-label overrode its content, so the visible
"· analysed" suffix was never announced — the analysed state is now
mirrored into the label (at render and when a highlights run completes).
The chips toggle's accessible text started with a stray space (left over
once the aria-hidden arrow is dropped), which read as an indent; the
separator space now lives inside the aria-hidden arrow span.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:16:39 +02:00
3 changed files with 78 additions and 27 deletions
+2 -2
View File
@@ -38,8 +38,8 @@ 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. 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`/`scoreCursor` globals, `playClip()`, `playFileSection()`, `hideClipBar()`; markup is the `#clip-bar` div. The clip label shows wall-clock occurrence time + dB prominence + position (`17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)`): queue entries carry `absStart` (epoch s), derived from `fileStartEpoch(f.date)` — the filename clock — with in-file offsets as fallback for non-standard names; only the filename/in-file offset lives in the tooltip now. **`playClip()` builds one `text` string used for both `label.textContent` and the `announce()` aria-live message** — they must never diverge (a past bug had the announcement read `Clip N of M: …` while the label read the wall-clock form). **Two cursors, deliberately independent:** `clipCursor` is the time-order position and follows every play; `scoreCursor` is the loudness walk's own position in `scoreOrder()` and is moved *only* by U/I (and "highlights only" auto-advance) in `stepClip`'s by-score branch — J/K must never touch it, so a J/K detour to review nearby clips leaves the loudness walk's place intact and the next U/I resumes the ranking where it left off. `scoreCursor` resets to `-1` on every explicit jump / queue (re-)arm (`hideClipBar`, `playFileSection`, `jumpToDaySection`, both day-highlights arm points), so the next U/I re-anchors on `clipCursor` (the section just selected) else the loudest. `playClip(i, byScore)`: when `byScore` the count shows `scoreCursor` (rank, suffix "by loudness"), else `i` (time-order index, suffix "by time") — the suffix tells the blind user which dimension the count is in. `stepClip`'s by-score branch passes `byScore=true`; every other caller (chip click, J/K, Prev/Next) leaves it false.
- Day review: `dayHighlights()` builds `dayActiveSections` (chronological); `jumpToDaySection()` arms the queue. Section `absStart` comes from `fileStartEpoch(f.date)` (filename clock), mtimeduration only as fallback. **The user is blind and uses a screen reader — there is deliberately no day-timeline SVG** (one existed and was removed on request as useless); the highlights panel is linear text/buttons: summary line → key-hint note → chips toggle → chips. Do not add decorative visualizations; any future graphic must be aria-hidden and must not be the only carrier of information. 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`. - Day review: `dayHighlights()` builds `dayActiveSections` (chronological); `jumpToDaySection()` arms the queue. Section `absStart` comes from `fileStartEpoch(f.date)` (filename clock), mtimeduration only as fallback. **The user is blind and uses a screen reader — there is deliberately no day-timeline SVG** (one existed and was removed on request as useless); the highlights panel is linear text/buttons: summary line → key-hint note → chips toggle → chips. Do not add decorative visualizations; any future graphic must be aria-hidden and must not be the only carrier of information. 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`. **`aria-label` on a button replaces its visible content for screen readers** — any status text rendered inside a labelled button (like the analysed suffix) must be mirrored into the label (render path + the end of `dayHighlights()`), or the blind user never hears it. Likewise, the accessible text of a button built as `aria-hidden arrow span + text node` must not start the text node with a space (the hidden arrow drops out and the leading space reads as an indent) — keep separator spaces inside the aria-hidden span.
- 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: meta line with section count + params, then chips — no waveform SVG, see day-review note on the blind user), `cachedParamsMatch()` (autoload guard). - Analysis: `fetchAnalysis()` (session `analysisCache`), `analyse()` (per-row render: meta line with section count + params, then chips — no waveform SVG, see day-review note on the blind user), `cachedParamsMatch()` (autoload guard).
+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. 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. The panel is plain text and buttons in linear reading order (screen-reader friendly): files-analysed/section totals, a key hint, then the section chips. 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. - **Day highlights** — click **Highlights** on any day heading to run loudness analysis across all WAV/FLAC files in that day. 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. The panel is plain text and buttons in linear reading order (screen-reader friendly): files-analysed/section totals, a key hint, then the section chips. 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. Holding **Shift** jumps straight to an extreme: **Shift+J**/**Shift+K** to the first/last section in time, **Shift+U**/**Shift+I** to the loudest/quietest.
- **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.
- **Loudness analysis** — on demand per file; computes RMS per 100 ms window and lists sections that stand out above the background as clickable chips (no visual waveform — the UI is built for screen-reader use). 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. - **Loudness analysis** — on demand per file; computes RMS per 100 ms window and lists sections that stand out above the background as clickable chips (no visual waveform — the UI is built for screen-reader use). 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. 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. - **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), its loudness above the background, and its position in the queue, e.g. `17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)` — and the exact same text is announced to screen readers, so the spoken and visible labels never differ. The filename and in-file offset 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** — and the count is then the chronological index, labelled **by time** (e.g. `30 / 426 by time` is the 30th section in time). **U** / **I** step through the same queue by loudness instead: **I** plays the next-loudest section, **U** goes back up the ranking, and the count becomes the rank in that loudness order, labelled **by loudness** (`1 / 426 by loudness` is the loudest of all reachable sections, `2 / 426 by loudness` the next-loudest, and so on) — the denominator stays the full count because U/I reaches every section, with no top-N cutoff; the chip list shown on screen is capped only for display. So a day with thousands of detections is reviewed loudest-first for as long as it stays interesting — just stop when it gets boring. The loudness walk keeps its own position **independently** of J/K: find a loud moment with **U** / **I**, press **J** / **K** to review the clips immediately before and after it in time, and pressing **U** / **I** again resumes the loudness ranking exactly where you left it — the J/K detour never moves your place in the loudest-first walk. (Clicking a chip is an explicit jump, so it re-anchors the next U / I there.) 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. Holding **Shift** with any of these jumps to the extreme in that direction — **Shift+J**/**Shift+K** to the first/last section in time, **Shift+U**/**Shift+I** to the loudest/quietest. 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.
+70 -19
View File
@@ -349,10 +349,21 @@ function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
// day's); J/K and the Prev/Next buttons step through it in time order, U/I // day's); J/K and the Prev/Next buttons step through it in time order, U/I
// step through the loudest-first ranking. The auto-advance radio picks what // 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. // plays when a clip ends: nothing, the next in time, or the next by loudness.
//
// Two cursors, deliberately independent. clipCursor is the time-order position
// and follows every play (chip click, J/K, U/I). scoreCursor is the loudness
// walk's own position in scoreOrder(): only U/I (and "highlights only" auto-
// advance) move it; J/K detours to review nearby clips leave it alone, so
// coming back to U/I resumes the ranking exactly where you left it. An explicit
// jump (chip click, new queue) resets it to -1 so the next U/I re-anchors there.
let clipQueue = []; let clipQueue = [];
let clipCursor = -1; let clipCursor = -1;
let scoreCursor = -1;
function playClip(i) { // byScore: this play is part of a loudness walk (U/I or "highlights only"
// auto-advance), so the position shown is scoreCursor — the rank in the
// loudest-first ranking, labelled "by loudness" — not the time-order index.
function playClip(i, byScore) {
if (i < 0 || i >= clipQueue.length) return; if (i < 0 || i >= clipQueue.length) return;
clipCursor = i; clipCursor = i;
const c = clipQueue[i]; const c = clipQueue[i];
@@ -363,18 +374,24 @@ function playClip(i) {
+ '&start=' + cs.toFixed(1) + '&end=' + ce.toFixed(1); + '&start=' + cs.toFixed(1) + '&end=' + ce.toFixed(1);
a.play().catch(() => {}); a.play().catch(() => {});
// Label = wall-clock time of occurrence (absStart from the filename clock); // Label = wall-clock time of occurrence (absStart from the filename clock);
// falls back to in-file offsets for non-standard filenames. // falls back to in-file offsets for non-standard filenames. The visible
// label and the screen-reader announcement use one identical string:
// "17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)" — the count is the
// time-order index "by time" (J/K) or the loudness rank "by loudness" (U/I).
const when = c.absStart != null const when = c.absStart != null
? `${fmtClock(c.absStart)} to ${fmtClock(c.absStart + (c.end - c.start))}` ? `${fmtClock(c.absStart)} to ${fmtClock(c.absStart + (c.end - c.start))}`
: `${fmtDur(c.start)} to ${fmtDur(c.end)}`; : `${fmtDur(c.start)} to ${fmtDur(c.end)}`;
const score = c.score != null ? ` · +${Math.round(c.score)} dB` : '';
const pos = byScore ? scoreCursor : i;
const dim = byScore ? 'by loudness' : 'by time';
const text = `${when}${score} (${pos + 1} / ${clipQueue.length} ${dim})`;
const label = document.getElementById('clip-label'); const label = document.getElementById('clip-label');
label.textContent = `${when} (${i + 1} / ${clipQueue.length})`; label.textContent = text;
label.title = `${c.filename} @ ${fmtDur(c.start)}${fmtDur(c.end)}` label.title = `${c.filename} @ ${fmtDur(c.start)}${fmtDur(c.end)}`;
+ (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}: ${when}`); announce(text);
} }
function hideClipBar() { function hideClipBar() {
@@ -385,6 +402,7 @@ function hideClipBar() {
document.body.classList.remove('clip-open'); document.body.classList.remove('clip-open');
clipQueue = []; clipQueue = [];
clipCursor = -1; clipCursor = -1;
scoreCursor = -1;
} }
function playFileSection(idx, filename, si) { function playFileSection(idx, filename, si) {
@@ -393,6 +411,7 @@ function playFileSection(idx, filename, si) {
const epoch = f ? fileStartEpoch(f.date) : null; const epoch = f ? fileStartEpoch(f.date) : null;
clipQueue = secs.map(s => ({fileIdx: idx, filename, start: s.start, end: s.end, score: s.score, clipQueue = secs.map(s => ({fileIdx: idx, filename, start: s.start, end: s.end, score: s.score,
absStart: epoch != null ? epoch + s.start : null})); absStart: epoch != null ? epoch + s.start : null}));
scoreCursor = -1; // explicit jump: next U/I re-anchors here
playClip(si); playClip(si);
} }
@@ -410,18 +429,29 @@ const advanceMode = () =>
document.querySelector('input[name="clip-adv"]:checked')?.value || 'off'; document.querySelector('input[name="clip-adv"]:checked')?.value || 'off';
// Step the clip queue by dir (±1): in time order, or with byScore down/up // Step the clip queue by dir (±1): in time order, or with byScore down/up
// the loudest-first ranking (next = next quieter). // the loudest-first ranking (next = next quieter). With jump=true, go straight
function stepClip(dir, byScore) { // to the extreme in that direction (first/last section, or loudest/quietest).
function stepClip(dir, byScore, jump) {
if (!clipQueue.length) return; if (!clipQueue.length) return;
if (byScore) { if (byScore) {
// Walk scoreCursor, not the shared clipCursor: a J/K detour never moved
// it, so we resume the ranking where we left off. scoreCursor === -1 means
// the walk hasn't started since the queue was armed or an explicit jump —
// anchor on the section currently selected (clipCursor), else the loudest.
const order = scoreOrder(clipQueue); const order = scoreOrder(clipQueue);
const pos = order.indexOf(clipCursor); // -1: nothing played yet let next;
const next = pos === -1 ? (dir > 0 ? 0 : -1) : pos + dir; if (jump) next = dir > 0 ? order.length - 1 : 0;
if (next >= 0 && next < order.length) playClip(order[next]); else if (scoreCursor !== -1) next = scoreCursor + dir;
else {
const anchor = clipCursor >= 0 ? order.indexOf(clipCursor) : -1;
next = anchor === -1 ? (dir > 0 ? 0 : -1) : anchor + dir;
}
if (next >= 0 && next < order.length) { scoreCursor = next; playClip(order[next], true); }
else announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights'); else announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights');
return; return;
} }
const i = clipCursor + dir; // J/K (time order) deliberately does NOT touch scoreCursor.
const i = jump ? (dir > 0 ? clipQueue.length - 1 : 0) : clipCursor + dir;
if (i >= 0 && i < clipQueue.length) playClip(i); if (i >= 0 && i < clipQueue.length) playClip(i);
else announce(dir < 0 ? 'Beginning of sections' : 'End of sections'); else announce(dir < 0 ? 'Beginning of sections' : 'End of sections');
} }
@@ -534,8 +564,9 @@ async function analyse(idx, filename, cell, btn, force = false) {
} }
// J/K = previous/next section in time order, U/I = up/down the loudest-first // J/K = previous/next section in time order, U/I = up/down the loudest-first
// ranking, O = open the current clip in the full file. // ranking, O = open the current clip in the full file. Holding Shift jumps to
// Only when focus is not in an input. // the extreme in that direction: Shift+J/K = first/last section in time,
// Shift+U/I = loudest/quietest. 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.ctrlKey || e.metaKey || e.altKey) return; if (e.ctrlKey || e.metaKey || e.altKey) return;
@@ -545,10 +576,11 @@ document.addEventListener('keydown', e => {
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 byScore = key === 'u' || key === 'i'; const byScore = key === 'u' || key === 'i';
const jump = e.shiftKey;
// 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, byScore); stepClip(dir, byScore, jump);
return; return;
} }
@@ -570,10 +602,12 @@ document.addEventListener('keydown', e => {
if (byScore) { if (byScore) {
// U/I walk the ranking; the anchor is the section the playhead sits in // 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. // (incl. its preroll lead-in). Anywhere else, I starts at the loudest.
// Shift jumps straight to the loudest (Shift+U) or quietest (Shift+I).
const order = scoreOrder(all); const order = scoreOrder(all);
const curIdx = all.findIndex(s => cur >= s.start - preroll - 0.5 && cur <= s.end + 0.5); 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 pos = curIdx === -1 ? -1 : order.indexOf(curIdx);
const next = pos === -1 ? (dir > 0 ? 0 : -1) : pos + dir; const next = jump ? (dir > 0 ? order.length - 1 : 0)
: pos === -1 ? (dir > 0 ? 0 : -1) : pos + dir;
if (next >= 0 && next < order.length) if (next >= 0 && next < order.length)
jumpTo(all[order[next]], next, order.length, 'Highlight'); jumpTo(all[order[next]], next, order.length, 'Highlight');
else else
@@ -581,6 +615,13 @@ document.addEventListener('keydown', e => {
return; return;
} }
// Shift+J/K jump to the first/last section regardless of the playhead.
if (jump) {
const i = dir < 0 ? 0 : all.length - 1;
jumpTo(all[i], i, all.length, 'Section');
return;
}
if (dir < 0) { if (dir < 0) {
for (let i = all.length - 1; i >= 0; i--) { for (let i = all.length - 1; i >= 0; i--) {
if (all[i].start < cur - 1) { jumpTo(all[i], i, all.length, 'Section'); return; } if (all[i].start < cur - 1) { jumpTo(all[i], i, all.length, 'Section'); return; }
@@ -765,7 +806,7 @@ function renderFiles(files) {
</h2> </h2>
${canHl ? `<button class="day-hl" id="dayhln-${dayId}" ${canHl ? `<button class="day-hl" id="dayhln-${dayId}"
aria-expanded="false" aria-controls="dayhl-${dayId}" aria-expanded="false" aria-controls="dayhl-${dayId}"
aria-label="Day highlights for ${esc(day)}"> aria-label="Day highlights for ${esc(day)}${analysed ? ', analysed' : ''}">
<span class="day-arrow" aria-hidden="true">▸</span> Highlights<span <span class="day-arrow" aria-hidden="true">▸</span> Highlights<span
class="day-hl-status" id="dayhls-${dayId}"${analysed ? '' : ' hidden'}> · analysed</span></button>` : ''}`; class="day-hl-status" id="dayhls-${dayId}"${analysed ? '' : ' hidden'}> · analysed</span></button>` : ''}`;
section.appendChild(headBar); section.appendChild(headBar);
@@ -906,6 +947,7 @@ function renderFiles(files) {
dayActiveId = dayId; dayActiveId = dayId;
clipQueue = dayActiveSections; clipQueue = dayActiveSections;
clipCursor = -1; clipCursor = -1;
scoreCursor = -1;
return; return;
} }
dayHighlights(dayId, hlFiles); dayHighlights(dayId, hlFiles);
@@ -996,6 +1038,7 @@ async function dayHighlights(dayId, analyzableFiles) {
// Arm the clip queue so J/K steps through the day immediately // Arm the clip queue so J/K steps through the day immediately
clipQueue = dayActiveSections; clipQueue = dayActiveSections;
clipCursor = -1; clipCursor = -1;
scoreCursor = -1;
const box = document.createElement('div'); const box = document.createElement('div');
box.className = 'wbox'; box.className = 'wbox';
@@ -1012,7 +1055,7 @@ 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 = 'J / K plays through all sections in time order, U / I by loudness (loudest first)'; note.textContent = 'J / K plays through all sections in time order, U / I by loudness (loudest first). Hold Shift to jump to the first / last section (Shift + J / K) or the loudest / quietest (Shift + U / I)';
box.appendChild(note); box.appendChild(note);
const MAX_DAY_CHIPS = 50; const MAX_DAY_CHIPS = 50;
@@ -1046,6 +1089,8 @@ async function dayHighlights(dayId, analyzableFiles) {
const arrow = document.createElement('span'); const arrow = document.createElement('span');
arrow.className = 'day-arrow'; arrow.className = 'day-arrow';
arrow.setAttribute('aria-hidden', 'true'); arrow.setAttribute('aria-hidden', 'true');
// the separator space lives inside the aria-hidden arrow: a leading
// space in the text node shows up as an indent in screen readers
arrow.textContent = '▸ '; arrow.textContent = '▸ ';
tog.appendChild(arrow); tog.appendChild(arrow);
tog.appendChild(document.createTextNode(truncated tog.appendChild(document.createTextNode(truncated
@@ -1067,14 +1112,20 @@ async function dayHighlights(dayId, analyzableFiles) {
hlRow.dataset.loaded = hlParams(); hlRow.dataset.loaded = hlParams();
dayHlSections.set(dayId, dayActiveSections); dayHlSections.set(dayId, dayActiveSections);
// Every file is now cached with the current params // Every file is now cached with the current params
if (results.length === n) if (results.length === n) {
document.getElementById('dayhls-' + dayId)?.removeAttribute('hidden'); document.getElementById('dayhls-' + dayId)?.removeAttribute('hidden');
// aria-label overrides the button content, so the analysed marker has to
// be mirrored into it or screen readers never hear it
if (btn && !/, analysed$/.test(btn.getAttribute('aria-label') || ''))
btn.setAttribute('aria-label', btn.getAttribute('aria-label') + ', analysed');
}
if (btn) btn.disabled = false; if (btn) btn.disabled = false;
} }
function jumpToDaySection(si) { function jumpToDaySection(si) {
if (si < 0 || si >= dayActiveSections.length) return; if (si < 0 || si >= dayActiveSections.length) return;
clipQueue = dayActiveSections; clipQueue = dayActiveSections;
scoreCursor = -1; // explicit jump: next U/I re-anchors here
playClip(si); playClip(si);
} }