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:
@@ -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.
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@ WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY isr.py web.py webui.html ./
|
||||
COPY isr.py web.py ./
|
||||
COPY webui/ ./webui/
|
||||
COPY isrweb/ ./isrweb/
|
||||
|
||||
RUN mkdir -p /recordings
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -75,3 +75,43 @@ def test_clip_failure_before_headers_still_reports_json(tmp_path, write_wav):
|
||||
assert h.close_connection is False
|
||||
assert h.codes == [500]
|
||||
assert b'could not open' in h.wfile.getvalue()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Browser UI static files (webui/)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_ui_serves_index_and_modules_with_their_types(tmp_path):
|
||||
h = _OfflineHandler(tmp_path)
|
||||
types = []
|
||||
h.send_header = lambda key, value: types.append(value) if key == 'Content-Type' else None
|
||||
|
||||
h._ui('index.html')
|
||||
h._ui('js/main.js')
|
||||
h._ui('style.css')
|
||||
|
||||
assert h.codes == [200, 200, 200]
|
||||
assert types == ['text/html; charset=utf-8', 'text/javascript; charset=utf-8',
|
||||
'text/css; charset=utf-8']
|
||||
|
||||
|
||||
def test_ui_refuses_paths_outside_webui_and_unlisted_types(tmp_path):
|
||||
h = _OfflineHandler(tmp_path)
|
||||
|
||||
h._ui('../web.py') # escapes webui/
|
||||
h._ui('..\web.py')
|
||||
h._ui('js') # a directory
|
||||
h._ui('missing.js')
|
||||
|
||||
assert h.codes == [404, 404, 404, 404]
|
||||
assert b'import' not in h.wfile.getvalue()
|
||||
|
||||
|
||||
def test_every_ui_module_import_resolves():
|
||||
# No JS toolchain on the dev box: at least catch a renamed module or a
|
||||
# misspelt relative import before the browser does.
|
||||
import re
|
||||
js = web.WEBUI_DIR / 'js'
|
||||
for mod in js.glob('*.js'):
|
||||
for target in re.findall(r"from '\./([\w.]+)'", mod.read_text(encoding='utf-8')):
|
||||
assert (js / target).is_file(), f'{mod.name} imports missing {target}'
|
||||
|
||||
@@ -245,7 +245,9 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
p = parsed.path
|
||||
|
||||
if p == '/':
|
||||
self._html()
|
||||
self._ui('index.html')
|
||||
elif p.startswith('/ui/'):
|
||||
self._ui(unquote(p[len('/ui/'):]))
|
||||
elif p == '/api/files':
|
||||
self._api_files()
|
||||
elif p == '/api/analyze':
|
||||
@@ -267,8 +269,21 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
self._send(404, b'Not found', 'text/plain')
|
||||
|
||||
def _html(self):
|
||||
self._send(200, _HTML.encode('utf-8'), 'text/html; charset=utf-8')
|
||||
def _ui(self, name: str):
|
||||
"""Serve a file of the browser UI from webui/. Read per request: it is
|
||||
a handful of small files, and edits show up on reload without a server
|
||||
restart. Only the listed types are served, and the path must stay
|
||||
inside webui/ (same guard as _safe_path, but for the UI folder)."""
|
||||
try:
|
||||
path = (WEBUI_DIR / name).resolve()
|
||||
path.relative_to(WEBUI_DIR)
|
||||
except ValueError:
|
||||
path = None
|
||||
ctype = _UI_TYPES.get(path.suffix) if path else None
|
||||
if ctype is None or not path.is_file():
|
||||
self._send(404, b'Not found', 'text/plain')
|
||||
return
|
||||
self._send(200, path.read_bytes(), ctype)
|
||||
|
||||
def _api_files(self):
|
||||
files = list_files(self.recordings_dir)
|
||||
@@ -738,10 +753,15 @@ class _Server(ThreadingHTTPServer):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UI page — single-page HTML/CSS/JS, loaded once at startup
|
||||
# Browser UI — static files in webui/ (index.html, style.css, js/*.js modules)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_HTML = (Path(__file__).resolve().parent / 'webui.html').read_text(encoding='utf-8')
|
||||
WEBUI_DIR = Path(__file__).resolve().parent / 'webui'
|
||||
_UI_TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -773,6 +793,8 @@ def main():
|
||||
|
||||
if not rec_dir.exists():
|
||||
print(f"Warning: recordings directory '{rec_dir}' does not exist yet.")
|
||||
if not (WEBUI_DIR / 'index.html').is_file():
|
||||
sys.exit(f"Error: browser UI not found at {WEBUI_DIR} — keep webui/ next to web.py.")
|
||||
|
||||
prune_orphan_analyses(analyses_dir, rec_dir)
|
||||
|
||||
|
||||
-1250
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>ISR Archive</title>
|
||||
<link rel="stylesheet" href="/ui/style.css">
|
||||
<script type="module" src="/ui/js/main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sr-announce" aria-live="polite" aria-atomic="true" class="sr"></div>
|
||||
<a href="#days" class="skip">Skip to recordings</a>
|
||||
|
||||
<header>
|
||||
<h1 id="page-title" tabindex="-1">ISR Archive</h1>
|
||||
<p id="subtitle">Loading…</p>
|
||||
<p id="storage-info"></p>
|
||||
<button id="refresh-btn">Refresh</button>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<details id="settings">
|
||||
<summary>Settings</summary>
|
||||
<div class="settings-grid">
|
||||
<label for="margin-input">Loudness margin</label>
|
||||
<input type="number" id="margin-input" min="1" max="60" step="1" aria-describedby="margin-hint">
|
||||
<span id="margin-hint" class="hint">dB above the background noise for a section to count as loud</span>
|
||||
|
||||
<label for="min-gap-input">Grace period</label>
|
||||
<input type="number" id="min-gap-input" min="0" max="300" step="0.5" aria-describedby="min-gap-hint">
|
||||
<span id="min-gap-hint" class="hint">seconds; loud sections closer than this are merged</span>
|
||||
|
||||
<label for="min-duration-input">Minimum length</label>
|
||||
<input type="number" id="min-duration-input" min="0" max="60" step="0.1" aria-describedby="min-duration-hint">
|
||||
<span id="min-duration-hint" class="hint">seconds; shorter loud sections are ignored</span>
|
||||
|
||||
<label for="preroll-input">Pre-roll</label>
|
||||
<input type="number" id="preroll-input" min="0" max="30" step="0.5" value="3" aria-describedby="preroll-hint">
|
||||
<span id="preroll-hint" class="hint">seconds played before each section starts</span>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="filter-bar" role="search" aria-label="Filter recordings">
|
||||
<label for="filter-name">Search</label>
|
||||
<input type="search" id="filter-name" placeholder="filename…" autocomplete="off">
|
||||
<label for="filter-from">From</label>
|
||||
<input type="date" id="filter-from">
|
||||
<label for="filter-to">To</label>
|
||||
<input type="date" id="filter-to">
|
||||
<button id="filter-clear">Clear filters</button>
|
||||
</div>
|
||||
|
||||
<div id="days" tabindex="-1"></div>
|
||||
<p id="empty" class="empty" hidden>No recordings found.</p>
|
||||
</main>
|
||||
|
||||
<section id="clip-bar" hidden aria-labelledby="clip-heading">
|
||||
<h2 id="clip-heading" class="sr">Clip player</h2>
|
||||
<div class="clip-row">
|
||||
<button id="clip-prev" aria-label="Previous section (J)" aria-keyshortcuts="J">Prev</button>
|
||||
<button id="clip-next" aria-label="Next section (K)" aria-keyshortcuts="K">Next</button>
|
||||
<p id="clip-label"></p>
|
||||
<button id="clip-context" aria-label="Open in file (O)" aria-keyshortcuts="O">Open in file</button>
|
||||
<button id="clip-close" aria-label="Close clip player">×</button>
|
||||
</div>
|
||||
<div class="clip-row">
|
||||
<audio id="clip-audio" controls preload="auto" aria-label="Section clip"></audio>
|
||||
<fieldset id="clip-adv">
|
||||
<legend>When a clip ends</legend>
|
||||
<label><input type="radio" name="clip-adv" value="off"> Stop</label>
|
||||
<label><input type="radio" name="clip-adv" value="all" checked> Next in time</label>
|
||||
<label><input type="radio" name="clip-adv" value="hl"> Next loudest</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,145 @@
|
||||
// Per-file loudness analysis: fetching (with a session cache) and the result
|
||||
// cell — a meta line, the section chips, a Re-analyse button.
|
||||
|
||||
import {fmtDur, fmtClock, fileStartEpoch, announce, chipToggle} from './util.js';
|
||||
import * as api from './api.js';
|
||||
import {fileByName, fileByIdx, fileSpan, sectionMap} from './store.js';
|
||||
import {analysisParams} from './settings.js';
|
||||
import {playFrom} from './clips.js';
|
||||
|
||||
// filename|margin|gap|minDur -> analysis result, so re-renders (filtering,
|
||||
// refresh) never refetch what this session already has
|
||||
const analysisCache = new Map();
|
||||
|
||||
export async function fetchAnalysis(filename, params, force = false) {
|
||||
const {margin, minGap, minDur} = params;
|
||||
const key = `${filename}|${margin}|${minGap}|${minDur}`;
|
||||
if (!force && analysisCache.has(key)) return analysisCache.get(key);
|
||||
const d = await api.analyze(filename, params);
|
||||
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 = fileByName(filename);
|
||||
if (f) f.cached_analysis = {margin, min_gap: minGap, min_duration: minDur};
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
export function forgetAnalyses(filename) {
|
||||
for (const k of [...analysisCache.keys()])
|
||||
if (k.startsWith(filename + '|')) analysisCache.delete(k);
|
||||
}
|
||||
|
||||
export function playFileSection(idx, si) {
|
||||
const f = fileByIdx(idx);
|
||||
if (!f) return;
|
||||
const epoch = fileStartEpoch(f.date);
|
||||
const queue = (sectionMap.get(idx) || []).map(s => ({
|
||||
filename: f.name, start: s.start, end: s.end, score: s.score,
|
||||
absStart: epoch != null ? epoch + s.start : null,
|
||||
}));
|
||||
playFrom(queue, si);
|
||||
}
|
||||
|
||||
export function analyseButton(idx, cell, text) {
|
||||
const f = fileByIdx(idx);
|
||||
const b = document.createElement('button');
|
||||
b.textContent = text;
|
||||
b.setAttribute('aria-label', `${text} ${f ? fileSpan(f) : ''}`);
|
||||
b.addEventListener('click', () =>
|
||||
analyse(idx, cell, b, {force: text === 'Re-analyse', speak: true}));
|
||||
return b;
|
||||
}
|
||||
|
||||
const para = (cls, text) => {
|
||||
const p = document.createElement('p');
|
||||
p.className = cls;
|
||||
p.textContent = text;
|
||||
return p;
|
||||
};
|
||||
|
||||
// btn is the button that started it (optional). It stays in place and
|
||||
// focusable while busy — disabling or replacing a focused button drops the
|
||||
// screen reader at the top of the page. speak: the user asked for this one, so
|
||||
// say how it went; background autoloads stay quiet.
|
||||
export async function analyse(idx, cell, btn, {force = false, speak = false} = {}) {
|
||||
const f = fileByIdx(idx);
|
||||
if (!f || cell.dataset.busy) return;
|
||||
cell.dataset.busy = '1';
|
||||
const span = fileSpan(f);
|
||||
if (btn) {
|
||||
btn.textContent = 'Analysing…';
|
||||
btn.setAttribute('aria-label', `Analysing ${span}`);
|
||||
btn.setAttribute('aria-disabled', 'true');
|
||||
} else {
|
||||
cell.replaceChildren(para('quiet', 'Analysing…'));
|
||||
}
|
||||
const params = analysisParams();
|
||||
let d;
|
||||
try {
|
||||
d = await fetchAnalysis(f.name, params, force);
|
||||
} catch (e) {
|
||||
d = {error: e.message};
|
||||
}
|
||||
delete cell.dataset.busy;
|
||||
// Focus inside the cell is about to be destroyed: move it to the new content
|
||||
const hadFocus = cell.contains(document.activeElement);
|
||||
|
||||
if (d.error) {
|
||||
const b = analyseButton(idx, cell, 'Analyse');
|
||||
cell.replaceChildren(para('quiet', `Error: ${d.error}`), b);
|
||||
if (hadFocus) b.focus();
|
||||
if (speak) announce(`Analysis of ${span} failed: ${d.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const secs = d.sections || [];
|
||||
const n = secs.length;
|
||||
const box = document.createElement('div');
|
||||
box.className = 'wbox';
|
||||
box.append(para('analysis-meta', `${n} loud section${n !== 1 ? 's' : ''}`
|
||||
+ ` · margin ${params.margin} dB · gap ${params.minGap} s · min ${params.minDur} s`
|
||||
+ (d.cached ? ' · cached' : '')));
|
||||
|
||||
if (n) {
|
||||
sectionMap.set(idx, secs);
|
||||
const epoch = fileStartEpoch(f.date);
|
||||
const chips = document.createElement('div');
|
||||
chips.className = 'chips';
|
||||
chips.setAttribute('role', 'group');
|
||||
chips.setAttribute('aria-label', 'Loud sections');
|
||||
secs.forEach((s, si) => {
|
||||
const c = document.createElement('button');
|
||||
c.className = 'chip';
|
||||
// Wall-clock time like the day highlights and the clip label; the
|
||||
// in-file offset only matters for cutting and lives in the tooltip
|
||||
c.textContent = (epoch != null ? fmtClock(epoch + s.start) : `${fmtDur(s.start)} – ${fmtDur(s.end)}`)
|
||||
+ (s.score != null ? ` · +${Math.round(s.score)} dB` : '');
|
||||
c.title = `${fmtDur(s.start)}–${fmtDur(s.end)} into ${f.name}`;
|
||||
c.addEventListener('click', () => playFileSection(idx, si));
|
||||
chips.appendChild(c);
|
||||
});
|
||||
const tog = chipToggle(chips, `${n} sections`);
|
||||
if (tog) box.append(tog);
|
||||
box.append(chips);
|
||||
} else {
|
||||
sectionMap.delete(idx);
|
||||
}
|
||||
box.append(analyseButton(idx, cell, 'Re-analyse'));
|
||||
box.lastChild.classList.add('reanalyse-btn');
|
||||
|
||||
cell.replaceChildren(box);
|
||||
// First control in reading order: the first chip, the chip toggle when the
|
||||
// list is collapsed, or Re-analyse when there are no sections
|
||||
if (hadFocus) box.querySelector('button').focus();
|
||||
if (speak) announce(n ? `${n} loud section${n !== 1 ? 's' : ''} in ${span}` : `No loud sections in ${span}`);
|
||||
}
|
||||
|
||||
// Run the deferred analyses of a freshly expanded day
|
||||
export function autoloadDayAnalyses(dayEl) {
|
||||
dayEl.querySelectorAll('td[data-autoload]').forEach(cell => {
|
||||
cell.removeAttribute('data-autoload');
|
||||
analyse(Number(cell.dataset.idx), cell, cell.querySelector('button'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Every URL the UI talks to, in one place. A leaf module.
|
||||
|
||||
const q = encodeURIComponent;
|
||||
|
||||
async function getJson(url) {
|
||||
return (await fetch(url)).json();
|
||||
}
|
||||
|
||||
export const getConfig = () => getJson('/api/config');
|
||||
export const getFiles = () => getJson('/api/files');
|
||||
export const getStatus = () => getJson('/api/status');
|
||||
export const getStorage = () => getJson('/api/storage');
|
||||
|
||||
export const analyze = (file, {margin, minGap, minDur}) =>
|
||||
getJson(`/api/analyze?file=${q(file)}&margin=${q(margin)}`
|
||||
+ `&min_gap=${q(minGap)}&min_duration=${q(minDur)}`);
|
||||
|
||||
export async function deleteFile(file) {
|
||||
const r = await fetch('/api/files/' + q(file), {method: 'DELETE'});
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
throw new Error(d.error || r.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
export const clipUrl = (file, start, end) =>
|
||||
`/api/clip?file=${q(file)}&start=${start.toFixed(1)}&end=${end.toFixed(1)}`;
|
||||
export const cutUrl = (file, start, end) => `/api/cut?file=${q(file)}&start=${start}&end=${end}`;
|
||||
export const streamUrl = file => '/stream/' + q(file);
|
||||
export const downloadUrl = file => '/download/' + q(file);
|
||||
@@ -0,0 +1,193 @@
|
||||
// The clip player: 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. The queue holds the active review list (one file's
|
||||
// sections, or a whole day's); J/K and Prev/Next step through it in time
|
||||
// order, U/I 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.
|
||||
//
|
||||
// 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 "next loudest" 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.
|
||||
|
||||
import {$, fmtDur, fmtClock, announce} from './util.js';
|
||||
import {fileIdxByName} from './store.js';
|
||||
import {advanceMode, getPreroll} from './settings.js';
|
||||
import {clipUrl} from './api.js';
|
||||
import {setCutFields, seekToSection} from './player.js';
|
||||
|
||||
let clipQueue = [];
|
||||
let clipCursor = -1;
|
||||
let scoreCursor = -1;
|
||||
// Where focus was when the bar opened, so closing it doesn't drop the user at
|
||||
// the top of the page
|
||||
let returnFocus = null;
|
||||
|
||||
export const hasQueue = () => clipQueue.length > 0;
|
||||
|
||||
// byScore: this play is part of a loudness walk (U/I or "next loudest"
|
||||
// auto-advance), so the position shown is scoreCursor — the rank in the
|
||||
// loudest-first ranking, labelled "by loudness" — not the time-order index.
|
||||
export function playClip(i, byScore) {
|
||||
if (i < 0 || i >= clipQueue.length) return;
|
||||
clipCursor = i;
|
||||
const c = clipQueue[i];
|
||||
const cs = Math.max(0, c.start - getPreroll());
|
||||
const ce = c.end + 1.5; // small tail after the section
|
||||
const a = $('clip-audio');
|
||||
a.src = clipUrl(c.filename, cs, ce);
|
||||
a.play().catch(() => {});
|
||||
// Label = wall-clock time of occurrence (absStart from the filename clock);
|
||||
// falls back to in-file offsets for non-standard filenames. The visible
|
||||
// label, the screen-reader announcement and the lock-screen title 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
|
||||
? `${fmtClock(c.absStart)} to ${fmtClock(c.absStart + (c.end - c.start))}`
|
||||
: `${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 = $('clip-label');
|
||||
label.textContent = text;
|
||||
label.title = `${c.filename} @ ${fmtDur(c.start)}–${fmtDur(c.end)}`;
|
||||
|
||||
const bar = $('clip-bar');
|
||||
const ae = document.activeElement;
|
||||
if (ae && ae !== document.body && !bar.contains(ae)) returnFocus = ae;
|
||||
bar.hidden = false;
|
||||
document.body.classList.add('clip-open');
|
||||
const rowIdx = fileIdxByName(c.filename);
|
||||
if (rowIdx != null) setCutFields(rowIdx, c.start, c.end);
|
||||
if ('mediaSession' in navigator && 'MediaMetadata' in window)
|
||||
navigator.mediaSession.metadata = new MediaMetadata({title: text, artist: 'ISR', album: c.filename});
|
||||
announce(text);
|
||||
}
|
||||
|
||||
export function hideClipBar() {
|
||||
const a = $('clip-audio');
|
||||
a.pause();
|
||||
a.removeAttribute('src');
|
||||
$('clip-bar').hidden = true;
|
||||
document.body.classList.remove('clip-open');
|
||||
if ('mediaSession' in navigator) navigator.mediaSession.metadata = null;
|
||||
clipQueue = [];
|
||||
clipCursor = -1;
|
||||
scoreCursor = -1;
|
||||
}
|
||||
|
||||
// Point the queue at a new section list without playing: nothing is selected
|
||||
// yet, and the next U/I must re-anchor here rather than resume a walk through
|
||||
// the previous queue.
|
||||
export function armQueue(sections) {
|
||||
clipQueue = sections;
|
||||
clipCursor = -1;
|
||||
scoreCursor = -1;
|
||||
}
|
||||
|
||||
// Explicit jump (chip click): play section i of sections; the next U/I
|
||||
// re-anchors on it
|
||||
export function playFrom(sections, i) {
|
||||
clipQueue = sections;
|
||||
scoreCursor = -1;
|
||||
playClip(i);
|
||||
}
|
||||
|
||||
// Highlight order = section indices sorted loudest-first. U/I (and "next
|
||||
// loudest" auto-advance) walk this ranking, so a review runs from the most to
|
||||
// the least interesting event until the user stops — no top-N cutoff.
|
||||
// Cached against the section array's identity: a day queue runs to thousands
|
||||
// of entries and U/I would otherwise re-sort all of them on every keypress.
|
||||
// Every arming path assigns a fresh array, so identity is a safe key.
|
||||
let scoreOrderCache = {secs: null, order: null};
|
||||
export function scoreOrder(secs) {
|
||||
if (scoreOrderCache.secs === secs) return scoreOrderCache.order;
|
||||
const order = secs.map((s, i) => ({i, score: s.score || 0}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(r => r.i);
|
||||
scoreOrderCache = {secs, order};
|
||||
return order;
|
||||
}
|
||||
|
||||
// Step a position within an ordering: pos + dir, or straight to the extreme in
|
||||
// that direction when jump. Returns null when the step runs off either end.
|
||||
// pos === -1 means "not anchored yet" — start at the first entry going forward,
|
||||
// or off the front going backward so the caller announces the boundary.
|
||||
// Shared by both U/I walks (clip queue and in-player); they differ only in what
|
||||
// the anchor is and what "play" means, and drifting apart is exactly how the
|
||||
// two would stop agreeing.
|
||||
export function nextInOrder(order, pos, dir, jump) {
|
||||
const next = jump ? (dir > 0 ? order.length - 1 : 0)
|
||||
: pos === -1 ? (dir > 0 ? 0 : -1) : pos + dir;
|
||||
return (next >= 0 && next < order.length) ? next : null;
|
||||
}
|
||||
|
||||
// Step the clip queue by dir (±1): in time order, or with byScore down/up
|
||||
// the loudest-first ranking (next = next quieter). With jump=true, go straight
|
||||
// to the extreme in that direction (first/last section, or loudest/quietest).
|
||||
export function stepClip(dir, byScore, jump) {
|
||||
if (!clipQueue.length) return;
|
||||
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 pos = scoreCursor !== -1 ? scoreCursor
|
||||
: clipCursor >= 0 ? order.indexOf(clipCursor)
|
||||
: -1;
|
||||
const next = nextInOrder(order, pos, dir, jump);
|
||||
if (next === null) { announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights'); return; }
|
||||
scoreCursor = next;
|
||||
playClip(order[next], true);
|
||||
return;
|
||||
}
|
||||
// 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);
|
||||
else announce(dir < 0 ? 'Beginning of sections' : 'End of sections');
|
||||
}
|
||||
|
||||
// "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.
|
||||
export function openClipInFile() {
|
||||
const c = clipQueue[clipCursor];
|
||||
if (!c) return;
|
||||
const idx = fileIdxByName(c.filename);
|
||||
if (idx == null) return;
|
||||
$('clip-audio').pause();
|
||||
const row = $('row-' + idx);
|
||||
if (row) {
|
||||
const wrap = row.closest('.table-wrap');
|
||||
if (wrap?.hidden) // file's day is collapsed: expand it
|
||||
$('daytgl-' + wrap.id.slice('daytbl-'.length))?.click();
|
||||
row.scrollIntoView({block: 'center'});
|
||||
}
|
||||
seekToSection(idx, c.start, c.end);
|
||||
}
|
||||
|
||||
function closeClipBar() {
|
||||
const back = returnFocus;
|
||||
returnFocus = null;
|
||||
hideClipBar();
|
||||
(back?.isConnected ? back : $('page-title')).focus();
|
||||
}
|
||||
|
||||
export function initClipBar() {
|
||||
$('clip-prev').addEventListener('click', () => stepClip(-1, false));
|
||||
$('clip-next').addEventListener('click', () => stepClip(1, false));
|
||||
$('clip-close').addEventListener('click', closeClipBar);
|
||||
$('clip-context').addEventListener('click', openClipInFile);
|
||||
$('clip-audio').addEventListener('ended', () => {
|
||||
const mode = advanceMode();
|
||||
if (mode !== 'off') stepClip(1, mode === 'hl');
|
||||
});
|
||||
$('clip-bar').addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); closeClipBar(); }
|
||||
});
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
// Day review: the chronological cross-file section list behind a day's
|
||||
// Highlights button.
|
||||
//
|
||||
// The user is blind and uses a screen reader — there is deliberately no day
|
||||
// timeline graphic. The panel is linear text and buttons: summary line ->
|
||||
// key-hint note -> chips toggle -> chips.
|
||||
|
||||
import {$, fmtDur, fmtClock, fileStartEpoch, announce, chipToggle} from './util.js';
|
||||
import {fetchAnalysis} from './analysis.js';
|
||||
import {armQueue, playFrom, hideClipBar} from './clips.js';
|
||||
import {analysisParams, hlParams} from './settings.js';
|
||||
|
||||
let dayActiveSections = [];
|
||||
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();
|
||||
|
||||
export function setHlExpanded(dayId, exp) {
|
||||
const btn = $('dayhln-' + dayId);
|
||||
if (!btn) return;
|
||||
btn.setAttribute('aria-expanded', exp);
|
||||
const arrow = btn.querySelector('.day-arrow');
|
||||
if (arrow) arrow.textContent = exp ? '▾ ' : '▸ ';
|
||||
}
|
||||
|
||||
// The status suffix inside the Highlights button (" · analysed", progress).
|
||||
// The button has no aria-label, so this text is part of its accessible name.
|
||||
const setHlStatus = (dayId, text) => {
|
||||
const el = $('dayhls-' + dayId);
|
||||
if (el) el.textContent = text;
|
||||
};
|
||||
|
||||
// Point the clip queue at a day's cross-file section list, from either the
|
||||
// fresh-build path or the re-open-a-kept-panel path. Coupled globals set in
|
||||
// two places is how the stale-_idx and diverging-label bugs happened.
|
||||
function armDayQueue(dayId, sections) {
|
||||
dayActiveSections = sections;
|
||||
dayActiveId = dayId;
|
||||
armQueue(sections);
|
||||
}
|
||||
|
||||
function jumpToDaySection(si) {
|
||||
if (si < 0 || si >= dayActiveSections.length) return;
|
||||
playFrom(dayActiveSections, si);
|
||||
}
|
||||
|
||||
// The day was collapsed: close its panel and drop its queue
|
||||
export function releaseDay(dayId) {
|
||||
const panel = $('dayhl-' + dayId);
|
||||
if (panel) panel.hidden = true;
|
||||
setHlExpanded(dayId, false);
|
||||
if (dayActiveId === dayId) {
|
||||
dayActiveSections = [];
|
||||
dayActiveId = null;
|
||||
hideClipBar();
|
||||
}
|
||||
}
|
||||
|
||||
// Highlights button: expand/collapse the panel; only (re)compute when it has
|
||||
// not been built yet this session or the analysis params changed
|
||||
export function toggleHighlights(dayId, files) {
|
||||
const panel = $('dayhl-' + dayId);
|
||||
const btn = $('dayhln-' + dayId);
|
||||
if (btn?.dataset.busy) return;
|
||||
if (!panel.hidden) { // collapse, keep the panel
|
||||
panel.hidden = true;
|
||||
setHlExpanded(dayId, false);
|
||||
return;
|
||||
}
|
||||
if (panel.dataset.loaded === hlParams()) { // re-open and re-arm J/K
|
||||
panel.hidden = false;
|
||||
setHlExpanded(dayId, true);
|
||||
armDayQueue(dayId, dayHlSections.get(dayId) || []);
|
||||
return;
|
||||
}
|
||||
dayHighlights(dayId, files);
|
||||
}
|
||||
|
||||
async function dayHighlights(dayId, files) {
|
||||
const panel = $('dayhl-' + dayId);
|
||||
const content = $('dayhlc-' + dayId);
|
||||
const btn = $('dayhln-' + dayId);
|
||||
const n = files.length;
|
||||
|
||||
panel.hidden = false;
|
||||
setHlExpanded(dayId, true);
|
||||
// Busy, not disabled: disabling the focused button would throw the screen
|
||||
// reader's focus to the top of the page
|
||||
btn.dataset.busy = '1';
|
||||
btn.setAttribute('aria-disabled', 'true');
|
||||
|
||||
const progBar = document.createElement('div'); progBar.className = 'prog-bar';
|
||||
const progFill = document.createElement('div'); progFill.className = 'prog-fill';
|
||||
progBar.setAttribute('aria-hidden', 'true');
|
||||
progBar.appendChild(progFill);
|
||||
const progFile = document.createElement('p'); progFile.className = 'prog-file';
|
||||
content.replaceChildren(progBar, progFile);
|
||||
|
||||
const params = analysisParams();
|
||||
const results = [];
|
||||
let nCached = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const f = files[i];
|
||||
progFile.textContent = `${i + 1} / ${n} — ${f.name}`;
|
||||
progFill.style.width = `${(i / n) * 100}%`;
|
||||
setHlStatus(dayId, ` · ${i + 1} of ${n}`);
|
||||
try {
|
||||
const d = await fetchAnalysis(f.name, params);
|
||||
if (!d.error) { results.push({f, data: d}); if (d.cached) nCached++; }
|
||||
} catch { /* counted as missing below */ }
|
||||
}
|
||||
delete btn.dataset.busy;
|
||||
btn.removeAttribute('aria-disabled');
|
||||
// Every file is now cached with the current params
|
||||
setHlStatus(dayId, results.length === n ? ' · analysed' : '');
|
||||
|
||||
// Map files onto the day. 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 sections = [];
|
||||
results.forEach(({f, data}) => {
|
||||
const fileDur = data.duration || f.duration || 0;
|
||||
if (fileDur <= 0) return;
|
||||
const startEpoch = fileStartEpoch(f.date);
|
||||
const fileStart = startEpoch != null ? startEpoch : f.mtime - fileDur;
|
||||
(data.sections || []).forEach(s => sections.push({
|
||||
filename: f.name, start: s.start, end: s.end, score: s.score,
|
||||
absStart: fileStart + s.start,
|
||||
}));
|
||||
});
|
||||
sections.sort((a, b) => a.absStart - b.absStart);
|
||||
// Arm the clip queue so J/K steps through the day immediately
|
||||
armDayQueue(dayId, sections);
|
||||
|
||||
const box = document.createElement('div');
|
||||
box.className = 'wbox';
|
||||
const nRes = results.length, nSec = sections.length;
|
||||
const missing = n - nRes;
|
||||
// Totals first, then the key hint, then the chips: linear reading order
|
||||
const summary = document.createElement('p');
|
||||
summary.className = 'quiet';
|
||||
summary.textContent = `${nRes} recording${nRes !== 1 ? 's' : ''} analysed`
|
||||
+ (nCached ? ` (${nCached} from cache)` : '')
|
||||
+ (missing ? `, ${missing} failed` : '')
|
||||
+ ` · ${nSec} loud section${nSec !== 1 ? 's' : ''}`;
|
||||
box.appendChild(summary);
|
||||
|
||||
if (nSec) {
|
||||
const note = document.createElement('p');
|
||||
note.className = 'quiet';
|
||||
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);
|
||||
|
||||
const MAX_DAY_CHIPS = 50;
|
||||
// When there are too many sections to show them all, show the ones most
|
||||
// worth reviewing: the top MAX_DAY_CHIPS by score, loudest first.
|
||||
let chipList = sections.map((sec, si) => ({sec, si}));
|
||||
const truncated = chipList.length > MAX_DAY_CHIPS;
|
||||
if (truncated) {
|
||||
chipList = chipList
|
||||
.sort((a, b) => (b.sec.score || 0) - (a.sec.score || 0))
|
||||
.slice(0, MAX_DAY_CHIPS);
|
||||
}
|
||||
const chips = document.createElement('div');
|
||||
chips.className = 'chips';
|
||||
chips.setAttribute('role', 'group');
|
||||
chips.setAttribute('aria-label', 'Day loud sections');
|
||||
chipList.forEach(({sec, si}) => {
|
||||
const c = document.createElement('button');
|
||||
c.className = 'chip';
|
||||
c.title = sec.filename + ' @ ' + fmtDur(sec.start);
|
||||
c.textContent = fmtClock(sec.absStart) + (sec.score != null ? ` · +${Math.round(sec.score)} dB` : '');
|
||||
c.addEventListener('click', () => jumpToDaySection(si));
|
||||
chips.appendChild(c);
|
||||
});
|
||||
const tog = chipToggle(chips, truncated
|
||||
? `Top ${chipList.length} sections by loudness`
|
||||
: `${chipList.length} sections`);
|
||||
if (tog) box.appendChild(tog);
|
||||
box.appendChild(chips);
|
||||
}
|
||||
|
||||
content.replaceChildren(box);
|
||||
panel.dataset.loaded = hlParams();
|
||||
dayHlSections.set(dayId, sections);
|
||||
announce(nSec
|
||||
? `${nSec} loud section${nSec !== 1 ? 's' : ''} across ${nRes} recording${nRes !== 1 ? 's' : ''}. K plays the first, I the loudest.`
|
||||
: `No loud sections in ${nRes} recording${nRes !== 1 ? 's' : ''}.`);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// The recordings listing: one collapsible section per day, a table of
|
||||
// recordings inside, plus loading, filtering, deleting and status polling.
|
||||
|
||||
import {$, esc, fmtDur, fmtDurWords, fmtSize, pad, parseTime, dayLabel, announce} from './util.js';
|
||||
import * as api from './api.js';
|
||||
import {allFiles, setFiles, removeFile, sectionMap, fileSpan, isAnalysable} from './store.js';
|
||||
import {cachedParamsMatch, hlParams} from './settings.js';
|
||||
import {analyse, analyseButton, autoloadDayAnalyses, forgetAnalyses} from './analysis.js';
|
||||
import {togglePlayer, closePlayer, forgetPlayer, toggleCut} from './player.js';
|
||||
import {toggleHighlights, releaseDay} from './day.js';
|
||||
|
||||
// dayId -> boolean, persists expanded state across re-renders
|
||||
const dayExpanded = new Map();
|
||||
|
||||
function groupByDay(files) {
|
||||
const map = new Map();
|
||||
files.forEach(f => {
|
||||
const day = f.date.slice(0, 10);
|
||||
if (!map.has(day)) map.set(day, []);
|
||||
map.get(day).push(f);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
// Local date, not toISOString() — f.date is the recording start in local
|
||||
// time, so a UTC "today" leaves the current day collapsed for part of every
|
||||
// day in any non-UTC timezone (00:00–02:00 in UTC+2, all evening in UTC−5).
|
||||
const localToday = () => {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||
};
|
||||
|
||||
// Everything a day's markup is built from. A day whose signature is unchanged
|
||||
// keeps its DOM across reloads — open players, analyses, the highlights panel
|
||||
// and, above all, the screen reader's position in it.
|
||||
const daySignature = dayFiles => hlParams() + '#'
|
||||
+ dayFiles.map(f => `${f.name}:${f.recording ? 1 : 0}:${f.duration ?? ''}`).join('|');
|
||||
|
||||
function buildRow(f, tbody, expanded) {
|
||||
const i = f._idx;
|
||||
const isRec = !!f.recording;
|
||||
const span = esc(fileSpan(f));
|
||||
const off = isRec ? ' disabled' : '';
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'data-row';
|
||||
tr.id = 'row-' + i;
|
||||
tr.dataset.idx = i;
|
||||
// Time is the row header, so table navigation names the row by its span.
|
||||
// The player opens inside its own cell; the options cell right after it is
|
||||
// always there, and Cut expands its fields inside it. Format, size and
|
||||
// filename come last: rarely needed, and the filename reads as a string of
|
||||
// digits.
|
||||
tr.innerHTML = `
|
||||
<th scope="row" class="when">${span} <span id="rec-${i}" class="badge-rec"${isRec ? '' : ' hidden'}><span aria-hidden="true">● </span>Recording</span></th>
|
||||
<td class="nowrap">${fmtDur(f.duration)}</td>
|
||||
<td id="wave-${i}" class="loudness">${isAnalysable(f) ? '' : '<span class="muted">Not available for this format</span>'}</td>
|
||||
<td class="player">
|
||||
<button id="pbtn-${i}" aria-expanded="false" aria-controls="aud-${i}" aria-label="Play ${span}">Play</button>
|
||||
<audio id="aud-${i}" controls preload="none" aria-label="Recording ${span}" hidden></audio>
|
||||
</td>
|
||||
<td class="options">
|
||||
<div class="actions">
|
||||
<a class="dl" href="${api.downloadUrl(f.name)}" aria-label="Download ${span}">Download</a>
|
||||
<button id="cutbtn-${i}" class="cut" aria-expanded="false" aria-controls="cut-${i}" aria-label="Cut ${span}"${off}><span
|
||||
class="day-arrow" aria-hidden="true">▸ </span>Cut</button>
|
||||
<button id="delbtn-${i}" class="del" aria-label="Delete ${span}"${off}>Delete</button>
|
||||
</div>
|
||||
<div class="cut-panel" id="cut-${i}" role="group" aria-label="Cut" hidden>
|
||||
<label class="cut-field">Start
|
||||
<input type="text" id="cut-start-${i}" class="cut-time" placeholder="m:ss or h:mm:ss" autocomplete="off">
|
||||
</label>
|
||||
<label class="cut-field">End
|
||||
<input type="text" id="cut-end-${i}" class="cut-time" placeholder="m:ss or h:mm:ss" autocomplete="off">
|
||||
</label>
|
||||
<button id="cut-dl-${i}" class="cut">Download cut</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="file"><span class="badge badge-${esc(f.ext)}">${esc(f.ext.toUpperCase())}</span> ${fmtSize(f.size)} · <span class="fn">${esc(f.name)}</span></td>`;
|
||||
tbody.append(tr);
|
||||
|
||||
if (isAnalysable(f)) {
|
||||
const cell = tr.querySelector('#wave-' + i);
|
||||
const abtn = analyseButton(i, cell, 'Analyse');
|
||||
cell.appendChild(abtn);
|
||||
if (isRec) {
|
||||
abtn.disabled = true;
|
||||
} else if (cachedParamsMatch(f.cached_analysis)) {
|
||||
if (expanded) analyse(i, cell, abtn);
|
||||
// Collapsed day: defer the fetch until the day is opened
|
||||
else { cell.dataset.autoload = '1'; cell.dataset.idx = i; }
|
||||
}
|
||||
}
|
||||
|
||||
tr.querySelector('#pbtn-' + i).addEventListener('click', () => togglePlayer(i));
|
||||
if (!isRec) {
|
||||
tr.querySelector('#cutbtn-' + i).addEventListener('click', () => toggleCut(i));
|
||||
tr.querySelector('#delbtn-' + i).addEventListener('click', () => deleteFile(f));
|
||||
tr.querySelector('#cut-dl-' + i).addEventListener('click', () => {
|
||||
const start = parseTime($('cut-start-' + i).value);
|
||||
const end = parseTime($('cut-end-' + i).value);
|
||||
if (start === null || end === null) { alert('Enter start and end times, e.g. 1:30 or 0:01:30'); return; }
|
||||
if (start >= end) { alert('Start must be before end'); return; }
|
||||
window.location.href = api.cutUrl(f.name, start, end);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildDay(day, dayFiles) {
|
||||
const dayId = 'day-' + day.replace(/-/g, '');
|
||||
if (!dayExpanded.has(dayId)) dayExpanded.set(dayId, day === localToday());
|
||||
const expanded = dayExpanded.get(dayId);
|
||||
const label = esc(dayLabel(day));
|
||||
|
||||
const totalSize = dayFiles.reduce((a, f) => a + f.size, 0);
|
||||
const totalDur = dayFiles.reduce((a, f) => a + (f.duration || 0), 0);
|
||||
const hlFiles = dayFiles.filter(f => isAnalysable(f) && !f.recording);
|
||||
// Every analysable file already has a cached analysis for the current
|
||||
// params -> the day's highlights are available without recomputing
|
||||
const analysed = hlFiles.length > 0 && hlFiles.every(f => cachedParamsMatch(f.cached_analysis));
|
||||
const meta = `${dayFiles.length} recording${dayFiles.length !== 1 ? 's' : ''}`
|
||||
+ (totalDur > 0 ? ' · ' + fmtDurWords(totalDur) : '') + ' · ' + fmtSize(totalSize);
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'day-section';
|
||||
el.id = 'daysec-' + dayId;
|
||||
el.dataset.day = day;
|
||||
// The Highlights button has no aria-label on purpose: its content is its
|
||||
// name, so the " · analysed" / progress suffix is heard without mirroring.
|
||||
el.innerHTML = `
|
||||
<div class="day-heading-bar${expanded ? ' open' : ''}">
|
||||
<h2 class="day-heading">
|
||||
<button class="day-toggle" id="daytgl-${dayId}" aria-expanded="${expanded}" aria-controls="daytbl-${dayId}"><span
|
||||
class="day-arrow" aria-hidden="true">${expanded ? '▾ ' : '▸ '}</span>${label}<span
|
||||
class="day-meta"><span class="sr">, </span>${esc(meta)}</span></button>
|
||||
</h2>
|
||||
${hlFiles.length ? `<button class="day-hl" id="dayhln-${dayId}" aria-expanded="false" aria-controls="dayhl-${dayId}"><span
|
||||
class="day-arrow" aria-hidden="true">▸ </span>Highlights<span class="sr"> for ${label}</span><span
|
||||
class="day-hl-status" id="dayhls-${dayId}">${analysed ? ' · analysed' : ''}</span></button>` : ''}
|
||||
</div>
|
||||
<div class="day-hl-container" id="dayhl-${dayId}" hidden><div id="dayhlc-${dayId}"></div></div>
|
||||
<div class="table-wrap" id="daytbl-${dayId}"${expanded ? '' : ' hidden'}>
|
||||
<table class="day-table">
|
||||
<caption class="sr">Recordings, ${label}</caption>
|
||||
<thead><tr>
|
||||
<th scope="col">Time</th>
|
||||
<th scope="col">Length</th>
|
||||
<th scope="col">Loudness</th>
|
||||
<th scope="col">Player</th>
|
||||
<th scope="col">Options</th>
|
||||
<th scope="col">File</th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
const tbody = el.querySelector('tbody');
|
||||
dayFiles.forEach(f => buildRow(f, tbody, expanded));
|
||||
|
||||
const headBar = el.querySelector('.day-heading-bar');
|
||||
const wrap = el.querySelector('.table-wrap');
|
||||
const tgl = el.querySelector('.day-toggle');
|
||||
tgl.addEventListener('click', () => {
|
||||
const nowExp = !dayExpanded.get(dayId);
|
||||
dayExpanded.set(dayId, nowExp);
|
||||
tgl.setAttribute('aria-expanded', nowExp);
|
||||
tgl.querySelector('.day-arrow').textContent = nowExp ? '▾ ' : '▸ ';
|
||||
headBar.classList.toggle('open', nowExp);
|
||||
wrap.hidden = !nowExp;
|
||||
if (nowExp) {
|
||||
autoloadDayAnalyses(wrap);
|
||||
} else {
|
||||
dayFiles.forEach(f => closePlayer(f._idx));
|
||||
releaseDay(dayId);
|
||||
}
|
||||
});
|
||||
el.querySelector('.day-hl')?.addEventListener('click', () => toggleHighlights(dayId, hlFiles));
|
||||
return el;
|
||||
}
|
||||
|
||||
function discardDay(el) {
|
||||
el.querySelectorAll('audio').forEach(a => a.pause());
|
||||
el.querySelectorAll('tr.data-row').forEach(tr => {
|
||||
const i = Number(tr.dataset.idx);
|
||||
sectionMap.delete(i);
|
||||
forgetPlayer(i);
|
||||
});
|
||||
el.remove();
|
||||
}
|
||||
|
||||
// Re-render the listing, rebuilding only the days whose content changed. A
|
||||
// rollover touches today alone, so the rest of the page — and whatever the
|
||||
// screen reader is reading — stays put. Focus inside a rebuilt day is put back
|
||||
// on the element with the same id (ids are stable per filename, see store.js).
|
||||
function renderFiles(files) {
|
||||
const container = $('days');
|
||||
const focusId = document.activeElement?.id || null;
|
||||
|
||||
const total = allFiles.length, visible = files.length;
|
||||
$('subtitle').textContent = total === visible
|
||||
? `${total} recording${total !== 1 ? 's' : ''}`
|
||||
: `${visible} of ${total} recording${total !== 1 ? 's' : ''} shown`;
|
||||
$('empty').hidden = visible > 0;
|
||||
|
||||
const old = new Map([...container.children].map(el => [el.dataset.day, el]));
|
||||
let prev = null;
|
||||
groupByDay(files).forEach((dayFiles, day) => {
|
||||
const sig = daySignature(dayFiles);
|
||||
let el = old.get(day);
|
||||
old.delete(day);
|
||||
if (!el || el.dataset.sig !== sig) {
|
||||
if (el) discardDay(el);
|
||||
el = buildDay(day, dayFiles);
|
||||
el.dataset.sig = sig;
|
||||
}
|
||||
// Only move nodes that are out of place: moving one blurs focus inside it
|
||||
const ref = prev ? prev.nextSibling : container.firstChild;
|
||||
if (el !== ref) container.insertBefore(el, ref);
|
||||
prev = el;
|
||||
});
|
||||
old.forEach(discardDay);
|
||||
|
||||
if (focusId && document.activeElement?.id !== focusId) $(focusId)?.focus();
|
||||
}
|
||||
|
||||
export function applyFilters({speak = false} = {}) {
|
||||
const nameQ = $('filter-name').value.toLowerCase().trim();
|
||||
const fromD = $('filter-from').value;
|
||||
const toD = $('filter-to').value;
|
||||
renderFiles(allFiles.filter(f => {
|
||||
if (nameQ && !f.name.toLowerCase().includes(nameQ)) return false;
|
||||
if (fromD && f.date < fromD + ' 00:00:00') return false;
|
||||
if (toD && f.date > toD + ' 23:59:59') return false;
|
||||
return true;
|
||||
}));
|
||||
if (speak) announce($('subtitle').textContent);
|
||||
}
|
||||
|
||||
async function updateStorage() {
|
||||
try {
|
||||
const s = await api.getStorage();
|
||||
const used = allFiles.reduce((a, f) => a + f.size, 0);
|
||||
let txt = fmtSize(used) + ' used';
|
||||
if (s.disk_free != null) txt += ' · ' + fmtSize(s.disk_free) + ' free';
|
||||
if (s.disk_total != null) txt += ' of ' + fmtSize(s.disk_total);
|
||||
$('storage-info').textContent = txt;
|
||||
} catch { /* keep the last value */ }
|
||||
}
|
||||
|
||||
let loading = false;
|
||||
// speak: the user pressed Refresh, so say what came back. Poll-driven reloads
|
||||
// stay silent — a rollover every hour must not interrupt whatever is being read.
|
||||
export async function load({speak = false} = {}) {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
const btn = $('refresh-btn');
|
||||
btn.setAttribute('aria-disabled', 'true');
|
||||
try {
|
||||
setFiles(await api.getFiles());
|
||||
updateStorage();
|
||||
applyFilters();
|
||||
if (speak) announce('Refreshed. ' + $('subtitle').textContent);
|
||||
} catch {
|
||||
$('subtitle').textContent = 'Error loading recordings';
|
||||
if (speak) announce('Could not load recordings');
|
||||
} finally {
|
||||
loading = false;
|
||||
btn.removeAttribute('aria-disabled');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFile(f) {
|
||||
const span = fileSpan(f);
|
||||
if (!confirm(`Delete the recording ${span} (${f.name})?\nThis cannot be undone.`)) return;
|
||||
const btn = $('delbtn-' + f._idx);
|
||||
if (btn) btn.setAttribute('aria-disabled', 'true');
|
||||
// Where focus goes once the row is gone: the next recording's Play button,
|
||||
// else the previous one's, else the day's heading button
|
||||
const row = $('row-' + f._idx);
|
||||
const rows = [...row.parentNode.querySelectorAll('tr.data-row')];
|
||||
const k = rows.indexOf(row);
|
||||
const near = rows[k + 1] || rows[k - 1];
|
||||
const dayToggle = row.closest('.day-section').querySelector('.day-toggle');
|
||||
try {
|
||||
await api.deleteFile(f.name);
|
||||
} catch (e) {
|
||||
if (btn) btn.removeAttribute('aria-disabled');
|
||||
alert('Delete failed: ' + e.message);
|
||||
return;
|
||||
}
|
||||
removeFile(f.name);
|
||||
forgetAnalyses(f.name);
|
||||
applyFilters();
|
||||
updateStorage();
|
||||
// The day was rebuilt without the row; look the targets up again by id
|
||||
const target = (near && $('pbtn-' + near.dataset.idx)) || $(dayToggle.id) || $('page-title');
|
||||
target.focus();
|
||||
announce(`Deleted ${span}`);
|
||||
}
|
||||
|
||||
// Poll recording status every 5 s to update REC badges
|
||||
let lastActiveKey = null;
|
||||
export async function pollStatus() {
|
||||
try {
|
||||
const s = await api.getStatus();
|
||||
const active = new Set(s.active || []);
|
||||
// Rows not currently rendered (filtered out) have no badge element, so
|
||||
// getElementById skips them.
|
||||
allFiles.forEach(f => {
|
||||
const badge = $('rec-' + f._idx);
|
||||
if (badge) badge.hidden = !active.has(f.name);
|
||||
});
|
||||
// Active set changed (recording started/stopped or rolled over to a new
|
||||
// split file): refresh the list so new files appear — but never yank the
|
||||
// DOM out from under an in-progress playback.
|
||||
const key = [...active].sort().join('|');
|
||||
if (lastActiveKey === null) { lastActiveKey = key; return; }
|
||||
if (key !== lastActiveKey) {
|
||||
const playing = [...document.querySelectorAll('audio')].some(a => !a.paused && !a.ended);
|
||||
if (!playing) { lastActiveKey = key; load(); }
|
||||
}
|
||||
} catch { /* next poll */ }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Entry point: wire the page up and start.
|
||||
//
|
||||
// Modules import only from modules earlier in this list:
|
||||
// util, api, store, settings, player, clips, analysis, day, list, nav, main
|
||||
// Keep it that way. A module that needs something from a later one reaches it
|
||||
// through the DOM (openClipInFile clicks the day toggle) rather than an import.
|
||||
|
||||
import {$} from './util.js';
|
||||
import {getConfig} from './api.js';
|
||||
import {initSettings} from './settings.js';
|
||||
import {initClipBar} from './clips.js';
|
||||
import {initNav} from './nav.js';
|
||||
import {load, applyFilters, pollStatus} from './list.js';
|
||||
|
||||
initClipBar();
|
||||
initNav();
|
||||
|
||||
$('refresh-btn').addEventListener('click', () => load({speak: true}));
|
||||
|
||||
let filterDebounce;
|
||||
$('filter-name').addEventListener('input', () => {
|
||||
clearTimeout(filterDebounce);
|
||||
filterDebounce = setTimeout(() => applyFilters({speak: true}), 400);
|
||||
});
|
||||
$('filter-from').addEventListener('change', () => applyFilters({speak: true}));
|
||||
$('filter-to').addEventListener('change', () => applyFilters({speak: true}));
|
||||
$('filter-clear').addEventListener('click', () => {
|
||||
$('filter-name').value = '';
|
||||
$('filter-from').value = '';
|
||||
$('filter-to').value = '';
|
||||
applyFilters({speak: true});
|
||||
});
|
||||
|
||||
getConfig().catch(() => ({})).then(initSettings)
|
||||
.finally(() => load().then(() => setInterval(pollStatus, 5000)));
|
||||
@@ -0,0 +1,89 @@
|
||||
// Section navigation from the keyboard (J/K/U/I/O) and from media keys —
|
||||
// headset buttons, AirPods, the lock screen — via the Media Session API.
|
||||
//
|
||||
// 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. Holding Shift jumps to
|
||||
// the extreme in that direction: Shift+J/K = first/last section in time,
|
||||
// Shift+U/I = loudest/quietest. The clip queue takes priority; with no queue
|
||||
// armed, the keys step the playhead of the open full-file player instead.
|
||||
|
||||
import {fmtDur, fmtClock, fileStartEpoch, announce} from './util.js';
|
||||
import {hasQueue, stepClip, openClipInFile, scoreOrder, nextInOrder} from './clips.js';
|
||||
import {activePlayerIdx, setCutFields} from './player.js';
|
||||
import {sectionMap, fileByIdx} from './store.js';
|
||||
import {getPreroll} from './settings.js';
|
||||
|
||||
export function navigate(dir, byScore, jump) {
|
||||
if (hasQueue()) { stepClip(dir, byScore, jump); return; }
|
||||
|
||||
// Per-file in-player navigation (full-file listening, no clip queue)
|
||||
const idx = activePlayerIdx;
|
||||
if (idx === null) return;
|
||||
const all = sectionMap.get(idx) || [];
|
||||
const audio = document.getElementById('aud-' + idx);
|
||||
if (!all.length || !audio) return;
|
||||
const preroll = getPreroll();
|
||||
const cur = audio.currentTime;
|
||||
const epoch = fileStartEpoch(fileByIdx(idx)?.date);
|
||||
|
||||
const jumpTo = (s, i, n, word) => {
|
||||
audio.currentTime = Math.max(0, s.start - preroll);
|
||||
setCutFields(idx, s.start, s.end);
|
||||
const when = epoch != null
|
||||
? `${fmtClock(epoch + s.start)} to ${fmtClock(epoch + s.end)}`
|
||||
: `${fmtDur(s.start)} to ${fmtDur(s.end)}`;
|
||||
announce(`${word} ${i + 1} of ${n}: ${when}`);
|
||||
};
|
||||
|
||||
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.
|
||||
// Shift jumps straight to the loudest (Shift+U) or quietest (Shift+I).
|
||||
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 = nextInOrder(order, pos, dir, jump);
|
||||
if (next === null) { announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights'); return; }
|
||||
jumpTo(all[order[next]], next, order.length, 'Highlight');
|
||||
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) {
|
||||
for (let i = all.length - 1; i >= 0; i--)
|
||||
if (all[i].start < cur - 1) { jumpTo(all[i], i, all.length, 'Section'); return; }
|
||||
announce('Beginning of sections');
|
||||
} else {
|
||||
for (let i = 0; i < all.length; i++)
|
||||
if (all[i].start > cur + preroll) { jumpTo(all[i], i, all.length, 'Section'); return; }
|
||||
announce('End of sections');
|
||||
}
|
||||
}
|
||||
|
||||
export function initNav() {
|
||||
document.addEventListener('keydown', e => {
|
||||
const t = e.target;
|
||||
if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable) return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const key = e.key.toLowerCase();
|
||||
if (!['j', 'k', 'u', 'i', 'o'].includes(key)) return;
|
||||
e.preventDefault();
|
||||
if (key === 'o') { openClipInFile(); return; }
|
||||
navigate(key === 'j' || key === 'u' ? -1 : 1, key === 'u' || key === 'i', e.shiftKey);
|
||||
});
|
||||
|
||||
// Previous/next track = J/K. Works from AirPods, the iOS lock screen and
|
||||
// Control Center, and the media keys on a Windows keyboard.
|
||||
if ('mediaSession' in navigator) {
|
||||
for (const [action, dir] of [['previoustrack', -1], ['nexttrack', 1]]) {
|
||||
try { navigator.mediaSession.setActionHandler(action, () => navigate(dir, false, false)); }
|
||||
catch { /* action not supported here */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// The per-row full-file player and its cut fields. Section review normally
|
||||
// goes through clips.js; this is for listening around a section ("Open in
|
||||
// file") or to a whole recording.
|
||||
|
||||
import {$, fmtDur} from './util.js';
|
||||
import {fileByIdx, fileSpan} from './store.js';
|
||||
import {streamUrl} from './api.js';
|
||||
import {getPreroll} from './settings.js';
|
||||
|
||||
// Row of the full-file player opened last. The J/K/U/I fallback steps through
|
||||
// its sections when no clip queue is armed. Live binding — read, don't assign.
|
||||
export let activePlayerIdx = null;
|
||||
|
||||
export function forgetPlayer(idx) {
|
||||
if (activePlayerIdx === idx) activePlayerIdx = null;
|
||||
}
|
||||
|
||||
export function setCutFields(idx, startSec, endSec) {
|
||||
const startEl = $('cut-start-' + idx);
|
||||
const endEl = $('cut-end-' + idx);
|
||||
if (startEl) startEl.value = fmtDur(startSec);
|
||||
if (endEl && endSec != null) endEl.value = fmtDur(endSec);
|
||||
}
|
||||
|
||||
const isOpen = idx => $('pbtn-' + idx)?.getAttribute('aria-expanded') === 'true';
|
||||
|
||||
function setPlayButton(idx, open) {
|
||||
const btn = $('pbtn-' + idx);
|
||||
const f = fileByIdx(idx);
|
||||
if (!btn || !f) return;
|
||||
btn.setAttribute('aria-expanded', open);
|
||||
btn.textContent = open ? 'Hide' : 'Play';
|
||||
btn.setAttribute('aria-label', (open ? 'Hide player for ' : 'Play ') + fileSpan(f));
|
||||
}
|
||||
|
||||
// The player lives in its own table cell: the Play button stays, and the
|
||||
// audio element appears right after it
|
||||
export function closePlayer(idx) {
|
||||
const audio = $('aud-' + idx);
|
||||
if (!audio || audio.hidden) return;
|
||||
audio.pause();
|
||||
audio.hidden = true;
|
||||
setPlayButton(idx, false);
|
||||
}
|
||||
|
||||
export function togglePlayer(idx) {
|
||||
if (isOpen(idx)) { closePlayer(idx); return; }
|
||||
const f = fileByIdx(idx);
|
||||
const audio = $('aud-' + idx);
|
||||
if (!f || !audio) return;
|
||||
if (!audio.dataset.srcSet) {
|
||||
audio.preload = 'auto';
|
||||
audio.src = streamUrl(f.name);
|
||||
audio.load();
|
||||
audio.dataset.srcSet = '1';
|
||||
} else if (!$('rec-' + idx)?.hidden) {
|
||||
// Still recording: re-fetch so duration and seek range cover the audio
|
||||
// captured since the source was last loaded
|
||||
audio.load();
|
||||
}
|
||||
activePlayerIdx = idx;
|
||||
audio.hidden = false;
|
||||
setPlayButton(idx, true);
|
||||
audio.focus();
|
||||
}
|
||||
|
||||
export function seekToSection(idx, startSec, endSec) {
|
||||
if (!isOpen(idx)) togglePlayer(idx);
|
||||
activePlayerIdx = idx;
|
||||
const audio = $('aud-' + idx);
|
||||
if (!audio) return;
|
||||
audio.preload = 'auto';
|
||||
const seekTo = Math.max(0, startSec - getPreroll());
|
||||
const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); };
|
||||
if (audio.readyState >= 1) doSeek();
|
||||
else audio.addEventListener('loadedmetadata', doSeek, {once: true});
|
||||
setCutFields(idx, startSec, endSec);
|
||||
}
|
||||
|
||||
// Cut button: show/hide the start/end fields inside the options cell. Opening
|
||||
// puts focus in Start. Clip playback pre-fills the fields either way.
|
||||
export function toggleCut(idx) {
|
||||
const btn = $('cutbtn-' + idx);
|
||||
const panel = $('cut-' + idx);
|
||||
if (!btn || !panel) return;
|
||||
const open = panel.hidden;
|
||||
panel.hidden = !open;
|
||||
btn.setAttribute('aria-expanded', open);
|
||||
btn.querySelector('.day-arrow').textContent = open ? '▾ ' : '▸ ';
|
||||
if (open) $('cut-start-' + idx)?.focus();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// The settings panel: analysis params, pre-roll and the auto-advance mode.
|
||||
|
||||
import {$, remember} from './util.js';
|
||||
|
||||
// The three analysis controls, read straight from the DOM. Deliberately no
|
||||
// hardcoded fallbacks: the inputs are seeded from /api/config at startup, so
|
||||
// web.py's MARGIN_DB / MIN_GAP_SECONDS / MIN_DURATION_SECONDS stay the single
|
||||
// source of truth. An empty input sends an empty param, which _qs_float()
|
||||
// falls back to the server default for.
|
||||
export const analysisParams = () => ({
|
||||
margin: $('margin-input').value,
|
||||
minGap: $('min-gap-input').value,
|
||||
minDur: $('min-duration-input').value,
|
||||
});
|
||||
// Same values as one string; a highlights panel built with other values is
|
||||
// stale and gets recomputed on the next expand
|
||||
export const hlParams = () => Object.values(analysisParams()).join('|');
|
||||
|
||||
export const getPreroll = () => {
|
||||
const v = parseFloat($('preroll-input').value);
|
||||
return isNaN(v) || v < 0 ? 0 : v;
|
||||
};
|
||||
|
||||
export const advanceMode = () =>
|
||||
document.querySelector('input[name="clip-adv"]:checked')?.value || 'off';
|
||||
|
||||
// Does a server-side cached analysis match the current control values?
|
||||
// Auto-loading on a mismatch would silently recompute every file.
|
||||
export function cachedParamsMatch(ca) {
|
||||
const p = analysisParams();
|
||||
return ca != null
|
||||
&& Number(ca.margin) === parseFloat(p.margin)
|
||||
&& Number(ca.min_gap) === parseFloat(p.minGap)
|
||||
&& Number(ca.min_duration) === parseFloat(p.minDur);
|
||||
}
|
||||
|
||||
// Seed the analysis params from /api/config, and restore the per-browser
|
||||
// conveniences. Margin/gap/min-duration are not remembered client-side: a
|
||||
// stale browser value would quietly disagree with the server's defaults.
|
||||
export function initSettings(cfg) {
|
||||
if (cfg.margin != null) $('margin-input').value = cfg.margin;
|
||||
if (cfg.min_gap != null) $('min-gap-input').value = cfg.min_gap;
|
||||
if (cfg.min_duration != null) $('min-duration-input').value = cfg.min_duration;
|
||||
|
||||
const pre = remember.get('preroll');
|
||||
if (pre != null) $('preroll-input').value = pre;
|
||||
$('preroll-input').addEventListener('change', e => remember.set('preroll', e.target.value));
|
||||
|
||||
const adv = remember.get('advance');
|
||||
const radio = adv && document.querySelector(`input[name="clip-adv"][value="${adv}"]`);
|
||||
if (radio) radio.checked = true;
|
||||
document.querySelectorAll('input[name="clip-adv"]').forEach(r =>
|
||||
r.addEventListener('change', () => remember.set('advance', r.value)));
|
||||
|
||||
const panel = $('settings');
|
||||
panel.open = remember.get('settingsOpen') === '1';
|
||||
panel.addEventListener('toggle', () => remember.set('settingsOpen', panel.open ? '1' : '0'));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// The file listing and what the UI knows about each file. A leaf apart from
|
||||
// util.js.
|
||||
|
||||
import {fileStartEpoch, fmtHM} from './util.js';
|
||||
|
||||
// Full listing from /api/files. Reassigned on every load; importers see the
|
||||
// new array through the live binding.
|
||||
export let allFiles = [];
|
||||
|
||||
// _idx is per filename and stable for the whole session: assigned the first
|
||||
// time a name is seen and never reused. DOM ids (row-, pbtn-, aud-, …) are
|
||||
// built from it, so they survive reloads — that is what lets a re-rendered day
|
||||
// keep focus where it was. It used to be the position in the listing, which
|
||||
// shifted on every rollover and left stored indexes pointing one row off.
|
||||
const idxByName = new Map();
|
||||
const stableIdx = name => {
|
||||
if (!idxByName.has(name)) idxByName.set(name, idxByName.size);
|
||||
return idxByName.get(name);
|
||||
};
|
||||
|
||||
export function setFiles(list) {
|
||||
allFiles = list.map(f => ({...f, _idx: stableIdx(f.name)}));
|
||||
}
|
||||
|
||||
export function removeFile(name) {
|
||||
allFiles = allFiles.filter(f => f.name !== name);
|
||||
}
|
||||
|
||||
export const fileByName = name => allFiles.find(f => f.name === name) ?? null;
|
||||
export const fileByIdx = idx => allFiles.find(f => f._idx === idx) ?? null;
|
||||
// Queue entries carry the filename, never a row index; resolve at use time.
|
||||
// Null when the file is gone from the current listing.
|
||||
export const fileIdxByName = name => fileByName(name)?._idx ?? null;
|
||||
|
||||
export const isAnalysable = f => f.ext === 'wav' || f.ext === 'flac';
|
||||
|
||||
// idx -> [{start, end, score}], populated by analyse()
|
||||
export const sectionMap = new Map();
|
||||
|
||||
// What a recording is called wherever it is spoken: its wall-clock span from
|
||||
// the filename clock, "22:00 to 23:00". The filename itself ("20260523_220000")
|
||||
// reads as a string of numbers. Non-standard names fall back to the name.
|
||||
export function fileSpan(f) {
|
||||
const start = fileStartEpoch(f.date);
|
||||
if (start == null) return f.name;
|
||||
if (f.recording) return `${fmtHM(start)} to now`;
|
||||
if (f.duration == null) return fmtHM(start);
|
||||
return `${fmtHM(start)} to ${fmtHM(Math.round((start + f.duration) / 60) * 60)}`;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Formatting and screen-reader helpers shared by every module. A leaf: it
|
||||
// imports nothing, so any module can use it without creating a cycle.
|
||||
|
||||
export const $ = id => document.getElementById(id);
|
||||
|
||||
export const esc = s => String(s)
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
export const pad = n => String(n).padStart(2, '0');
|
||||
|
||||
export const fmtDur = s => {
|
||||
if (s == null) return '—';
|
||||
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = Math.floor(s % 60);
|
||||
return h ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
|
||||
};
|
||||
|
||||
// "2 h 59 min" — for running text a screen reader speaks, where "2:59:59"
|
||||
// reads like a clock time
|
||||
export const fmtDurWords = s => {
|
||||
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
|
||||
if (h) return m ? `${h} h ${m} min` : `${h} h`;
|
||||
return m ? `${m} min` : `${Math.round(s)} s`;
|
||||
};
|
||||
|
||||
export const fmtSize = b => {
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1 << 20) return (b / 1024).toFixed(0) + ' KB';
|
||||
if (b < 1 << 30) return (b / (1 << 20)).toFixed(1) + ' MB';
|
||||
return (b / (1 << 30)).toFixed(2) + ' GB';
|
||||
};
|
||||
|
||||
// Epoch seconds -> local wall-clock "HH:MM:SS"
|
||||
export const fmtClock = ts => {
|
||||
const d = new Date(ts * 1000);
|
||||
return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
||||
};
|
||||
|
||||
// Epoch seconds -> local "HH:MM"
|
||||
export const fmtHM = ts => {
|
||||
const d = new Date(ts * 1000);
|
||||
return pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
};
|
||||
|
||||
// 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
|
||||
export const fileStartEpoch = date => {
|
||||
const t = Date.parse(String(date).replace(' ', 'T'));
|
||||
return isNaN(t) ? null : t / 1000;
|
||||
};
|
||||
|
||||
// "1:30", "0:01:30" or "90" -> seconds; null when unparseable
|
||||
export function parseTime(s) {
|
||||
if (!s || !s.trim()) return null;
|
||||
const parts = s.trim().split(':').map(v => parseFloat(v));
|
||||
if (parts.some(isNaN) || parts.length > 3) return null;
|
||||
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
// "2026-10-17" -> "Today, Saturday 17 October". Spoken far better than the
|
||||
// ISO date, which screen readers read digit group by digit group. en-GB to
|
||||
// match the page's lang="en", so the speech language never switches mid-page.
|
||||
export function dayLabel(day) {
|
||||
const [y, m, d] = day.split('-').map(Number);
|
||||
const date = new Date(y, m - 1, d);
|
||||
if (isNaN(date)) return day;
|
||||
const now = new Date();
|
||||
const opts = {weekday: 'long', day: 'numeric', month: 'long'};
|
||||
if (y !== now.getFullYear()) opts.year = 'numeric';
|
||||
const text = date.toLocaleDateString('en-GB', opts);
|
||||
// Math.round absorbs the 23/25 h days around DST switches
|
||||
const ago = Math.round((new Date(now.getFullYear(), now.getMonth(), now.getDate()) - date) / 86400000);
|
||||
return ago === 0 ? 'Today, ' + text : ago === 1 ? 'Yesterday, ' + text : text;
|
||||
}
|
||||
|
||||
export function announce(msg) {
|
||||
const el = $('sr-announce');
|
||||
if (!el) return;
|
||||
el.textContent = ''; // clear first so same text re-triggers
|
||||
setTimeout(() => { el.textContent = msg; }, 50);
|
||||
}
|
||||
|
||||
// A long chip list is a wall of buttons: hide it behind an aria-expanded
|
||||
// toggle. Returns the toggle to insert before the chips, or null when the list
|
||||
// is short enough to show as is. The separator space lives inside the
|
||||
// aria-hidden arrow — a leading space in the text node reads as an indent.
|
||||
export function chipToggle(chips, text) {
|
||||
if (chips.children.length <= 12) return null;
|
||||
chips.hidden = true;
|
||||
const tog = document.createElement('button');
|
||||
tog.className = 'chip-toggle';
|
||||
tog.setAttribute('aria-expanded', 'false');
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'day-arrow';
|
||||
arrow.setAttribute('aria-hidden', 'true');
|
||||
arrow.textContent = '▸ ';
|
||||
tog.append(arrow, text);
|
||||
tog.addEventListener('click', () => {
|
||||
const exp = chips.hidden;
|
||||
chips.hidden = !exp;
|
||||
tog.setAttribute('aria-expanded', exp);
|
||||
arrow.textContent = exp ? '▾ ' : '▸ ';
|
||||
});
|
||||
return tog;
|
||||
}
|
||||
|
||||
// localStorage for per-browser conveniences (pre-roll, auto-advance, whether
|
||||
// the settings are open). It can be missing or throw (private mode, blocked
|
||||
// storage), and nothing here may depend on it working.
|
||||
export const remember = {
|
||||
get(key) { try { return localStorage.getItem('isr.' + key); } catch { return null; } },
|
||||
set(key, value) { try { localStorage.setItem('isr.' + key, value); } catch { /* not kept */ } },
|
||||
};
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
:root{--bg:#0f1117;--surf:#1a1d27;--brd:#2c3040;--txt:#e2e8f0;--muted:#8b93ad;
|
||||
--accent:#4f9cf9;--orange:#f97316;--green:#22c55e;--red:#ef4444;
|
||||
--gutter:28px}
|
||||
@media (max-width:640px){:root{--gutter:16px}}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
/* One rule for every [hidden] element. Author display rules (flex chips, the
|
||||
inline-flex REC badge, the flex clip bar) beat the UA's [hidden] otherwise —
|
||||
the REC badge used to show on every row that way. */
|
||||
[hidden]{display:none !important}
|
||||
body{background:var(--bg);color:var(--txt);font:15px/1.5 system-ui,sans-serif}
|
||||
.sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;
|
||||
overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
||||
.skip{position:absolute;left:-999px;top:0;padding:8px 14px;background:var(--accent);
|
||||
color:#000;border-radius:0 0 4px 4px;text-decoration:none;font-weight:600;z-index:30}
|
||||
.skip:focus{left:0}
|
||||
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
#page-title:focus,#days:focus{outline:none}
|
||||
.muted{color:var(--muted)}
|
||||
.nowrap{white-space:nowrap}
|
||||
|
||||
header{padding:16px var(--gutter);border-bottom:1px solid var(--brd);
|
||||
display:flex;align-items:baseline;gap:6px 14px;flex-wrap:wrap}
|
||||
header h1{font-size:20px;font-weight:600}
|
||||
#subtitle{color:var(--muted)}
|
||||
#storage-info{color:var(--muted);font-size:13px;margin-right:auto}
|
||||
|
||||
button{cursor:pointer;border:1px solid var(--brd);background:var(--surf);color:var(--txt);
|
||||
padding:6px 12px;border-radius:6px;font:inherit;font-size:13px;white-space:nowrap;min-height:32px}
|
||||
button:hover{background:var(--brd)}
|
||||
button:disabled,button[aria-disabled=true]{opacity:.55;cursor:default}
|
||||
input{font:inherit;font-size:14px;background:var(--bg);border:1px solid var(--brd);
|
||||
color:var(--txt);padding:5px 8px;border-radius:5px;min-height:32px}
|
||||
input[type=date]{color-scheme:dark}
|
||||
input[type=radio]{min-height:0;accent-color:var(--accent)}
|
||||
|
||||
/* settings */
|
||||
#settings{border-bottom:1px solid var(--brd);background:var(--surf)}
|
||||
#settings summary{padding:10px var(--gutter);cursor:pointer;color:var(--muted);font-size:14px}
|
||||
#settings summary:hover{color:var(--txt)}
|
||||
.settings-grid{display:grid;grid-template-columns:max-content 90px 1fr;gap:8px 12px;
|
||||
align-items:center;padding:4px var(--gutter) 14px}
|
||||
.settings-grid label{font-size:14px}
|
||||
.hint{font-size:12px;color:var(--muted)}
|
||||
@media (max-width:640px){
|
||||
.settings-grid{grid-template-columns:1fr 90px}
|
||||
.settings-grid .hint{grid-column:1 / -1;margin-top:-6px}
|
||||
}
|
||||
|
||||
/* filters */
|
||||
.filter-bar{display:flex;align-items:center;gap:8px 10px;padding:10px var(--gutter);
|
||||
border-bottom:1px solid var(--brd);flex-wrap:wrap}
|
||||
.filter-bar label{font-size:14px;color:var(--muted)}
|
||||
#filter-name{width:200px}
|
||||
|
||||
/* days */
|
||||
#days{padding:20px var(--gutter)}
|
||||
.day-section{margin-bottom:18px}
|
||||
.day-heading-bar{background:var(--surf);padding:6px 10px;border:1px solid var(--brd);
|
||||
border-radius:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.day-heading-bar.open{border-radius:8px 8px 0 0}
|
||||
h2.day-heading{font-size:inherit;font-weight:inherit;flex:1 1 auto}
|
||||
.day-toggle{background:none;border:none;color:var(--txt);font-size:15px;font-weight:600;
|
||||
padding:4px 0;display:inline-flex;align-items:baseline;gap:4px 10px;flex-wrap:wrap;text-align:left;white-space:normal}
|
||||
.day-toggle:hover{background:none;color:var(--accent)}
|
||||
.day-meta{color:var(--muted);font-size:13px;font-weight:400}
|
||||
.day-arrow{font-size:10px}
|
||||
button.day-hl{color:var(--green);border-color:#166534;background:#052e16}
|
||||
button.day-hl:hover{background:#0a3d1f}
|
||||
.day-hl-status{opacity:.8}
|
||||
.day-hl-container{border:1px solid var(--brd);border-top:none;padding:10px 12px 12px}
|
||||
|
||||
/* recordings table; scrolls sideways on narrow screens instead of the page */
|
||||
.table-wrap{overflow-x:auto;border:1px solid var(--brd);border-top:none}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th,td{padding:9px 10px;border-bottom:1px solid var(--brd);vertical-align:middle;text-align:left}
|
||||
thead th{color:var(--muted);font-weight:500;font-size:13px;white-space:nowrap}
|
||||
th.when{font-weight:600;white-space:nowrap}
|
||||
tr.data-row:hover>*{background:var(--surf)}
|
||||
td.file{color:var(--muted);font-size:12px;white-space:nowrap}
|
||||
.fn{font-family:ui-monospace,monospace}
|
||||
.badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;
|
||||
font-weight:700;border:1px solid}
|
||||
.badge-wav{color:var(--green);border-color:#166534;background:#052e16}
|
||||
.badge-mp3{color:var(--accent);border-color:#1e40af;background:#0c1a40}
|
||||
.badge-ogg{color:#c084fc;border-color:#6b21a8;background:#2d1157}
|
||||
.badge-flac{color:#fb923c;border-color:#7c2d12;background:#2c0e04}
|
||||
.badge-aac,.badge-opus{color:var(--muted);border-color:var(--brd);background:var(--surf)}
|
||||
.badge-rec{display:inline-flex;align-items:center;padding:1px 6px;border-radius:3px;
|
||||
font-size:11px;font-weight:700;color:var(--red);border:1px solid #7f1d1d;background:#2d0808;
|
||||
animation:pulse 1.5s ease-in-out infinite}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
|
||||
@media (prefers-reduced-motion:reduce){.badge-rec{animation:none}}
|
||||
.actions{display:flex;gap:6px;align-items:center}
|
||||
a.dl{color:var(--accent);text-decoration:none;font-size:13px;padding:6px 4px}
|
||||
a.dl:hover{text-decoration:underline}
|
||||
button.del{color:var(--red);border-color:#7f1d1d}
|
||||
button.del:hover:not(:disabled){background:#2d0808}
|
||||
button.cut{color:var(--accent);border-color:#1e40af;background:#0c1a40}
|
||||
button.cut:hover:not(:disabled){background:#1e3a8a}
|
||||
|
||||
/* analysis */
|
||||
td.loudness{min-width:200px}
|
||||
.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:8px 10px}
|
||||
.analysis-meta{font-size:12px;color:var(--muted)}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
|
||||
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:5px;
|
||||
padding:4px 9px;font-size:12px;font-family:ui-monospace,monospace;min-height:30px}
|
||||
.chip:hover{background:#6c1f08;border-color:#9a3412}
|
||||
.chip-toggle{margin-top:8px}
|
||||
button.reanalyse-btn{margin-top:8px;font-size:12px;color:var(--muted);background:transparent}
|
||||
.quiet{color:var(--muted);font-size:13px}
|
||||
.quiet+.quiet{margin-top:6px}
|
||||
.prog-bar{height:4px;background:var(--brd);border-radius:2px;margin:6px 0 4px;overflow:hidden}
|
||||
.prog-fill{height:100%;width:0;background:var(--accent);transition:width .15s ease}
|
||||
@media (prefers-reduced-motion:reduce){.prog-fill{transition:none}}
|
||||
.prog-file{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.empty{text-align:center;padding:60px 16px;color:var(--muted)}
|
||||
|
||||
/* full-file player cell, options cell */
|
||||
audio{width:100%;height:40px;display:block;color-scheme:dark;accent-color:var(--accent)}
|
||||
td.player audio{width:300px;margin-top:6px}
|
||||
.cut-panel{display:flex;align-items:center;gap:8px 12px;margin-top:8px;flex-wrap:wrap}
|
||||
.cut-field{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--muted)}
|
||||
.cut-time{width:110px;font-family:ui-monospace,monospace}
|
||||
|
||||
/* clip player bar */
|
||||
#clip-bar{position:fixed;bottom:0;left:0;right:0;z-index:20;background:var(--surf);
|
||||
border-top:1px solid var(--brd);padding:8px var(--gutter);display:flex;flex-direction:column;gap:6px}
|
||||
.clip-row{display:flex;align-items:center;gap:8px 10px;flex-wrap:wrap}
|
||||
#clip-label{flex:1 1 240px;font-family:ui-monospace,monospace;font-size:13px;min-width:0}
|
||||
#clip-audio{flex:1 1 260px;min-width:200px;width:auto}
|
||||
#clip-adv{border:none;display:flex;align-items:center;gap:4px 12px;flex-wrap:wrap}
|
||||
#clip-adv legend{float:left;font-size:12px;color:var(--muted);margin-right:4px}
|
||||
#clip-adv label{font-size:13px;display:flex;align-items:center;gap:5px;cursor:pointer}
|
||||
body.clip-open{padding-bottom:130px}
|
||||
Reference in New Issue
Block a user