feat: split the web UI into modules and rework it for screen readers
webui.html becomes webui/ (index.html, style.css, ES modules in js/, no
build step); web.py serves it under / and /ui/<path> with the same
containment check as recordings.
How it feels with a screen reader:
- Days read "Today, Saturday 17 October"; recordings are named by their
wall-clock span ("22:00 to 23:00"), which is the row header and what
every button says. The filename moves to the last column.
- Focus is never dropped: busy buttons stay focused (aria-disabled),
finished analyses focus their first section, delete moves to the next
row, closing the clip bar (x or Escape) returns to where you were.
- Reloads rebuild only the days whose content changed; DOM ids are stable
per filename, so a rollover no longer resets the reading position.
- Only user-initiated actions are announced; highlights completion is
spoken (the progress area was stuck aria-busy before).
- Media Session previous/next track step sections like J/K (AirPods,
lock screen, media keys).
- Row layout: Time | Length | Loudness | Player | Options | File. The
player opens inside its cell; Download/Cut/Delete are always there,
and Cut toggles its start/end fields in place.
- Settings tucked behind a <details>; pre-roll and auto-advance are
remembered per browser; no CSS uppercase on headers or badges.
Fixes the REC badge showing on every row: its inline-flex display beat
the UA [hidden] rule. One global [hidden]{display:none !important} now
covers every case.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -149,30 +149,33 @@ python web.py --min-duration 1 # discard loud sections shorter than
|
||||
python web.py --analyses-dir /path/to/dir # where to store analysis cache files (default: <recordings>/analyses)
|
||||
```
|
||||
|
||||
The browser UI (HTML/CSS/JS) lives in `webui.html`, which `web.py` loads at startup. `web.py` also imports the `isrweb/` package (the loud-section detector and the audio-format helpers) — keep `web.py`, `webui.html` and `isrweb/` together.
|
||||
The browser UI lives in the `webui/` folder (`index.html`, `style.css` and plain JavaScript modules in `webui/js/` — no build step), which `web.py` serves under `/ui/`. `web.py` also imports the `isrweb/` package (the loud-section detector and the audio-format helpers) — keep `web.py`, `webui/` and `isrweb/` together.
|
||||
|
||||
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 the date in words (`Today, Saturday 17 October`, `Yesterday, …`), recording count, total duration, and total size. Today is expanded by default; older days start collapsed. Expanded state is preserved across filter changes.
|
||||
- **Recordings named by time** — each row is named by the wall-clock span it covers (`22:00 to 23:00`, `12:00 to now` while recording), taken from the filename clock; that name is the table's row header and what every button says (`Play 22:00 to 23:00`). Format, size and the raw filename are in the last column.
|
||||
- **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.
|
||||
- **Row layout** — columns are Time, Length, Loudness, Player, Options, File. The **Player** cell holds the `Play` button; pressing it opens the audio player right there in the same cell (the button becomes `Hide`). The **Options** cell next to it is always available: Download, Cut, Delete.
|
||||
- **Inline playback** — the player opens in the row's Player cell; 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.
|
||||
- **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 15–30 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.
|
||||
- **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`.
|
||||
- **Grace period** — configurable under **Settings** (default 2 s). Loud sections separated by less than this gap are merged into one. Raise this (e.g. to 15–30 s) when a single event generates many timestamps due to brief quiet gaps within it.
|
||||
- **Min duration** — configurable under **Settings** (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), 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 **When a clip ends** choice in the player bar picks what plays next: **Stop**, **Next in time**, or **Next loudest** — it never changes what J/K do, and it is remembered in the browser along with the pre-roll. 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. **Escape** in the player bar closes it and returns focus to where you were.
|
||||
- **Media keys and headsets** — previous/next track step through the sections like J/K: AirPods and headset buttons, the iOS lock screen and Control Center, and the media keys on a keyboard. The lock screen shows the same clip label as the player bar.
|
||||
- **Cut & download** — the `Cut` button in the Options cell expands the start/end fields right below it (and collapses them again); focus goes to Start, and playing a section pre-fills both. 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.
|
||||
- **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>`, then moves focus to the next recording's Play button.
|
||||
- **Live REC badge** — files currently being written by `isr.py` show an animated REC indicator, polled every 5 seconds via `/api/status`. Duration for in-progress files shows `—` in the table (header is unfinalized until recording stops). The file list refreshes automatically when a recording starts, stops, or rolls over to a new split file (unless audio is playing).
|
||||
- **Listen while recording** — in-progress files are playable and seekable. For WAV and FLAC the server patches the (still unfinalized) header on the fly so the browser sees the real duration-so-far — for FLAC the exact sample count is parsed from the last frame header in the file tail. Reopening the player reloads the source to pick up newly recorded audio. Live responses are sent with `Cache-Control: no-store`.
|
||||
- **Fast loading** — analysis results are cached server-side on disk and client-side per session; cached analyses load only for expanded day groups, and collapsed days fetch nothing until opened.
|
||||
- **WCAG-compliant** — skip link, `aria-expanded`/`aria-controls` on the player toggle, `aria-live` status, focus management; all information is plain text and buttons in linear reading order, with no visual-only elements.
|
||||
- **Built for screen readers** — landmarks and headings (one heading per day, the clip player has its own), analysis settings tucked behind a collapsed **Settings** disclosure, skip link, `aria-expanded`/`aria-controls` on every toggle; all information is plain text and buttons in linear reading order, with no visual-only elements. Focus is never dropped: a busy button stays focused while it works, finished analyses move focus to their first section, and background reloads (a recording rolling over) rebuild only the day that changed, so the rest of the page — and your reading position — stays put. Things you asked for are announced (analysis results, highlights ready, refresh and filter counts); background events stay silent.
|
||||
|
||||
### Too many sections per day?
|
||||
|
||||
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** — 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.
|
||||
1. **Review by rank instead of thinning the list** — use **U**/**I** (or **Next loudest** in the player bar) 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 → 15–30 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 → 15–18 dB) — demands more prominence above the background. The quietest events disappear first, so move in small steps.
|
||||
4. **Raise min duration** (0.5 → 1–2 s) — drops short rustles and pops, but beware: single bangs or knocks are themselves short.
|
||||
@@ -196,7 +199,9 @@ Everything the UI does goes through these endpoints, so they can also be scripte
|
||||
| `GET /api/config` | Server-side defaults for margin, min-gap, and min-duration (seeds the UI controls) |
|
||||
| `DELETE /api/files/<name>` | Delete a recording and its analysis cache |
|
||||
|
||||
`HEAD` is answered for `/`, `/stream/<name>` and `/download/<name>` (headers only, no body) — the generated endpoints (`/api/clip`, `/api/cut`) return `405` rather than doing the work and discarding it.
|
||||
The UI itself is `GET /` plus its static files under `GET /ui/<path>`.
|
||||
|
||||
`HEAD` is answered for `/`, `/ui/<path>`, `/stream/<name>` and `/download/<name>` (headers only, no body) — the generated endpoints (`/api/clip`, `/api/cut`) return `405` rather than doing the work and discarding it.
|
||||
|
||||
Analysis, clips, cut, and delete return `409` for files that are still being recorded.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user