Compare commits

...

20 Commits

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:16:39 +02:00
admin 9c58e35546 feat: drop rms_display from /api/analyze
The UI no longer draws waveforms, so the server stops computing and
sending the ~800-point RMS preview; the payload is now just sections,
duration, window. Old analysis caches stay valid: rms/rms_display are
popped when serving a cache hit (same pattern as the earlier full-RMS
removal), so no DETECTOR_VERSION bump.

Verified: 63 tests pass; endpoint smoke test (fresh + cached analyze)
confirms no RMS keys and correct section detection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:02:47 +02:00
admin 91701ce4d3 feat: remove per-file waveform SVG from the analyse view
Same rationale as the day timeline: purely visual, useless via screen
reader. The section count it carried in its aria-label moved into the
meta line ("N loud sections - margin: 12 dB - gap: 2s - min: 0.5s").
drawWave() and the svg.wave CSS are gone; the UI now renders no SVG at
all. /api/analyze still returns rms_display for API stability, but the
bundled UI no longer reads it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:58:14 +02:00
admin 41d921a42a feat: remove decorative day-timeline SVG, linear highlights panel
The user is blind; the day activity timeline (and its hour labels) was
purely visual, non-interactive, and carried no information not already
in the chips/summary. The highlights panel is now linear text and
buttons in reading order: summary line ("N files analysed - M loud
sections"), key-hint note (now always shown, J/K/U/I is the primary
interface), chips toggle, chips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:49:59 +02:00
admin 653084e90b feat: cleaner day highlights panel
- Day timeline axis now labels round wall-clock hours with tick marks
  on the bar (start/end fallback when the span has <2 round hours),
  replacing the arbitrary start/midpoint/end labels.
- Long chip lists (>12) collapse behind an aria-expanded toggle button
  so the panel is not a wall of 50 buttons; J/K/U/I are unaffected.
- Chips group aria-labels shortened to "Day loud sections" / "Loud
  sections" - the key-binding explanation lives only in the visible
  note, not repeated in the group name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:39:00 +02:00
admin 98d2d7085d feat: auto-advance radio, J/K decoupled from highlights, U/I ranked stepping
- J/K and Prev/Next now always step the clip queue in time order; the old
  "Highlights only" checkbox could silently change what J/K did.
- Auto-advance is a three-way radio: don't auto-advance / auto-advance
  (next in time) / auto-advance highlights only (next by loudness). It
  only affects what plays when a clip ends.
- The "Top" highlight-count input is gone. U/I (and highlights-only
  auto-advance) walk the full loudest-first ranking from scoreOrder()
  with no top-N cutoff - review simply stops when the user stops.
- In-player U/I (full-file playback) step the same ranking, anchored on
  the section under the playhead.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:28:23 +02:00
admin 9f1a6ff711 feat: O key opens the current clip in the full file
Extracts the "Open in file" button handler into openClipInFile() and
binds O in the shared keydown listener as a keyboard alternative, so
clip review never needs the mouse: J/K/U/I to step, O to drop into the
full recording for context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:24:30 +02:00
admin 13419244e8 fix: clip bar no longer visible before any clip has played
The author rule #clip-bar{display:flex} overrides the UA stylesheet's
[hidden]{display:none}, so the hidden attribute toggled by playClip()/
hideClipBar() had no visual effect: the bar rendered from page load and
the close button appeared to do nothing. Restate display:none for the
hidden state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:21:41 +02:00
admin f6031cfa16 feat: onset-aware section scoring so slow swells rank at the bottom
A section's score is now its peak dB above the noise floor capped by
the sharpest rise within ONSET_SECONDS (0.5 s). Real events (voices,
impacts, barks) rise fast and keep their full prominence; a gradual
swell that outruns the 30 s floor blocks (gusts, distant approaching
cars) still flags but scores near zero, so score-ranked review (chips,
U/I highlights, "Highlights only" mode) surfaces events first. A
section starting in a file's first 0.5 s is scored against the floor
instead, so events cut off by a file split are not punished as swells.

Old cached analyses carry now-wrong scores, so the cache gains a
leading "detector" version key (DETECTOR_VERSION = 2) checked by both
_cached_analysis_params() and the /api/analyze cache hit path; v1
caches never match and are recomputed on the next analyse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:57:19 +02:00
admin 6431918989 docs: add a "too many sections per day" tuning note to the README
Consolidates the existing per-knob hints (margin, grace period, min
duration, highlights-only review) into one ordered list of what to
adjust when the detector flags too much, and why ranking by score is
preferable to thinning the list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:43:30 +02:00
admin f52eb62215 feat: U/I keys and "Highlights only" mode to review top-scored sections
J/K still steps through every queued section in time order; U/I steps
through only the highlights, defined as the top-N sections by score
(new "Top" input in the clip bar, default 50, matching the day chips).
A "Highlights only" checkbox makes J/K, Prev/Next, and Auto-advance
skip non-highlights too, so a day with thousands of detections plays
as a short reel of just the loudest events. Both key pairs also work
during full-file playback, and modified keypresses (Ctrl+K, Ctrl+U)
are no longer hijacked from the browser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:38:56 +02:00
admin 5e7620627b feat: name cut clips by wall-clock time; fix recording filename format
Cut downloads were named by byte offsets (`..._cut_740s-750s.flac`). They are
now named by the actual recording time the slice covers, e.g.
`20260523_22-31-30_22-32-30.flac` for a 22:31:30->22:32:30 cut of a recording
started at 22:00:00.

To make this reliable, the recording filename is now a fixed
`%Y%m%d_%H%M%S` start-time format (`FILENAME_FORMAT`) shared by isr.py and
web.py, replacing the user-configurable `filename_pattern` (web.py never reads
config.ini, so a custom pattern could not be parsed back). web.py parses the
start time out of the filename via `_recording_start()` and builds cut names
with `_cut_filename()`. The DATE column now also comes from the filename
(falling back to mtime only for non-standard names), since mtime is the last
write, not the start.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 14:30:30 +02:00
admin 2caf23f17d style: replace emoji glyphs in the UI with plain text labels
Buttons read Play/Hide, Cut, Delete, Download, Highlights, Prev/Next,
Open in file, Refresh, Clear; the clip-bar close button uses a
typographic multiplication sign. Day disclosure arrows switch to the
text-presentation triangles (U+25B8/U+25BE) so they can never render as
colored emoji. The red REC dot stays: U+25CF is text-presentation and
takes the badge's CSS color.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:03:32 +02:00
admin f3716d3ff1 feat: minimum section duration filter (--min-duration, default 0.5 s)
A single 100 ms RMS window above the noise floor used to become its own
section, so isolated pops (clicks, single raindrops) flooded a day with
thousands of sub-second clips like "21:18 to 21:18". Sections shorter
than min_duration (measured after min_gap merging, so a cluster of blips
spanning longer still flags) are now discarded.

