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:
2026-10-18 01:05:15 +02:00
co-authored by Claude Opus 5.5
parent c91264c253
commit 2a19515c27
19 changed files with 1639 additions and 1279 deletions
+23 -12
View File
@@ -5,17 +5,17 @@ Guidance for Claude Code when working in this repository.
## Rules
- **Always update `README.md`** when user-facing behaviour changes (flags, endpoints, Docker setup, features), and **commit it in the same commit** as the code change. README is the external reference; CLAUDE.md documents internals.
- Run `python -m pytest tests/` after changing `isr.py`, `web.py` or `isrweb/` (tests cover the recorder, the detector, the filename clock and the HTTP layer). Test files mirror the modules: `test_isr.py`, `test_loudness.py`, `test_audiofile.py`, `test_web.py`; `tests/conftest.py` owns the sys.path setup and the `write_wav` fixture.
- Run `python -m pytest tests/` after changing `isr.py`, `web.py`, `isrweb/` or `webui/js/` (tests cover the recorder, the detector, the filename clock and the HTTP layer). Test files mirror the modules: `test_isr.py`, `test_loudness.py`, `test_audiofile.py`, `test_web.py`; `tests/conftest.py` owns the sys.path setup and the `write_wav` fixture.
## Files
| File | Purpose |
|------|---------|
| `isr.py` | Recorder: streams (Icecast/HTTP) + ALSA soundcards, time-aligned file splits |
| `web.py` | Archive browser: HTTP server, file listing, analysis cache, cut/delete. Entry point — stays at the repo root so `python web.py` and the `webui.html` lookup work |
| `web.py` | Archive browser: HTTP server, file listing, analysis cache, cut/delete. Entry point — stays at the repo root so `python web.py` and the `webui/` lookup work |
| `isrweb/loudness.py` | The adaptive loud-section detector. Pure computation, imports nothing from the app — future spectral/VAD work lands here and never touches HTTP |
| `isrweb/audiofile.py` | What an audio file is and what its name means: the filename clock, FLAC/WAV header parsing, live-header patching. Also a leaf |
| `webui.html` | Single-page UI (HTML/CSS/JS), loaded by `web.py` at startup — must sit next to `web.py` and be copied in the Dockerfile |
| `webui/` | Browser UI: `index.html`, `style.css`, ES modules in `js/` (no build step). Served by `web.py` (`/` and `/ui/<path>`) — must sit next to `web.py` and be copied in the Dockerfile |
| `config.ini` | Recording sources; copy from `config.example.ini`. `[general]` gives defaults, every other section is a source (`type = stream` or `type = soundcard`) |
| `asound.conf` | dsnoop device `shared_mic` so ISR and other ALSA apps can share a soundcard |
@@ -43,18 +43,29 @@ Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC
`web.py`:
- Clips: `_api_clip()` validates params, `_clip_wav()` / `_clip_flac()` stream the decoded slice.
- Serving: `_serve_bytes()` is the single Range-capable file sender — `_stream()` (live header patch via its `prefix` argument, `no_store`) and `_download()` (`Content-Disposition`) both go through it, and it is what makes `do_HEAD` a body-less `do_GET`. Plus `_copy_to_response()`, `_safe_path()` (path traversal guard).
- Serving: `_serve_bytes()` is the single Range-capable file sender — `_stream()` (live header patch via its `prefix` argument, `no_store`) and `_download()` (`Content-Disposition`) both go through it, and it is what makes `do_HEAD` a body-less `do_GET`. Plus `_copy_to_response()`, `_safe_path()` (path traversal guard). `_ui()` serves `webui/` (same containment check, html/css/js only, read per request so UI edits need no restart).
`webui.html` (one `<script>` block):
- 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. **Queue entries carry the filename, never a row index:** `_idx` is reassigned positionally on every `load()`, and `pollStatus()` reloads whenever a recording starts/stops/rolls over, so a stored index goes stale mid-review (a past bug had "Open in file"/cut fields target the row one off after a split rollover). Resolve at use time via `fileIdxByName()`. **`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. Both U/I walks — `stepClip`'s by-score branch and the in-player branch of the keydown handler — share `nextInOrder(order, pos, dir, jump)` for the stepping arithmetic (returns `null` at either end); they differ only in what anchors `pos` and what "play" means, and must not re-inline it.
- Day review: `dayHighlights()` builds the chronological cross-file section list; `armDayQueue(dayId, sections)` is the single place that points the queue at a day (sets `dayActiveSections`/`dayActiveId`/`clipQueue` and resets both cursors) — used by the fresh-build path and the re-open-a-kept-panel path alike. `jumpToDaySection()` arms the queue. Section `absStart` comes from `fileStartEpoch(f.date)` (filename clock), mtime−duration 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.
- 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; its `btn` argument is optional), `cachedParamsMatch()` (autoload guard). `analysisParams()` is the only reader of the three control inputs — no hardcoded client-side defaults, so web.py's constants stay the single source of truth via `/api/config`; `hlParams()` is just its values joined.
`webui/js/` — native ES modules, one concern each. **Each module imports only from modules earlier in this list:** `util` → `api` → `store` → `settings` → `player` → `clips` → `analysis` → `day` → `list` → `nav` → `main`. Keep it acyclic; when an earlier module needs a later one it goes through the DOM (`openClipInFile()` clicks the day toggle) rather than an import. Mutable shared state is a module-level `export let` reassigned only inside its own module (importers get the live binding).
- `util.js`: formatting (`fmtDur`, `fmtClock`, `fmtHM`, `fmtDurWords`, `dayLabel()` → "Today, Saturday 17 October"), `announce()` (the single `#sr-announce` live region), `chipToggle()` (collapses chip lists > 12 behind an `aria-expanded` button), `remember` (try/catch localStorage for per-browser conveniences only: pre-roll, auto-advance, settings open).
- `api.js`: every URL. `store.js`: `allFiles`, `sectionMap`, `fileSpan(f)` (the spoken name of a recording, "22:00 to 23:00", from the filename clock). **`_idx` is stable per filename for the whole session** (assigned on first sight, never reused), so DOM ids (`row-`, `pbtn-`, `aud-`, `wave-`, …) survive reloads. Queue entries still carry the filename, never an index — the file can disappear; resolve at use time via `fileIdxByName()`.
- `settings.js`: `analysisParams()` is the only reader of the three analysis inputs — no hardcoded client-side defaults, so web.py's constants stay the single source of truth via `/api/config` (and they are deliberately *not* remembered in localStorage); `hlParams()` is just its values joined. `cachedParamsMatch()` (autoload guard), `getPreroll()`, `advanceMode()`.
- `player.js`: per-row full-file player and cut fields (`togglePlayer`, `seekToSection`, `setCutFields`, `toggleCut`, `activePlayerIdx`). Row layout (user's choice): Time (row header) | Length | Loudness | Player | Options | File — one table row per recording. The `<audio>` opens *inside* the Player cell next to the Play/Hide button (there is no separate player row any more); the Options cell (Download, Cut, Delete) is always visible, and Cut is an `aria-expanded` toggle for the start/end fields inside it.
- `clips.js` — clip review: `playClip()`, `hideClipBar()`, `armQueue()` (point the queue at a list, reset both cursors), `playFrom()` (explicit jump), `stepClip()`, `openClipInFile()`. The clip label shows wall-clock occurrence time + dB prominence + position (`17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)`); `absStart` (epoch s) comes from `fileStartEpoch(f.date)` — the filename clock — with in-file offsets as fallback; the filename/offset lives in the tooltip. **`playClip()` builds one `text` string used for `label.textContent`, the `announce()` message and the Media Session title** — 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 "Next loudest" auto-advance) in `stepClip`'s by-score branch — J/K must never touch it, so a J/K detour leaves the loudness walk's place intact and the next U/I resumes the ranking. `scoreCursor` resets to `-1` on every explicit jump / queue (re-)arm (`hideClipBar`, `armQueue`, `playFrom`), so the next U/I re-anchors on `clipCursor` else the loudest. `playClip(i, byScore)`: when `byScore` the count shows `scoreCursor` (suffix "by loudness"), else `i` ("by time") — the suffix tells the blind user which dimension the count is in. Both U/I walks (`stepClip` and the in-player branch of `nav.navigate()`) share `nextInOrder(order, pos, dir, jump)`; they must not re-inline it. Closing the bar (× or Escape) returns focus to where it was when the bar opened.
- `analysis.js`: `fetchAnalysis()` (session cache; updates `f.cached_analysis` client-side so the analysed marker survives re-renders), `analyse()` (result cell: meta line → chips with wall-clock labels → Re-analyse), `analyseButton()`, `autoloadDayAnalyses()`.
- `day.js` — day review: `toggleHighlights()` (the Highlights button is a collapse/expand toggle; a built panel is kept and re-armed from `dayHlSections`, keyed by `panel.dataset.loaded = hlParams()` so changed params force a re-run), `dayHighlights()` builds the chronological cross-file section list, `armDayQueue()` is the single place that points the queue at a day, `releaseDay()` on day collapse. **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 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. 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.
- `list.js`: `renderFiles()` is **incremental** — each day section carries `data-sig` (`daySignature()`: params + names + recording flags + durations) and is rebuilt only when that changes, so a rollover touches today alone and the rest of the page (and the screen reader's position) stays put; focus inside a rebuilt day is restored by id. `pollStatus()` still reloads only when no audio is playing. `deleteFile()` moves focus to the next row's Play button. `load({speak})`/`applyFilters({speak})`: only user-initiated actions announce; background reloads are silent.
- `nav.js`: the single document-level J/K/U/I/O `keydown` listener and `navigate()` — clip queue first, in-player `currentTime` stepping as the fallback; O = `openClipInFile()`. Media Session previous/next track call `navigate(∓1)` (AirPods, lock screen, keyboard media keys). J/K (and Prev/Next) always step in time order; U/I walk `scoreOrder()` with no top-N cutoff (a `#clip-top` input and `#clip-hl-only` checkbox were removed deliberately; J/K must never be affected by an auto-advance setting). Auto-advance is the `input[name="clip-adv"]` radio (`off` / `all` = next in time / `hl` = next loudest).
Screen-reader rules learned the hard way (apply to any markup change):
- **`aria-label` on a button replaces its visible content** — status text rendered inside a labelled button is never heard. The day Highlights button therefore has *no* aria-label: its content (`Highlights` + sr-only "for <day>" + the `#dayhls-` " · analysed" / progress suffix) is its name.
- The accessible text of a button built as `aria-hidden arrow span + text` must not start the text node with a space (reads as an indent) — keep separator spaces inside the aria-hidden span.
- Never disable or replace the focused element: busy buttons get `aria-disabled` + a guard, and code that replaces a container moves focus to its replacement (`analyse()` → first chip).
- `[hidden]{display:none !important}` in style.css is load-bearing: author `display` rules (flex chips, inline-flex REC badge, flex clip bar) otherwise beat the UA `[hidden]` rule — the REC badge once showed on every row that way.
## Verifying changes
- `python -m pytest tests/` covers the recorder (`test_isr.py`) and the detector (`test_web.py`).
- There is no JS toolchain and no `node` on the dev box. After editing `webui.html`, cross-check every `getElementById('x')` against an `id="x"` declaration, and smoke-test endpoints.
- There is no JS toolchain and no `node` on the dev box. `test_web.py` checks every relative module import resolves. After editing `webui/`, cross-check every `$('x')`/`getElementById('x')` against an `id="x"` declaration, then smoke-test in a real browser: headless Edge is installed (`C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe --headless=new --virtual-time-budget=60000 --dump-dom <url>`). Serve `web.py` over generated WAVs with `WEBUI_DIR` pointed at a scratch copy of `webui/` plus a harness page that iframes `/`, drives it (click, dispatch `keydown`, stub `confirm`), and writes PASS/FAIL lines into its own DOM for `--dump-dom` to print.
- Endpoint smoke pattern: write a temp WAV/FLAC with a known loud burst, subclass `web._Handler` with `recordings_dir`/`analyses_dir` pointing at the temp dir, serve `web._Server(('127.0.0.1', 0), H)` in a daemon thread, then hit `/api/analyze` and `/api/clip` with urllib — assert section start/score and that `Content-Length == len(body) == 44 + frames × channels × 2`.
- Dev box is Windows / PowerShell 5.1. Multi-line commit messages: use the Bash tool with `git commit -F - <<'EOF'` — PowerShell here-strings containing quotes get mangled into separate arguments.
@@ -67,10 +78,10 @@ Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC
- **ALSA:** capture spawns `arecord` as a subprocess, raw PCM read in 100 ms chunks by a thread. Device spec resolution: `default` → exact `hw:X,Y` → partial name → fallback to any literal ALSA PCM name (so `shared_mic` from asound.conf works without appearing in `arecord -l`).
- **Shutdown:** SIGTERM is converted to KeyboardInterrupt in `main()`; `RecorderManager.stop()` joins all threads against a single shared 25 s deadline to stay inside Docker's `stop_grace_period: 30s`.
- **Loud-section detection is adaptive — do not regress it to an absolute threshold.** Per-window dB is compared against a rolling noise floor (`NOISE_PERCENTILE`-th percentile per `NOISE_BLOCK_SECONDS` block, min-smoothed over ±2 blocks so events can't raise their own floor; clamped to ≥ `MIN_RMS`). A section needs `margin` dB of prominence and carries a `score` used for ranking: peak dB above floor, **capped by the sharpest rise within `ONSET_SECONDS` (0.5 s)** — so a short (~10 s) swell that outruns the 30 s floor blocks still flags but scores ≈ 0 and sinks in the U/I highlight ranking, while sharp events keep their full prominence. A section starting in the first 0.5 s of a file is scored against the floor instead (events cut off by a file split must not be punished as swells). Do not regress the scoring to raw peak, and do not fight swells with a higher margin. If flagging itself (not just ranking) ever needs improving, the next step is a spectral filter or optional Silero VAD over candidate sections. Sections shorter than `min_duration` (default 0.5 s, after `min_gap` merging) are discarded — without this, isolated 100 ms pops (clicks, single raindrops) produced thousands of zero-length sections per day. The original fixed RMS threshold flagged every ambience change (passing cars, rain) and produced ~600 useless sections/day — that is why it was replaced. Tests in `tests/test_web.py`.
- **Analysis params are coupled in five places.** CLI `--margin`/`--min-gap`/`--min-duration` → `/api/config` → UI inputs `#margin-input`/`#min-gap-input`/`#min-duration-input` → `/api/analyze` query params → cache JSON keys. Renaming or adding a param means touching all five plus `cachedParamsMatch()` and `_cached_analysis_params()`. Query-string parsing/clamping for all three goes through the single `_qs_float()` helper (see the threshold→margin change `c84b7d8` and the min_duration addition).
- **Analysis params are coupled in five places.** CLI `--margin`/`--min-gap`/`--min-duration` → `/api/config` → UI inputs `#margin-input`/`#min-gap-input`/`#min-duration-input` → `/api/analyze` query params → cache JSON keys. Renaming or adding a param means touching all five plus `analysisParams()`/`cachedParamsMatch()`/`initSettings()` (settings.js), `api.analyze()` and `_cached_analysis_params()`. Query-string parsing/clamping for all three goes through the single `_qs_float()` helper (see the threshold→margin change `c84b7d8` and the min_duration addition).
- **Analysis cache:** results stored as `<analyses-dir>/<file>.analysis.json` keyed by margin+min_gap+min_duration; orphans pruned at web startup. In Docker the recordings mount is **read-only** for the web container, so docker-compose layers a read-write `./recordings/analyses` bind mount over it. `_cached_analysis_params()` just `json.load`s the file — key order carries no meaning. (It used to regex the four param keys out of the first 256 bytes, which forced them to be written first; that was worth it only while caches embedded the RMS arrays, and those are gone.) `detector` is `DETECTOR_VERSION`: bump it whenever detection/scoring changes make old cached results wrong (v2 = onset-capped scores, v3 = a section open at EOF ends at its last loud window rather than at the file duration); caches with another version (or missing keys) never match and get overwritten on the next analyse.
- **Analyze responses:** `/api/analyze` returns only `sections` and `duration` — no RMS data of any kind. `rms_display` (~800-point waveform preview), the full per-window list, and `window` (the window length, which only existed to place RMS bars) were all dropped when the waveform SVGs were removed (user is blind, see webui notes). Old caches that embed `rms`/`rms_display` have both keys popped when served from cache.
- **Section playback uses clips, not seeks:** `/api/clip?file&start&end` decodes the slice server-side (wave/soundfile) and returns a standalone 16-bit WAV with exact Content-Length (capped at `CLIP_MAX_SECONDS`), `Cache-Control: private` so re-listening is free. The UI plays chips/J-K through the bottom clip bar (`clipQueue` in webui.html); seeking the full file only happens via "Open in file". Rationale (finding): libsndfile writes FLAC **without a SEEKTABLE**, so a browser seek bisects the whole multi-hundred-MB file with Range requests — seeking big FLACs in `<audio>` is inherently slow and must not be reintroduced as the primary navigation. Server-side `sf.SoundFile.seek()` on local disk is fast and frame-accurate.
- **Section playback uses clips, not seeks:** `/api/clip?file&start&end` decodes the slice server-side (wave/soundfile) and returns a standalone 16-bit WAV with exact Content-Length (capped at `CLIP_MAX_SECONDS`), `Cache-Control: private` so re-listening is free. The UI plays chips/J-K through the bottom clip bar (`clipQueue` in `webui/js/clips.js`); seeking the full file only happens via "Open in file". Rationale (finding): libsndfile writes FLAC **without a SEEKTABLE**, so a browser seek bisects the whole multi-hundred-MB file with Range requests — seeking big FLACs in `<audio>` is inherently slow and must not be reintroduced as the primary navigation. Server-side `sf.SoundFile.seek()` on local disk is fast and frame-accurate.
- **HTTP/1.1 keep-alive:** `_Handler.protocol_version = 'HTTP/1.1'`; every response path must set an accurate `Content-Length`. `_copy_to_response()` force-closes the connection if it under-delivers (file truncated mid-serve).
- **Live playback:** for files listed in status.json, `/stream/` patches the header on the fly so the browser sees the duration recorded so far and can seek; responses get `Cache-Control: no-store`. WAV: `_live_wav_header` derives sizes from the byte count. FLAC: `_live_flac_header` parses the sample count out of the last frame header in the file tail (CRC-8-verified to reject false sync matches) and rewrites STREAMINFO total_samples — duration is NOT derivable from byte size for FLAC.
- **Path safety:** every file parameter in `web.py` goes through `_safe_path()`, which resolves and verifies the path stays inside the recordings dir.