Wired through all coupled places: CLI flag, /api/config, controls-bar
input, /api/analyze query param, and the analysis-cache head keys (old
two-key caches no longer match and are recomputed on next analyse).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:00:37 +02:00
8 changed files with 592 additions and 317 deletions
+11 -9
View File
@@ -21,7 +21,7 @@ Guidance for Claude Code when working in this repository.
```bash
python isr.py [config.ini] # recorder; --list-devices to list ALSA inputs
python web.py # web UI on :8080 (--dir, --port, --margin, --min-gap, --analyses-dir)
python web.py # web UI on :8080 (--dir, --port, --margin, --min-gap, --min-duration, --analyses-dir)
python -m pytest tests/ # test suite
docker compose up -d / down # web UI mapped to host port 8050
```
@@ -33,14 +33,15 @@ Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC
`web.py`:
- Detection: `_compute_rms_windows_wav()` / `analyze_flac()` produce 100 ms RMS windows → `_noise_floor_db()` estimates the rolling floor → `_loud_sections()` emits scored sections → `_package_result()` shapes the `/api/analyze` payload.
- Clips: `_api_clip()` validates params, `_clip_wav()` / `_clip_flac()` stream the decoded slice, `_wav_header()` builds the 44-byte PCM header.
- Filenames as a clock: `_recording_start()` parses the start time out of a filename stem; `_cut_filename()` turns a (stem, ext, start, end) into a wall-clock-named cut. Both the listing `date` field and `_api_cut()` use them.
- Live headers: `_live_wav_header()`, `_live_flac_header()` (+ `_flac_frame_samples()`, CRC-8 verified).
- Serving: `_stream()` (Range support), `_copy_to_response()`, `_safe_path()` (path traversal guard).
`webui.html` (one `<script>` block):
- Clip review: `clipQueue`/`clipCursor` globals, `playClip()`, `playFileSection()`, `hideClipBar()`; markup is the `#clip-bar` div.
- Day review: `dayHighlights()` builds `dayActiveSections` (chronological); `jumpToDaySection()` arms the queue.
- J/K: single document-level `keydown` listener — clip queue takes priority, in-player `currentTime` stepping is the fallback when no queue is armed.
- Analysis: `fetchAnalysis()` (session `analysisCache`), `analyse()` (per-row render), `cachedParamsMatch()` (autoload guard).
- Clip review: `clipQueue`/`clipCursor`/`scoreCursor` globals, `playClip()`, `playFileSection()`, `hideClipBar()`; markup is the `#clip-bar` div. The clip label shows wall-clock occurrence time + dB prominence + position (`17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)`): queue entries carry `absStart` (epoch s), derived from `fileStartEpoch(f.date)` — the filename clock — with in-file offsets as fallback for non-standard names; only the filename/in-file offset lives in the tooltip now. **`playClip()` builds one `text` string used for both `label.textContent` and the `announce()` aria-live message** — they must never diverge (a past bug had the announcement read `Clip N of M: …` while the label read the wall-clock form). **Two cursors, deliberately independent:** `clipCursor` is the time-order position and follows every play; `scoreCursor` is the loudness walk's own position in `scoreOrder()` and is moved *only* by U/I (and "highlights only" auto-advance) in `stepClip`'s by-score branch — J/K must never touch it, so a J/K detour to review nearby clips leaves the loudness walk's place intact and the next U/I resumes the ranking where it left off. `scoreCursor` resets to `-1` on every explicit jump / queue (re-)arm (`hideClipBar`, `playFileSection`, `jumpToDaySection`, both day-highlights arm points), so the next U/I re-anchors on `clipCursor` (the section just selected) else the loudest. `playClip(i, byScore)`: when `byScore` the count shows `scoreCursor` (rank, suffix "by loudness"), else `i` (time-order index, suffix "by time") — the suffix tells the blind user which dimension the count is in. `stepClip`'s by-score branch passes `byScore=true`; every other caller (chip click, J/K, Prev/Next) leaves it false.
- Day review: `dayHighlights()` builds `dayActiveSections` (chronological); `jumpToDaySection()` arms the queue. Section `absStart` comes from `fileStartEpoch(f.date)` (filename clock), mtimeduration only as fallback. **The user is blind and uses a screen reader — there is deliberately no day-timeline SVG** (one existed and was removed on request as useless); the highlights panel is linear text/buttons: summary line → key-hint note → chips toggle → chips. Do not add decorative visualizations; any future graphic must be aria-hidden and must not be the only carrier of information. Chip lists longer than 12 are collapsed behind an `aria-expanded` toggle button (the `.chips[hidden]{display:none}` rule is required — the author-level `display:flex` on `.chips` would otherwise override the UA `[hidden]` rule). Group `aria-label`s stay short ("Day loud sections") — the J/K/U/I key explanation lives only in the visible note, per user feedback against repeating info text in labels. The Highlights button is a collapse/expand toggle (`setHlExpanded()` keeps arrow + `aria-expanded` in sync, also from the day-collapse path): a built panel is kept and re-armed from `dayHlSections` instead of recomputing, keyed by `hlRow.dataset.loaded = hlParams()` (margin|gap|minDur string) so changed params force a re-run. The `#dayhls-<dayId>` "· analysed" suffix appears when every file's `cached_analysis` passes `cachedParamsMatch()`; `fetchAnalysis()` updates `f.cached_analysis` client-side so the marker survives re-renders without refetching `/api/files`. **`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), `cachedParamsMatch()` (autoload guard).
## Verifying changes
@@ -54,12 +55,13 @@ Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC
- **Recorder/web coupling is one file:** `RecorderManager` atomically writes `recordings/status.json` every 2 s listing in-progress files; deleted on clean shutdown. `web.py` reads it to show REC badges and to refuse analyse/cut/delete on active files. In-progress WAV/FLAC headers are unfinalized, so durations are not read for active files.
- **Stream splits:** OGG/Opus/FLAC codec headers are extracted from the first ~16 KB of each connection and prepended to every split file so each file plays standalone. A new file is always opened on reconnect (gap in stream). MP3/AAC need no headers.
- **Split timing:** files split at clock-aligned boundaries (`get_next_split_time()`), e.g. `split_minutes = 60` → on the hour.
- **Filename is the clock — fixed format, not configurable.** Recordings are named `%Y%m%d_%H%M%S.<ext>` (the *start* time). This is hardcoded as `FILENAME_FORMAT`, defined in **both** `isr.py` (recorder writes it) and `web.py` (reads it back) — the two copies must stay in sync. There is no `filename_pattern` config option (removed; `web.py` can't see `config.ini`, so a configurable pattern would break parsing). `web.py` derives the displayed DATE column from the filename via `_recording_start()` (falling back to mtime only for non-standard names — mtime is the last write ≈ end, not the start). Cut downloads are named by the wall-clock span they cover via `_cut_filename()`: a 22:31:30→22:32:30 slice of `20260523_220000.flac` becomes `20260523_22-31-30_22-32-30.flac`; non-standard source names fall back to `<stem>_cut_<start>s-<end>s`.
- **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` (peak dB above floor) used for ranking. The original fixed RMS threshold flagged every ambience change (passing cars, rain) and produced ~600 useless sections/day — that is why it was replaced. Known limitation: a short (~10 s) swell on a quiet street still flags because the floor blocks are 30 s; the planned fix is an onset/spectral filter or optional Silero VAD, **not** a higher margin. Tests in `tests/test_web.py`.
- **Analysis params are coupled in five places.** CLI `--margin`/`--min-gap``/api/config` → UI inputs `#margin-input`/`#min-gap-input``/api/analyze` query params → cache JSON head keys. Renaming or adding a param means touching all five plus `cachedParamsMatch()` (see the threshold→margin change, commit `c84b7d8`).
- **Analysis cache:** results stored as `<analyses-dir>/<file>.analysis.json` keyed by margin+min_gap; 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. The `margin` and `min_gap` keys MUST stay first in the cache JSON — `_cached_analysis_params()` reads only the first 256 bytes to avoid parsing the large embedded result. Old `threshold`-keyed caches never match and get overwritten on the next analyse.
- **Analyze responses:** `/api/analyze` returns `rms_display` (~800 points), never the full per-window RMS list — the UI doesn't use it and it is ~45x larger.
- **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 head keys. Renaming or adding a param means touching all five plus `cachedParamsMatch()` and the `_cached_analysis_params()` regex (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. The `detector`, `margin`, `min_gap`, and `min_duration` keys MUST stay first in the cache JSON — `_cached_analysis_params()` reads only the first 256 bytes to avoid parsing the large embedded result. `detector` is `DETECTOR_VERSION`: bump it whenever detection/scoring changes make old cached results wrong (e.g. v2 = onset-capped scores); caches with another version (or missing keys) never match and get overwritten on the next analyse.
- **Analyze responses:** `/api/analyze` returns only `sections`, `duration`, `window` — no RMS data of any kind. `rms_display` (~800-point waveform preview) and the full per-window list were dropped when the waveform SVGs were removed (user is blind, see webui notes). Old caches that embed `rms`/`rms_display` are still valid; both keys are popped when serving from cache, so no DETECTOR_VERSION bump was needed.
- **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.
- **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.
+25 -21
View File
@@ -67,7 +67,6 @@ docker compose up -d --build
|-----|---------|-------------|
| `output_directory` | `recordings` | Output path relative to the working directory (or absolute). The Docker setup mounts `./recordings` at `/app/recordings` so this default works unchanged. |
| `split_minutes` | `60` | Split into a new file every N minutes, aligned to clock boundaries (e.g. 60 → files start at :00, 30 → at :00 and :30). |
| `filename_pattern` | `%Y%m%d_%H%M%S` | strftime pattern; file extension is appended automatically. |
| `max_retries` | `10` | Give up after this many consecutive failures per source. |
| `retry_delay_seconds` | `5` | Wait between retries. |
| `log_level` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL` |
@@ -124,25 +123,17 @@ split_minutes = 60
[radio1]
type = stream
url = http://radio.example.com:8000/stream1
filename_pattern = radio1_%Y%m%d_%H%M%S
[system_audio]
type = soundcard
device = hw:0,0
filename_pattern = system_%Y%m%d_%H%M%S
```
---
## Filename patterns
## Filenames
strftime codes are substituted at split time. The file extension is added automatically.
| Pattern | Example |
|---------|---------|
| `%Y%m%d_%H%M%S` | `20241225_143000.mp3` |
| `radio_%Y-%m-%d_%H%M` | `radio_2024-12-25_1430.mp3` |
| `%Y/%m/%d/rec_%H%M%S` | `2024/12/25/rec_143000.mp3` *(subdirs created automatically)* |
Recordings are named `<YYYYMMDD>_<HHMMSS>.<ext>` from the time the file is opened — its **start** time — e.g. `20241225_143000.flac`. This format is fixed and not configurable: the web UI parses the start time back out of the filename to show the recording date and to name cut clips with real wall-clock times (see below).
---
@@ -154,6 +145,7 @@ python web.py --dir /path/to/audio # custom recordings directory
python web.py --port 8888 # custom port
python web.py --margin 15 # dB above background noise for a section to count as loud (default 12)
python web.py --min-gap 15 # grace period in seconds for merging loud sections (default 2)
python web.py --min-duration 1 # discard loud sections shorter than this many seconds (default 0.5)
python web.py --analyses-dir /path/to/dir # where to store analysis cache files (default: <recordings>/analyses)
```
@@ -162,18 +154,30 @@ The browser UI (HTML/CSS/JS) lives in `webui.html`, which `web.py` loads at star
Shows recordings grouped by day with collapsible sections. Features:
- **Day groups** — recordings are grouped under a collapsible day heading showing date, file count, total duration, and total size. The most recent day is expanded by default; older days start collapsed. Expanded state is preserved across filter changes.
- **Day highlights** — click **Highlights** on any day heading to run loudness analysis across all WAV/FLAC files in that day and display a combined activity timeline SVG. Orange segments show when loud sections occurred relative to the day's time span; blue shows the file extents. Labels show the start, midpoint, and end times. When a day has more sections than fit as chips, the chips show the top 50 by score (loudest-above-background first) so the most promising events are reviewed first; J/K still steps through all sections in time order.
- **Inline playback** — collapsible `Play` button per row; audio loads lazily via a seekable `/stream/` endpoint with HTTP Range support. Metadata is fetched immediately so the duration is visible without pressing play.
- **Waveform analysis** — on demand per file; computes RMS per 100 ms window and marks sections that stand out above the background. Detection is **adaptive**: a rolling noise floor (20th percentile per 30 s block) is estimated across the file, and a section is flagged when the level rises at least *margin* dB (default 12) above that floor. Slow ambience changes — rain setting in, day/night traffic hum — move the floor instead of producing false positives. Each section gets a **score** (its peak dB above the floor) used to rank sections by how much they stand out. 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 and min-gap 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.
- **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.
- **Loudness analysis** — on demand per file; computes RMS per 100 ms window and lists sections that stand out above the background as clickable chips (no visual waveform — the UI is built for screen-reader use). Detection is **adaptive**: a rolling noise floor (20th percentile per 30 s block) is estimated across the file, and a section is flagged when the level rises at least *margin* dB (default 12) above that floor. Slow ambience changes — rain setting in, day/night traffic hum — move the floor instead of producing false positives. Each section gets a **score** used to rank it: its peak dB above the floor, capped by the sharpest rise within 0.5 s. Abrupt events — voices, impacts, barks — rise fast, so their score is their full prominence; a gradual swell (a gust, a distant approaching car) that drifts up faster than the floor can track still gets flagged, but scores near zero and sinks to the bottom of the highlight ranking. Supported for WAV and FLAC (FLAC requires `numpy` + `soundfile`). Pure-Python fallback for WAV when numpy is absent. Results are cached in `recordings/analyses/<filename>.analysis.json`; subsequent requests at the same margin, min-gap, and min-duration settings return instantly without re-reading the audio. The cache file is deleted automatically when the audio file is deleted. Orphaned cache files (audio deleted outside the UI) are pruned on startup.
- **Grace period** — configurable in the controls bar (default 2 s). Loud sections separated by less than this gap are merged into one. Raise this (e.g. to 1530 s) when a single event generates many timestamps due to brief quiet gaps within it.
- **Clip playback** — clicking a loud-section chip plays a short server-rendered WAV clip (`/api/clip`, pre-roll included) in a player bar at the bottom of the page. Playback starts instantly even for sections deep inside multi-hundred-MB FLACs, because the browser never has to seek the full file. **J** / **K** (or ⏮ / ⏭) step through the queued sections — one file's, or a whole day's after ★ Highlights — and **Auto-advance** plays the next section when one ends, turning a day's detections into a continuous review reel. **⤴ Open in file** 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).
- **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`.
- **Filters** — live filename search and from/to date pickers above the table; applied client-side with no additional requests. Shows `N of M shown` when a filter is active.
- **Delete** — `Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/<name>` and re-renders the table.
- **Delete** — `Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/<name>` and re-renders the table.
- **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 waveforms 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, `role=img` on SVG waveforms.
- **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.
### 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.
2. **Raise the grace period** (2 → 1530 s) — merges clusters of related noise (a rain burst, one long conversation) into a single section. Cuts the count heavily without dropping any audio from review.
3. **Raise the margin** (12 → 1518 dB) — demands more prominence above the background. The quietest events disappear first, so move in small steps.
4. **Raise min duration** (0.5 → 12 s) — drops short rustles and pops, but beware: single bangs or knocks are themselves short.
Changing any of these re-runs the analysis (results are cached per parameter combination), so experimenting on one day before re-analysing everything is cheapest.
### HTTP API
@@ -182,14 +186,14 @@ Everything the UI does goes through these endpoints, so they can also be scripte
| Endpoint | Description |
|----------|-------------|
| `GET /api/files` | File listing with size, mtime, duration, recording state, cached-analysis params |
| `GET /api/analyze?file=&margin=&min_gap=` | Loud-section analysis: `rms_display` (~800-point waveform), scored `sections`, `duration` |
| `GET /api/analyze?file=&margin=&min_gap=&min_duration=` | Loud-section analysis: scored `sections`, `duration` |
| `GET /api/clip?file=&start=&end=` | Section of a WAV/FLAC decoded server-side, returned as a standalone WAV (max 600 s) |
| `GET /api/cut?file=&start=&end=` | ffmpeg-trimmed copy of the file as a download |
| `GET /stream/<name>` | Inline playback with HTTP Range support; live files get an on-the-fly patched header |
| `GET /download/<name>` | Raw file download |
| `GET /api/status` | Currently recording files (`status.json` passthrough) |
| `GET /api/storage` | Disk free/total |
| `GET /api/config` | Server-side defaults for margin and min-gap (seeds the UI controls) |
| `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 |
Analysis, clips, cut, and delete return `409` for files that are still being recorded.
+4 -12
View File
@@ -17,13 +17,10 @@ output_directory = recordings
# Duration in minutes after which to split into a new file
split_minutes = 60
# Filename pattern with strftime format codes
# Examples:
# %Y%m%d_%H%M%S -> 20241216_143000.ext
# recording_%Y-%m-%d_%H%M -> recording_2024-12-16_1430.ext
# %Y/%m/%d/audio_%H%M%S -> 2024/12/16/audio_143000.ext (creates subdirs)
# Common codes: %Y=year, %m=month, %d=day, %H=hour, %M=minute, %S=second
filename_pattern = %Y%m%d_%H%M%S
# Recording filenames are fixed as <YYYYMMDD>_<HHMMSS>.<ext> (the start time,
# e.g. 20241216_143000.flac). This is not configurable: the web UI parses the
# start time back out of the filename to show the date and to name cut clips
# with real wall-clock times.
# Maximum number of connection/recording retry attempts before giving up
max_retries = 10
@@ -58,7 +55,6 @@ format = auto
# Override general settings for this source (optional):
# output_directory = recordings/streams
# split_minutes = 30
# filename_pattern = mystream_%Y%m%d_%H%M%S
# =============================================================================
@@ -93,7 +89,6 @@ format = auto
# # Override general settings for this source (optional):
# # output_directory = recordings/soundcard
# # split_minutes = 60
# # filename_pattern = soundcard_%Y%m%d_%H%M%S
# =============================================================================
@@ -105,13 +100,11 @@ format = auto
# type = stream
# url = http://radio1.example.com:8000/live
# format = auto
# filename_pattern = radio1_%Y%m%d_%H%M%S
#
# [radio_station_2]
# type = stream
# url = http://radio2.example.com:8000/live
# format = auto
# filename_pattern = radio2_%Y%m%d_%H%M%S
#
# [system_audio]
# type = soundcard
@@ -119,7 +112,6 @@ format = auto
# sample_rate = 48000
# channels = 2
# format = flac
# filename_pattern = system_%Y%m%d_%H%M%S
# =============================================================================
+9 -4
View File
@@ -42,6 +42,13 @@ except ImportError:
SOUNDFILE_AVAILABLE = False
# Fixed recording-filename timestamp format. This is the recording's *start*
# time and is the single source of truth for the clock: web.py parses it back
# out to derive the displayed date and to name cut clips with real wall-clock
# times. It is intentionally not configurable — both files must agree on it.
FILENAME_FORMAT = '%Y%m%d_%H%M%S'
# =============================================================================
# Audio Device & Backend System
# =============================================================================
@@ -329,7 +336,6 @@ class BaseRecorder(ABC):
# Common settings
self.split_duration = config.get('split_minutes', 60)
self.output_dir = config.get('output_directory', 'recordings')
self.filename_pattern = config.get('filename_pattern', '%Y%m%d_%H%M%S')
self.max_retries = config.get('max_retries', 10)
self.retry_delay = config.get('retry_delay_seconds', 5)
self.file_format = config.get('format', 'auto')
@@ -343,9 +349,9 @@ class BaseRecorder(ABC):
return next_split.replace(second=0, microsecond=0)
def generate_filename(self, ext: str) -> str:
"""Generate filename from pattern with strftime substitution."""
"""Generate filename from the fixed start-time format (see FILENAME_FORMAT)."""
now = self._clock()
filename = now.strftime(self.filename_pattern) + f".{ext}"
filename = now.strftime(FILENAME_FORMAT) + f".{ext}"
full_path = os.path.join(self.output_dir, filename)
Path(full_path).parent.mkdir(parents=True, exist_ok=True)
return full_path
@@ -806,7 +812,6 @@ class RecorderManager:
general = {
'output_directory': config.get('general', 'output_directory', fallback='recordings'),
'split_minutes': config.getint('general', 'split_minutes', fallback=60),
'filename_pattern': config.get('general', 'filename_pattern', fallback='%Y%m%d_%H%M%S', raw=True),
'max_retries': config.getint('general', 'max_retries', fallback=10),
'retry_delay_seconds': config.getint('general', 'retry_delay_seconds', fallback=5),
'log_level': config.get('general', 'log_level', fallback='INFO').upper(),
+5 -23
View File
@@ -142,7 +142,6 @@ class TestGetNextSplitTime:
r._clock = fixed_clock(now)
r.split_duration = split_minutes
r.output_dir = cfg["output_directory"]
r.filename_pattern = "%Y%m%d_%H%M%S"
r.max_retries = 3
r.retry_delay = 1
r.file_format = "auto"
@@ -184,7 +183,7 @@ class TestGetNextSplitTime:
class TestGenerateFilename:
"""Tests for BaseRecorder.generate_filename()."""
def _recorder(self, pattern: str, now: datetime, output_dir: str) -> isr.BaseRecorder:
def _recorder(self, now: datetime, output_dir: str) -> isr.BaseRecorder:
class _Rec(isr.BaseRecorder):
def record(self): pass
@@ -198,28 +197,21 @@ class TestGenerateFilename:
r._clock = fixed_clock(now)
r.split_duration = 60
r.output_dir = output_dir
r.filename_pattern = pattern
r.max_retries = 3
r.retry_delay = 1
r.file_format = "auto"
return r
def test_basic_pattern(self, tmp_path):
def test_fixed_format(self, tmp_path):
# Filenames are always <YYYYMMDD>_<HHMMSS>.<ext> — the recording start.
now = datetime(2024, 12, 25, 14, 30, 0)
r = self._recorder("%Y%m%d_%H%M%S", now, str(tmp_path))
r = self._recorder(now, str(tmp_path))
name = r.generate_filename("mp3")
assert name.endswith("20241225_143000.mp3")
def test_subdirectory_created(self, tmp_path):
now = datetime(2024, 12, 25, 14, 30, 0)
r = self._recorder("%Y/%m/%d/rec_%H%M%S", now, str(tmp_path))
name = r.generate_filename("ogg")
parent = Path(name).parent
assert parent.exists()
def test_output_dir_prefix(self, tmp_path):
now = datetime(2024, 1, 1, 0, 0, 0)
r = self._recorder("%Y%m%d", now, str(tmp_path))
r = self._recorder(now, str(tmp_path))
name = r.generate_filename("wav")
assert name.startswith(str(tmp_path))
@@ -236,7 +228,6 @@ class TestDetectFormat:
"url": "http://example.com/stream",
"output_directory": "/tmp",
"split_minutes": 60,
"filename_pattern": "%Y%m%d_%H%M%S",
"max_retries": 1,
"retry_delay_seconds": 0,
"format": "auto",
@@ -252,7 +243,6 @@ class TestDetectFormat:
r._clock = datetime.now
r.split_duration = 60
r.output_dir = "/tmp"
r.filename_pattern = "%Y%m%d_%H%M%S"
r.max_retries = 1
r.retry_delay = 0
r.file_format = "auto"
@@ -446,7 +436,6 @@ class TestRecorderManagerLoadConfig:
[general]
output_directory = {str(tmp_path / "recordings")}
split_minutes = 30
filename_pattern = test_%Y%m%d
max_retries = 3
retry_delay_seconds = 2
log_level = WARNING
@@ -491,7 +480,6 @@ format = ogg
[general]
output_directory = {str(tmp_path / "recordings")}
split_minutes = 60
filename_pattern = %Y%m%d_%H%M%S
max_retries = 10
retry_delay_seconds = 5
log_level = WARNING
@@ -501,7 +489,6 @@ log_file = {log_file}
type = stream
url = http://example.com/stream
split_minutes = 15
filename_pattern = custom_%Y%m%d
"""
cfg_path = tmp_path / "config.ini"
cfg_path.write_text(config_text)
@@ -512,7 +499,6 @@ filename_pattern = custom_%Y%m%d
rec = mgr.recorders[0]
assert rec.split_duration == 15
assert rec.filename_pattern == "custom_%Y%m%d"
def test_unknown_type_is_skipped(self, tmp_path):
log_file = str(tmp_path / "test.log")
@@ -558,7 +544,6 @@ class TestStreamRecorderRecord:
"url": "http://example.com/stream",
"output_directory": "", # overridden per-test with tmp_path
"split_minutes": 60,
"filename_pattern": "%Y%m%d_%H%M%S",
"max_retries": 2,
"retry_delay_seconds": 0,
"format": fmt,
@@ -610,7 +595,6 @@ class TestSoundcardRecorder:
"format": "wav",
"output_directory": str(tmp_path),
"split_minutes": 60,
"filename_pattern": "%Y%m%d_%H%M%S",
"max_retries": 1,
"retry_delay_seconds": 0,
}
@@ -639,7 +623,6 @@ class TestSoundcardRecorder:
"format": "flac",
"output_directory": str(tmp_path),
"split_minutes": 60,
"filename_pattern": "%Y%m%d_%H%M%S",
"max_retries": 1,
"retry_delay_seconds": 0,
}
@@ -662,7 +645,6 @@ class TestSoundcardRecorder:
"format": "flac",
"output_directory": str(tmp_path),
"split_minutes": 60,
"filename_pattern": "%Y%m%d_%H%M%S",
"max_retries": 1,
"retry_delay_seconds": 0,
}
+96 -3
View File
@@ -2,14 +2,14 @@
import math
from web import _loud_sections, _noise_floor_db
from web import _cut_filename, _loud_sections, _noise_floor_db, _recording_start
WINDOW_DUR = 0.1 # 100 ms windows, as produced by WINDOW_SAMPLES at 48 kHz
def _run(rms, margin_db=12.0, min_gap=2.0):
def _run(rms, margin_db=12.0, min_gap=2.0, min_duration=0.5):
duration = len(rms) * WINDOW_DUR
return _loud_sections(rms, WINDOW_DUR, duration, margin_db, min_gap)
return _loud_sections(rms, WINDOW_DUR, duration, margin_db, min_gap, min_duration)
def test_burst_above_quiet_floor_is_detected():
@@ -60,6 +60,69 @@ def test_min_gap_merges_nearby_bursts():
assert sections[1]['start'] == 90.0
def test_min_duration_drops_subsecond_blips():
# Isolated single-window pops (clicks, single raindrops) spaced wider than
# min_gap must not each become their own section — this is what used to
# produce thousands of zero-length sections per day.
rms = [0.002] * 1200
for i in range(600, 660, 30): # 0.1 s blips, 3 s apart (> min_gap)
rms[i] = 0.05
assert _run(rms) == []
# With the filter disabled they are all reported
assert len(_run(rms, min_duration=0.0)) == 2
def test_min_duration_keeps_sections_at_or_above_it():
rms = [0.002] * 1200
rms[600:605] = [0.05] * 5 # exactly 0.5 s
sections = _run(rms, min_duration=0.5)
assert len(sections) == 1
assert sections[0]['start'] == 60.0
def test_min_duration_applies_after_gap_merging():
# Two sub-min_duration blips within min_gap merge into one section whose
# loud span exceeds min_duration — the merged section must survive.
rms = [0.002] * 1200
rms[600] = 0.05
rms[610] = 0.05 # 1 s apart < 2 s min_gap → merged, 1.1 s span
sections = _run(rms, min_duration=1.0)
assert len(sections) == 1
assert sections[0]['start'] == 60.0
assert sections[0]['end'] >= 61.0
def test_slow_swell_scores_near_zero_sharp_burst_scores_high():
# A ~10 s swell rises faster than the 30 s floor blocks can track, so it
# still flags — but its score must collapse (onset cap) so it ranks at the
# bottom, while a sharp burst with the same peak keeps its full score.
rms = [0.002] * 1200
for k in range(80): # rise 54 → 28 dB over 8 s
rms[400 + k] = 0.002 * 10 ** (26 * (k / 80) / 20)
rms[480:500] = [0.04] * 20 # hold 2 s at 28 dB
for k in range(80): # fall back over 8 s
rms[500 + k] = 0.04 * 10 ** (-26 * (k / 80) / 20)
rms[900:910] = [0.04] * 10 # sharp 1 s burst, same peak
sections = _run(rms)
assert len(sections) == 2
swell, burst = sections
assert swell['start'] < 50.0 < 90.0 <= burst['start']
assert burst['score'] >= 24.0 # ≈ full 26 dB prominence
assert swell['score'] <= 5.0 # capped by its slow onset
assert swell['score'] < burst['score']
def test_burst_at_file_start_keeps_full_score():
# No pre-event history to measure a rise against: the onset cap falls back
# to prominence above the floor, so events cut off by a file split are not
# punished as swells.
rms = [0.05] * 10 + [0.002] * 1190
sections = _run(rms)
assert len(sections) == 1
assert sections[0]['start'] == 0.0
assert sections[0]['score'] >= 24.0
def test_noise_floor_tracks_blocks_and_ignores_short_events():
quiet_db = 20 * math.log10(0.002)
db = [quiet_db] * 1200
@@ -67,3 +130,33 @@ def test_noise_floor_tracks_blocks_and_ignores_short_events():
floor = _noise_floor_db(db, WINDOW_DUR)
assert len(floor) == len(db)
assert all(abs(f - quiet_db) < 1.0 for f in floor)
# ---------------------------------------------------------------------------
# Filename parsing / cut naming
# ---------------------------------------------------------------------------
def test_recording_start_parses_standard_name():
from datetime import datetime
assert _recording_start("20260523_220000") == datetime(2026, 5, 23, 22, 0, 0)
def test_recording_start_rejects_nonstandard_name():
assert _recording_start("radio1_20260523") is None
assert _recording_start("notes") is None
def test_cut_filename_uses_wall_clock_span():
# Recording started 22:00:00; cut covers 22:31:30 → 22:32:30.
name = _cut_filename("20260523_220000", ".flac", 1890, 1950)
assert name == "20260523_22-31-30_22-32-30.flac"
def test_cut_filename_rolls_over_the_hour():
name = _cut_filename("20260523_220000", ".wav", 3590, 3661)
assert name == "20260523_22-59-50_23-01-01.wav"
def test_cut_filename_falls_back_for_nonstandard_name():
name = _cut_filename("mixtape", ".mp3", 740, 750.4)
assert name == "mixtape_cut_740s-750s.mp3"
+128 -40
View File
@@ -24,7 +24,7 @@ import subprocess
import tempfile
import threading
import wave
from datetime import datetime
from datetime import datetime, timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
@@ -49,14 +49,28 @@ AUDIO_EXTENSIONS = {'.wav', '.mp3', '.ogg', '.flac', '.aac', '.opus'}
WINDOW_SAMPLES = 4800 # 100 ms at 48 kHz
MARGIN_DB = 12.0 # sections must rise this many dB above the noise floor
MIN_GAP_SECONDS = 2.0 # merge loud sections separated by less than this
MIN_DURATION_SECONDS = 0.5 # discard loud sections shorter than this
NOISE_BLOCK_SECONDS = 30.0 # noise floor is estimated per block of this length
NOISE_PERCENTILE = 20 # percentile of windowed dB levels taken as the floor
MIN_RMS = 0.002 # ≈ 54 dBFS; the floor never drops below this, so
# digital silence does not make every tiny sound loud
ONSET_SECONDS = 0.5 # a section's score is capped by its sharpest dB rise
# within this span, so slow swells rank low
# Bumped whenever section detection/scoring changes in a way that makes old
# cached results wrong (not just differently parameterised). Caches written
# with another version never match and are recomputed on the next analyse.
DETECTOR_VERSION = 2
CLIP_MAX_SECONDS = 600 # upper bound on /api/clip length
# Recording filenames encode the start time as this strftime format (kept in
# sync with isr.FILENAME_FORMAT). It is the authoritative recording start and
# the only reliable clock anchor — mtime drifts to the last write, so cut clip
# names and the displayed date are both derived from this.
FILENAME_FORMAT = '%Y%m%d_%H%M%S'
MIME_TYPES = {
'.wav': 'audio/wav',
'.mp3': 'audio/mpeg',
@@ -71,6 +85,35 @@ MIME_TYPES = {
# Audio analysis helpers
# ---------------------------------------------------------------------------
def _recording_start(stem: str):
"""Parse the recording start time encoded in a filename stem.
Returns a datetime, or None if the stem is not in FILENAME_FORMAT
(e.g. a manually renamed file). strptime ignores any extension because
callers pass Path.stem.
"""
try:
return datetime.strptime(stem, FILENAME_FORMAT)
except ValueError:
return None
def _cut_filename(stem: str, ext: str, start: float, end: float) -> str:
"""Name a cut by the real wall-clock span it covers.
For a recording that started at 22:00:00, a 22:31:30→22:32:30 slice
(start=1890, end=1950) becomes ``20260523_22-31-30_22-32-30.flac``.
Falls back to the source stem plus second offsets when the filename is
not in FILENAME_FORMAT (e.g. a manually renamed recording).
"""
started = _recording_start(stem)
if started is None:
return f'{stem}_cut_{int(start)}s-{int(end)}s{ext}'
cut_start = started + timedelta(seconds=start)
cut_end = started + timedelta(seconds=end)
return f'{cut_start:%Y%m%d}_{cut_start:%H-%M-%S}_{cut_end:%H-%M-%S}{ext}'
def _live_wav_header(path: Path, size: int):
"""Return the WAV header (through the 'data' chunk header) with RIFF and
data sizes rewritten to match the current file size, or None.
@@ -292,18 +335,33 @@ def _noise_floor_db(db_values: list, window_dur: float) -> list:
def _loud_sections(rms_values: list, window_dur: float, duration: float,
margin_db: float, min_gap: float = MIN_GAP_SECONDS) -> list:
margin_db: float, min_gap: float = MIN_GAP_SECONDS,
min_duration: float = MIN_DURATION_SECONDS) -> list:
"""Sections whose level rises at least margin_db above the local noise
floor. Each section carries a 'score': its peak dB above the floor, used
by the UI to rank sections by how much they stand out."""
floor. Each section carries a 'score': its peak dB above the floor,
capped by the sharpest rise observed within ONSET_SECONDS. Real events
(voices, impacts, barks) have steep onsets, so their cap equals their
peak; a swell that drifts up slower than the noise-floor blocks can track
(wind, a distant approaching car) still flags but scores near zero, so
score-ranked review (chips, U/I highlights) surfaces events first.
Sections shorter than min_duration (after min_gap merging) are discarded:
without this, every isolated 100 ms window that pops above the floor — a
click, a single raindrop — becomes its own section and a day can drown in
thousands of sub-second clips."""
db = [20 * math.log10(max(r, 1e-6)) for r in rms_values]
floor = _noise_floor_db(db, window_dur)
min_db = 20 * math.log10(MIN_RMS)
onset_win = max(1, int(round(ONSET_SECONDS / window_dur)))
sections = []
start_t = None
last_loud_t = None
peak = 0.0
onset = 0.0
def _score():
return round(max(0.0, min(peak, onset)), 1)
for i, d in enumerate(db):
t = i * window_dur
@@ -312,40 +370,42 @@ def _loud_sections(rms_values: list, window_dur: float, duration: float,
if start_t is None:
start_t = t
peak = 0.0
onset = 0.0
last_loud_t = t
peak = max(peak, d - floor_eff)
# Rise within the onset span; a section starting before the file
# has history is measured against the floor instead (an event cut
# off by a file split must not be punished as a swell).
rise = d - db[i - onset_win] if i >= onset_win else d - floor_eff
onset = max(onset, rise)
else:
if start_t is not None and (t - last_loud_t) > min_gap:
end_t = last_loud_t + window_dur
if end_t - start_t >= min_duration - 1e-9:
sections.append({'start': round(start_t, 1),
'end': round(last_loud_t + window_dur, 1),
'score': round(peak, 1)})
'end': round(end_t, 1),
'score': _score()})
start_t = None
last_loud_t = None
if start_t is not None:
if start_t is not None and (last_loud_t + window_dur - start_t) >= min_duration - 1e-9:
sections.append({'start': round(start_t, 1), 'end': round(duration, 1),
'score': round(peak, 1)})
'score': _score()})
return sections
def _package_result(rms_values: list, framerate: int, n_frames: int,
window_samples: int, margin_db: float,
min_gap: float = MIN_GAP_SECONDS) -> dict:
min_gap: float = MIN_GAP_SECONDS,
min_duration: float = MIN_DURATION_SECONDS) -> dict:
window_dur = window_samples / framerate
duration = n_frames / framerate
if len(rms_values) > 800:
step = len(rms_values) / 800
rms_display = [rms_values[int(i * step)] for i in range(800)]
else:
rms_display = rms_values
# Note: the full per-window RMS list is deliberately NOT returned — the UI
# only renders rms_display (~800 points), and the full list is ~45x larger.
# Note: no RMS data is returned — the UI is screen-reader oriented and
# draws no waveform, so sections + duration is all it needs.
return {
'rms_display': rms_display,
'sections': _loud_sections(rms_values, window_dur, duration, margin_db, min_gap),
'sections': _loud_sections(rms_values, window_dur, duration, margin_db, min_gap, min_duration),
'duration': round(duration, 2),
'window': round(window_dur, 4),
}
@@ -353,7 +413,8 @@ def _package_result(rms_values: list, framerate: int, n_frames: int,
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
margin_db: float = MARGIN_DB,
min_gap: float = MIN_GAP_SECONDS) -> dict:
min_gap: float = MIN_GAP_SECONDS,
min_duration: float = MIN_DURATION_SECONDS) -> dict:
try:
with wave.open(str(path), 'rb') as wf:
channels = wf.getnchannels()
@@ -365,12 +426,13 @@ def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
except Exception as e:
return {'error': str(e)}
return _package_result(rms_values, framerate, n_frames, window_samples, margin_db, min_gap)
return _package_result(rms_values, framerate, n_frames, window_samples, margin_db, min_gap, min_duration)
def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
margin_db: float = MARGIN_DB,
min_gap: float = MIN_GAP_SECONDS) -> dict:
min_gap: float = MIN_GAP_SECONDS,
min_duration: float = MIN_DURATION_SECONDS) -> dict:
"""Analyse a FLAC file for loudness. Requires numpy and soundfile."""
if not NUMPY_AVAILABLE or not SOUNDFILE_AVAILABLE:
return {'error': 'FLAC analysis requires: pip install numpy soundfile'}
@@ -392,7 +454,7 @@ def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
except Exception as e:
return {'error': str(e)}
return _package_result(rms_values, framerate, n_frames, window_samples, margin_db, min_gap)
return _package_result(rms_values, framerate, n_frames, window_samples, margin_db, min_gap, min_duration)
# ---------------------------------------------------------------------------
@@ -405,19 +467,22 @@ def _analysis_cache_path(analyses_base: Path, recordings_base: Path, audio_path:
def _cached_analysis_params(cache_path: Path):
"""Read just margin/min_gap from a cache file without parsing the whole
JSON (the embedded result can be hundreds of KB). Relies on the writer in
_api_analyze putting these two keys first. Caches written by the old
fixed-threshold detector have no margin key and simply never match."""
"""Read just detector/margin/min_gap/min_duration from a cache file
without parsing the whole JSON (the embedded result can be hundreds of
KB). Relies on the writer in _api_analyze putting these keys first.
Caches written by other detector versions (or so old they lack a key)
simply never match and get recomputed on the next analyse."""
try:
with open(cache_path, 'r', encoding='utf-8') as fh:
head = fh.read(256)
except OSError:
return None
m = re.search(r'"margin":\s*([0-9.eE+-]+),\s*"min_gap":\s*([0-9.eE+-]+)', head)
if not m:
m = re.search(r'"detector":\s*(\d+),\s*"margin":\s*([0-9.eE+-]+),'
r'\s*"min_gap":\s*([0-9.eE+-]+),\s*"min_duration":\s*([0-9.eE+-]+)', head)
if not m or int(m.group(1)) != DETECTOR_VERSION:
return None
return {'margin': float(m.group(1)), 'min_gap': float(m.group(2))}
return {'margin': float(m.group(2)), 'min_gap': float(m.group(3)),
'min_duration': float(m.group(4))}
def prune_orphan_analyses(analyses_base: Path, recordings_base: Path):
@@ -486,6 +551,11 @@ def list_files(recordings_dir: str):
rel = str(path.relative_to(base)).replace('\\', '/')
is_active = rel in active_files
# The recording start is encoded in the filename and is the true clock
# anchor; mtime is only a fallback for files not in FILENAME_FORMAT.
started = _recording_start(path.stem)
date = (started or datetime.fromtimestamp(stat.st_mtime)).strftime('%Y-%m-%d %H:%M:%S')
# Skip reading partial headers for in-progress files — the WAV nframes
# field and FLAC total_samples are both unfinalized while recording,
# producing wildly incorrect values (e.g. 53375995583:39:01).
@@ -495,7 +565,7 @@ def list_files(recordings_dir: str):
'name': rel,
'size': stat.st_size,
'mtime': stat.st_mtime,
'date': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
'date': date,
'duration': duration,
'ext': path.suffix.lower().lstrip('.'),
'recording': is_active,
@@ -518,6 +588,7 @@ class _Handler(BaseHTTPRequestHandler):
analyses_dir: str = 'recordings/analyses'
margin_db: float = MARGIN_DB
min_gap: float = MIN_GAP_SECONDS
min_duration: float = MIN_DURATION_SECONDS
def do_DELETE(self):
parsed = urlparse(self.path)
@@ -593,6 +664,12 @@ class _Handler(BaseHTTPRequestHandler):
except (ValueError, TypeError):
min_gap = self.min_gap
try:
min_duration = float(qs.get('min_duration', [self.min_duration])[0])
min_duration = max(0.0, min(60.0, min_duration))
except (ValueError, TypeError):
min_duration = self.min_duration
if self._is_active(filename):
self._json_err(409, 'File is currently being recorded — analysis unavailable until recording stops')
return
@@ -602,9 +679,13 @@ class _Handler(BaseHTTPRequestHandler):
cache_path = _analysis_cache_path(analyses_base, recordings_base, path)
try:
cached = json.loads(cache_path.read_text('utf-8'))
if cached.get('margin') == margin and cached.get('min_gap') == min_gap:
if (cached.get('detector') == DETECTOR_VERSION
and cached.get('margin') == margin and cached.get('min_gap') == min_gap
and cached.get('min_duration') == min_duration):
payload = dict(cached['result'])
payload.pop('rms', None) # caches written before the full-RMS field was dropped
# strip fields that older cache versions embedded
payload.pop('rms', None) # full per-window RMS list
payload.pop('rms_display', None) # ~800-point waveform preview
payload['cached'] = True
self._send(200, json.dumps(payload).encode('utf-8'), 'application/json')
return
@@ -613,12 +694,12 @@ class _Handler(BaseHTTPRequestHandler):
ext = path.suffix.lower()
if ext == '.wav':
result = analyze_wav(path, margin_db=margin, min_gap=min_gap)
result = analyze_wav(path, margin_db=margin, min_gap=min_gap, min_duration=min_duration)
elif ext == '.flac':
if not (NUMPY_AVAILABLE and SOUNDFILE_AVAILABLE):
self._json_err(400, 'FLAC analysis requires: pip install numpy soundfile')
return
result = analyze_flac(path, margin_db=margin, min_gap=min_gap)
result = analyze_flac(path, margin_db=margin, min_gap=min_gap, min_duration=min_duration)
else:
self._json_err(400, f'Loudness analysis is not available for {ext} files')
return
@@ -626,9 +707,11 @@ class _Handler(BaseHTTPRequestHandler):
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
tmp = cache_path.with_suffix('.tmp')
# margin and min_gap MUST stay first: _cached_analysis_params reads
# only the first 256 bytes of this file
tmp.write_text(json.dumps({'margin': margin, 'min_gap': min_gap, 'result': result}), 'utf-8')
# detector, margin, min_gap and min_duration MUST stay first:
# _cached_analysis_params reads only the first 256 bytes of this file
tmp.write_text(json.dumps({'detector': DETECTOR_VERSION,
'margin': margin, 'min_gap': min_gap,
'min_duration': min_duration, 'result': result}), 'utf-8')
os.replace(tmp, cache_path)
except Exception as e:
print(f'Warning: could not write analysis cache {cache_path}: {e}', flush=True)
@@ -745,7 +828,8 @@ class _Handler(BaseHTTPRequestHandler):
self._send(200, data.encode(), 'application/json')
def _api_config(self):
data = json.dumps({'margin': self.margin_db, 'min_gap': self.min_gap})
data = json.dumps({'margin': self.margin_db, 'min_gap': self.min_gap,
'min_duration': self.min_duration})
self._send(200, data.encode(), 'application/json')
def _api_delete(self, filename: str):
@@ -807,7 +891,7 @@ class _Handler(BaseHTTPRequestHandler):
return
ext = path.suffix.lower()
out_name = f'{path.stem}_cut_{int(start)}s-{int(end)}s{ext}'
out_name = _cut_filename(path.stem, ext, start, end)
# For lossless formats, re-encode (not copy) so the container header
# is rewritten with the correct duration/size. For lossy formats,
@@ -1041,6 +1125,9 @@ def main():
f'to count as loud (default: {MARGIN_DB})')
parser.add_argument('--min-gap', type=float, default=MIN_GAP_SECONDS,
help=f'Seconds gap for merging loud sections (default: {MIN_GAP_SECONDS})')
parser.add_argument('--min-duration', type=float, default=MIN_DURATION_SECONDS,
help=f'Discard loud sections shorter than this many seconds '
f'(default: {MIN_DURATION_SECONDS})')
parser.add_argument('--analyses-dir', default=None,
help='Directory for analysis cache files (default: <recordings-dir>/analyses)')
args = parser.parse_args()
@@ -1059,6 +1146,7 @@ def main():
analyses_dir = str(_analyses_dir)
margin_db = args.margin
min_gap = args.min_gap
min_duration = args.min_duration
server = _Server((args.host, args.port), Handler)
+310 -201
View File
@@ -63,8 +63,8 @@ button.del{color:var(--red);border-color:#7f1d1d}
button.del:hover:not(:disabled){background:#2d0808}
/* waveform */
.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:10px 12px}
svg.wave{display:block;width:100%;height:56px}
.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
.chips[hidden]{display:none}
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px;
padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace}
button.chip{cursor:pointer}
@@ -115,21 +115,24 @@ button.cut:hover:not(:disabled){background:#1e3a8a}
button.day-hl{color:var(--green);border-color:#166534;background:#052e16;font-size:11px}
button.day-hl:hover:not(:disabled){background:#0a3d1f}
button.day-hl:disabled{opacity:.5;cursor:default}
button.day-hl .day-arrow{font-size:9px}
.day-hl-status{opacity:.75;font-weight:400}
h2.day-heading{margin:0;font-size:inherit;font-weight:inherit;line-height:inherit;flex:1 1 auto}
.day-hl-container{background:var(--bg);border:1px solid var(--brd);border-top:none;padding:8px 12px 12px}
table.day-table{width:100%;border-collapse:collapse;border:1px solid var(--brd);border-top:none}
svg.day-timeline{display:block;width:100%;height:22px}
.day-tl-labels{display:flex;justify-content:space-between;font-size:10px;
color:var(--muted);font-family:ui-monospace,monospace;margin-top:2px}
/* 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 28px;display:flex;align-items:center;
gap:10px;flex-wrap:wrap}
/* display:flex above beats the UA's [hidden]{display:none} — restate it, or
the bar is visible from page load and the close button looks dead */
#clip-bar[hidden]{display:none}
#clip-bar audio{flex:1 1 240px;min-width:180px;height:32px}
#clip-label{font-size:12px;color:var(--muted);font-family:ui-monospace,monospace;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:38%}
#clip-auto-label{font-size:12px;color:var(--muted);display:flex;align-items:center;
gap:4px;white-space:nowrap;cursor:pointer}
#clip-adv{display:flex;align-items:center;gap:10px}
#clip-adv label{font-size:12px;color:var(--muted);
display:flex;align-items:center;gap:4px;white-space:nowrap;cursor:pointer}
body.clip-open{padding-bottom:70px}
</style>
</head>
@@ -140,7 +143,7 @@ body.clip-open{padding-bottom:70px}
<h1>ISR Archive</h1>
<span id="subtitle" aria-live="polite" aria-atomic="true">Loading…</span>
<span id="storage-info" aria-live="polite"></span>
<button id="refresh-btn" aria-label="Refresh file list">&#8635; Refresh</button>
<button id="refresh-btn" aria-label="Refresh file list">Refresh</button>
</header>
<div class="controls-bar">
<label for="margin-input">Loudness margin:</label>
@@ -155,6 +158,10 @@ body.clip-open{padding-bottom:70px}
<input type="number" id="min-gap-input" min="0" max="300" step="0.5" value="2"
aria-describedby="min-gap-hint">
<span id="min-gap-hint" class="controls-hint">seconds — merge loud sections closer than this</span>
<label for="min-duration-input" style="margin-left:16px">Min duration:</label>
<input type="number" id="min-duration-input" min="0" max="60" step="0.1" value="0.5"
aria-describedby="min-duration-hint">
<span id="min-duration-hint" class="controls-hint">seconds — ignore loud sections shorter than this</span>
</div>
<div class="filter-bar" role="search" aria-label="Filter recordings">
<label for="filter-name">Search:</label>
@@ -163,20 +170,24 @@ body.clip-open{padding-bottom:70px}
<input type="date" id="filter-from" aria-label="From date">
<label for="filter-to">To:</label>
<input type="date" id="filter-to" aria-label="To date">
<button id="filter-clear" aria-label="Clear all filters">Clear</button>
<button id="filter-clear" aria-label="Clear all filters">Clear</button>
</div>
<div class="wrap" id="main">
<div id="tbody" role="region" aria-label="Recordings archive"></div>
<div id="empty" class="empty" style="display:none" role="status">No recordings found.</div>
</div>
<div id="clip-bar" hidden role="region" aria-label="Section clip player">
<button id="clip-prev" aria-label="Previous section (J)"></button>
<button id="clip-next" aria-label="Next section (K)"></button>
<button id="clip-prev" aria-label="Previous section (J)">Prev</button>
<button id="clip-next" aria-label="Next section (K)">Next</button>
<span id="clip-label"></span>
<audio id="clip-audio" controls preload="auto" aria-label="Section clip playback"></audio>
<label id="clip-auto-label"><input type="checkbox" id="clip-auto" checked> Auto-advance</label>
<button id="clip-context" title="Open the full recording at this position">⤴ Open in file</button>
<button id="clip-close" aria-label="Close clip player"></button>
<span id="clip-adv" role="radiogroup" aria-label="Auto-advance mode">
<label><input type="radio" name="clip-adv" value="off"> Don't auto-advance</label>
<label><input type="radio" name="clip-adv" value="all" checked> Auto-advance</label>
<label title="When a clip ends, play the next-loudest section instead of the next in time"><input type="radio" name="clip-adv" value="hl"> Auto-advance highlights only</label>
</span>
<button id="clip-context" title="Open the full recording at this position (O)" aria-label="Open in file (O)">Open in file</button>
<button id="clip-close" aria-label="Close clip player">&times;</button>
</div>
<script>
const esc = s => String(s)
@@ -195,6 +206,17 @@ const fmtSize = b => {
return (b/(1<<30)).toFixed(2)+' GB';
};
const pad = n => String(n).padStart(2,'0');
// Epoch seconds -> local wall-clock "HH:MM:SS"
const fmtClock = ts => {
const d = new Date(ts * 1000);
return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
};
// Listing `date` ("YYYY-MM-DD HH:MM:SS", the recording start parsed out of
// the filename server-side) -> epoch seconds, or null for unparseable values
const fileStartEpoch = date => {
const t = Date.parse(String(date).replace(' ', 'T'));
return isNaN(t) ? null : t / 1000;
};
function announce(msg) {
const el = document.getElementById('sr-announce');
@@ -222,9 +244,26 @@ let activePlayerIdx = null;
let allFiles = [];
// dayId -> boolean, persists expanded state across re-renders
const dayExpanded = new Map();
// cross-file day section list (populated by Highlights)
// cross-file day section list (populated by the day Highlights button)
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();
// Current analysis params as one string; a highlights panel built with other
// values is stale and gets recomputed on the next expand
const hlParams = () =>
['margin-input', 'min-gap-input', 'min-duration-input']
.map(id => document.getElementById(id).value).join('|');
function setHlExpanded(dayId, exp) {
const btn = document.getElementById('dayhln-' + dayId);
if (!btn) return;
btn.setAttribute('aria-expanded', exp);
const arrow = btn.querySelector('.day-arrow');
if (arrow) arrow.textContent = exp ? '▾' : '▸';
}
function groupByDay(files) {
const map = new Map();
@@ -244,7 +283,7 @@ function closePlayer(idx) {
const btn = document.getElementById('pbtn-'+idx);
if (btn) {
btn.setAttribute('aria-expanded','false');
btn.textContent = 'Play';
btn.textContent = 'Play';
btn.setAttribute('aria-label','Play '+(recMap.get(idx) || ''));
}
}
@@ -272,47 +311,11 @@ function togglePlayer(idx, filename) {
activePlayerIdx = idx;
prow.hidden = false;
btn.setAttribute('aria-expanded','true');
btn.textContent = 'Hide';
btn.textContent = 'Hide';
btn.setAttribute('aria-label','Hide player for '+filename);
audio.focus();
}
function drawWave(rms, sections, duration, filename) {
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns,'svg');
svg.setAttribute('class','wave');
svg.setAttribute('viewBox',`0 0 ${rms.length} 1`);
svg.setAttribute('preserveAspectRatio','none');
svg.setAttribute('role','img');
const nSec = sections ? sections.length : 0;
svg.setAttribute('aria-label',
`Waveform for ${filename}: duration ${fmtDur(duration)}, ${nSec} loud section${nSec!==1?'s':''}`);
if (duration > 0 && sections) {
sections.forEach(s => {
const r = document.createElementNS(ns,'rect');
r.setAttribute('x', (s.start/duration)*rms.length);
r.setAttribute('y', 0);
r.setAttribute('width', ((s.end-s.start)/duration)*rms.length);
r.setAttribute('height', 1);
r.setAttribute('fill','rgba(249,115,22,0.22)');
r.setAttribute('aria-hidden','true');
svg.appendChild(r);
});
}
const maxV = Math.max(...rms, 0.001);
rms.forEach((v,i) => {
const h = v/maxV;
const r = document.createElementNS(ns,'rect');
r.setAttribute('x',i); r.setAttribute('y',1-h);
r.setAttribute('width',1); r.setAttribute('height',h);
r.setAttribute('fill','#4f9cf9');
r.setAttribute('aria-hidden','true');
svg.appendChild(r);
});
return svg;
}
function parseTime(s) {
if (!s || !s.trim()) return null;
const parts = s.trim().split(':').map(v => parseFloat(v));
@@ -343,11 +346,24 @@ function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
// 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.
// clipQueue holds the active review list (one file's sections, or a whole
// day's); J/K and ⏮/⏭ step through it.
// day's); J/K and the Prev/Next buttons step through it in time order, U/I
// step through the loudest-first ranking. The auto-advance radio picks what
// plays when a clip ends: nothing, the next in time, or the next by loudness.
//
// Two cursors, deliberately independent. clipCursor is the time-order position
// and follows every play (chip click, J/K, U/I). scoreCursor is the loudness
// walk's own position in scoreOrder(): only U/I (and "highlights only" auto-
// advance) move it; J/K detours to review nearby clips leave it alone, so
// coming back to U/I resumes the ranking exactly where you left it. An explicit
// jump (chip click, new queue) resets it to -1 so the next U/I re-anchors there.
let clipQueue = [];
let clipCursor = -1;
let scoreCursor = -1;
function playClip(i) {
// byScore: this play is part of a loudness walk (U/I or "highlights only"
// auto-advance), so the position shown is scoreCursor — the rank in the
// loudest-first ranking, labelled "by loudness" — not the time-order index.
function playClip(i, byScore) {
if (i < 0 || i >= clipQueue.length) return;
clipCursor = i;
const c = clipQueue[i];
@@ -357,13 +373,25 @@ function playClip(i) {
a.src = '/api/clip?file=' + encodeURIComponent(c.filename)
+ '&start=' + cs.toFixed(1) + '&end=' + ce.toFixed(1);
a.play().catch(() => {});
document.getElementById('clip-label').textContent =
`${i + 1}/${clipQueue.length} · ${c.filename} @ ${fmtDur(c.start)}${fmtDur(c.end)}`
+ (c.score != null ? ` · +${Math.round(c.score)} dB` : '');
// Label = wall-clock time of occurrence (absStart from the filename clock);
// falls back to in-file offsets for non-standard filenames. The visible
// label and the screen-reader announcement use one identical string:
// "17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)" — the count is the
// time-order index "by time" (J/K) or the loudness rank "by loudness" (U/I).
const when = c.absStart != null
? `${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 = document.getElementById('clip-label');
label.textContent = text;
label.title = `${c.filename} @ ${fmtDur(c.start)}${fmtDur(c.end)}`;
document.getElementById('clip-bar').hidden = false;
document.body.classList.add('clip-open');
setCutFields(c.fileIdx, c.start, c.end);
announce(`Clip ${i + 1} of ${clipQueue.length}: ${fmtDur(c.start)} to ${fmtDur(c.end)} in ${c.filename}`);
announce(text);
}
function hideClipBar() {
@@ -374,22 +402,70 @@ function hideClipBar() {
document.body.classList.remove('clip-open');
clipQueue = [];
clipCursor = -1;
scoreCursor = -1;
}
function playFileSection(idx, filename, si) {
const secs = sectionMap.get(idx) || [];
clipQueue = secs.map(s => ({fileIdx: idx, filename, start: s.start, end: s.end, score: s.score}));
const f = allFiles.find(f => f._idx === idx);
const epoch = f ? fileStartEpoch(f.date) : null;
clipQueue = secs.map(s => ({fileIdx: idx, filename, start: s.start, end: s.end, score: s.score,
absStart: epoch != null ? epoch + s.start : null}));
scoreCursor = -1; // explicit jump: next U/I re-anchors here
playClip(si);
}
document.getElementById('clip-prev').addEventListener('click', () => playClip(clipCursor - 1));
document.getElementById('clip-next').addEventListener('click', () => playClip(clipCursor + 1));
// Highlight order = section indices sorted loudest-first. U/I (and
// auto-advance in "highlights only" mode) walk this ranking, so a review
// runs from the most to the least interesting event until the user stops —
// no top-N cutoff.
function scoreOrder(secs) {
return secs.map((s, i) => ({i, score: s.score || 0}))
.sort((a, b) => b.score - a.score)
.map(r => r.i);
}
const advanceMode = () =>
document.querySelector('input[name="clip-adv"]:checked')?.value || 'off';
// 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).
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);
let next;
if (jump) next = dir > 0 ? order.length - 1 : 0;
else if (scoreCursor !== -1) next = scoreCursor + dir;
else {
const anchor = clipCursor >= 0 ? order.indexOf(clipCursor) : -1;
next = anchor === -1 ? (dir > 0 ? 0 : -1) : anchor + dir;
}
if (next >= 0 && next < order.length) { scoreCursor = next; playClip(order[next], true); }
else announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights');
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');
}
document.getElementById('clip-prev').addEventListener('click', () => stepClip(-1, false));
document.getElementById('clip-next').addEventListener('click', () => stepClip(1, false));
document.getElementById('clip-close').addEventListener('click', hideClipBar);
document.getElementById('clip-audio').addEventListener('ended', () => {
if (document.getElementById('clip-auto').checked && clipCursor + 1 < clipQueue.length)
playClip(clipCursor + 1);
const mode = advanceMode();
if (mode !== 'off') stepClip(1, mode === 'hl');
});
document.getElementById('clip-context').addEventListener('click', () => {
// "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.
function openClipInFile() {
const c = clipQueue[clipCursor];
if (!c) return;
document.getElementById('clip-audio').pause();
@@ -401,20 +477,28 @@ document.getElementById('clip-context').addEventListener('click', () => {
row.scrollIntoView({block: 'center'});
}
seekToSection(c.fileIdx, c.filename, c.start, c.end, null);
});
}
document.getElementById('clip-context').addEventListener('click', openClipInFile);
// filename|margin|gap -> analysis result, so re-renders (filtering,
// filename|margin|gap|minDur -> analysis result, so re-renders (filtering,
// refresh) never refetch what this session already has
const analysisCache = new Map();
async function fetchAnalysis(filename, margin, minGap, force = false) {
const key = `${filename}|${margin}|${minGap}`;
async function fetchAnalysis(filename, margin, minGap, minDur, force = false) {
const key = `${filename}|${margin}|${minGap}|${minDur}`;
if (!force && analysisCache.has(key)) return analysisCache.get(key);
const r = await fetch('/api/analyze?file='+encodeURIComponent(filename)
+'&margin='+encodeURIComponent(margin)
+'&min_gap='+encodeURIComponent(minGap));
+'&min_gap='+encodeURIComponent(minGap)
+'&min_duration='+encodeURIComponent(minDur));
const d = await r.json();
if (!d.error) analysisCache.set(key, d);
if (!d.error) {
analysisCache.set(key, d);
// Keep the listing's cache info current so re-renders (filtering) still
// know the file is analysed without refetching /api/files
const f = allFiles.find(f => f.name === filename);
if (f) f.cached_analysis = {margin, min_gap: minGap, min_duration: minDur};
}
return d;
}
@@ -424,28 +508,29 @@ async function analyse(idx, filename, cell, btn, force = false) {
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
const margin = document.getElementById('margin-input').value || '12';
const minGap = document.getElementById('min-gap-input').value || '2';
const minDur = document.getElementById('min-duration-input').value || '0.5';
const restoreBtn = () => {
btn.textContent = 'Analyse'; btn.disabled = false;
btn.onclick = () => analyse(idx, filename, cell, btn);
if (!cell.contains(btn)) cell.appendChild(btn);
};
try {
const d = await fetchAnalysis(filename, margin, minGap, force);
const d = await fetchAnalysis(filename, margin, minGap, minDur, force);
if (d.error) {
cell.innerHTML = `<div class="spin" role="alert">Error: ${esc(d.error)}</div>`;
restoreBtn(); return;
}
const box = document.createElement('div'); box.className='wbox';
box.appendChild(drawWave(d.rms_display||[], d.sections||[], d.duration||0, filename));
const nSec = (d.sections || []).length;
const meta = document.createElement('div'); meta.className='analysis-meta';
meta.textContent = `margin: ${margin} dB · gap: ${minGap}s${d.cached ? ' · cached' : ''}`;
meta.textContent = `${nSec} loud section${nSec!==1?'s':''}`
+ ` · margin: ${margin} dB · gap: ${minGap}s · min: ${minDur}s${d.cached ? ' · cached' : ''}`;
box.appendChild(meta);
const chips = document.createElement('div');
chips.className='chips';
chips.setAttribute('role','group');
chips.setAttribute('aria-label','Loud sections — click to jump, J/K to step');
chips.setAttribute('aria-label','Loud sections');
if (d.sections && d.sections.length) {
sectionMap.set(idx, d.sections);
d.sections.forEach((s, si) => {
@@ -478,60 +563,75 @@ async function analyse(idx, filename, cell, btn, force = false) {
}
}
// J = previous section, K = next section (only when focus is not in an input)
// 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. Only when focus is not in an input.
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key !== 'j' && e.key !== 'J' && e.key !== 'k' && e.key !== 'K') return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
const key = e.key.toLowerCase();
if (key !== 'j' && key !== 'k' && key !== 'u' && key !== 'i' && key !== 'o') return;
e.preventDefault();
if (key === 'o') { openClipInFile(); return; }
const dir = (key === 'j' || key === 'u') ? -1 : 1;
const byScore = key === 'u' || key === 'i';
const jump = e.shiftKey;
// Clip queue navigation (a chip was clicked or day highlights are loaded)
if (clipQueue.length) {
if (e.key === 'j' || e.key === 'J') {
if (clipCursor > 0) playClip(clipCursor - 1);
else announce('Beginning of sections');
} else {
if (clipCursor + 1 < clipQueue.length) playClip(clipCursor + 1);
else announce('End of sections');
}
stepClip(dir, byScore, jump);
return;
}
// Per-file in-player navigation (full-file listening, no clip queue)
if (activePlayerIdx === null) return;
const sections = sectionMap.get(activePlayerIdx) || [];
if (!sections.length) return;
const all = sectionMap.get(activePlayerIdx) || [];
if (!all.length) return;
const audio = document.getElementById('aud-'+activePlayerIdx);
if (!audio) return;
const preroll = getPreroll();
const cur = audio.currentTime;
if (e.key === 'j' || e.key === 'J') {
const cur = audio.currentTime;
let targetIdx = -1;
for (let i = sections.length - 1; i >= 0; i--) {
if (sections[i].start < cur - 1) { targetIdx = i; break; }
}
if (targetIdx >= 0) {
const s = sections[targetIdx];
const jumpTo = (s, i, n, word) => {
audio.currentTime = Math.max(0, s.start - preroll);
setCutFields(activePlayerIdx, s.start, s.end);
announce(`Section ${targetIdx + 1} of ${sections.length}: ${fmtDur(s.start)} to ${fmtDur(s.end)}`);
} else {
announce(`${word} ${i + 1} of ${n}: ${fmtDur(s.start)} to ${fmtDur(s.end)}`);
};
if (byScore) {
// U/I walk the ranking; the anchor is the section the playhead sits in
// (incl. its preroll lead-in). Anywhere else, I starts at the loudest.
// 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 = jump ? (dir > 0 ? order.length - 1 : 0)
: pos === -1 ? (dir > 0 ? 0 : -1) : pos + dir;
if (next >= 0 && next < order.length)
jumpTo(all[order[next]], next, order.length, 'Highlight');
else
announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights');
return;
}
// 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 {
const cur = audio.currentTime;
let jumped = false;
for (let i = 0; i < sections.length; i++) {
if (sections[i].start > cur + preroll) {
const s = sections[i];
audio.currentTime = Math.max(0, s.start - preroll);
setCutFields(activePlayerIdx, s.start, s.end);
announce(`Section ${i + 1} of ${sections.length}: ${fmtDur(s.start)} to ${fmtDur(s.end)}`);
jumped = true;
break;
for (let i = 0; i < all.length; i++) {
if (all[i].start > cur + preroll) { jumpTo(all[i], i, all.length, 'Section'); return; }
}
}
if (!jumped) announce('End of sections');
announce('End of sections');
}
});
@@ -551,11 +651,11 @@ async function deleteFile(idx, filename) {
} else {
const d = await r.json().catch(()=>({}));
alert('Delete failed: '+(d.error||r.statusText));
if (btn) { btn.disabled = false; btn.textContent = 'Delete'; }
if (btn) { btn.disabled = false; btn.textContent = 'Delete'; }
}
} catch(e) {
alert('Delete failed: '+e.message);
if (btn) { btn.disabled = false; btn.textContent = 'Delete'; }
if (btn) { btn.disabled = false; btn.textContent = 'Delete'; }
}
}
@@ -576,7 +676,8 @@ async function updateStorage() {
function cachedParamsMatch(ca) {
return ca != null
&& Number(ca.margin) === parseFloat(document.getElementById('margin-input').value)
&& Number(ca.min_gap) === parseFloat(document.getElementById('min-gap-input').value);
&& Number(ca.min_gap) === parseFloat(document.getElementById('min-gap-input').value)
&& Number(ca.min_duration) === parseFloat(document.getElementById('min-duration-input').value);
}
// Run the deferred analyses of a freshly expanded day
@@ -675,7 +776,11 @@ function renderFiles(files) {
const totalSize = dayFiles.reduce((a, f) => a + f.size, 0);
const totalDur = dayFiles.reduce((a, f) => a + (f.duration || 0), 0);
const canHl = dayFiles.some(f => (f.ext === 'wav' || f.ext === 'flac') && !f.recording);
const hlFiles = dayFiles.filter(f => (f.ext === 'wav' || f.ext === 'flac') && !f.recording);
const canHl = hlFiles.length > 0;
// Every analysable file already has a cached analysis for the current
// params -> the day's highlights are available without recomputing
const analysed = canHl && hlFiles.every(f => cachedParamsMatch(f.cached_analysis));
const durStr = totalDur > 0 ? ' · ' + fmtDur(Math.round(totalDur)) : '';
const sizeStr = ' · ' + fmtSize(totalSize);
const fileStr = `${dayFiles.length} file${dayFiles.length !== 1 ? 's' : ''}`;
@@ -694,13 +799,16 @@ function renderFiles(files) {
<button class="day-toggle" id="daytgl-${dayId}"
aria-expanded="${expanded}"
aria-controls="daytbl-${dayId}">
<span class="day-arrow" aria-hidden="true">${expanded ? '' : ''}</span>
<span class="day-arrow" aria-hidden="true">${expanded ? '' : ''}</span>
${esc(day)}
<span class="day-meta">${fileStr}${durStr}${sizeStr}</span>
</button>
</h2>
${canHl ? `<button class="day-hl" id="dayhln-${dayId}"
aria-label="Show day highlights for ${esc(day)}">★ Highlights</button>` : ''}`;
aria-expanded="false" aria-controls="dayhl-${dayId}"
aria-label="Day highlights for ${esc(day)}${analysed ? ', analysed' : ''}">
<span class="day-arrow" aria-hidden="true"></span> Highlights<span
class="day-hl-status" id="dayhls-${dayId}"${analysed ? '' : ' hidden'}> · analysed</span></button>` : ''}`;
section.appendChild(headBar);
// Highlights panel (hidden until button clicked)
@@ -759,15 +867,15 @@ function renderFiles(files) {
<button id="pbtn-${i}"
aria-expanded="false"
aria-controls="prow-${i}"
aria-label="Play ${esc(f.name)}">Play</button>
aria-label="Play ${esc(f.name)}">Play</button>
<a class="dl" href="/download/${encodeURIComponent(f.name)}"
aria-label="Download ${esc(f.name)}">Download</a>
aria-label="Download ${esc(f.name)}">Download</a>
<button id="cutbtn-${i}" class="cut"
aria-label="Cut ${esc(f.name)}"
${isRec ? 'disabled title="Cannot cut while recording"' : ''}>Cut</button>
${isRec ? 'disabled title="Cannot cut while recording"' : ''}>Cut</button>
<button id="delbtn-${i}" class="del"
aria-label="Delete ${esc(f.name)}"
${isRec ? 'disabled title="Cannot delete while recording"' : ''}>Delete</button>
${isRec ? 'disabled title="Cannot delete while recording"' : ''}>Delete</button>
</div>
</td>`;
dayTbody.appendChild(tr);
@@ -783,7 +891,7 @@ function renderFiles(files) {
<audio id="aud-${i}" controls preload="none"
aria-label="Playback: ${esc(f.name)}"></audio>${durLabel}
<div class="cut-panel">
<span class="cut-label">Cut:</span>
<span class="cut-label">Cut:</span>
<label class="cut-field">Start
<input type="text" id="cut-start-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
</label>
@@ -792,7 +900,7 @@ function renderFiles(files) {
</label>
<button id="cut-dl-${i}" class="cut"
${isRec ? 'disabled title="Cannot cut while recording"' : ''}
aria-label="Download cut of ${esc(f.name)}">Download cut</button>
aria-label="Download cut of ${esc(f.name)}">Download cut</button>
</div>
</td>`;
dayTbody.appendChild(prow);
@@ -806,13 +914,14 @@ function renderFiles(files) {
dayExpanded.set(dayId, nowExp);
const tgl = document.getElementById('daytgl-' + dayId);
tgl.setAttribute('aria-expanded', nowExp);
tgl.querySelector('.day-arrow').textContent = nowExp ? '' : '';
tgl.querySelector('.day-arrow').textContent = nowExp ? '' : '';
headBar.classList.toggle('open', nowExp);
document.getElementById('daytbl-' + dayId).hidden = !nowExp;
if (nowExp) autoloadDayAnalyses(dayId);
if (!nowExp) {
dayFiles.forEach(f => closePlayer(f._idx));
document.getElementById('dayhl-' + dayId).hidden = true;
setHlExpanded(dayId, false);
if (dayActiveId === dayId) {
dayActiveSections = [];
dayActiveId = null;
@@ -821,10 +930,26 @@ function renderFiles(files) {
}
});
// Highlights button handler
// Highlights button: expand/collapse the panel; only (re)compute when it
// has not been built yet this session or the analysis params changed
if (canHl) {
document.getElementById('dayhln-' + dayId)?.addEventListener('click', () => {
const hlFiles = dayFiles.filter(f => (f.ext === 'wav' || f.ext === 'flac') && !f.recording);
const hlRow = document.getElementById('dayhl-' + dayId);
if (!hlRow.hidden) { // collapse, keep the panel
hlRow.hidden = true;
setHlExpanded(dayId, false);
return;
}
if (hlRow.dataset.loaded === hlParams()) { // re-open and re-arm J/K
hlRow.hidden = false;
setHlExpanded(dayId, true);
dayActiveSections = dayHlSections.get(dayId) || [];
dayActiveId = dayId;
clipQueue = dayActiveSections;
clipCursor = -1;
scoreCursor = -1;
return;
}
dayHighlights(dayId, hlFiles);
});
}
@@ -837,6 +962,7 @@ async function dayHighlights(dayId, analyzableFiles) {
const btn = document.getElementById('dayhln-' + dayId);
hlRow.hidden = false;
setHlExpanded(dayId, true);
const n = analyzableFiles.length;
if (btn) btn.disabled = true;
@@ -852,6 +978,7 @@ async function dayHighlights(dayId, analyzableFiles) {
const margin = document.getElementById('margin-input').value || '12';
const minGap = document.getElementById('min-gap-input').value || '2';
const minDur = document.getElementById('min-duration-input').value || '0.5';
const results = [];
let nCached = 0, nLive = 0;
@@ -860,7 +987,7 @@ async function dayHighlights(dayId, analyzableFiles) {
progFile.textContent = `${i + 1} / ${n} — ${f.name}`;
progFill.style.width = `${(i / n) * 100}%`;
try {
const d = await fetchAnalysis(f.name, margin, minGap);
const d = await fetchAnalysis(f.name, margin, minGap, minDur);
if (!d.error) { results.push({ f, data: d }); d.cached ? nCached++ : nLive++; }
} catch(e) {}
}
@@ -874,12 +1001,14 @@ async function dayHighlights(dayId, analyzableFiles) {
return;
}
// Map files onto the day timeline using mtime as file-end, duration for start
// Map files onto the day timeline. The filename is the clock: f.date is the
// recording start parsed server-side; mtime (≈ file end) is only a fallback
// for non-standard names.
const positioned = results.map(({ f, data }) => {
const fileEnd = f.mtime;
const fileDur = data.duration || f.duration || 0;
const fileStart = fileEnd - fileDur;
return { f, data, fileStart, fileEnd, fileDur };
const startEpoch = fileStartEpoch(f.date);
const fileStart = startEpoch != null ? startEpoch : f.mtime - fileDur;
return { f, data, fileStart, fileEnd: fileStart + fileDur, fileDur };
}).filter(r => r.fileDur > 0);
if (!positioned.length) {
@@ -888,57 +1017,7 @@ async function dayHighlights(dayId, analyzableFiles) {
return;
}
const minT = Math.min(...positioned.map(r => r.fileStart));
const maxT = Math.max(...positioned.map(r => r.fileEnd));
const spanT = maxT - minT || 1;
const W = 800, H = 22;
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('class', 'wave day-timeline');
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
svg.setAttribute('preserveAspectRatio', 'none');
svg.setAttribute('role', 'img');
const totalSecs = positioned.reduce((a, r) => a + r.data.sections.length, 0);
svg.setAttribute('aria-label', `Day activity: ${results.length} file${results.length!==1?'s':''}, ${totalSecs} loud section${totalSecs!==1?'s':''}`);
// Background track
const bgR = document.createElementNS(ns, 'rect');
bgR.setAttribute('x', 0); bgR.setAttribute('y', 7);
bgR.setAttribute('width', W); bgR.setAttribute('height', 8);
bgR.setAttribute('fill', '#1e2535');
svg.appendChild(bgR);
positioned.forEach(({ f, data, fileStart, fileEnd, fileDur }) => {
const fx = ((fileStart - minT) / spanT) * W;
const fw = Math.max(1, ((fileEnd - fileStart) / spanT) * W);
// File span (dim blue)
const fr = document.createElementNS(ns, 'rect');
fr.setAttribute('x', fx); fr.setAttribute('y', 8);
fr.setAttribute('width', fw); fr.setAttribute('height', 6);
fr.setAttribute('fill', '#1e3a5f');
fr.setAttribute('aria-hidden', 'true');
svg.appendChild(fr);
// Loud sections (orange)
(data.sections || []).forEach(s => {
const sx = fx + (s.start / fileDur) * fw;
const sw = Math.max(1, ((s.end - s.start) / fileDur) * fw);
const sr = document.createElementNS(ns, 'rect');
sr.setAttribute('x', sx); sr.setAttribute('y', 4);
sr.setAttribute('width', sw); sr.setAttribute('height', 14);
sr.setAttribute('fill', '#f97316');
sr.setAttribute('rx', '1');
sr.setAttribute('aria-hidden', 'true');
svg.appendChild(sr);
});
});
const fmtHM = ts => {
const d = new Date(ts * 1000);
return d.getHours().toString().padStart(2,'0') + ':' + d.getMinutes().toString().padStart(2,'0');
};
// Build cross-file section list for J/K navigation and chips
dayActiveSections = [];
@@ -959,18 +1038,26 @@ async function dayHighlights(dayId, analyzableFiles) {
// Arm the clip queue so J/K steps through the day immediately
clipQueue = dayActiveSections;
clipCursor = -1;
scoreCursor = -1;
const box = document.createElement('div');
box.className = 'wbox';
box.style.marginBottom = '4px';
box.appendChild(svg);
const labels = document.createElement('div');
labels.className = 'day-tl-labels';
labels.innerHTML = `<span>${esc(fmtHM(minT))}</span><span>${esc(fmtHM((minT+maxT)/2))}</span><span>${esc(fmtHM(maxT))}</span>`;
box.appendChild(labels);
// Totals first, then the key hint, then the chips: linear reading order
const summary = document.createElement('div');
summary.className = 'quiet';
summary.style.marginTop = '0';
summary.textContent = `${results.length} file${results.length!==1?'s':''} analysed · ${totalSecs} loud section${totalSecs!==1?'s':''}`;
box.appendChild(summary);
if (dayActiveSections.length) {
const note = document.createElement('p');
note.className = 'quiet';
note.style.marginTop = '6px';
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.
@@ -980,45 +1067,65 @@ async function dayHighlights(dayId, analyzableFiles) {
chipList = chipList
.sort((a, b) => (b.sec.score || 0) - (a.sec.score || 0))
.slice(0, MAX_DAY_CHIPS);
const note = document.createElement('p');
note.className = 'quiet';
note.style.marginTop = '6px';
note.textContent = `${dayActiveSections.length} sections — chips show the top ${MAX_DAY_CHIPS} by loudness; J / K steps through all in time order`;
box.appendChild(note);
}
const chips = document.createElement('div');
chips.className = 'chips';
chips.setAttribute('role', 'group');
chips.setAttribute('aria-label', 'Day loud sections — click to jump, J/K to step across files');
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);
const d = new Date(sec.absStart * 1000);
const hms = d.getHours().toString().padStart(2,'0') + ':'
+ d.getMinutes().toString().padStart(2,'0') + ':'
+ d.getSeconds().toString().padStart(2,'0');
c.textContent = hms + (sec.score != null ? ` · +${Math.round(sec.score)} dB` : '');
c.textContent = fmtClock(sec.absStart) + (sec.score != null ? ` · +${Math.round(sec.score)} dB` : '');
c.addEventListener('click', () => jumpToDaySection(si));
chips.appendChild(c);
});
// A long chip list is a wall of buttons: collapse it behind a toggle
if (chipList.length > 12) {
chips.hidden = true;
const tog = document.createElement('button');
tog.style.marginTop = '8px';
tog.setAttribute('aria-expanded', 'false');
const arrow = document.createElement('span');
arrow.className = 'day-arrow';
arrow.setAttribute('aria-hidden', 'true');
// the separator space lives inside the aria-hidden arrow: a leading
// space in the text node shows up as an indent in screen readers
arrow.textContent = '▸ ';
tog.appendChild(arrow);
tog.appendChild(document.createTextNode(truncated
? `Top ${chipList.length} sections by loudness`
: `${chipList.length} sections`));
tog.addEventListener('click', () => {
const exp = chips.hidden;
chips.hidden = !exp;
tog.setAttribute('aria-expanded', exp);
arrow.textContent = exp ? '▾ ' : '▸ ';
});
box.appendChild(tog);
}
box.appendChild(chips);
}
const summary = document.createElement('div');
summary.className = 'quiet';
summary.style.marginTop = '4px';
summary.textContent = `${results.length} file${results.length!==1?'s':''} analysed · ${totalSecs} loud section${totalSecs!==1?'s':''}`;
box.appendChild(summary);
contentEl.innerHTML = '';
contentEl.appendChild(box);
hlRow.dataset.loaded = hlParams();
dayHlSections.set(dayId, dayActiveSections);
// Every file is now cached with the current params
if (results.length === n) {
document.getElementById('dayhls-' + dayId)?.removeAttribute('hidden');
// aria-label overrides the button content, so the analysed marker has to
// be mirrored into it or screen readers never hear it
if (btn && !/, analysed$/.test(btn.getAttribute('aria-label') || ''))
btn.setAttribute('aria-label', btn.getAttribute('aria-label') + ', analysed');
}
if (btn) btn.disabled = false;
}
function jumpToDaySection(si) {
if (si < 0 || si >= dayActiveSections.length) return;
clipQueue = dayActiveSections;
scoreCursor = -1; // explicit jump: next U/I re-anchors here
playClip(si);
}
@@ -1099,6 +1206,8 @@ fetch('/api/config').then(r => r.json()).then(cfg => {
document.getElementById('margin-input').value = cfg.margin;
if (cfg.min_gap != null)
document.getElementById('min-gap-input').value = cfg.min_gap;
if (cfg.min_duration != null)
document.getElementById('min-duration-input').value = cfg.min_duration;
}).catch(() => {}).finally(() => load().then(() => setInterval(pollStatus, 5000)));
</script>
</body>