Compare commits

..
11 Commits
Author SHA1 Message Date
adminandClaude Opus 5.5 2a19515c27 feat: split the web UI into modules and rework it for screen readers
webui.html becomes webui/ (index.html, style.css, ES modules in js/, no
build step); web.py serves it under / and /ui/<path> with the same
containment check as recordings.

How it feels with a screen reader:
- Days read "Today, Saturday 17 October"; recordings are named by their
  wall-clock span ("22:00 to 23:00"), which is the row header and what
  every button says. The filename moves to the last column.
- Focus is never dropped: busy buttons stay focused (aria-disabled),
  finished analyses focus their first section, delete moves to the next
  row, closing the clip bar (x or Escape) returns to where you were.
- Reloads rebuild only the days whose content changed; DOM ids are stable
  per filename, so a rollover no longer resets the reading position.
- Only user-initiated actions are announced; highlights completion is
  spoken (the progress area was stuck aria-busy before).
- Media Session previous/next track step sections like J/K (AirPods,
  lock screen, media keys).
- Row layout: Time | Length | Loudness | Player | Options | File. The
  player opens inside its cell; Download/Cut/Delete are always there,
  and Cut toggles its start/end fields in place.
- Settings tucked behind a <details>; pre-roll and auto-advance are
  remembered per browser; no CSS uppercase on headers or badges.

Fixes the REC badge showing on every row: its inline-flex display beat
the UA [hidden] rule. One global [hidden]{display:none !important} now
covers every case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:05:15 +02:00
adminandClaude Opus 5 c91264c253 refactor: split web.py — detector and audio-format helpers into isrweb/
Pure move, no behaviour change. web.py 1221 -> 807 lines.

The reason is not file size but isolation of change. CLAUDE.md records
that the next step for detection, should flagging ever need improving, is
a spectral filter or an optional Silero VAD over candidate sections —
that work now lands entirely in isrweb/loudness.py and never has to open
the HTTP layer. The byte-level format code (FLAC frame parsing, CRC-8,
live header patching) is a second self-contained cluster, touched only
when a container format misbehaves.

  isrweb/loudness.py   229  detector + its tuning constants
  isrweb/audiofile.py  250  filename clock, WAV/FLAC headers, MIME types
  web.py               807  cache, listing, handler, main()

Import direction is strictly web.py -> {loudness, audiofile}; the two
leaves import neither each other nor web.py, which is what lets the
detector be imported and tested without an HTTP server.

A package rather than flat modules because `audiofile` is a real PyPI
name and `detect` is generic enough to collide; a local file silently
shadows an installed one, which fails confusingly rather than loudly.
web.py stays at the repo root so `python web.py`, the docker-compose
command and the webui.html lookup are all unchanged.

Each module keeps its own optional numpy/soundfile try-import rather than
sharing one. That block cannot meaningfully diverge — if numpy is
installed every copy says True — and sharing it would stop isr.py being a
standalone file you can drop on a Pi and run.

Tests mirror the modules: test_loudness.py, test_audiofile.py, test_web.py
(+ unchanged test_isr.py). New tests/conftest.py owns the sys.path setup
that was previously an inline hack in test_isr.py — which only worked for
the other modules because that file happened to import first — and
provides the write_wav fixture the split would otherwise have duplicated.

Verified:
* 78 passed, same count as before the split; each test file also passes
  standalone.
* All 21 moved functions diffed against HEAD:web.py — byte-identical.
* Import graph asserted acyclic; both leaves import with `web` absent
  from sys.modules.
* An analysis cache written *before* the split still hits afterwards,
  with identical sections — the one way a "pure" move could quietly
  invalidate every user's cache.
* Endpoint smoke (analyze bounds, cache hit, cached_analysis in the
  listing, clip Content-Length, 8-bit rejection) and the byte-serving
  harness (ranges, download Range, HEAD, 405, live-header patching whole
  and straddling) both re-run green.
* Dockerfile COPY set verified by building the image's file tree in a
  temp dir and importing web.py with nothing else present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 23:14:54 +02:00
adminandClaude Opus 5 138f6db0a5 refactor: one Range-capable file sender; HEAD support; assorted hygiene
web.py
* _download and _stream each had their own copy loop; _download had no
  Range support at all, so an interrupted download of a multi-hundred-MB
  recording had to restart. Both now go through _serve_bytes(), which
  keeps _stream's live-header `prefix` trick (the patched bytes are the
  same length as the header they replace, so Range math is unaffected).
* do_HEAD answers / , /stream/ and /download/ as a body-less do_GET.
  /api/clip and /api/cut return 405 instead — generating a clip or
  shelling out to ffmpeg only to discard the result is worse than not
  answering.
* _api_delete drops the deleted file's _DURATION_CACHE entry, which was
  never evicted.
* WAV durations are rounded to 2dp like FLAC's already were.

isr.py
* _open_output_file flushed the buffer and then called
  close_current_file(), which is overridden to flush again.
* log_level/log_file were merged into the dict copied into every
  source's config; no recorder reads them. Read them where they are used.

webui.html
* scoreOrder() is memoised on the section array's identity — U/I
  otherwise re-sorted a whole day (thousands of sections) per keypress.

Verified with a byte-level harness: full/partial/open-ended/unsatisfiable
ranges byte-identical to disk, download Range, HEAD on all three
supported paths returning headers with an empty body, 405 on /api/clip,
and live-header patching still correct both whole-file and for a range
straddling the patched/disk boundary.

Not done from the audit: BaseRecorder.generate_filename's mkdir per file
open. It runs once per split (once an hour by default), so "once at
startup suffices" was not worth a behaviour change. `nul` stays in
.gitignore — it guards against a Windows shell artifact on a Windows box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 21:29:57 +02:00
adminandClaude Opus 5 0e7ea82da6 refactor: de-duplicate webui.html — shared ranking step, one param reader, one day-arm
No behaviour change. Targeted extractions only: the globals-heavy state
machine is left alone deliberately, since its interactions encode
screen-reader semantics with no JS test harness to catch a regression.

* The U/I loudness walk was implemented twice — once in stepClip's
  by-score branch, once in the keydown handler's in-player branch —
  including the anchor resolution, the bounds check and both boundary
  announcements. Extracted nextInOrder(order, pos, dir, jump). Verified
  behaviourally identical to both originals by exhaustive comparison
  over every (length, cursor, direction, shift) combination.

* The five coupled globals that arm a day's queue were set in two
  places (fresh build, re-open of a kept panel). Now armDayQueue().

* The three analysis inputs were read in three places, two of them with
  hardcoded fallbacks '12'/'2'/'0.5' — duplicating web.py's constants
  and the value= attributes, so the same three numbers lived in four
  client-side places. analysisParams() is now the only reader and the
  fallbacks are gone: an empty input sends an empty param, which
  _qs_float() resolves to the server default. hlParams() is its values
  joined.

* recMap (idx -> filename) duplicated allFiles; dropped, with
  fileNameByIdx() for the one label that needed it. pollStatus iterates
  allFiles and lets a missing badge element mark an unrendered row.

* analyse()'s btn is optional, so autoloadDayAnalyses stops fabricating
  a detached button just to have something to disable; the error path
  still produces a retry button either way.

* Unused dayId parameter on _attachFileRowHandlers; stale "waveform"
  CSS comment; vestigial .chip / button.chip split (all chips are
  buttons).

Verified: getElementById cross-check against declared and dynamic ids
(no unresolved), page serves and parses with every required id present,
pytest 78 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 21:15:49 +02:00
adminandClaude Opus 5 625ee0e7db refactor: simplify web.py — one param parser, plain cache reads, no generated handler class
No behaviour change beyond dropping an unread response field.

* _api_analyze had three identical parse-and-clamp blocks differing only
  in name/default/bounds. One _qs_float() helper; adding a fourth param
  is now a line rather than a copy of the block.

* _cached_analysis_params regexed four keys out of the first 256 bytes of
  each cache file to avoid parsing "the embedded result (hundreds of
  KB)", which forced those keys to be written first — a constraint
  documented in three places and silently fatal if ever violated. The
  RMS arrays that justified it were dropped when the waveforms went; a
  cache is now a few floats and a section list, so json.load is both
  simpler and immune to key order.

* /api/analyze no longer returns `window`. It existed to place RMS bars
  and has had no consumer since the waveform SVGs were removed.

* main() generated an anonymous _Handler subclass just to inject five
  attributes, needing an alias to work around class-body scoping. Set
  them on _Handler directly.

* `import sys` moved out of _Server.handle_error to the top of the file.

Verified with the CLAUDE.md endpoint smoke pattern: /api/analyze section
bounds, cache round-trip through the rewritten reader, cached_analysis
surfacing in /api/files, and /api/clip Content-Length == len(body) ==
44 + frames*channels*2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:46:14 +02:00
adminandClaude Opus 5 a836948882 fix: order the file listing by the filename clock, not mtime
list_files sorted on mtime while `date` — and so the DATE column, the day
grouping and both date filters — came from _recording_start(path.stem).
The two disagree for any file whose mtime drifted from its name: a copy,
a restore, an ffmpeg round-trip. Such a file landed in the right day
group but at the wrong position inside it, and could drag its whole day
group to the wrong place on the page.

'date' is fixed-width zero-padded, so sorting the string is already
chronological — no extra field needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:31:27 +02:00
adminandClaude Opus 5 b661e166ed fix: detector rejects non-16-bit WAV and stops padding the last section
_compute_rms_windows_wav honours sampwidth for frame arithmetic but
decodes as int16 unconditionally, so an 8- or 24-bit WAV produced
meaningless RMS values and therefore meaningless sections, silently.
The recorder only writes 16-bit, but --dir points at arbitrary
directories. analyze_wav now refuses anything else, turning a silent
wrong answer into a visible one.

_loud_sections closed mid-file sections at last_loud_t + window_dur but
the trailing one at the file duration. A file whose last loud event is
followed by less than min_gap of quiet reported a section stretched by
up to min_gap seconds, and the clip served for it carried that silence.
Now capped at the last loud window, consistent with every other section
and with the score, which only ever covered the loud windows.

DETECTOR_VERSION 2 -> 3 so existing caches are recomputed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:30:46 +02:00
adminandClaude Opus 5 9bb1229aa9 fix: two error paths that made a failure worse than the thing that failed
/api/clip: _clip_wav/_clip_flac send audio headers and then stream frames.
A decode failure mid-stream unwound into _api_clip's handler, which called
_json_err(500) — writing a second status line and a JSON body into a
response that had already promised Content-Type: audio/wav and an exact
Content-Length. Under HTTP/1.1 keep-alive that desyncs the connection for
every subsequent request on it, so one bad clip corrupted unrelated
traffic. Track whether the headers went out; if they did, log and force
the connection closed instead, the same way _copy_to_response already
handles under-delivery.

config.ini: the int() coercion for split_minutes/max_retries/etc sat
outside the try that wraps recorder construction, so `split_minutes =
sixty` aborted the entire config load with an unhandled ValueError rather
than skipping that one section like every other config error. Moved
inside. split_duration is also clamped to >= 1 — 0 divided by zero in
get_next_split_time().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:29:54 +02:00
adminandClaude Opus 5 1541110ffb refactor: collapse the single-implementation backend abstraction, fix device resolution
isr.py carried a plugin architecture — AudioBackend ABC, a _backend_classes
registry, _discover_backends, available_backends/get_backend, a
preferred_backend config knob — for exactly one implementation. It was
also the source of three real bugs:

* find_device() returned None before reaching the ALSA PCM-name fallback
  whenever `arecord -l` enumerated nothing, so the documented
  `device = shared_mic` (dsnoop from asound.conf) case failed outright on
  any host with no hardware card. The fallback is the whole point: those
  PCMs never appear in `arecord -l`.

* `device = monitor` could never resolve. ALSA has no generic monitor
  source and list_devices hardcoded is_monitor=False, so the keyword —
  advertised in config.example.ini as the "EASY" way to capture system
  audio — only ever produced "device not found". Removed; it is a
  PulseAudio idea that outlived the PulseAudio backend.

* list_all_devices() deduplicated by lowercased name "in case a device
  appears in multiple backends". With one backend what it actually did
  was delete the second of two identical USB cards. Dedup dropped.

find_device() now always returns a device, so _resolve_device's
unreachable "Available devices:" error is gone; an unknown spec is
passed to ALSA verbatim and logged. AudioDevice loses backend/
is_monitor plus channels/sample_rate — the latter were hardcoded 2/44100
and only ever printed by --list-devices, i.e. numbers with no bearing on
what gets recorded.

--list-devices now says outright that it lists hardware cards only, which
is where the shared_mic confusion started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:28:30 +02:00
adminandClaude Opus 5 4aa7f7253e fix: day grouping compares against the local date, not the UTC one
renderFiles() derived "today" from new Date().toISOString(), which is
UTC, and compared it to f.date — the recording start in local time,
parsed server-side out of the filename. In any non-UTC timezone the two
disagree for part of every day, and the current day's group (the one
always wanted open) renders collapsed: 00:00-02:00 local in UTC+2, most
of the evening in UTC-5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:21:17 +02:00
adminandClaude Opus 5 46af41b12d fix: soundcard capture detects a dead arecord instead of silently recording nothing
ALSAStream._read_loop just returned when arecord died or hit EOF, and
__exit__ never checked the exit status. SoundcardRecorder.record's inner
loop only slept and flushed, so nothing ever noticed: the recorder stayed
"running", kept rolling over to a new split file on schedule, and wrote
zero bytes into every one of them. The max_retries/reconnect path below
it was unreachable. Worst kind of failure for an unattended recorder —
files keep appearing, at the right times, with the right names.

ALSAStream now tracks when audio last arrived and exposes check_alive(),
which raises if the process exited or nothing has been captured for
SILENCE_TIMEOUT (15 s). The record loop calls it every 0.5 s tick, so a
dead device unwinds into the existing retry handler.

retry_count resets only when the stream actually delivered audio —
resetting on every healthy tick would let a device that opens but never
captures retry forever, which is the same silent failure by another
route.

Also: join the stderr thread in __exit__, and break _read_loop on EOF
rather than looping until poll() catches up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:21:05 +02:00
29 changed files with 2961 additions and 2222 deletions
+1
View File
@@ -7,3 +7,4 @@ __pycache__/
.pytest_cache/ .pytest_cache/
.claude/ .claude/
nul nul
.claude-notes/
+38 -19
View File
@@ -5,15 +5,17 @@ Guidance for Claude Code when working in this repository.
## Rules ## Rules
- **Always update `README.md`** when user-facing behaviour changes (flags, endpoints, Docker setup, features), and **commit it in the same commit** as the code change. README is the external reference; CLAUDE.md documents internals. - **Always update `README.md`** when user-facing behaviour changes (flags, endpoints, Docker setup, features), and **commit it in the same commit** as the code change. README is the external reference; CLAUDE.md documents internals.
- Run `python -m pytest tests/` after changing `isr.py` or `web.py` (tests cover the recorder and the loud-section detector). - Run `python -m pytest tests/` after changing `isr.py`, `web.py`, `isrweb/` or `webui/js/` (tests cover the recorder, the detector, the filename clock and the HTTP layer). Test files mirror the modules: `test_isr.py`, `test_loudness.py`, `test_audiofile.py`, `test_web.py`; `tests/conftest.py` owns the sys.path setup and the `write_wav` fixture.
## Files ## Files
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `isr.py` | Recorder: streams (Icecast/HTTP) + ALSA soundcards, time-aligned file splits | | `isr.py` | Recorder: streams (Icecast/HTTP) + ALSA soundcards, time-aligned file splits |
| `web.py` | Archive browser: HTTP server, file listing, RMS loudness analysis, cut/delete | | `web.py` | Archive browser: HTTP server, file listing, analysis cache, cut/delete. Entry point — stays at the repo root so `python web.py` and the `webui/` lookup work |
| `webui.html` | Single-page UI (HTML/CSS/JS), loaded by `web.py` at startup — must sit next to `web.py` and be copied in the Dockerfile | | `isrweb/loudness.py` | The adaptive loud-section detector. Pure computation, imports nothing from the app — future spectral/VAD work lands here and never touches HTTP |
| `isrweb/audiofile.py` | What an audio file is and what its name means: the filename clock, FLAC/WAV header parsing, live-header patching. Also a leaf |
| `webui/` | Browser UI: `index.html`, `style.css`, ES modules in `js/` (no build step). Served by `web.py` (`/` and `/ui/<path>`) — must sit next to `web.py` and be copied in the Dockerfile |
| `config.ini` | Recording sources; copy from `config.example.ini`. `[general]` gives defaults, every other section is a source (`type = stream` or `type = soundcard`) | | `config.ini` | Recording sources; copy from `config.example.ini`. `[general]` gives defaults, every other section is a source (`type = stream` or `type = soundcard`) |
| `asound.conf` | dsnoop device `shared_mic` so ISR and other ALSA apps can share a soundcard | | `asound.conf` | dsnoop device `shared_mic` so ISR and other ALSA apps can share a soundcard |
@@ -30,23 +32,40 @@ Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC
## Code map ## Code map
`web.py`: **Import direction is strictly `web.py → {isrweb.loudness, isrweb.audiofile}`.** The two leaves never import each other or `web.py`; keep it that way, it is what lets the detector be tested without an HTTP server. Each module carries its own optional numpy/soundfile try-import — that boilerplate cannot meaningfully diverge, and sharing it would stop `isr.py` being a standalone file.
- 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): `isrweb/loudness.py`:
- Clip review: `clipQueue`/`clipCursor`/`scoreCursor` globals, `playClip()`, `playFileSection()`, `hideClipBar()`; markup is the `#clip-bar` div. The clip label shows wall-clock occurrence time + dB prominence + position (`17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)`): queue entries carry `absStart` (epoch s), derived from `fileStartEpoch(f.date)` — the filename clock — with in-file offsets as fallback for non-standard names; only the filename/in-file offset lives in the tooltip now. **Queue entries carry the filename, never a row index:** `_idx` is reassigned positionally on every `load()`, and `pollStatus()` reloads whenever a recording starts/stops/rolls over, so a stored index goes stale mid-review (a past bug had "Open in file"/cut fields target the row one off after a split rollover). Resolve at use time via `fileIdxByName()`. **`playClip()` builds one `text` string used for both `label.textContent` and the `announce()` aria-live message** — they must never diverge (a past bug had the announcement read `Clip N of M: …` while the label read the wall-clock form). **Two cursors, deliberately independent:** `clipCursor` is the time-order position and follows every play; `scoreCursor` is the loudness walk's own position in `scoreOrder()` and is moved *only* by U/I (and "highlights only" auto-advance) in `stepClip`'s by-score branch — J/K must never touch it, so a J/K detour to review nearby clips leaves the loudness walk's place intact and the next U/I resumes the ranking where it left off. `scoreCursor` resets to `-1` on every explicit jump / queue (re-)arm (`hideClipBar`, `playFileSection`, `jumpToDaySection`, both day-highlights arm points), so the next U/I re-anchors on `clipCursor` (the section just selected) else the loudest. `playClip(i, byScore)`: when `byScore` the count shows `scoreCursor` (rank, suffix "by loudness"), else `i` (time-order index, suffix "by time") — the suffix tells the blind user which dimension the count is in. `stepClip`'s by-score branch passes `byScore=true`; every other caller (chip click, J/K, Prev/Next) leaves it false. - Detection: `_compute_rms_windows_wav()` / `analyze_flac()` produce 100 ms RMS windows (at 48 kHz — `WINDOW_SAMPLES` is a fixed sample count, so windows are wider on lower-rate files; `analyze_wav()` rejects anything that is not 16-bit, which the RMS decoder assumes) → `_noise_floor_db()` estimates the rolling floor → `_loud_sections()` emits scored sections → `_package_result()` shapes the `/api/analyze` payload.
- Day review: `dayHighlights()` builds `dayActiveSections` (chronological); `jumpToDaySection()` arms the queue. Section `absStart` comes from `fileStartEpoch(f.date)` (filename clock), mtime−duration only as fallback. **The user is blind and uses a screen reader — there is deliberately no day-timeline SVG** (one existed and was removed on request as useless); the highlights panel is linear text/buttons: summary line → key-hint note → chips toggle → chips. Do not add decorative visualizations; any future graphic must be aria-hidden and must not be the only carrier of information. Chip lists longer than 12 are collapsed behind an `aria-expanded` toggle button (the `.chips[hidden]{display:none}` rule is required — the author-level `display:flex` on `.chips` would otherwise override the UA `[hidden]` rule). Group `aria-label`s stay short ("Day loud sections") — the J/K/U/I key explanation lives only in the visible note, per user feedback against repeating info text in labels. The Highlights button is a collapse/expand toggle (`setHlExpanded()` keeps arrow + `aria-expanded` in sync, also from the day-collapse path): a built panel is kept and re-armed from `dayHlSections` instead of recomputing, keyed by `hlRow.dataset.loaded = hlParams()` (margin|gap|minDur string) so changed params force a re-run. The `#dayhls-<dayId>` "· analysed" suffix appears when every file's `cached_analysis` passes `cachedParamsMatch()`; `fetchAnalysis()` updates `f.cached_analysis` client-side so the marker survives re-renders without refetching `/api/files`. **`aria-label` on a button replaces its visible content for screen readers** — any status text rendered inside a labelled button (like the analysed suffix) must be mirrored into the label (render path + the end of `dayHighlights()`), or the blind user never hears it. Likewise, the accessible text of a button built as `aria-hidden arrow span + text node` must not start the text node with a space (the hidden arrow drops out and the leading space reads as an indent) — keep separator spaces inside the aria-hidden span.
- J/K/U/I/O: single document-level `keydown` listener — clip queue takes priority, in-player `currentTime` stepping is the fallback when no queue is armed; O calls `openClipInFile()` (shared with the "Open in file" button). J/K (and Prev/Next) always step in time order; U/I walk the loudest-first ranking from `scoreOrder()` — no top-N cutoff (the `#clip-top` input and `#clip-hl-only` checkbox were removed deliberately; J/K must never be affected by an auto-advance/highlights setting). Auto-advance is the `input[name="clip-adv"]` radio (off / next in time / next by loudness), read by `advanceMode()`; `stepClip(dir, byScore)` is the shared queue-stepping path. In-player U/I anchor the ranking on the section under the playhead, else start at the loudest. `isrweb/audiofile.py`:
- 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). - 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). Plus `_wav_header()` (44-byte PCM header for clips), `_get_audio_duration()`, `MIME_TYPES`, `AUDIO_EXTENSIONS`.
`web.py`:
- Clips: `_api_clip()` validates params, `_clip_wav()` / `_clip_flac()` stream the decoded slice.
- Serving: `_serve_bytes()` is the single Range-capable file sender — `_stream()` (live header patch via its `prefix` argument, `no_store`) and `_download()` (`Content-Disposition`) both go through it, and it is what makes `do_HEAD` a body-less `do_GET`. Plus `_copy_to_response()`, `_safe_path()` (path traversal guard). `_ui()` serves `webui/` (same containment check, html/css/js only, read per request so UI edits need no restart).
`webui/js/` — native ES modules, one concern each. **Each module imports only from modules earlier in this list:** `util` → `api` → `store` → `settings` → `player` → `clips` → `analysis` → `day` → `list` → `nav` → `main`. Keep it acyclic; when an earlier module needs a later one it goes through the DOM (`openClipInFile()` clicks the day toggle) rather than an import. Mutable shared state is a module-level `export let` reassigned only inside its own module (importers get the live binding).
- `util.js`: formatting (`fmtDur`, `fmtClock`, `fmtHM`, `fmtDurWords`, `dayLabel()` → "Today, Saturday 17 October"), `announce()` (the single `#sr-announce` live region), `chipToggle()` (collapses chip lists > 12 behind an `aria-expanded` button), `remember` (try/catch localStorage for per-browser conveniences only: pre-roll, auto-advance, settings open).
- `api.js`: every URL. `store.js`: `allFiles`, `sectionMap`, `fileSpan(f)` (the spoken name of a recording, "22:00 to 23:00", from the filename clock). **`_idx` is stable per filename for the whole session** (assigned on first sight, never reused), so DOM ids (`row-`, `pbtn-`, `aud-`, `wave-`, …) survive reloads. Queue entries still carry the filename, never an index — the file can disappear; resolve at use time via `fileIdxByName()`.
- `settings.js`: `analysisParams()` is the only reader of the three analysis inputs — no hardcoded client-side defaults, so web.py's constants stay the single source of truth via `/api/config` (and they are deliberately *not* remembered in localStorage); `hlParams()` is just its values joined. `cachedParamsMatch()` (autoload guard), `getPreroll()`, `advanceMode()`.
- `player.js`: per-row full-file player and cut fields (`togglePlayer`, `seekToSection`, `setCutFields`, `toggleCut`, `activePlayerIdx`). Row layout (user's choice): Time (row header) | Length | Loudness | Player | Options | File — one table row per recording. The `<audio>` opens *inside* the Player cell next to the Play/Hide button (there is no separate player row any more); the Options cell (Download, Cut, Delete) is always visible, and Cut is an `aria-expanded` toggle for the start/end fields inside it.
- `clips.js` — clip review: `playClip()`, `hideClipBar()`, `armQueue()` (point the queue at a list, reset both cursors), `playFrom()` (explicit jump), `stepClip()`, `openClipInFile()`. The clip label shows wall-clock occurrence time + dB prominence + position (`17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)`); `absStart` (epoch s) comes from `fileStartEpoch(f.date)` — the filename clock — with in-file offsets as fallback; the filename/offset lives in the tooltip. **`playClip()` builds one `text` string used for `label.textContent`, the `announce()` message and the Media Session title** — they must never diverge (a past bug had the announcement read `Clip N of M: …` while the label read the wall-clock form). **Two cursors, deliberately independent:** `clipCursor` is the time-order position and follows every play; `scoreCursor` is the loudness walk's own position in `scoreOrder()` and is moved *only* by U/I (and "Next loudest" auto-advance) in `stepClip`'s by-score branch — J/K must never touch it, so a J/K detour leaves the loudness walk's place intact and the next U/I resumes the ranking. `scoreCursor` resets to `-1` on every explicit jump / queue (re-)arm (`hideClipBar`, `armQueue`, `playFrom`), so the next U/I re-anchors on `clipCursor` else the loudest. `playClip(i, byScore)`: when `byScore` the count shows `scoreCursor` (suffix "by loudness"), else `i` ("by time") — the suffix tells the blind user which dimension the count is in. Both U/I walks (`stepClip` and the in-player branch of `nav.navigate()`) share `nextInOrder(order, pos, dir, jump)`; they must not re-inline it. Closing the bar (× or Escape) returns focus to where it was when the bar opened.
- `analysis.js`: `fetchAnalysis()` (session cache; updates `f.cached_analysis` client-side so the analysed marker survives re-renders), `analyse()` (result cell: meta line → chips with wall-clock labels → Re-analyse), `analyseButton()`, `autoloadDayAnalyses()`.
- `day.js` — day review: `toggleHighlights()` (the Highlights button is a collapse/expand toggle; a built panel is kept and re-armed from `dayHlSections`, keyed by `panel.dataset.loaded = hlParams()` so changed params force a re-run), `dayHighlights()` builds the chronological cross-file section list, `armDayQueue()` is the single place that points the queue at a day, `releaseDay()` on day collapse. **The user is blind and uses a screen reader — there is deliberately no day-timeline SVG** (one existed and was removed on request as useless); the panel is linear text/buttons: summary line → key-hint note → chips toggle → chips. Do not add decorative visualizations; any future graphic must be aria-hidden and must not be the only carrier of information. Group `aria-label`s stay short ("Day loud sections") — the J/K/U/I key explanation lives only in the visible note, per user feedback against repeating info text in labels.
- `list.js`: `renderFiles()` is **incremental** — each day section carries `data-sig` (`daySignature()`: params + names + recording flags + durations) and is rebuilt only when that changes, so a rollover touches today alone and the rest of the page (and the screen reader's position) stays put; focus inside a rebuilt day is restored by id. `pollStatus()` still reloads only when no audio is playing. `deleteFile()` moves focus to the next row's Play button. `load({speak})`/`applyFilters({speak})`: only user-initiated actions announce; background reloads are silent.
- `nav.js`: the single document-level J/K/U/I/O `keydown` listener and `navigate()` — clip queue first, in-player `currentTime` stepping as the fallback; O = `openClipInFile()`. Media Session previous/next track call `navigate(∓1)` (AirPods, lock screen, keyboard media keys). J/K (and Prev/Next) always step in time order; U/I walk `scoreOrder()` with no top-N cutoff (a `#clip-top` input and `#clip-hl-only` checkbox were removed deliberately; J/K must never be affected by an auto-advance setting). Auto-advance is the `input[name="clip-adv"]` radio (`off` / `all` = next in time / `hl` = next loudest).
Screen-reader rules learned the hard way (apply to any markup change):
- **`aria-label` on a button replaces its visible content** — status text rendered inside a labelled button is never heard. The day Highlights button therefore has *no* aria-label: its content (`Highlights` + sr-only "for <day>" + the `#dayhls-` " · analysed" / progress suffix) is its name.
- The accessible text of a button built as `aria-hidden arrow span + text` must not start the text node with a space (reads as an indent) — keep separator spaces inside the aria-hidden span.
- Never disable or replace the focused element: busy buttons get `aria-disabled` + a guard, and code that replaces a container moves focus to its replacement (`analyse()` → first chip).
- `[hidden]{display:none !important}` in style.css is load-bearing: author `display` rules (flex chips, inline-flex REC badge, flex clip bar) otherwise beat the UA `[hidden]` rule — the REC badge once showed on every row that way.
## Verifying changes ## Verifying changes
- `python -m pytest tests/` covers the recorder (`test_isr.py`) and the detector (`test_web.py`). - `python -m pytest tests/` covers the recorder (`test_isr.py`) and the detector (`test_web.py`).
- There is no JS toolchain and no `node` on the dev box. After editing `webui.html`, cross-check every `getElementById('x')` against an `id="x"` declaration, and smoke-test endpoints. - There is no JS toolchain and no `node` on the dev box. `test_web.py` checks every relative module import resolves. After editing `webui/`, cross-check every `$('x')`/`getElementById('x')` against an `id="x"` declaration, then smoke-test in a real browser: headless Edge is installed (`C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe --headless=new --virtual-time-budget=60000 --dump-dom <url>`). Serve `web.py` over generated WAVs with `WEBUI_DIR` pointed at a scratch copy of `webui/` plus a harness page that iframes `/`, drives it (click, dispatch `keydown`, stub `confirm`), and writes PASS/FAIL lines into its own DOM for `--dump-dom` to print.
- Endpoint smoke pattern: write a temp WAV/FLAC with a known loud burst, subclass `web._Handler` with `recordings_dir`/`analyses_dir` pointing at the temp dir, serve `web._Server(('127.0.0.1', 0), H)` in a daemon thread, then hit `/api/analyze` and `/api/clip` with urllib — assert section start/score and that `Content-Length == len(body) == 44 + frames × channels × 2`. - Endpoint smoke pattern: write a temp WAV/FLAC with a known loud burst, subclass `web._Handler` with `recordings_dir`/`analyses_dir` pointing at the temp dir, serve `web._Server(('127.0.0.1', 0), H)` in a daemon thread, then hit `/api/analyze` and `/api/clip` with urllib — assert section start/score and that `Content-Length == len(body) == 44 + frames × channels × 2`.
- Dev box is Windows / PowerShell 5.1. Multi-line commit messages: use the Bash tool with `git commit -F - <<'EOF'` — PowerShell here-strings containing quotes get mangled into separate arguments. - Dev box is Windows / PowerShell 5.1. Multi-line commit messages: use the Bash tool with `git commit -F - <<'EOF'` — PowerShell here-strings containing quotes get mangled into separate arguments.
@@ -59,10 +78,10 @@ Dependencies: `requests` (streams), `numpy` + `soundfile` (FLAC output and FLAC
- **ALSA:** capture spawns `arecord` as a subprocess, raw PCM read in 100 ms chunks by a thread. Device spec resolution: `default` → exact `hw:X,Y` → partial name → fallback to any literal ALSA PCM name (so `shared_mic` from asound.conf works without appearing in `arecord -l`). - **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`. - **Shutdown:** SIGTERM is converted to KeyboardInterrupt in `main()`; `RecorderManager.stop()` joins all threads against a single shared 25 s deadline to stay inside Docker's `stop_grace_period: 30s`.
- **Loud-section detection is adaptive — do not regress it to an absolute threshold.** Per-window dB is compared against a rolling noise floor (`NOISE_PERCENTILE`-th percentile per `NOISE_BLOCK_SECONDS` block, min-smoothed over ±2 blocks so events can't raise their own floor; clamped to ≥ `MIN_RMS`). A section needs `margin` dB of prominence and carries a `score` used for ranking: peak dB above floor, **capped by the sharpest rise within `ONSET_SECONDS` (0.5 s)** — so a short (~10 s) swell that outruns the 30 s floor blocks still flags but scores ≈ 0 and sinks in the U/I highlight ranking, while sharp events keep their full prominence. A section starting in the first 0.5 s of a file is scored against the floor instead (events cut off by a file split must not be punished as swells). Do not regress the scoring to raw peak, and do not fight swells with a higher margin. If flagging itself (not just ranking) ever needs improving, the next step is a spectral filter or optional Silero VAD over candidate sections. Sections shorter than `min_duration` (default 0.5 s, after `min_gap` merging) are discarded — without this, isolated 100 ms pops (clicks, single raindrops) produced thousands of zero-length sections per day. The original fixed RMS threshold flagged every ambience change (passing cars, rain) and produced ~600 useless sections/day — that is why it was replaced. Tests in `tests/test_web.py`. - **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 params are coupled in five places.** CLI `--margin`/`--min-gap`/`--min-duration` → `/api/config` → UI inputs `#margin-input`/`#min-gap-input`/`#min-duration-input` → `/api/analyze` query params → cache JSON keys. Renaming or adding a param means touching all five plus `analysisParams()`/`cachedParamsMatch()`/`initSettings()` (settings.js), `api.analyze()` and `_cached_analysis_params()`. Query-string parsing/clamping for all three goes through the single `_qs_float()` helper (see the threshold→margin change `c84b7d8` and the min_duration addition).
- **Analysis cache:** results stored as `<analyses-dir>/<file>.analysis.json` keyed by margin+min_gap+min_duration; orphans pruned at web startup. In Docker the recordings mount is **read-only** for the web container, so docker-compose layers a read-write `./recordings/analyses` bind mount over it. 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. - **Analysis cache:** results stored as `<analyses-dir>/<file>.analysis.json` keyed by margin+min_gap+min_duration; orphans pruned at web startup. In Docker the recordings mount is **read-only** for the web container, so docker-compose layers a read-write `./recordings/analyses` bind mount over it. `_cached_analysis_params()` just `json.load`s the file — key order carries no meaning. (It used to regex the four param keys out of the first 256 bytes, which forced them to be written first; that was worth it only while caches embedded the RMS arrays, and those are gone.) `detector` is `DETECTOR_VERSION`: bump it whenever detection/scoring changes make old cached results wrong (v2 = onset-capped scores, v3 = a section open at EOF ends at its last loud window rather than at the file duration); caches with another version (or missing keys) never match and get overwritten on the next analyse.
- **Analyze responses:** `/api/analyze` returns only `sections`, `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. - **Analyze responses:** `/api/analyze` returns only `sections` and `duration` — no RMS data of any kind. `rms_display` (~800-point waveform preview), the full per-window list, and `window` (the window length, which only existed to place RMS bars) were all dropped when the waveform SVGs were removed (user is blind, see webui notes). Old caches that embed `rms`/`rms_display` have both keys popped when served from cache.
- **Section playback uses clips, not seeks:** `/api/clip?file&start&end` decodes the slice server-side (wave/soundfile) and returns a standalone 16-bit WAV with exact Content-Length (capped at `CLIP_MAX_SECONDS`), `Cache-Control: private` so re-listening is free. The UI plays chips/J-K through the bottom clip bar (`clipQueue` in webui.html); seeking the full file only happens via "Open in file". Rationale (finding): libsndfile writes FLAC **without a SEEKTABLE**, so a browser seek bisects the whole multi-hundred-MB file with Range requests — seeking big FLACs in `<audio>` is inherently slow and must not be reintroduced as the primary navigation. Server-side `sf.SoundFile.seek()` on local disk is fast and frame-accurate. - **Section playback uses clips, not seeks:** `/api/clip?file&start&end` decodes the slice server-side (wave/soundfile) and returns a standalone 16-bit WAV with exact Content-Length (capped at `CLIP_MAX_SECONDS`), `Cache-Control: private` so re-listening is free. The UI plays chips/J-K through the bottom clip bar (`clipQueue` in `webui/js/clips.js`); seeking the full file only happens via "Open in file". Rationale (finding): libsndfile writes FLAC **without a SEEKTABLE**, so a browser seek bisects the whole multi-hundred-MB file with Range requests — seeking big FLACs in `<audio>` is inherently slow and must not be reintroduced as the primary navigation. Server-side `sf.SoundFile.seek()` on local disk is fast and frame-accurate.
- **HTTP/1.1 keep-alive:** `_Handler.protocol_version = 'HTTP/1.1'`; every response path must set an accurate `Content-Length`. `_copy_to_response()` force-closes the connection if it under-delivers (file truncated mid-serve). - **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. - **Live playback:** for files listed in status.json, `/stream/` patches the header on the fly so the browser sees the duration recorded so far and can seek; responses get `Cache-Control: no-store`. WAV: `_live_wav_header` derives sizes from the byte count. FLAC: `_live_flac_header` parses the sample count out of the last frame header in the file tail (CRC-8-verified to reject false sync matches) and rewrites STREAMINFO total_samples — duration is NOT derivable from byte size for FLAC.
- **Path safety:** every file parameter in `web.py` goes through `_safe_path()`, which resolves and verifies the path stays inside the recordings dir. - **Path safety:** every file parameter in `web.py` goes through `_safe_path()`, which resolves and verifies the path stays inside the recordings dir.
+3 -1
View File
@@ -10,7 +10,9 @@ WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY isr.py web.py webui.html ./ COPY isr.py web.py ./
COPY webui/ ./webui/
COPY isrweb/ ./isrweb/
RUN mkdir -p /recordings RUN mkdir -p /recordings
+21 -12
View File
@@ -107,7 +107,7 @@ format = wav # wav | flac
| `hw:X,Y` | Exact ALSA hardware ID | | `hw:X,Y` | Exact ALSA hardware ID |
| `<pcm name>` | Any ALSA PCM defined in `asound.conf` (e.g. the `shared_mic` dsnoop device), even if it doesn't appear in `arecord -l` | | `<pcm name>` | Any ALSA PCM defined in `asound.conf` (e.g. the `shared_mic` dsnoop device), even if it doesn't appear in `arecord -l` |
Run `python isr.py --list-devices` (or `arecord -l`) to see available devices and their IDs. Run `python isr.py --list-devices` (or `arecord -l`) to see available devices and their IDs. It lists **hardware cards only** — a PCM name from `asound.conf` never appears there but can still be used directly, including on hosts where no hardware card is enumerable at all. An unrecognised value is passed to ALSA verbatim, so a typo shows up as an `arecord` error in the log rather than at startup.
FLAC output requires `pip install soundfile numpy`. FLAC output requires `pip install soundfile numpy`.
@@ -149,30 +149,33 @@ python web.py --min-duration 1 # discard loud sections shorter than
python web.py --analyses-dir /path/to/dir # where to store analysis cache files (default: <recordings>/analyses) python web.py --analyses-dir /path/to/dir # where to store analysis cache files (default: <recordings>/analyses)
``` ```
The browser UI (HTML/CSS/JS) lives in `webui.html`, which `web.py` loads at startup — keep the two files together. The browser UI lives in the `webui/` folder (`index.html`, `style.css` and plain JavaScript modules in `webui/js/` — no build step), which `web.py` serves under `/ui/`. `web.py` also imports the `isrweb/` package (the loud-section detector and the audio-format helpers) — keep `web.py`, `webui/` and `isrweb/` together.
Shows recordings grouped by day with collapsible sections. Features: Shows recordings grouped by day with collapsible sections. Features:
- **Day groups** — recordings are grouped under a collapsible day heading showing date, file count, total duration, and total size. The most recent day is expanded by default; older days start collapsed. Expanded state is preserved across filter changes. - **Day groups** — recordings are grouped under a collapsible day heading showing the date in words (`Today, Saturday 17 October`, `Yesterday, …`), recording count, total duration, and total size. Today is expanded by default; older days start collapsed. Expanded state is preserved across filter changes.
- **Recordings named by time** — each row is named by the wall-clock span it covers (`22:00 to 23:00`, `12:00 to now` while recording), taken from the filename clock; that name is the table's row header and what every button says (`Play 22:00 to 23:00`). Format, size and the raw filename are in the last column.
- **Day highlights** — click **Highlights** on any day heading to run loudness analysis across all WAV/FLAC files in that day. The button is a toggle: clicking again collapses the panel, and re-expanding it reuses the already-computed results (they are only recomputed when the analysis parameters change). A **· analysed** suffix on the button marks days where every file already has a cached analysis for the current parameters, i.e. highlights open instantly. The panel is plain text and buttons in linear reading order (screen-reader friendly): files-analysed/section totals, a key hint, then the section chips. When a day has more sections than fit as chips, the chips are the top 50 by score (loudest-above-background first) so the most promising events are reviewed first; long chip lists are collapsed behind a toggle button so the panel stays compact. J/K still steps through all sections in time order, and U/I steps through them by loudness. Holding **Shift** jumps straight to an extreme: **Shift+J**/**Shift+K** to the first/last section in time, **Shift+U**/**Shift+I** to the loudest/quietest. - **Day highlights** — click **Highlights** on any day heading to run loudness analysis across all WAV/FLAC files in that day. The button is a toggle: clicking again collapses the panel, and re-expanding it reuses the already-computed results (they are only recomputed when the analysis parameters change). A **· analysed** suffix on the button marks days where every file already has a cached analysis for the current parameters, i.e. highlights open instantly. The panel is plain text and buttons in linear reading order (screen-reader friendly): files-analysed/section totals, a key hint, then the section chips. When a day has more sections than fit as chips, the chips are the top 50 by score (loudest-above-background first) so the most promising events are reviewed first; long chip lists are collapsed behind a toggle button so the panel stays compact. J/K still steps through all sections in time order, and U/I steps through them by loudness. Holding **Shift** jumps straight to an extreme: **Shift+J**/**Shift+K** to the first/last section in time, **Shift+U**/**Shift+I** to the loudest/quietest.
- **Inline playback** — collapsible `Play` button per row; audio loads lazily via a seekable `/stream/` endpoint with HTTP Range support. Metadata is fetched immediately so the duration is visible without pressing play. - **Row layout** — columns are Time, Length, Loudness, Player, Options, File. The **Player** cell holds the `Play` button; pressing it opens the audio player right there in the same cell (the button becomes `Hide`). The **Options** cell next to it is always available: Download, Cut, Delete.
- **Inline playback** — the player opens in the row's Player cell; audio loads lazily via a seekable `/stream/` endpoint with HTTP Range support. Metadata is fetched immediately so the duration is visible without pressing play.
- **Loudness analysis** — on demand per file; computes RMS per 100 ms window and lists sections that stand out above the background as clickable chips (no visual waveform — the UI is built for screen-reader use). Detection is **adaptive**: a rolling noise floor (20th percentile per 30 s block) is estimated across the file, and a section is flagged when the level rises at least *margin* dB (default 12) above that floor. Slow ambience changes — rain setting in, day/night traffic hum — move the floor instead of producing false positives. Each section gets a **score** used to rank it: its peak dB above the floor, capped by the sharpest rise within 0.5 s. Abrupt events — voices, impacts, barks — rise fast, so their score is their full prominence; a gradual swell (a gust, a distant approaching car) that drifts up faster than the floor can track still gets flagged, but scores near zero and sinks to the bottom of the highlight ranking. Supported for WAV and FLAC (FLAC requires `numpy` + `soundfile`). Pure-Python fallback for WAV when numpy is absent. Results are cached in `recordings/analyses/<filename>.analysis.json`; subsequent requests at the same margin, min-gap, and min-duration settings return instantly without re-reading the audio. The cache file is deleted automatically when the audio file is deleted. Orphaned cache files (audio deleted outside the UI) are pruned on startup. - **Loudness analysis** — on demand per file; computes RMS per 100 ms window and lists sections that stand out above the background as clickable chips (no visual waveform — the UI is built for screen-reader use). Detection is **adaptive**: a rolling noise floor (20th percentile per 30 s block) is estimated across the file, and a section is flagged when the level rises at least *margin* dB (default 12) above that floor. Slow ambience changes — rain setting in, day/night traffic hum — move the floor instead of producing false positives. Each section gets a **score** used to rank it: its peak dB above the floor, capped by the sharpest rise within 0.5 s. Abrupt events — voices, impacts, barks — rise fast, so their score is their full prominence; a gradual swell (a gust, a distant approaching car) that drifts up faster than the floor can track still gets flagged, but scores near zero and sinks to the bottom of the highlight ranking. Supported for WAV and FLAC (FLAC requires `numpy` + `soundfile`). Pure-Python fallback for WAV when numpy is absent. Results are cached in `recordings/analyses/<filename>.analysis.json`; subsequent requests at the same margin, min-gap, and min-duration settings return instantly without re-reading the audio. The cache file is deleted automatically when the audio file is deleted. Orphaned cache files (audio deleted outside the UI) are pruned on startup.
- **Grace period** — configurable in the controls bar (default 2 s). Loud sections separated by less than this gap are merged into one. Raise this (e.g. to 15–30 s) when a single event generates many timestamps due to brief quiet gaps within it. - **Grace period** — configurable under **Settings** (default 2 s). Loud sections separated by less than this gap are merged into one. Raise this (e.g. to 15–30 s) when a single event generates many timestamps due to brief quiet gaps within it.
- **Min duration** — configurable in the controls bar (default 0.5 s). Loud sections shorter than this (after grace-period merging) are discarded, so isolated sub-second pops — a click, a single raindrop — don't flood a day with thousands of near-zero-length sections. Set to 0 to disable. - **Min duration** — configurable under **Settings** (default 0.5 s). Loud sections shorter than this (after grace-period merging) are discarded, so isolated sub-second pops — a click, a single raindrop — don't flood a day with thousands of near-zero-length sections. Set to 0 to disable.
- **Clip playback** — clicking a loud-section chip plays a short server-rendered WAV clip (`/api/clip`, pre-roll included) in a player bar at the bottom of the page. Playback starts instantly even for sections deep inside multi-hundred-MB FLACs, because the browser never has to seek the full file. The player bar labels each clip with the wall-clock time it occurred (derived from the recording's filename), its loudness above the background, and its position in the queue, e.g. `17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)` — and the exact same text is announced to screen readers, so the spoken and visible labels never differ. The filename and in-file offset are in the label's hover tooltip. **J** / **K** (or the **Prev** / **Next** buttons) always step through the queued sections in time order — one file's, or a whole day's after **Highlights** — and the count is then the chronological index, labelled **by time** (e.g. `30 / 426 by time` is the 30th section in time). **U** / **I** step through the same queue by loudness instead: **I** plays the next-loudest section, **U** goes back up the ranking, and the count becomes the rank in that loudness order, labelled **by loudness** (`1 / 426 by loudness` is the loudest of all reachable sections, `2 / 426 by loudness` the next-loudest, and so on) — the denominator stays the full count because U/I reaches every section, with no top-N cutoff; the chip list shown on screen is capped only for display. So a day with thousands of detections is reviewed loudest-first for as long as it stays interesting — just stop when it gets boring. The loudness walk keeps its own position **independently** of J/K: find a loud moment with **U** / **I**, press **J** / **K** to review the clips immediately before and after it in time, and pressing **U** / **I** again resumes the loudness ranking exactly where you left it — the J/K detour never moves your place in the loudest-first walk. (Clicking a chip is an explicit jump, so it re-anchors the next U / I there.) The 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. - **Clip playback** — clicking a loud-section chip plays a short server-rendered WAV clip (`/api/clip`, pre-roll included) in a player bar at the bottom of the page. Playback starts instantly even for sections deep inside multi-hundred-MB FLACs, because the browser never has to seek the full file. The player bar labels each clip with the wall-clock time it occurred (derived from the recording's filename), its loudness above the background, and its position in the queue, e.g. `17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)` — and the exact same text is announced to screen readers, so the spoken and visible labels never differ. The filename and in-file offset are in the label's hover tooltip. **J** / **K** (or the **Prev** / **Next** buttons) always step through the queued sections in time order — one file's, or a whole day's after **Highlights** — and the count is then the chronological index, labelled **by time** (e.g. `30 / 426 by time` is the 30th section in time). **U** / **I** step through the same queue by loudness instead: **I** plays the next-loudest section, **U** goes back up the ranking, and the count becomes the rank in that loudness order, labelled **by loudness** (`1 / 426 by loudness` is the loudest of all reachable sections, `2 / 426 by loudness` the next-loudest, and so on) — the denominator stays the full count because U/I reaches every section, with no top-N cutoff; the chip list shown on screen is capped only for display. So a day with thousands of detections is reviewed loudest-first for as long as it stays interesting — just stop when it gets boring. The loudness walk keeps its own position **independently** of J/K: find a loud moment with **U** / **I**, press **J** / **K** to review the clips immediately before and after it in time, and pressing **U** / **I** again resumes the loudness ranking exactly where you left it — the J/K detour never moves your place in the loudest-first walk. (Clicking a chip is an explicit jump, so it re-anchors the next U / I there.) The **When a clip ends** choice in the player bar picks what plays next: **Stop**, **Next in time**, or **Next loudest** — it never changes what J/K do, and it is remembered in the browser along with the pre-roll. Holding **Shift** with any of these jumps to the extreme in that direction — **Shift+J**/**Shift+K** to the first/last section in time, **Shift+U**/**Shift+I** to the loudest/quietest. The same keys work during full-file playback, seeking the open recording to the next section in time (J/K) or by loudness (U/I). **Open in file** (or the **O** key) switches to the full recording at the same position for context; each chip click also pre-fills the cut panel. **Escape** in the player bar closes it and returns focus to where you were.
- **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`. - **Media keys and headsets** — previous/next track step through the sections like J/K: AirPods and headset buttons, the iOS lock screen and Control Center, and the media keys on a keyboard. The lock screen shows the same clip label as the player bar.
- **Cut & download** — the `Cut` button in the Options cell expands the start/end fields right below it (and collapses them again); focus goes to Start, and playing a section pre-fills both. Enter start and end times in `m:ss` or `h:mm:ss` format and click **Download cut** to receive an ffmpeg-trimmed copy without re-encoding. Requires ffmpeg (included in the Docker image). The cut is named with the real wall-clock span it covers — `<YYYYMMDD>_<HH-MM-SS>_<HH-MM-SS>.<ext>`, e.g. a 22:31:30→22:32:30 slice of a recording started at 22:00:00 becomes `20260523_22-31-30_22-32-30.flac`.
- **Filters** — live filename search and from/to date pickers above the table; applied client-side with no additional requests. Shows `N of M shown` when a filter is active. - **Filters** — live filename search and from/to date pickers above the table; applied client-side with no additional requests. Shows `N of M shown` when a filter is active.
- **Delete** — `Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/<name>` and re-renders the table. - **Delete** — `Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/<name>`, then moves focus to the next recording's Play button.
- **Live REC badge** — files currently being written by `isr.py` show an animated REC indicator, polled every 5 seconds via `/api/status`. Duration for in-progress files shows `—` in the table (header is unfinalized until recording stops). The file list refreshes automatically when a recording starts, stops, or rolls over to a new split file (unless audio is playing). - **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`. - **Listen while recording** — in-progress files are playable and seekable. For WAV and FLAC the server patches the (still unfinalized) header on the fly so the browser sees the real duration-so-far — for FLAC the exact sample count is parsed from the last frame header in the file tail. Reopening the player reloads the source to pick up newly recorded audio. Live responses are sent with `Cache-Control: no-store`.
- **Fast loading** — analysis results are cached server-side on disk and client-side per session; cached analyses load only for expanded day groups, and collapsed days fetch nothing until opened. - **Fast loading** — analysis results are cached server-side on disk and client-side per session; cached analyses load only for expanded day groups, and collapsed days fetch nothing until opened.
- **WCAG-compliant** — skip link, `aria-expanded`/`aria-controls` on the player toggle, `aria-live` status, focus management; all information is plain text and buttons in linear reading order, with no visual-only elements. - **Built for screen readers** — landmarks and headings (one heading per day, the clip player has its own), analysis settings tucked behind a collapsed **Settings** disclosure, skip link, `aria-expanded`/`aria-controls` on every toggle; all information is plain text and buttons in linear reading order, with no visual-only elements. Focus is never dropped: a busy button stays focused while it works, finished analyses move focus to their first section, and background reloads (a recording rolling over) rebuild only the day that changed, so the rest of the page — and your reading position — stays put. Things you asked for are announced (analysis results, highlights ready, refresh and filter counts); background events stay silent.
### Too many sections per day? ### 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: The detector is purely energy-based: anything that rises *margin* dB above the rolling background floor for at least *min duration* seconds is flagged. In a quiet environment the floor sits very low, so even modest sounds — a gust, a distant car, rustling — clear the margin, and an outdoor day can easily produce over a thousand sections. What to adjust, in rough order of preference:
1. **Review by rank instead of thinning the list** — use **U**/**I** (or **Auto-advance highlights only**) to step through the sections loudest-first. Scores favour sharp onsets, so slow ambience swells sort themselves to the bottom automatically. Nothing is discarded and there is no cutoff — just stop when the events stop being interesting. 1. **Review by rank instead of thinning the list** — use **U**/**I** (or **Next loudest** in the player bar) to step through the sections loudest-first. Scores favour sharp onsets, so slow ambience swells sort themselves to the bottom automatically. Nothing is discarded and there is no cutoff — just stop when the events stop being interesting.
2. **Raise the grace period** (2 → 15–30 s) — merges clusters of related noise (a rain burst, one long conversation) into a single section. Cuts the count heavily without dropping any audio from review. 2. **Raise the grace period** (2 → 15–30 s) — merges clusters of related noise (a rain burst, one long conversation) into a single section. Cuts the count heavily without dropping any audio from review.
3. **Raise the margin** (12 → 15–18 dB) — demands more prominence above the background. The quietest events disappear first, so move in small steps. 3. **Raise the margin** (12 → 15–18 dB) — demands more prominence above the background. The quietest events disappear first, so move in small steps.
4. **Raise min duration** (0.5 → 1–2 s) — drops short rustles and pops, but beware: single bangs or knocks are themselves short. 4. **Raise min duration** (0.5 → 1–2 s) — drops short rustles and pops, but beware: single bangs or knocks are themselves short.
@@ -190,12 +193,16 @@ Everything the UI does goes through these endpoints, so they can also be scripte
| `GET /api/clip?file=&start=&end=` | Section of a WAV/FLAC decoded server-side, returned as a standalone WAV (max 600 s) | | `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 /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 /stream/<name>` | Inline playback with HTTP Range support; live files get an on-the-fly patched header |
| `GET /download/<name>` | Raw file download | | `GET /download/<name>` | Raw file download, with HTTP Range support so an interrupted transfer can resume |
| `GET /api/status` | Currently recording files (`status.json` passthrough) | | `GET /api/status` | Currently recording files (`status.json` passthrough) |
| `GET /api/storage` | Disk free/total | | `GET /api/storage` | Disk free/total |
| `GET /api/config` | Server-side defaults for margin, min-gap, and min-duration (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 | | `DELETE /api/files/<name>` | Delete a recording and its analysis cache |
The UI itself is `GET /` plus its static files under `GET /ui/<path>`.
`HEAD` is answered for `/`, `/ui/<path>`, `/stream/<name>` and `/download/<name>` (headers only, no body) — the generated endpoints (`/api/clip`, `/api/cut`) return `405` rather than doing the work and discarding it.
Analysis, clips, cut, and delete return `409` for files that are still being recorded. Analysis, clips, cut, and delete return `409` for files that are still being recorded.
--- ---
@@ -208,6 +215,8 @@ Analysis, clips, cut, and delete return `409` for files that are still being rec
Both recorder types run in separate threads and retry independently up to `max_retries`. Both recorder types run in separate threads and retry independently up to `max_retries`.
Soundcard capture is health-checked twice a second: if `arecord` exits, or delivers no audio for 15 seconds while still running (device unplugged, a `dsnoop` device that went away), the source is torn down and reopened as a normal retry. Only a stream that actually delivered audio resets the retry counter, so a device that opens but never captures stops after `max_retries` instead of producing an endless series of empty split files.
--- ---
## Docker notes ## Docker notes
+3 -13
View File
@@ -67,16 +67,14 @@ format = auto
# #
# # Device selection (use one of these methods): # # Device selection (use one of these methods):
# # device = default - Use system default input device # # device = default - Use system default input device
# # device = monitor - Auto-select first monitor/loopback source (for system audio)
# # device = <name> - Match device by partial name (e.g., "Stereo Mix") # # device = <name> - Match device by partial name (e.g., "Stereo Mix")
# # device = <id> - Use exact device ID from --list-devices # # device = <id> - Use exact device ID from --list-devices
# # device = <pcm> - Any ALSA PCM name from asound.conf (e.g. shared_mic);
# # these never show up in --list-devices but work anyway
# # # #
# # To list available devices, run: python isr.py --list-devices # # To list available devices, run: python isr.py --list-devices
# device = default # device = default
# #
# # Audio backend (only 'alsa' is supported):
# # backend = alsa
#
# # Sample rate in Hz (common: 44100, 48000, 96000) # # Sample rate in Hz (common: 44100, 48000, 96000)
# sample_rate = 44100 # sample_rate = 44100
# #
@@ -122,17 +120,10 @@ format = auto
# #
# Run: python isr.py --list-devices (or: arecord -l) to list devices. # Run: python isr.py --list-devices (or: arecord -l) to list devices.
# #
# EASY: Use the "monitor" keyword to capture system/loopback audio: # Specify a device by ALSA hardware ID from --list-devices:
# [system_audio]
# type = soundcard
# device = monitor
# format = wav
#
# MANUAL: Specify a device by ALSA hardware ID from --list-devices:
# [usb_mic] # [usb_mic]
# type = soundcard # type = soundcard
# device = hw:1,0 # device = hw:1,0
# backend = alsa
# sample_rate = 44100 # sample_rate = 44100
# channels = 2 # channels = 2
# format = flac # format = flac
@@ -148,7 +139,6 @@ format = auto
# [usb_mic_shared] # [usb_mic_shared]
# type = soundcard # type = soundcard
# device = shared_mic # device = shared_mic
# backend = alsa
# sample_rate = 48000 # sample_rate = 48000
# channels = 2 # channels = 2
# format = flac # format = flac
+166 -230
View File
@@ -50,118 +50,34 @@ FILENAME_FORMAT = '%Y%m%d_%H%M%S'
# ============================================================================= # =============================================================================
# Audio Device & Backend System # Audio Device Discovery (ALSA)
# ============================================================================= # =============================================================================
@dataclass @dataclass
class AudioDevice: class AudioDevice:
"""Represents an audio input device.""" """An ALSA capture device.
id: str # Backend-specific identifier
name: str # Human-readable name Only the identity matters: capture format comes from config (sample_rate,
channels: int # Max input channels channels), not from the device, so this deliberately carries no
sample_rate: int # Default sample rate capability fields — reporting a card's channel count here would just be a
backend: str # Backend name ('alsa') number that has no bearing on what actually gets recorded.
is_default: bool = False # Is system default """
is_monitor: bool = False # Is a monitor/loopback source id: str # ALSA device spec: 'hw:1,0', or a PCM name
name: str # Human-readable name from `arecord -l`
is_default: bool = False
def __str__(self): def __str__(self):
flags = [] return f"{self.name} ({self.id})" + (" [DEFAULT]" if self.is_default else "")
if self.is_default:
flags.append("DEFAULT")
if self.is_monitor:
flags.append("MONITOR")
flag_str = f" [{', '.join(flags)}]" if flags else ""
return f"{self.name}{flag_str} ({self.backend})"
class AudioBackend(ABC):
"""Abstract base for audio capture backends."""
name: str = "base"
@classmethod
@abstractmethod
def is_available(cls) -> bool:
"""Check if this backend can be used on the current system."""
pass
@classmethod
@abstractmethod
def list_devices(cls) -> List[AudioDevice]:
"""List all available input devices."""
pass
@abstractmethod
def open_stream(self, device: AudioDevice, sample_rate: int, channels: int,
callback: Callable[[bytes], None]) -> Any:
"""Open an audio capture stream. Returns a context manager."""
pass
class ALSABackend(AudioBackend):
"""ALSA backend using arecord (raw PCM output, no sound server required)."""
name = "alsa"
@classmethod
def is_available(cls) -> bool:
return shutil.which('arecord') is not None
@classmethod
def list_devices(cls) -> List[AudioDevice]:
devices = []
try:
result = subprocess.run(
['arecord', '-l'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode != 0:
return devices
for line in result.stdout.split('\n'):
if not line.startswith('card '):
continue
try:
card_part, rest = line.split(':', 1)
card_num = card_part.replace('card', '').strip()
if ', device ' in rest:
dev_part = rest.split(', device ')[1]
dev_num = dev_part.split(':')[0].strip()
else:
dev_num = '0'
long_name = rest.split('[')[1].split(']')[0] if '[' in rest else rest.strip()
hw_id = f"hw:{card_num},{dev_num}"
devices.append(AudioDevice(
id=hw_id,
name=long_name,
channels=2,
sample_rate=44100,
backend=cls.name,
is_default=(card_num == '0' and dev_num == '0'),
is_monitor=False,
))
except (IndexError, ValueError):
continue
except Exception:
pass
return devices
def __init__(self, logger: logging.Logger):
self.logger = logger
def open_stream(self, device: AudioDevice, sample_rate: int, channels: int,
callback: Callable[[bytes], None]):
return ALSAStream(device, sample_rate, channels, callback, self.logger)
class ALSAStream: class ALSAStream:
"""Context manager for ALSA recording using arecord (raw PCM output).""" """Context manager for ALSA recording using arecord (raw PCM output)."""
# No audio for this long means capture is dead even though the process is
# still up (device yanked, dsnoop shm gone). Generous enough that normal
# scheduling hiccups never trip it.
SILENCE_TIMEOUT = 15.0
def __init__(self, device: AudioDevice, sample_rate: int, channels: int, def __init__(self, device: AudioDevice, sample_rate: int, channels: int,
callback: Callable[[bytes], None], logger: logging.Logger): callback: Callable[[bytes], None], logger: logging.Logger):
self.device = device self.device = device
@@ -169,12 +85,16 @@ class ALSAStream:
self.channels = channels self.channels = channels
self.callback = callback self.callback = callback
self.logger = logger self.logger = logger
self.received_audio = False
self._running = False self._running = False
self._thread = None self._thread = None
self._stderr_thread = None
self._process = None self._process = None
self._last_data = 0.0
def __enter__(self): def __enter__(self):
self._running = True self._running = True
self._last_data = time.monotonic()
cmd = [ cmd = [
'arecord', 'arecord',
'-D', self.device.id, '-D', self.device.id,
@@ -207,8 +127,25 @@ class ALSAStream:
self._process.wait(timeout=2) self._process.wait(timeout=2)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
self._process.kill() self._process.kill()
if self._thread: for thread in (self._thread, self._stderr_thread):
self._thread.join(timeout=1) if thread:
thread.join(timeout=1)
def check_alive(self):
"""Raise if capture has stopped delivering audio.
Nothing else notices a dead arecord: _read_loop just returns, so
without this the recorder would keep rolling over to new split files
on schedule and write nothing into any of them — a failure that is
invisible from the outside because the files still appear on time.
"""
rc = self._process.poll()
if rc is not None:
raise RuntimeError(f"arecord exited with code {rc}")
idle = time.monotonic() - self._last_data
if idle > self.SILENCE_TIMEOUT:
raise RuntimeError(
f"no audio from '{self.device.id}' for {idle:.0f}s")
def _log_stderr(self): def _log_stderr(self):
for line in self._process.stderr: for line in self._process.stderr:
@@ -221,7 +158,10 @@ class ALSAStream:
while self._running and self._process.poll() is None: while self._running and self._process.poll() is None:
try: try:
data = self._process.stdout.read(chunk_size) data = self._process.stdout.read(chunk_size)
if data: if not data:
break # EOF: arecord is on its way out, don't spin
self._last_data = time.monotonic()
self.received_audio = True
self.callback(data) self.callback(data)
except Exception as e: except Exception as e:
self.logger.error(f"ALSA read error: {e}") self.logger.error(f"ALSA read error: {e}")
@@ -229,95 +169,94 @@ class ALSAStream:
class AudioSystem: class AudioSystem:
"""Manages audio backends and device discovery.""" """Discovers ALSA capture devices and opens capture streams.
# All available backend classes ALSA (via `arecord`) is the only backend. This was once a plugin system
_backend_classes: List[type] = [ALSABackend] with a registry and an abstract base class; there has only ever been one
implementation, so it is a plain class now.
"""
def __init__(self, logger: logging.Logger): def __init__(self, logger: logging.Logger):
self.logger = logger self.logger = logger
self._backends: Dict[str, AudioBackend] = {} self.available = shutil.which('arecord') is not None
self._discover_backends()
def _discover_backends(self): def list_devices(self) -> List[AudioDevice]:
"""Find and initialize available backends.""" """Hardware capture devices, parsed out of `arecord -l`.
for cls in self._backend_classes:
if cls.is_available():
self._backends[cls.name] = cls(self.logger)
self.logger.debug(f"Audio backend available: {cls.name}")
@property Virtual PCMs defined in asound.conf (dsnoop, plug, ...) are NOT listed
def available_backends(self) -> List[str]: by `arecord -l` and so never appear here — see find_device().
return list(self._backends.keys()) """
devices = []
try:
result = subprocess.run(
['arecord', '-l'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode != 0:
return devices
def get_backend(self, name: str) -> Optional[AudioBackend]: for line in result.stdout.split('\n'):
return self._backends.get(name) if not line.startswith('card '):
continue
try:
card_part, rest = line.split(':', 1)
card_num = card_part.replace('card', '').strip()
def list_all_devices(self) -> List[AudioDevice]: if ', device ' in rest:
"""List devices from all available backends.""" dev_part = rest.split(', device ')[1]
all_devices = [] dev_num = dev_part.split(':')[0].strip()
seen_names = set() else:
dev_num = '0'
for cls in self._backend_classes: long_name = rest.split('[')[1].split(']')[0] if '[' in rest else rest.strip()
if cls.name in self._backends:
for dev in cls.list_devices():
# Deduplicate by name (same device may appear in multiple backends)
key = dev.name.lower()
if key not in seen_names:
all_devices.append(dev)
seen_names.add(key)
return all_devices devices.append(AudioDevice(
id=f"hw:{card_num},{dev_num}",
name=long_name,
is_default=(card_num == '0' and dev_num == '0'),
))
except (IndexError, ValueError):
continue
except Exception:
pass
return devices
def find_device(self, spec: str, preferred_backend: str = None) -> Optional[AudioDevice]: def find_device(self, spec: str) -> AudioDevice:
"""Find a device by ID, name, or pattern.""" """Resolve a config `device =` value to an ALSA device.
devices = self.list_all_devices()
if not devices:
return None
# "default" - get the default device from preferred backend
if spec.lower() == 'default':
for dev in devices:
if dev.is_default:
if preferred_backend is None or dev.backend == preferred_backend:
return dev
return devices[0] if devices else None
# "monitor" - get first monitor source
if spec.lower() == 'monitor':
for dev in devices:
if dev.is_monitor:
if preferred_backend is None or dev.backend == preferred_backend:
return dev
return None
Never returns None. An unrecognised spec is passed to ALSA verbatim,
because a valid PCM name from asound.conf (`shared_mic`, `plughw:0`)
does not appear in `arecord -l` — and that must keep working even on a
host where no hardware card is enumerable at all, which is exactly the
case an earlier "no devices, give up" guard broke.
"""
devices = self.list_devices()
spec_lower = spec.lower() spec_lower = spec.lower()
# Try exact ID match first if spec_lower == 'default':
for dev in devices: for dev in devices:
if dev.id == spec: if dev.is_default:
return dev return dev
# 'default' is itself a valid ALSA PCM when no card is enumerable.
return devices[0] if devices else AudioDevice(id='default', name='default')
# Try exact name match exact_id = [d for d in devices if d.id == spec]
for dev in devices: exact_name = [d for d in devices if d.name.lower() == spec_lower]
if dev.name.lower() == spec_lower: partial = [d for d in devices if spec_lower in d.name.lower()]
return dev for candidates in (exact_id, exact_name, partial):
if candidates:
return candidates[0]
# Try partial name match self.logger.info(
for dev in devices: f"Device '{spec}' is not in `arecord -l` — using it as an ALSA PCM name")
if spec_lower in dev.name.lower(): return AudioDevice(id=spec, name=spec)
return dev
# No hardware device matched. Treat spec as a direct ALSA PCM name so def open_stream(self, device: AudioDevice, sample_rate: int, channels: int,
# virtual devices defined in asound.conf (dsnoop, plug, etc.) work without callback: Callable[[bytes], None]) -> 'ALSAStream':
# appearing in `arecord -l`. """Open a capture stream. Returns a context manager."""
if 'alsa' in self._backends: return ALSAStream(device, sample_rate, channels, callback, self.logger)
return AudioDevice(
id=spec, name=spec, channels=2, sample_rate=44100, backend='alsa'
)
return None
class BaseRecorder(ABC): class BaseRecorder(ABC):
@@ -333,8 +272,9 @@ class BaseRecorder(ABC):
self.current_filename = None self.current_filename = None
self._clock = clock or datetime.now self._clock = clock or datetime.now
# Common settings # Common settings. split_duration is clamped: 0 would divide by zero
self.split_duration = config.get('split_minutes', 60) # in get_next_split_time(), negative would split every tick.
self.split_duration = max(1, config.get('split_minutes', 60))
self.output_dir = config.get('output_directory', 'recordings') self.output_dir = config.get('output_directory', 'recordings')
self.max_retries = config.get('max_retries', 10) self.max_retries = config.get('max_retries', 10)
self.retry_delay = config.get('retry_delay_seconds', 5) self.retry_delay = config.get('retry_delay_seconds', 5)
@@ -646,7 +586,7 @@ class _AudioFileWriter:
class SoundcardRecorder(BaseRecorder): class SoundcardRecorder(BaseRecorder):
"""Records from soundcard/audio devices using multiple backends.""" """Records from a soundcard/audio device via ALSA."""
def __init__(self, name: str, config: Dict[str, Any], logger: logging.Logger, def __init__(self, name: str, config: Dict[str, Any], logger: logging.Logger,
audio_system: 'AudioSystem' = None, audio_system: 'AudioSystem' = None,
@@ -656,9 +596,9 @@ class SoundcardRecorder(BaseRecorder):
# Initialize audio system if not provided # Initialize audio system if not provided
self.audio_system = audio_system or AudioSystem(logger) self.audio_system = audio_system or AudioSystem(logger)
if not self.audio_system.available_backends: if not self.audio_system.available:
raise RuntimeError( raise RuntimeError(
"No audio backends available.\n" "arecord not found — soundcard capture is unavailable.\n"
"Install ALSA utilities: sudo apt install alsa-utils\n" "Install ALSA utilities: sudo apt install alsa-utils\n"
"(In Docker, also ensure /dev/snd is mapped into the container)" "(In Docker, also ensure /dev/snd is mapped into the container)"
) )
@@ -667,7 +607,6 @@ class SoundcardRecorder(BaseRecorder):
self.device_spec = config.get('device', 'default') self.device_spec = config.get('device', 'default')
self.sample_rate = config.get('sample_rate', 44100) self.sample_rate = config.get('sample_rate', 44100)
self.channels = config.get('channels', 2) self.channels = config.get('channels', 2)
self.preferred_backend = config.get('backend', None)
# Validate format # Validate format
if self.file_format not in ('wav', 'flac') and self.file_format != 'auto': if self.file_format not in ('wav', 'flac') and self.file_format != 'auto':
@@ -684,30 +623,25 @@ class SoundcardRecorder(BaseRecorder):
self.buffer_lock = threading.Lock() self.buffer_lock = threading.Lock()
def _resolve_device(self) -> AudioDevice: def _resolve_device(self) -> AudioDevice:
"""Resolve device specification to AudioDevice.""" """Resolve the configured device spec to an AudioDevice.
device = self.audio_system.find_device(self.device_spec, self.preferred_backend)
if device is None:
# List available devices in error
available = self.audio_system.list_all_devices()
device_list = "\n ".join(str(d) for d in available) if available else "None found"
raise ValueError(
f"[{self.name}] Device '{self.device_spec}' not found.\n"
f"Available devices:\n {device_list}"
)
find_device() always succeeds — an unknown spec becomes a literal ALSA
PCM name — so a typo surfaces as an arecord failure at capture time
rather than here. That is the price of supporting asound.conf PCMs,
which are invisible to `arecord -l`.
"""
device = self.audio_system.find_device(self.device_spec)
self.logger.info(f"[{self.name}] Using device: {device}") self.logger.info(f"[{self.name}] Using device: {device}")
return device return device
def _audio_callback(self, data: bytes): def _audio_callback(self, data: bytes):
"""Callback for audio data from backend.""" """Callback for audio data from the capture thread."""
with self.buffer_lock: with self.buffer_lock:
self.audio_buffer.append(data) self.audio_buffer.append(data)
def _open_output_file(self): def _open_output_file(self):
"""Open a new WAV or FLAC output file for recording.""" """Open a new WAV or FLAC output file for recording."""
self._flush_buffer_to_file() self.close_current_file() # overridden below to flush first
self.close_current_file()
self.current_filename = self.generate_filename(self.file_format) self.current_filename = self.generate_filename(self.file_format)
self.current_file = _AudioFileWriter( self.current_file = _AudioFileWriter(
@@ -738,13 +672,8 @@ class SoundcardRecorder(BaseRecorder):
retry_count = 0 retry_count = 0
next_split = self.get_next_split_time() next_split = self.get_next_split_time()
backend = self.audio_system.get_backend(self.device.backend)
if not backend:
self.logger.error(f"[{self.name}] Backend '{self.device.backend}' not available")
return
self.logger.info(f"[{self.name}] Starting recorder - Split every {self.split_duration} min") self.logger.info(f"[{self.name}] Starting recorder - Split every {self.split_duration} min")
self.logger.info(f"[{self.name}] Backend: {self.device.backend}, Device: {self.device.name}") self.logger.info(f"[{self.name}] Device: {self.device}")
self.logger.info(f"[{self.name}] Sample rate: {self.sample_rate}, Channels: {self.channels}") self.logger.info(f"[{self.name}] Sample rate: {self.sample_rate}, Channels: {self.channels}")
self.logger.info(f"[{self.name}] Next split: {next_split.strftime('%H:%M:%S')}") self.logger.info(f"[{self.name}] Next split: {next_split.strftime('%H:%M:%S')}")
@@ -753,15 +682,23 @@ class SoundcardRecorder(BaseRecorder):
if self.current_file is None: if self.current_file is None:
self._open_output_file() self._open_output_file()
with backend.open_stream( with self.audio_system.open_stream(
self.device, self.sample_rate, self.channels, self._audio_callback self.device, self.sample_rate, self.channels, self._audio_callback
): ) as stream:
self.logger.info(f"[{self.name}] Recording started") self.logger.info(f"[{self.name}] Recording started")
retry_count = 0
while self.running: while self.running:
time.sleep(0.5) time.sleep(0.5)
self._flush_buffer_to_file() self._flush_buffer_to_file()
# Raises if capture died; the retry logic below then
# reopens the device instead of silently recording
# empty files forever.
stream.check_alive()
# Only a stream that actually delivered audio counts as
# progress. Resetting on every healthy tick would let a
# device that opens but never captures retry forever.
if stream.received_audio:
retry_count = 0
if self._clock() >= next_split: if self._clock() >= next_split:
self._open_output_file() self._open_output_file()
@@ -809,24 +746,26 @@ class RecorderManager:
config.read(self.config_file) config.read(self.config_file)
# Get general settings (defaults) # Get general settings (defaults)
# Defaults every source inherits. Logging is read separately — it is
# a process-wide setting, not something a recorder ever looks at.
general = { general = {
'output_directory': config.get('general', 'output_directory', fallback='recordings'), 'output_directory': config.get('general', 'output_directory', fallback='recordings'),
'split_minutes': config.getint('general', 'split_minutes', fallback=60), 'split_minutes': config.getint('general', 'split_minutes', fallback=60),
'max_retries': config.getint('general', 'max_retries', fallback=10), 'max_retries': config.getint('general', 'max_retries', fallback=10),
'retry_delay_seconds': config.getint('general', 'retry_delay_seconds', fallback=5), 'retry_delay_seconds': config.getint('general', 'retry_delay_seconds', fallback=5),
'log_level': config.get('general', 'log_level', fallback='INFO').upper(),
'log_file': config.get('general', 'log_file', fallback='recorder.log'),
} }
self.output_dir = general['output_directory'] self.output_dir = general['output_directory']
# Setup logging # Setup logging
self._setup_logging(general['log_level'], general['log_file']) self._setup_logging(
config.get('general', 'log_level', fallback='INFO').upper(),
config.get('general', 'log_file', fallback='recorder.log'))
# Initialize shared audio system for soundcard recorders # Initialize shared audio system for soundcard recorders
self.audio_system = AudioSystem(self.logger) self.audio_system = AudioSystem(self.logger)
if self.audio_system.available_backends: if self.audio_system.available:
self.logger.info(f"Audio backends: {', '.join(self.audio_system.available_backends)}") self.logger.info("Audio capture available: ALSA (arecord)")
# Parse source sections # Parse source sections
for section in config.sections(): for section in config.sections():
@@ -838,7 +777,10 @@ class RecorderManager:
self.logger.warning(f"Section [{section}] has no 'type', skipping") self.logger.warning(f"Section [{section}] has no 'type', skipping")
continue continue
# Build source config by merging general with section-specific # Build the source config and create the recorder. Both are inside
# the try: a non-numeric split_minutes used to raise out here and
# abort the whole config load instead of skipping one section.
try:
source_config = general.copy() source_config = general.copy()
for key, value in config.items(section): for key, value in config.items(section):
@@ -849,8 +791,6 @@ class RecorderManager:
else: else:
source_config[key] = value source_config[key] = value
# Create recorder
try:
if source_type == 'stream': if source_type == 'stream':
recorder = StreamRecorder(section, source_config, self.logger) recorder = StreamRecorder(section, source_config, self.logger)
self.recorders.append(recorder) self.recorders.append(recorder)
@@ -968,7 +908,7 @@ class RecorderManager:
def list_audio_devices(): def list_audio_devices():
"""List available audio input devices from all backends.""" """Print the ALSA capture devices usable as `device =` in config.ini."""
# Create a minimal logger for device listing # Create a minimal logger for device listing
logger = logging.getLogger('isr-devices') logger = logging.getLogger('isr-devices')
logger.setLevel(logging.WARNING) # Suppress debug output logger.setLevel(logging.WARNING) # Suppress debug output
@@ -977,40 +917,36 @@ def list_audio_devices():
print(" ISR Audio Device Discovery") print(" ISR Audio Device Discovery")
print("=" * 70) print("=" * 70)
if not ALSABackend.is_available(): audio_system = AudioSystem(logger)
print("\n No audio backends available!") if not audio_system.available:
print("\n arecord not found!")
print("\n Install ALSA utilities:") print("\n Install ALSA utilities:")
print(" sudo apt install alsa-utils") print(" sudo apt install alsa-utils")
print() print()
return return
print("\n Available Backends:") devices = audio_system.list_devices()
print(" - ALSA (arecord)") if devices:
print("\n Hardware Input Devices:")
# Initialize audio system and list devices
audio_system = AudioSystem(logger)
devices = audio_system.list_all_devices()
if not devices:
print("\n No input devices found!")
print()
return
print("\n Input Devices:")
print(" " + "-" * 68) print(" " + "-" * 68)
for dev in devices: for dev in devices:
flag_str = " [DEFAULT]" if dev.is_default else "" flag_str = " [DEFAULT]" if dev.is_default else ""
print(f"\n {dev.name}{flag_str}") print(f"\n {dev.name}{flag_str}")
print(f" ID: {dev.id} | Backend: {dev.backend}") print(f" ID: {dev.id}")
print(f" Channels: {dev.channels} | Sample Rate: {dev.sample_rate} Hz") else:
print("\n No hardware capture devices found.")
# This listing only ever shows hardware cards, so say so — a working
# asound.conf PCM will never appear here and users have gone looking.
print("\n" + "=" * 70) print("\n" + "=" * 70)
print(" Configuration Examples:") print(" Configuration Examples:")
print("-" * 70) print("-" * 70)
print(" device = default # Use system default input") print(" device = default # Use system default input")
print(" device = <name> # Match by partial name") print(" device = <name> # Match by partial name")
print(" device = <id> # Use exact ALSA ID, e.g. hw:0,0") print(" device = <id> # Use exact ALSA ID, e.g. hw:0,0")
print(" device = <pcm> # Any ALSA PCM name from asound.conf, e.g. shared_mic") print(" device = <pcm> # Any ALSA PCM name from asound.conf, e.g.")
print(" # shared_mic — these are never listed above,")
print(" # but work regardless")
print("=" * 70 + "\n") print("=" * 70 + "\n")
+1
View File
@@ -0,0 +1 @@
"""Supporting modules for web.py (the archive browser)."""
+250
View File
@@ -0,0 +1,250 @@
"""What an audio file is, and what its name means.
Two clusters that share a theme — facts about the files on disk — and share
the property of being touched only when a container format misbehaves:
* the filename clock (recordings are named for their *start* time, and that
name is the only reliable time anchor the archive has), and
* byte-level format handling: the 44-byte PCM header for clips, duration
probing, and the live-header patching that lets a browser show a duration
for a file still being recorded.
Imports nothing from the rest of the app.
"""
import struct
import wave
from datetime import datetime, timedelta
from pathlib import Path
try:
import soundfile as sf
SOUNDFILE_AVAILABLE = True
except ImportError:
SOUNDFILE_AVAILABLE = False
AUDIO_EXTENSIONS = {'.wav', '.mp3', '.ogg', '.flac', '.aac', '.opus'}
# 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',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
'.aac': 'audio/aac',
'.opus': 'audio/ogg',
}
# ---------------------------------------------------------------------------
# The filename clock
# ---------------------------------------------------------------------------
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.
While a WAV file is still being recorded its header claims ~0 frames, so
browsers show no duration and refuse to seek. Serving a header patched to
the bytes recorded so far fixes both; the patch is the same length as the
original header, so all byte offsets and Range math stay valid.
"""
try:
with open(path, 'rb') as fh:
hdr = fh.read(512)
if len(hdr) < 44 or hdr[:4] != b'RIFF' or hdr[8:12] != b'WAVE':
return None
pos = 12
while pos + 8 <= len(hdr):
chunk_id = hdr[pos:pos + 4]
chunk_size = int.from_bytes(hdr[pos + 4:pos + 8], 'little')
if chunk_id == b'data':
data_off = pos + 8
patched = bytearray(hdr[:data_off])
patched[4:8] = (size - 8).to_bytes(4, 'little')
patched[pos + 4:pos + 8] = (size - data_off).to_bytes(4, 'little')
return bytes(patched)
pos += 8 + chunk_size + (chunk_size & 1)
return None
except Exception:
return None
# CRC-8 (poly 0x07) used by FLAC frame headers
_CRC8_TABLE = []
for _i in range(256):
_c = _i
for _ in range(8):
_c = ((_c << 1) ^ 0x07) & 0xFF if _c & 0x80 else (_c << 1) & 0xFF
_CRC8_TABLE.append(_c)
_FLAC_BLOCKSIZES = {1: 192, 2: 576, 3: 1152, 4: 2304, 5: 4608, 8: 256, 9: 512,
10: 1024, 11: 2048, 12: 4096, 13: 8192, 14: 16384, 15: 32768}
def _crc8(data: bytes) -> int:
crc = 0
for b in data:
crc = _CRC8_TABLE[crc ^ b]
return crc
def _flac_coded_number(buf: bytes, pos: int):
"""Decode the UTF-8-style frame/sample number; returns (value, next_pos)."""
b0 = buf[pos]
if b0 < 0x80:
return b0, pos + 1
n, mask = 0, 0x40
while b0 & mask:
n += 1
mask >>= 1
if n < 1 or n > 6: # 10xxxxxx is not a valid leading byte
return None
val = b0 & (mask - 1)
for i in range(1, n + 1):
c = buf[pos + i]
if c & 0xC0 != 0x80:
return None
val = (val << 6) | (c & 0x3F)
return val, pos + 1 + n
def _flac_frame_samples(buf: bytes, pos: int, fixed_bs: int):
"""If a valid FLAC frame header starts at pos, return the stream sample
count through the end of that frame, else None. Validity is confirmed by
the header's CRC-8, so false sync matches in compressed data are rejected."""
try:
variable = buf[pos + 1] & 0x01
bs_code = buf[pos + 2] >> 4
sr_code = buf[pos + 2] & 0x0F
if bs_code == 0 or sr_code == 15 or buf[pos + 3] & 0x01:
return None
if (buf[pos + 3] >> 4) > 10: # reserved channel assignment
return None
coded = _flac_coded_number(buf, pos + 4)
if coded is None:
return None
val, p = coded
bs = _FLAC_BLOCKSIZES.get(bs_code)
if bs_code == 6:
bs = buf[p] + 1
p += 1
elif bs_code == 7:
bs = int.from_bytes(buf[p:p + 2], 'big') + 1
p += 2
if sr_code == 12:
p += 1
elif sr_code in (13, 14):
p += 2
if _crc8(buf[pos:p]) != buf[p]:
return None
if variable: # val is the frame's starting sample number
return val + (bs or 0)
return val * (fixed_bs or bs or 4096) + (bs or 0)
except IndexError:
return None
def _live_flac_header(path: Path, size: int):
"""Return the first 26 bytes of a FLAC file with STREAMINFO total_samples
patched to the samples recorded so far, or None.
Like _live_wav_header, but FLAC duration cannot be derived from the byte
count (variable compression). Instead the sample count is parsed out of
the last frame header in the file tail — each FLAC frame carries its own
frame/sample number.
"""
try:
with open(path, 'rb') as fh:
head = fh.read(42)
if len(head) < 42 or head[:4] != b'fLaC':
return None
# STREAMINFO must be the first metadata block
if head[4] & 0x7F != 0 or int.from_bytes(head[5:8], 'big') != 34:
return None
fixed_bs = int.from_bytes(head[8:10], 'big')
tail_len = min(size, 65536)
fh.seek(size - tail_len)
buf = fh.read(tail_len)
samples = None
for i in range(len(buf) - 20, -1, -1):
if buf[i] == 0xFF and (buf[i + 1] & 0xFC) == 0xF8:
samples = _flac_frame_samples(buf, i, fixed_bs)
if samples:
break
if not samples:
return None
# Bytes 18-25 hold: sample rate (20 bits) | channels-1 (3) |
# bps-1 (5) | total_samples (36). Replace only the low 36 bits.
field = int.from_bytes(head[18:26], 'big')
field = (field & ~((1 << 36) - 1)) | min(samples, (1 << 36) - 1)
patched = bytearray(head[:26])
patched[18:26] = field.to_bytes(8, 'big')
return bytes(patched)
except Exception:
return None
def _wav_header(n_frames: int, channels: int, framerate: int, sampwidth: int) -> bytes:
"""Standard 44-byte PCM WAV header for a clip of known length."""
data_len = n_frames * channels * sampwidth
byte_rate = framerate * channels * sampwidth
return (b'RIFF' + (36 + data_len).to_bytes(4, 'little') + b'WAVE'
+ b'fmt ' + (16).to_bytes(4, 'little')
+ struct.pack('<HHIIHH', 1, channels, framerate, byte_rate,
channels * sampwidth, sampwidth * 8)
+ b'data' + data_len.to_bytes(4, 'little'))
def _get_audio_duration(path: Path):
"""Return duration in seconds for any supported audio file, or None."""
ext = path.suffix.lower()
if ext == '.wav':
try:
with wave.open(str(path), 'rb') as wf:
return round(wf.getnframes() / wf.getframerate(), 2)
except Exception:
return None
if SOUNDFILE_AVAILABLE and ext in ('.flac', '.ogg', '.opus'):
try:
with sf.SoundFile(path) as f:
return round(len(f) / f.samplerate, 2)
except Exception:
return None
return None
+229
View File
@@ -0,0 +1,229 @@
"""Adaptive loud-section detection.
Pure computation: RMS windows -> a rolling noise floor -> scored sections.
Nothing here knows about HTTP, caching or the archive on disk, and nothing
here imports the rest of the app — so the detector can be worked on, and
tested, on its own. That matters because this is the part most likely to
change: if flagging (rather than ranking) ever needs improving, the next
step is a spectral filter or an optional Silero VAD over candidate sections,
and it all lands in this file.
**Detection is adaptive — do not regress it to an absolute threshold.** A
fixed RMS threshold flagged every ambience change (passing cars, rain) and
produced ~600 useless sections a day; that is why it was replaced.
"""
import math
import struct
import wave
try:
import numpy as np
NUMPY_AVAILABLE = True
except ImportError:
NUMPY_AVAILABLE = False
try:
import soundfile as sf
SOUNDFILE_AVAILABLE = True
except ImportError:
SOUNDFILE_AVAILABLE = False
# ---------------------------------------------------------------------------
# Tuning
# ---------------------------------------------------------------------------
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 = 3 # v3: a section open at EOF ends at its last loud
# window, not at the file duration
def _compute_rms_windows_wav(wf, channels: int, sampwidth: int, framerate: int,
window_samples: int):
"""Yield rms_0_to_1 for every window in the open wave file."""
while True:
raw = wf.readframes(window_samples)
if not raw:
break
n_samp = len(raw) // (sampwidth * channels)
if n_samp == 0:
break
if NUMPY_AVAILABLE:
arr = np.frombuffer(raw[:n_samp * sampwidth * channels], dtype='<i2')
if channels > 1:
arr = arr.reshape(-1, channels).mean(axis=1)
rms = float(np.sqrt(np.mean(arr.astype(np.float64) ** 2))) / 32768.0
else:
fmt = f'<{n_samp * channels}h'
samples = struct.unpack(fmt, raw[:n_samp * sampwidth * channels])
mono = samples[::channels] if channels > 1 else samples
rms = math.sqrt(sum(s * s for s in mono) / len(mono)) / 32768.0
yield round(rms, 5)
def _noise_floor_db(db_values: list, window_dur: float) -> list:
"""Per-window background noise floor in dBFS.
The floor is the NOISE_PERCENTILE-th percentile of the windowed dB levels
in each NOISE_BLOCK_SECONDS block, then min-smoothed over ±2 neighbouring
blocks so an event spanning a whole block cannot raise its own floor.
Tracks slow ambience changes (day/night, rain, traffic hum) so detection
is relative to "how loud it normally is right now"."""
n = len(db_values)
block = max(1, int(round(NOISE_BLOCK_SECONDS / window_dur)))
floors = []
for i in range(0, n, block):
chunk = sorted(db_values[i:i + block])
floors.append(chunk[int(len(chunk) * NOISE_PERCENTILE / 100)])
smoothed = [min(floors[max(0, b - 2):b + 3]) for b in range(len(floors))]
return [smoothed[min(i // block, len(smoothed) - 1)] for i in range(n)]
def _loud_sections(rms_values: list, window_dur: float, duration: float,
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,
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
floor_eff = max(floor[i], min_db)
if d >= floor_eff + margin_db:
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(end_t, 1),
'score': _score()})
start_t = None
last_loud_t = None
# A section still open at EOF ends at its last loud window, exactly like
# every section closed inside the loop — not at the file duration, which
# used to stretch it by up to min_gap of trailing silence and put that
# silence into the clip served for it.
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(min(last_loud_t + window_dur, duration), 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,
min_duration: float = MIN_DURATION_SECONDS) -> dict:
window_dur = window_samples / framerate
duration = n_frames / framerate
# Note: no RMS data is returned — the UI is screen-reader oriented and
# draws no waveform, so sections + duration is all it needs. The window
# length went the same way: it only ever existed to place RMS bars.
return {
'sections': _loud_sections(rms_values, window_dur, duration, margin_db, min_gap, min_duration),
'duration': round(duration, 2),
}
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
margin_db: float = MARGIN_DB,
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()
sampwidth = wf.getsampwidth()
framerate = wf.getframerate()
n_frames = wf.getnframes()
# _compute_rms_windows_wav decodes as int16 unconditionally, so an
# 8- or 24-bit file would yield garbage RMS and therefore garbage
# sections — silently. The recorder only writes 16-bit, but --dir
# can point at any directory.
if sampwidth != 2:
return {'error': f'only 16-bit WAV files can be analysed '
f'(this one is {sampwidth * 8}-bit)'}
rms_values = list(_compute_rms_windows_wav(
wf, channels, sampwidth, framerate, window_samples))
except Exception as e:
return {'error': str(e)}
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,
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'}
try:
with sf.SoundFile(path) as f:
framerate = f.samplerate
channels = f.channels
n_frames = len(f)
rms_values = []
while True:
frames = f.read(window_samples, dtype='float32', always_2d=True)
if len(frames) == 0:
break
mono = frames.mean(axis=1) if channels > 1 else frames[:, 0]
rms = float(np.sqrt(np.mean(mono.astype(np.float64) ** 2)))
rms_values.append(round(rms, 5))
except Exception as e:
return {'error': str(e)}
return _package_result(rms_values, framerate, n_frames, window_samples, margin_db, min_gap, min_duration)
+30
View File
@@ -0,0 +1,30 @@
"""Shared test setup.
Puts the repo root on sys.path so `import isr`, `import web` and
`import isrweb.*` work regardless of where pytest is invoked from. This used
to be an inline sys.path.insert at the top of test_isr.py, which only
happened to work for the other test modules because that file imported first.
"""
import os
import sys
import wave
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
@pytest.fixture
def write_wav():
"""Factory for small 16-bit WAV files. Returns the path written."""
def _write(path, seconds=2, framerate=8000, channels=1, sampwidth=2,
frames=None):
with wave.open(str(path), 'wb') as wf:
wf.setnchannels(channels)
wf.setsampwidth(sampwidth)
wf.setframerate(framerate)
wf.writeframes(frames if frames is not None
else b'\x00\x00' * (seconds * framerate * channels))
return path
return _write
+35
View File
@@ -0,0 +1,35 @@
"""Tests for the filename clock in isrweb/audiofile.py.
Recordings are named for their *start* time, and that name is the only
reliable time anchor the archive has — mtime drifts to the last write.
"""
from isrweb.audiofile import _cut_filename, _recording_start
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"
+181 -47
View File
@@ -6,6 +6,7 @@ Run with: pytest tests/
import logging import logging
import struct import struct
import time
import wave import wave
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -30,14 +31,10 @@ def fixed_clock(dt: datetime):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Import the module under test # Import the module under test (sys.path is set up in conftest.py)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
import sys import isr
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import isr # noqa: E402 (must come after sys.path manipulation)
# =========================================================================== # ===========================================================================
@@ -50,15 +47,12 @@ class TestAudioSystemFindDevice:
def _make_system(self, devices: List[isr.AudioDevice]) -> isr.AudioSystem: def _make_system(self, devices: List[isr.AudioDevice]) -> isr.AudioSystem:
system = isr.AudioSystem.__new__(isr.AudioSystem) system = isr.AudioSystem.__new__(isr.AudioSystem)
system.logger = make_logger() system.logger = make_logger()
system._backends = {} system.available = True
system.list_all_devices = lambda: devices system.list_devices = lambda: devices
return system return system
def _dev(self, id_, name, *, is_default=False, is_monitor=False, backend="portaudio"): def _dev(self, id_, name, *, is_default=False):
return isr.AudioDevice( return isr.AudioDevice(id=id_, name=name, is_default=is_default)
id=id_, name=name, channels=2, sample_rate=44100,
backend=backend, is_default=is_default, is_monitor=is_monitor,
)
def test_default_returns_default_device(self): def test_default_returns_default_device(self):
devices = [ devices = [
@@ -75,19 +69,9 @@ class TestAudioSystemFindDevice:
result = system.find_device("default") result = system.find_device("default")
assert result.id == "0" assert result.id == "0"
def test_monitor_returns_monitor_device(self): def test_default_with_no_cards_uses_the_alsa_default_pcm(self):
devices = [ system = self._make_system([])
self._dev("0", "Microphone"), assert system.find_device("default").id == "default"
self._dev("1", "Monitor of Built-in Audio", is_monitor=True),
]
system = self._make_system(devices)
result = system.find_device("monitor")
assert result.id == "1"
def test_monitor_returns_none_when_no_monitor(self):
devices = [self._dev("0", "Mic")]
system = self._make_system(devices)
assert system.find_device("monitor") is None
def test_exact_id_match(self): def test_exact_id_match(self):
devices = [self._dev("42", "Device 42"), self._dev("7", "Device 7")] devices = [self._dev("42", "Device 42"), self._dev("7", "Device 7")]
@@ -104,19 +88,26 @@ class TestAudioSystemFindDevice:
system = self._make_system(devices) system = self._make_system(devices)
assert system.find_device("stereo").id == "0" assert system.find_device("stereo").id == "0"
def test_returns_none_for_unknown_spec(self): def test_exact_id_wins_over_partial_name(self):
devices = [self._dev("0", "Mic")] # Two identical USB cards: both must stay reachable. An earlier
# dedup-by-name dropped the second one entirely.
devices = [self._dev("hw:1,0", "USB Audio"), self._dev("hw:2,0", "USB Audio")]
system = self._make_system(devices) system = self._make_system(devices)
assert system.find_device("nonexistent_device_xyz") is None assert system.find_device("hw:2,0").id == "hw:2,0"
def test_preferred_backend_filter_on_default(self): def test_unknown_spec_becomes_a_literal_alsa_pcm_name(self):
devices = [ # This is how `device = shared_mic` (a dsnoop PCM from asound.conf)
self._dev("0", "Mic", is_default=True, backend="pulseaudio"), # resolves — it is never listed by `arecord -l`.
self._dev("1", "Mic", is_default=True, backend="portaudio"), devices = [self._dev("hw:0,0", "Mic")]
]
system = self._make_system(devices) system = self._make_system(devices)
result = system.find_device("default", preferred_backend="portaudio") assert system.find_device("shared_mic").id == "shared_mic"
assert result.backend == "portaudio"
def test_pcm_fallback_works_when_no_cards_are_enumerable(self):
# Regression: a `if not devices: return None` guard used to fire before
# the fallback, so shared_mic failed outright on a host with no
# enumerable hardware card.
system = self._make_system([])
assert system.find_device("shared_mic").id == "shared_mic"
# =========================================================================== # ===========================================================================
@@ -423,6 +414,77 @@ class TestAudioFileWriter:
assert info.samplerate == 44100 assert info.samplerate == 44100
# ===========================================================================
# ALSAStream — capture liveness
# ===========================================================================
class TestALSAStreamLiveness:
"""check_alive() is what stops a dead arecord from being silently ignored."""
def _stream(self, poll_result=None) -> isr.ALSAStream:
device = isr.AudioDevice(
id="hw:0,0", name="Test",
)
s = isr.ALSAStream(device, 44100, 2, lambda data: None, make_logger())
s._process = MagicMock()
s._process.poll.return_value = poll_result
s._last_data = time.monotonic()
return s
def test_healthy_stream_does_not_raise(self):
self._stream().check_alive()
def test_raises_when_arecord_exited(self):
s = self._stream(poll_result=1)
with pytest.raises(RuntimeError, match="exited with code 1"):
s.check_alive()
def test_raises_when_no_audio_within_timeout(self):
# Process still up, but nothing has arrived — device yanked, or the
# dsnoop shared memory went away underneath us.
s = self._stream()
s._last_data = time.monotonic() - (isr.ALSAStream.SILENCE_TIMEOUT + 1)
with pytest.raises(RuntimeError, match="no audio"):
s.check_alive()
def test_read_loop_records_arrival_of_audio(self):
s = self._stream()
s._running = True
s._process.stdout.read.side_effect = [b"\x00" * 64, b""] # one chunk, then EOF
chunks = []
s.callback = chunks.append
s._read_loop()
assert chunks == [b"\x00" * 64]
assert s.received_audio is True
def test_read_loop_stops_at_eof_without_spinning(self):
s = self._stream()
s._running = True
s._process.stdout.read.return_value = b""
s._read_loop()
assert s._process.stdout.read.call_count == 1
assert s.received_audio is False
class _DeadStream:
"""Stand-in for a capture stream that opens but never delivers audio."""
received_audio = False
def __enter__(self):
return self
def __exit__(self, *args):
return False
def check_alive(self):
raise RuntimeError("arecord exited with code 1")
# =========================================================================== # ===========================================================================
# RecorderManager — config loading # RecorderManager — config loading
# =========================================================================== # ===========================================================================
@@ -453,7 +515,7 @@ format = ogg
# Patch AudioSystem so it doesn't probe real hardware # Patch AudioSystem so it doesn't probe real hardware
with patch.object(isr, "AudioSystem") as mock_as: with patch.object(isr, "AudioSystem") as mock_as:
mock_as.return_value.available_backends = [] mock_as.return_value.available = False
mgr = isr.RecorderManager(str(cfg_path)) mgr = isr.RecorderManager(str(cfg_path))
assert len(mgr.recorders) == 1 assert len(mgr.recorders) == 1
@@ -467,7 +529,7 @@ format = ogg
cfg_path.write_text(self._minimal_config(tmp_path)) cfg_path.write_text(self._minimal_config(tmp_path))
with patch.object(isr, "AudioSystem") as mock_as: with patch.object(isr, "AudioSystem") as mock_as:
mock_as.return_value.available_backends = [] mock_as.return_value.available = False
mgr = isr.RecorderManager(str(cfg_path)) mgr = isr.RecorderManager(str(cfg_path))
rec = mgr.recorders[0] rec = mgr.recorders[0]
@@ -494,7 +556,7 @@ split_minutes = 15
cfg_path.write_text(config_text) cfg_path.write_text(config_text)
with patch.object(isr, "AudioSystem") as mock_as: with patch.object(isr, "AudioSystem") as mock_as:
mock_as.return_value.available_backends = [] mock_as.return_value.available = False
mgr = isr.RecorderManager(str(cfg_path)) mgr = isr.RecorderManager(str(cfg_path))
rec = mgr.recorders[0] rec = mgr.recorders[0]
@@ -519,12 +581,63 @@ url = http://x.com/s2
cfg_path.write_text(config_text) cfg_path.write_text(config_text)
with patch.object(isr, "AudioSystem") as mock_as: with patch.object(isr, "AudioSystem") as mock_as:
mock_as.return_value.available_backends = [] mock_as.return_value.available = False
mgr = isr.RecorderManager(str(cfg_path)) mgr = isr.RecorderManager(str(cfg_path))
assert len(mgr.recorders) == 1 assert len(mgr.recorders) == 1
assert mgr.recorders[0].name == "good_source" assert mgr.recorders[0].name == "good_source"
def test_bad_integer_skips_only_that_source(self, tmp_path):
# A non-numeric split_minutes used to raise ValueError out of the
# config loop and abort the whole load, taking healthy sources with it.
log_file = str(tmp_path / "test.log")
config_text = f"""
[general]
log_level = WARNING
log_file = {log_file}
[broken]
type = stream
url = http://x.com/s
split_minutes = sixty
[good_source]
type = stream
url = http://x.com/s2
"""
cfg_path = tmp_path / "config.ini"
cfg_path.write_text(config_text)
with patch.object(isr, "AudioSystem") as mock_as:
mock_as.return_value.available = False
mgr = isr.RecorderManager(str(cfg_path))
assert [r.name for r in mgr.recorders] == ["good_source"]
def test_zero_split_minutes_is_clamped(self, tmp_path):
# split_minutes = 0 reached get_next_split_time() and divided by zero.
log_file = str(tmp_path / "test.log")
config_text = f"""
[general]
log_level = WARNING
log_file = {log_file}
[zero_split]
type = stream
url = http://x.com/s
split_minutes = 0
"""
cfg_path = tmp_path / "config.ini"
cfg_path.write_text(config_text)
with patch.object(isr, "AudioSystem") as mock_as:
mock_as.return_value.available = False
mgr = isr.RecorderManager(str(cfg_path))
rec = mgr.recorders[0]
assert rec.split_duration == 1
rec.get_next_split_time() # must not raise ZeroDivisionError
def test_missing_config_file_exits(self, tmp_path): def test_missing_config_file_exits(self, tmp_path):
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
# main() calls sys.exit(1) when the file is missing # main() calls sys.exit(1) when the file is missing
@@ -571,22 +684,18 @@ class TestStreamRecorderRecord:
# =========================================================================== # ===========================================================================
class TestSoundcardRecorder: class TestSoundcardRecorder:
def _make_device(self, backend="portaudio") -> isr.AudioDevice: def _make_device(self) -> isr.AudioDevice:
return isr.AudioDevice( return isr.AudioDevice(id="0", name="Test Device")
id="0", name="Test Device", channels=2,
sample_rate=44100, backend=backend,
)
def _make_audio_system(self, device: isr.AudioDevice) -> isr.AudioSystem: def _make_audio_system(self, device: isr.AudioDevice) -> isr.AudioSystem:
system = MagicMock(spec=isr.AudioSystem) system = MagicMock(spec=isr.AudioSystem)
system.available_backends = [device.backend] system.available = True
system.find_device.return_value = device system.find_device.return_value = device
return system return system
def test_wav_file_written_correctly(self, tmp_path): def test_wav_file_written_correctly(self, tmp_path):
device = self._make_device() device = self._make_device()
audio_system = self._make_audio_system(device) audio_system = self._make_audio_system(device)
audio_system.get_backend.return_value = MagicMock()
cfg = { cfg = {
"device": "default", "device": "default",
@@ -631,6 +740,31 @@ class TestSoundcardRecorder:
with pytest.raises(ImportError, match="soundfile"): with pytest.raises(ImportError, match="soundfile"):
rec._open_output_file() rec._open_output_file()
def test_dead_capture_gives_up_instead_of_recording_nothing(self, tmp_path):
# A capture that opens but never delivers audio must exhaust
# max_retries and stop. Before check_alive() existed, record() sat in
# its inner loop forever, rolling over to a new split file every
# split_minutes and writing zero bytes into each one.
device = self._make_device()
audio_system = self._make_audio_system(device)
audio_system.open_stream.return_value = _DeadStream()
cfg = {
"device": "default",
"sample_rate": 44100,
"channels": 2,
"format": "wav",
"output_directory": str(tmp_path),
"split_minutes": 60,
"max_retries": 2,
"retry_delay_seconds": 0,
}
rec = isr.SoundcardRecorder("sc_dead", cfg, make_logger(), audio_system=audio_system)
with patch.object(isr.time, "sleep"): # don't pay the 0.5 s poll interval
rec.record()
assert audio_system.open_stream.call_count == cfg["max_retries"]
@pytest.mark.skipif(not isr.SOUNDFILE_AVAILABLE, reason="soundfile not installed") @pytest.mark.skipif(not isr.SOUNDFILE_AVAILABLE, reason="soundfile not installed")
def test_flac_file_written_correctly(self, tmp_path): def test_flac_file_written_correctly(self, tmp_path):
import soundfile as sf import soundfile as sf
+176
View File
@@ -0,0 +1,176 @@
"""Tests for the adaptive loud-section detector in isrweb/loudness.py."""
import math
import wave
from isrweb.loudness import _loud_sections, _noise_floor_db, analyze_wav
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, min_duration=0.5):
duration = len(rms) * WINDOW_DUR
return _loud_sections(rms, WINDOW_DUR, duration, margin_db, min_gap, min_duration)
def test_burst_above_quiet_floor_is_detected():
rms = [0.002] * 1200 # 2 min of quiet ambience (−54 dBFS)
rms[600:610] = [0.05] * 10 # 1 s burst at −26 dBFS (+28 dB)
sections = _run(rms)
assert len(sections) == 1
s = sections[0]
assert s['start'] == 60.0
assert 60.5 <= s['end'] <= 62.0
assert 26.0 <= s['score'] <= 30.0
def test_slow_ambience_swell_is_not_detected():
# Level rises 20 dB over 10 minutes and back down — e.g. rain setting in.
# The old fixed threshold would have flagged the entire loud half.
up = [0.002 * 10 ** (i / 6000 * 1.0) for i in range(6000)] # −54 → −34 dB
down = list(reversed(up))
assert _run(up + down) == []
def test_burst_still_detected_on_loud_ambience():
# Same +28 dB prominence as the quiet test, but on a 20 dB louder floor.
rms = [0.02] * 1200
rms[600:610] = [0.5] * 10
sections = _run(rms)
assert len(sections) == 1
assert sections[0]['start'] == 60.0
def test_min_rms_floor_suppresses_blips_in_digital_silence():
rms = [0.000001] * 1200
rms[600:605] = [0.004] * 5 # −48 dBFS: audible blip, but below MIN_RMS+12dB
assert _run(rms) == []
rms[600:605] = [0.05] * 5 # −26 dBFS clearly clears the clamped floor
assert len(_run(rms)) == 1
def test_min_gap_merges_nearby_bursts():
rms = [0.002] * 1200
rms[600:605] = [0.05] * 5
rms[615:620] = [0.05] * 5 # 1 s gap < 2 s min_gap → one section
rms[900:905] = [0.05] * 5 # 28 s away → separate section
sections = _run(rms)
assert len(sections) == 2
assert sections[0]['start'] == 60.0
assert sections[0]['end'] >= 61.5
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_section_open_at_eof_ends_at_last_loud_window():
# Burst ends 1 s before EOF — less than min_gap, so the section is still
# open when the windows run out. It must end where the sound ended, not at
# the file duration; otherwise the clip carries a second of trailing
# silence and disagrees with every section closed mid-file.
rms = [0.002] * 1200
rms[1180:1190] = [0.05] * 10 # loud 118.0 → 119.0, file is 120.0 s
sections = _run(rms)
assert len(sections) == 1
assert sections[0]['start'] == 118.0
assert sections[0]['end'] == 119.0
def test_section_running_to_the_very_end_is_capped_at_duration():
rms = [0.002] * 1200
rms[1190:1200] = [0.05] * 10 # loud right up to EOF
sections = _run(rms)
assert len(sections) == 1
assert sections[0]['end'] == 120.0
def test_noise_floor_tracks_blocks_and_ignores_short_events():
quiet_db = 20 * math.log10(0.002)
db = [quiet_db] * 1200
db[600:650] = [-20.0] * 50 # 5 s event must not raise its own floor
floor = _noise_floor_db(db, WINDOW_DUR)
assert len(floor) == len(db)
assert all(abs(f - quiet_db) < 1.0 for f in floor)
def test_non_16_bit_wav_is_rejected_rather_than_misread(tmp_path, write_wav):
# RMS windowing decodes as int16 regardless of the declared sample width,
# so an 8-bit file used to produce meaningless sections with no warning.
path = tmp_path / 'eight_bit.wav'
with wave.open(str(path), 'wb') as wf:
wf.setnchannels(1)
wf.setsampwidth(1)
wf.setframerate(8000)
wf.writeframes(b'\x80' * 8000)
result = analyze_wav(path)
assert 'error' in result
assert '16-bit' in result['error']
def test_16_bit_wav_still_analyses(tmp_path, write_wav):
path = tmp_path / 'ok.wav'
write_wav(path)
assert 'error' not in analyze_wav(path)
+99 -144
View File
@@ -1,162 +1,117 @@
"""Tests for the adaptive loud-section detector in web.py.""" """Tests for the HTTP layer and file listing in web.py."""
import math import io
from web import _cut_filename, _loud_sections, _noise_floor_db, _recording_start import web
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, min_duration=0.5): def test_listing_is_ordered_by_the_filename_clock_not_mtime(tmp_path, write_wav):
duration = len(rms) * WINDOW_DUR # mtime is the last write (≈ the end of the recording) and drifts from the
return _loud_sections(rms, WINDOW_DUR, duration, margin_db, min_gap, min_duration) # name on any copy or round-trip. Here the older recording is touched last.
import os
early, late = tmp_path / '20260523_080000.wav', tmp_path / '20260523_090000.wav'
write_wav(early)
write_wav(late)
os.utime(late, (1000, 1000)) # newest recording, oldest mtime
names = [f['name'] for f in web.list_files(str(tmp_path))]
def test_burst_above_quiet_floor_is_detected(): assert names == ['20260523_090000.wav', '20260523_080000.wav']
rms = [0.002] * 1200 # 2 min of quiet ambience (−54 dBFS)
rms[600:610] = [0.05] * 10 # 1 s burst at −26 dBFS (+28 dB)
sections = _run(rms)
assert len(sections) == 1
s = sections[0]
assert s['start'] == 60.0
assert 60.5 <= s['end'] <= 62.0
assert 26.0 <= s['score'] <= 30.0
def test_slow_ambience_swell_is_not_detected():
# Level rises 20 dB over 10 minutes and back down — e.g. rain setting in.
# The old fixed threshold would have flagged the entire loud half.
up = [0.002 * 10 ** (i / 6000 * 1.0) for i in range(6000)] # −54 → −34 dB
down = list(reversed(up))
assert _run(up + down) == []
def test_burst_still_detected_on_loud_ambience():
# Same +28 dB prominence as the quiet test, but on a 20 dB louder floor.
rms = [0.02] * 1200
rms[600:610] = [0.5] * 10
sections = _run(rms)
assert len(sections) == 1
assert sections[0]['start'] == 60.0
def test_min_rms_floor_suppresses_blips_in_digital_silence():
rms = [0.000001] * 1200
rms[600:605] = [0.004] * 5 # −48 dBFS: audible blip, but below MIN_RMS+12dB
assert _run(rms) == []
rms[600:605] = [0.05] * 5 # −26 dBFS clearly clears the clamped floor
assert len(_run(rms)) == 1
def test_min_gap_merges_nearby_bursts():
rms = [0.002] * 1200
rms[600:605] = [0.05] * 5
rms[615:620] = [0.05] * 5 # 1 s gap < 2 s min_gap → one section
rms[900:905] = [0.05] * 5 # 28 s away → separate section
sections = _run(rms)
assert len(sections) == 2
assert sections[0]['start'] == 60.0
assert sections[0]['end'] >= 61.5
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
db[600:650] = [-20.0] * 50 # 5 s event must not raise its own floor
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 # /api/clip error handling
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_recording_start_parses_standard_name(): class _OfflineHandler(web._Handler):
from datetime import datetime """A _Handler with no socket, for driving handler methods directly."""
assert _recording_start("20260523_220000") == datetime(2026, 5, 23, 22, 0, 0)
def __init__(self, recordings_dir):
self.recordings_dir = str(recordings_dir)
self.analyses_dir = str(recordings_dir)
self.command = 'GET' # _send skips the body on HEAD
self.wfile = io.BytesIO()
self.close_connection = False
self.codes = []
def send_response(self, code):
self.codes.append(code)
def send_header(self, key, value):
pass
def end_headers(self):
pass
def test_recording_start_rejects_nonstandard_name(): def test_clip_failure_after_headers_closes_connection_instead_of_writing_json(tmp_path, write_wav):
assert _recording_start("radio1_20260523") is None # Once the audio headers are out, an error body would be a second response
assert _recording_start("notes") is None # on a keep-alive connection that already promised audio/wav plus an exact
# Content-Length — desyncing every later request on that connection.
write_wav(tmp_path / 'rec.wav')
h = _OfflineHandler(tmp_path)
def boom(path, start, end):
h._send_clip_headers(b'HDR', 10, 1, 2)
raise RuntimeError('decode exploded')
h._clip_wav = boom
h._api_clip({'file': ['rec.wav'], 'start': ['0'], 'end': ['1']})
assert h.close_connection is True
assert h.codes == [200] # no second status line
assert b'error' not in h.wfile.getvalue() # no JSON body
def test_cut_filename_uses_wall_clock_span(): def test_clip_failure_before_headers_still_reports_json(tmp_path, write_wav):
# Recording started 22:00:00; cut covers 22:31:30 → 22:32:30. write_wav(tmp_path / 'rec.wav')
name = _cut_filename("20260523_220000", ".flac", 1890, 1950) h = _OfflineHandler(tmp_path)
assert name == "20260523_22-31-30_22-32-30.flac"
def boom(path, start, end):
raise RuntimeError('could not open')
h._clip_wav = boom
h._api_clip({'file': ['rec.wav'], 'start': ['0'], 'end': ['1']})
assert h.close_connection is False
assert h.codes == [500]
assert b'could not open' in h.wfile.getvalue()
def test_cut_filename_rolls_over_the_hour(): # ---------------------------------------------------------------------------
name = _cut_filename("20260523_220000", ".wav", 3590, 3661) # Browser UI static files (webui/)
assert name == "20260523_22-59-50_23-01-01.wav" # ---------------------------------------------------------------------------
def test_ui_serves_index_and_modules_with_their_types(tmp_path):
h = _OfflineHandler(tmp_path)
types = []
h.send_header = lambda key, value: types.append(value) if key == 'Content-Type' else None
h._ui('index.html')
h._ui('js/main.js')
h._ui('style.css')
assert h.codes == [200, 200, 200]
assert types == ['text/html; charset=utf-8', 'text/javascript; charset=utf-8',
'text/css; charset=utf-8']
def test_cut_filename_falls_back_for_nonstandard_name(): def test_ui_refuses_paths_outside_webui_and_unlisted_types(tmp_path):
name = _cut_filename("mixtape", ".mp3", 740, 750.4) h = _OfflineHandler(tmp_path)
assert name == "mixtape_cut_740s-750s.mp3"
h._ui('../web.py') # escapes webui/
h._ui('..\web.py')
h._ui('js') # a directory
h._ui('missing.js')
assert h.codes == [404, 404, 404, 404]
assert b'import' not in h.wfile.getvalue()
def test_every_ui_module_import_resolves():
# No JS toolchain on the dev box: at least catch a renamed module or a
# misspelt relative import before the browser does.
import re
js = web.WEBUI_DIR / 'js'
for mod in js.glob('*.js'):
for target in re.findall(r"from '\./([\w.]+)'", mod.read_text(encoding='utf-8')):
assert (js / target).is_file(), f'{mod.name} imports missing {target}'
+179 -520
View File
@@ -15,23 +15,22 @@ Usage:
import argparse import argparse
import json import json
import math
import os import os
import re import re
import shutil import shutil
import struct
import subprocess import subprocess
import sys
import tempfile import tempfile
import threading import threading
import wave import wave
from datetime import datetime, timedelta from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse from urllib.parse import parse_qs, unquote, urlparse
try: try:
import numpy as np import numpy # noqa: F401 — only the availability flag is used here,
NUMPY_AVAILABLE = True NUMPY_AVAILABLE = True # to refuse FLAC work rather than fail halfway
except ImportError: except ImportError:
NUMPY_AVAILABLE = False NUMPY_AVAILABLE = False
@@ -41,421 +40,22 @@ try:
except ImportError: except ImportError:
SOUNDFILE_AVAILABLE = False SOUNDFILE_AVAILABLE = False
# The detector and the byte-level format helpers live beside this file. Both
# are leaves: they import nothing from here, so `web.py -> {loudness,
# audiofile}` is the only import edge and there are no cycles.
from isrweb.audiofile import (AUDIO_EXTENSIONS, MIME_TYPES, _cut_filename,
_get_audio_duration, _live_flac_header,
_live_wav_header, _recording_start, _wav_header)
from isrweb.loudness import (DETECTOR_VERSION, MARGIN_DB, MIN_DURATION_SECONDS,
MIN_GAP_SECONDS, analyze_flac, analyze_wav)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Constants # Constants
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
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 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',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
'.aac': 'audio/aac',
'.opus': 'audio/ogg',
}
# ---------------------------------------------------------------------------
# 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.
While a WAV file is still being recorded its header claims ~0 frames, so
browsers show no duration and refuse to seek. Serving a header patched to
the bytes recorded so far fixes both; the patch is the same length as the
original header, so all byte offsets and Range math stay valid.
"""
try:
with open(path, 'rb') as fh:
hdr = fh.read(512)
if len(hdr) < 44 or hdr[:4] != b'RIFF' or hdr[8:12] != b'WAVE':
return None
pos = 12
while pos + 8 <= len(hdr):
chunk_id = hdr[pos:pos + 4]
chunk_size = int.from_bytes(hdr[pos + 4:pos + 8], 'little')
if chunk_id == b'data':
data_off = pos + 8
patched = bytearray(hdr[:data_off])
patched[4:8] = (size - 8).to_bytes(4, 'little')
patched[pos + 4:pos + 8] = (size - data_off).to_bytes(4, 'little')
return bytes(patched)
pos += 8 + chunk_size + (chunk_size & 1)
return None
except Exception:
return None
# CRC-8 (poly 0x07) used by FLAC frame headers
_CRC8_TABLE = []
for _i in range(256):
_c = _i
for _ in range(8):
_c = ((_c << 1) ^ 0x07) & 0xFF if _c & 0x80 else (_c << 1) & 0xFF
_CRC8_TABLE.append(_c)
_FLAC_BLOCKSIZES = {1: 192, 2: 576, 3: 1152, 4: 2304, 5: 4608, 8: 256, 9: 512,
10: 1024, 11: 2048, 12: 4096, 13: 8192, 14: 16384, 15: 32768}
def _crc8(data: bytes) -> int:
crc = 0
for b in data:
crc = _CRC8_TABLE[crc ^ b]
return crc
def _flac_coded_number(buf: bytes, pos: int):
"""Decode the UTF-8-style frame/sample number; returns (value, next_pos)."""
b0 = buf[pos]
if b0 < 0x80:
return b0, pos + 1
n, mask = 0, 0x40
while b0 & mask:
n += 1
mask >>= 1
if n < 1 or n > 6: # 10xxxxxx is not a valid leading byte
return None
val = b0 & (mask - 1)
for i in range(1, n + 1):
c = buf[pos + i]
if c & 0xC0 != 0x80:
return None
val = (val << 6) | (c & 0x3F)
return val, pos + 1 + n
def _flac_frame_samples(buf: bytes, pos: int, fixed_bs: int):
"""If a valid FLAC frame header starts at pos, return the stream sample
count through the end of that frame, else None. Validity is confirmed by
the header's CRC-8, so false sync matches in compressed data are rejected."""
try:
variable = buf[pos + 1] & 0x01
bs_code = buf[pos + 2] >> 4
sr_code = buf[pos + 2] & 0x0F
if bs_code == 0 or sr_code == 15 or buf[pos + 3] & 0x01:
return None
if (buf[pos + 3] >> 4) > 10: # reserved channel assignment
return None
coded = _flac_coded_number(buf, pos + 4)
if coded is None:
return None
val, p = coded
bs = _FLAC_BLOCKSIZES.get(bs_code)
if bs_code == 6:
bs = buf[p] + 1
p += 1
elif bs_code == 7:
bs = int.from_bytes(buf[p:p + 2], 'big') + 1
p += 2
if sr_code == 12:
p += 1
elif sr_code in (13, 14):
p += 2
if _crc8(buf[pos:p]) != buf[p]:
return None
if variable: # val is the frame's starting sample number
return val + (bs or 0)
return val * (fixed_bs or bs or 4096) + (bs or 0)
except IndexError:
return None
def _live_flac_header(path: Path, size: int):
"""Return the first 26 bytes of a FLAC file with STREAMINFO total_samples
patched to the samples recorded so far, or None.
Like _live_wav_header, but FLAC duration cannot be derived from the byte
count (variable compression). Instead the sample count is parsed out of
the last frame header in the file tail — each FLAC frame carries its own
frame/sample number.
"""
try:
with open(path, 'rb') as fh:
head = fh.read(42)
if len(head) < 42 or head[:4] != b'fLaC':
return None
# STREAMINFO must be the first metadata block
if head[4] & 0x7F != 0 or int.from_bytes(head[5:8], 'big') != 34:
return None
fixed_bs = int.from_bytes(head[8:10], 'big')
tail_len = min(size, 65536)
fh.seek(size - tail_len)
buf = fh.read(tail_len)
samples = None
for i in range(len(buf) - 20, -1, -1):
if buf[i] == 0xFF and (buf[i + 1] & 0xFC) == 0xF8:
samples = _flac_frame_samples(buf, i, fixed_bs)
if samples:
break
if not samples:
return None
# Bytes 18-25 hold: sample rate (20 bits) | channels-1 (3) |
# bps-1 (5) | total_samples (36). Replace only the low 36 bits.
field = int.from_bytes(head[18:26], 'big')
field = (field & ~((1 << 36) - 1)) | min(samples, (1 << 36) - 1)
patched = bytearray(head[:26])
patched[18:26] = field.to_bytes(8, 'big')
return bytes(patched)
except Exception:
return None
def _wav_header(n_frames: int, channels: int, framerate: int, sampwidth: int) -> bytes:
"""Standard 44-byte PCM WAV header for a clip of known length."""
data_len = n_frames * channels * sampwidth
byte_rate = framerate * channels * sampwidth
return (b'RIFF' + (36 + data_len).to_bytes(4, 'little') + b'WAVE'
+ b'fmt ' + (16).to_bytes(4, 'little')
+ struct.pack('<HHIIHH', 1, channels, framerate, byte_rate,
channels * sampwidth, sampwidth * 8)
+ b'data' + data_len.to_bytes(4, 'little'))
def _get_audio_duration(path: Path):
"""Return duration in seconds for any supported audio file, or None."""
ext = path.suffix.lower()
if ext == '.wav':
try:
with wave.open(str(path), 'rb') as wf:
return wf.getnframes() / wf.getframerate()
except Exception:
return None
if SOUNDFILE_AVAILABLE and ext in ('.flac', '.ogg', '.opus'):
try:
with sf.SoundFile(path) as f:
return round(len(f) / f.samplerate, 2)
except Exception:
return None
return None
def _compute_rms_windows_wav(wf, channels: int, sampwidth: int, framerate: int,
window_samples: int):
"""Yield rms_0_to_1 for every window in the open wave file."""
while True:
raw = wf.readframes(window_samples)
if not raw:
break
n_samp = len(raw) // (sampwidth * channels)
if n_samp == 0:
break
if NUMPY_AVAILABLE:
arr = np.frombuffer(raw[:n_samp * sampwidth * channels], dtype='<i2')
if channels > 1:
arr = arr.reshape(-1, channels).mean(axis=1)
rms = float(np.sqrt(np.mean(arr.astype(np.float64) ** 2))) / 32768.0
else:
fmt = f'<{n_samp * channels}h'
samples = struct.unpack(fmt, raw[:n_samp * sampwidth * channels])
mono = samples[::channels] if channels > 1 else samples
rms = math.sqrt(sum(s * s for s in mono) / len(mono)) / 32768.0
yield round(rms, 5)
def _noise_floor_db(db_values: list, window_dur: float) -> list:
"""Per-window background noise floor in dBFS.
The floor is the NOISE_PERCENTILE-th percentile of the windowed dB levels
in each NOISE_BLOCK_SECONDS block, then min-smoothed over ±2 neighbouring
blocks so an event spanning a whole block cannot raise its own floor.
Tracks slow ambience changes (day/night, rain, traffic hum) so detection
is relative to "how loud it normally is right now"."""
n = len(db_values)
block = max(1, int(round(NOISE_BLOCK_SECONDS / window_dur)))
floors = []
for i in range(0, n, block):
chunk = sorted(db_values[i:i + block])
floors.append(chunk[int(len(chunk) * NOISE_PERCENTILE / 100)])
smoothed = [min(floors[max(0, b - 2):b + 3]) for b in range(len(floors))]
return [smoothed[min(i // block, len(smoothed) - 1)] for i in range(n)]
def _loud_sections(rms_values: list, window_dur: float, duration: float,
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,
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
floor_eff = max(floor[i], min_db)
if d >= floor_eff + margin_db:
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(end_t, 1),
'score': _score()})
start_t = None
last_loud_t = 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': _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,
min_duration: float = MIN_DURATION_SECONDS) -> dict:
window_dur = window_samples / framerate
duration = n_frames / framerate
# Note: no RMS data is returned — the UI is screen-reader oriented and
# draws no waveform, so sections + duration is all it needs.
return {
'sections': _loud_sections(rms_values, window_dur, duration, margin_db, min_gap, min_duration),
'duration': round(duration, 2),
'window': round(window_dur, 4),
}
def analyze_wav(path: Path, window_samples: int = WINDOW_SAMPLES,
margin_db: float = MARGIN_DB,
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()
sampwidth = wf.getsampwidth()
framerate = wf.getframerate()
n_frames = wf.getnframes()
rms_values = list(_compute_rms_windows_wav(
wf, channels, sampwidth, framerate, window_samples))
except Exception as e:
return {'error': str(e)}
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,
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'}
try:
with sf.SoundFile(path) as f:
framerate = f.samplerate
channels = f.channels
n_frames = len(f)
rms_values = []
while True:
frames = f.read(window_samples, dtype='float32', always_2d=True)
if len(frames) == 0:
break
mono = frames.mean(axis=1) if channels > 1 else frames[:, 0]
rms = float(np.sqrt(np.mean(mono.astype(np.float64) ** 2)))
rms_values.append(round(rms, 5))
except Exception as e:
return {'error': str(e)}
return _package_result(rms_values, framerate, n_frames, window_samples, margin_db, min_gap, min_duration)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Analysis cache helpers # Analysis cache helpers
@@ -467,22 +67,31 @@ def _analysis_cache_path(analyses_base: Path, recordings_base: Path, audio_path:
def _cached_analysis_params(cache_path: Path): def _cached_analysis_params(cache_path: Path):
"""Read just detector/margin/min_gap/min_duration from a cache file """The params a cache file was written with, or None if it is unusable.
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 from another DETECTOR_VERSION — or so old they lack a key — return
Caches written by other detector versions (or so old they lack a key) None and are recomputed on the next analyse.
simply never match and get recomputed on the next analyse."""
This used to regex four keys out of the first 256 bytes to avoid parsing
the whole file, which imposed a "these keys must be written first"
constraint on the writer. That was worth it when the cache embedded an
~800-point RMS preview and the full per-window list; those were dropped
when the waveforms went, so a cache is now a handful of floats and a
section list, and a plain load is both simpler and unbreakable by key
order."""
try: try:
with open(cache_path, 'r', encoding='utf-8') as fh: with open(cache_path, 'r', encoding='utf-8') as fh:
head = fh.read(256) cached = json.load(fh)
except OSError: except (OSError, ValueError):
return None return None
m = re.search(r'"detector":\s*(\d+),\s*"margin":\s*([0-9.eE+-]+),' if cached.get('detector') != DETECTOR_VERSION:
r'\s*"min_gap":\s*([0-9.eE+-]+),\s*"min_duration":\s*([0-9.eE+-]+)', head) return None
if not m or int(m.group(1)) != DETECTOR_VERSION: try:
return {'margin': float(cached['margin']),
'min_gap': float(cached['min_gap']),
'min_duration': float(cached['min_duration'])}
except (KeyError, TypeError, ValueError):
return None return None
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): def prune_orphan_analyses(analyses_base: Path, recordings_base: Path):
@@ -553,8 +162,8 @@ def list_files(recordings_dir: str):
# The recording start is encoded in the filename and is the true clock # 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. # anchor; mtime is only a fallback for files not in FILENAME_FORMAT.
started = _recording_start(path.stem) started = _recording_start(path.stem) or datetime.fromtimestamp(stat.st_mtime)
date = (started or datetime.fromtimestamp(stat.st_mtime)).strftime('%Y-%m-%d %H:%M:%S') date = started.strftime('%Y-%m-%d %H:%M:%S')
# Skip reading partial headers for in-progress files — the WAV nframes # Skip reading partial headers for in-progress files — the WAV nframes
# field and FLAC total_samples are both unfinalized while recording, # field and FLAC total_samples are both unfinalized while recording,
@@ -571,7 +180,13 @@ def list_files(recordings_dir: str):
'recording': is_active, 'recording': is_active,
}) })
files.sort(key=lambda f: f['mtime'], reverse=True) # Sort by the recording start, not mtime: the filename is the clock, and
# everything the user sees — the DATE column, the day grouping, the date
# filters — is derived from it. mtime is the last write (≈ the end), so a
# file whose mtime drifted from its name (a copy, a restore, an ffmpeg
# round-trip) used to land at the wrong position within its own day.
# 'date' is fixed-width zero-padded, so string order is time order.
files.sort(key=lambda f: f['date'], reverse=True)
return files return files
@@ -579,6 +194,19 @@ def list_files(recordings_dir: str):
# HTTP handler # HTTP handler
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _qs_float(qs, name: str, default: float, lo: float, hi: float) -> float:
"""A query-string float clamped to [lo, hi], or default if unparseable.
Analysis params are coupled across the CLI, /api/config, the UI inputs and
the cache keys — keeping the parse in one place means adding a fourth is a
one-line change here rather than another copy of this block.
"""
try:
return max(lo, min(hi, float(qs.get(name, [default])[0])))
except (ValueError, TypeError):
return default
class _Handler(BaseHTTPRequestHandler): class _Handler(BaseHTTPRequestHandler):
# Keep-alive: browsers reuse connections instead of a TCP handshake per # Keep-alive: browsers reuse connections instead of a TCP handshake per
# request. Safe because every response sets Content-Length. # request. Safe because every response sets Content-Length.
@@ -590,6 +218,9 @@ class _Handler(BaseHTTPRequestHandler):
min_gap: float = MIN_GAP_SECONDS min_gap: float = MIN_GAP_SECONDS
min_duration: float = MIN_DURATION_SECONDS min_duration: float = MIN_DURATION_SECONDS
# True once /api/clip has written its audio headers — see _api_clip
_clip_started: bool = False
def do_DELETE(self): def do_DELETE(self):
parsed = urlparse(self.path) parsed = urlparse(self.path)
p = parsed.path p = parsed.path
@@ -598,13 +229,25 @@ class _Handler(BaseHTTPRequestHandler):
else: else:
self._send(404, b'Not found', 'text/plain') self._send(404, b'Not found', 'text/plain')
def do_HEAD(self):
"""HEAD for the page and the audio files — some players probe before
fetching. Clips and cuts are generated on demand, so answering HEAD
for those would do all the work and throw the result away."""
p = urlparse(self.path).path
if p == '/' or p.startswith('/download/') or p.startswith('/stream/'):
self.do_GET()
else:
self._send(405, b'Method Not Allowed', 'text/plain')
def do_GET(self): def do_GET(self):
parsed = urlparse(self.path) parsed = urlparse(self.path)
qs = parse_qs(parsed.query) qs = parse_qs(parsed.query)
p = parsed.path p = parsed.path
if p == '/': if p == '/':
self._html() self._ui('index.html')
elif p.startswith('/ui/'):
self._ui(unquote(p[len('/ui/'):]))
elif p == '/api/files': elif p == '/api/files':
self._api_files() self._api_files()
elif p == '/api/analyze': elif p == '/api/analyze':
@@ -626,8 +269,21 @@ class _Handler(BaseHTTPRequestHandler):
else: else:
self._send(404, b'Not found', 'text/plain') self._send(404, b'Not found', 'text/plain')
def _html(self): def _ui(self, name: str):
self._send(200, _HTML.encode('utf-8'), 'text/html; charset=utf-8') """Serve a file of the browser UI from webui/. Read per request: it is
a handful of small files, and edits show up on reload without a server
restart. Only the listed types are served, and the path must stay
inside webui/ (same guard as _safe_path, but for the UI folder)."""
try:
path = (WEBUI_DIR / name).resolve()
path.relative_to(WEBUI_DIR)
except ValueError:
path = None
ctype = _UI_TYPES.get(path.suffix) if path else None
if ctype is None or not path.is_file():
self._send(404, b'Not found', 'text/plain')
return
self._send(200, path.read_bytes(), ctype)
def _api_files(self): def _api_files(self):
files = list_files(self.recordings_dir) files = list_files(self.recordings_dir)
@@ -652,23 +308,9 @@ class _Handler(BaseHTTPRequestHandler):
if path is None: if path is None:
return return
try: margin = _qs_float(qs, 'margin', self.margin_db, 1.0, 60.0)
margin = float(qs.get('margin', [self.margin_db])[0]) min_gap = _qs_float(qs, 'min_gap', self.min_gap, 0.0, 300.0)
margin = max(1.0, min(60.0, margin)) min_duration = _qs_float(qs, 'min_duration', self.min_duration, 0.0, 60.0)
except (ValueError, TypeError):
margin = self.margin_db
try:
min_gap = float(qs.get('min_gap', [self.min_gap])[0])
min_gap = max(0.0, min(300.0, min_gap))
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): if self._is_active(filename):
self._json_err(409, 'File is currently being recorded — analysis unavailable until recording stops') self._json_err(409, 'File is currently being recorded — analysis unavailable until recording stops')
@@ -707,8 +349,6 @@ class _Handler(BaseHTTPRequestHandler):
try: try:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)
tmp = cache_path.with_suffix('.tmp') tmp = cache_path.with_suffix('.tmp')
# 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, tmp.write_text(json.dumps({'detector': DETECTOR_VERSION,
'margin': margin, 'min_gap': min_gap, 'margin': margin, 'min_gap': min_gap,
'min_duration': min_duration, 'result': result}), 'utf-8') 'min_duration': min_duration, 'result': result}), 'utf-8')
@@ -728,20 +368,65 @@ class _Handler(BaseHTTPRequestHandler):
pass pass
self._send(200, b'{"active":[]}', 'application/json') self._send(200, b'{"active":[]}', 'application/json')
def _serve_bytes(self, path: Path, size: int, content_type: str,
prefix: bytes = b'', no_store: bool = False,
disposition: str = None):
"""Serve a file with HTTP Range support.
`prefix` replaces the first len(prefix) bytes of the file on the way
out — that is how in-progress recordings get a header patched to the
duration captured so far. It is the same length as the header it
replaces, so all offsets and Range math stay valid.
"""
range_header = self.headers.get('Range', '')
m = re.match(r'bytes=(\d+)-(\d*)', range_header) if range_header else None
if m:
start = int(m.group(1))
end = int(m.group(2)) if m.group(2) else size - 1
end = min(end, size - 1)
if start > end or start >= size:
self._send(416, b'Range Not Satisfiable', 'text/plain')
return
length = end - start + 1
self.send_response(206)
self.send_header('Content-Range', f'bytes {start}-{end}/{size}')
else:
start, length = 0, size
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(length))
self.send_header('Accept-Ranges', 'bytes')
if disposition:
self.send_header('Content-Disposition', disposition)
if no_store:
self.send_header('Cache-Control', 'no-store')
self.end_headers()
if self.command == 'HEAD':
return
with open(path, 'rb') as fh:
sent = 0
if start < len(prefix):
head = prefix[start:start + length]
self.wfile.write(head)
sent = len(head)
if sent < length:
fh.seek(start + sent)
# Bound the copy: the file may grow while we serve it, and
# writing more than Content-Length desyncs keep-alive.
self._copy_to_response(fh, length - sent)
def _download(self, filename: str): def _download(self, filename: str):
path = self._safe_path(filename) path = self._safe_path(filename)
if path is None: if path is None:
return return
# Range support matters here too: these files run to hundreds of MB
size = path.stat().st_size # and an interrupted download would otherwise have to restart.
self.send_response(200) self._serve_bytes(path, path.stat().st_size, 'application/octet-stream',
self.send_header('Content-Type', 'application/octet-stream') disposition=f'attachment; filename="{path.name}"')
self.send_header('Content-Disposition', f'attachment; filename="{path.name}"')
self.send_header('Content-Length', str(size))
self.end_headers()
with open(path, 'rb') as fh:
self._copy_to_response(fh, size)
def _stream(self, filename: str): def _stream(self, filename: str):
"""Serve audio for inline playback with HTTP Range support. """Serve audio for inline playback with HTTP Range support.
@@ -766,54 +451,7 @@ class _Handler(BaseHTTPRequestHandler):
elif ext == '.flac': elif ext == '.flac':
prefix = _live_flac_header(path, size) or b'' prefix = _live_flac_header(path, size) or b''
range_header = self.headers.get('Range', '') self._serve_bytes(path, size, content_type, prefix=prefix, no_store=is_active)
m = re.match(r'bytes=(\d+)-(\d*)', range_header) if range_header else None
if m:
start = int(m.group(1))
end = int(m.group(2)) if m.group(2) else size - 1
end = min(end, size - 1)
if start > end or start >= size:
self._send(416, b'Range Not Satisfiable', 'text/plain')
return
length = end - start + 1
self.send_response(206)
self.send_header('Content-Type', content_type)
self.send_header('Content-Range', f'bytes {start}-{end}/{size}')
self.send_header('Content-Length', str(length))
self.send_header('Accept-Ranges', 'bytes')
if is_active:
self.send_header('Cache-Control', 'no-store')
self.end_headers()
with open(path, 'rb') as fh:
sent = 0
if start < len(prefix):
head = prefix[start:start + length]
self.wfile.write(head)
sent = len(head)
if sent < length:
fh.seek(start + sent)
self._copy_to_response(fh, length - sent)
else:
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(size))
self.send_header('Accept-Ranges', 'bytes')
if is_active:
self.send_header('Cache-Control', 'no-store')
self.end_headers()
with open(path, 'rb') as fh:
if prefix:
self.wfile.write(prefix)
fh.seek(len(prefix))
self._copy_to_response(fh, size - len(prefix))
else:
# Bound the copy: the file may grow while we serve it, and
# writing more than Content-Length desyncs keep-alive.
self._copy_to_response(fh, size)
def _api_storage(self): def _api_storage(self):
# 'used' is computed client-side from the file list; walking the whole # 'used' is computed client-side from the file list; walking the whole
@@ -856,6 +494,9 @@ class _Handler(BaseHTTPRequestHandler):
except Exception: except Exception:
pass pass
with _DURATION_CACHE_LOCK:
_DURATION_CACHE.pop(filename, None)
self._send(200, json.dumps({'deleted': filename}).encode(), 'application/json') self._send(200, json.dumps({'deleted': filename}).encode(), 'application/json')
def _api_cut(self, qs): def _api_cut(self, qs):
@@ -962,6 +603,7 @@ class _Handler(BaseHTTPRequestHandler):
return return
ext = path.suffix.lower() ext = path.suffix.lower()
self._clip_started = False
try: try:
if ext == '.wav': if ext == '.wav':
self._clip_wav(path, start, end) self._clip_wav(path, start, end)
@@ -975,10 +617,19 @@ class _Handler(BaseHTTPRequestHandler):
except (ConnectionError, BrokenPipeError): except (ConnectionError, BrokenPipeError):
raise raise
except Exception as e: except Exception as e:
# Once the audio headers are out, a JSON error body would be a
# second response written into a stream that already promised
# audio/wav and a Content-Length — with keep-alive that desyncs
# the connection for every later request on it. Drop it instead.
if self._clip_started:
print(f'Clip failed mid-stream: {e}', flush=True)
self.close_connection = True
else:
self._json_err(500, f'Clip failed: {e}') self._json_err(500, f'Clip failed: {e}')
def _send_clip_headers(self, header: bytes, n_frames: int, channels: int, def _send_clip_headers(self, header: bytes, n_frames: int, channels: int,
sampwidth: int): sampwidth: int):
self._clip_started = True
self.send_response(200) self.send_response(200)
self.send_header('Content-Type', 'audio/wav') self.send_header('Content-Type', 'audio/wav')
self.send_header('Content-Length', str(len(header) + n_frames * channels * sampwidth)) self.send_header('Content-Length', str(len(header) + n_frames * channels * sampwidth))
@@ -1080,6 +731,7 @@ class _Handler(BaseHTTPRequestHandler):
self.send_header('Content-Type', content_type) self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(body))) self.send_header('Content-Length', str(len(body)))
self.end_headers() self.end_headers()
if self.command != 'HEAD':
self.wfile.write(body) self.wfile.write(body)
def _json_err(self, code: int, msg: str): def _json_err(self, code: int, msg: str):
@@ -1094,7 +746,6 @@ class _Server(ThreadingHTTPServer):
(browsers abort audio range requests constantly while seeking).""" (browsers abort audio range requests constantly while seeking)."""
def handle_error(self, request, client_address): def handle_error(self, request, client_address):
import sys
exc = sys.exc_info()[1] exc = sys.exc_info()[1]
if isinstance(exc, (ConnectionError, TimeoutError)): if isinstance(exc, (ConnectionError, TimeoutError)):
return return
@@ -1102,10 +753,15 @@ class _Server(ThreadingHTTPServer):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# UI page — single-page HTML/CSS/JS, loaded once at startup # Browser UI — static files in webui/ (index.html, style.css, js/*.js modules)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_HTML = (Path(__file__).resolve().parent / 'webui.html').read_text(encoding='utf-8') WEBUI_DIR = Path(__file__).resolve().parent / 'webui'
_UI_TYPES = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1137,18 +793,21 @@ def main():
if not rec_dir.exists(): if not rec_dir.exists():
print(f"Warning: recordings directory '{rec_dir}' does not exist yet.") print(f"Warning: recordings directory '{rec_dir}' does not exist yet.")
if not (WEBUI_DIR / 'index.html').is_file():
sys.exit(f"Error: browser UI not found at {WEBUI_DIR} — keep webui/ next to web.py.")
prune_orphan_analyses(analyses_dir, rec_dir) prune_orphan_analyses(analyses_dir, rec_dir)
_analyses_dir = analyses_dir # class body can't close over a name it also assigns # Config lives on the handler class (one server per process). Setting the
class Handler(_Handler): # attributes directly beats generating a subclass here, which also needed
recordings_dir = str(rec_dir) # an alias to dodge Python's class-body scoping.
analyses_dir = str(_analyses_dir) _Handler.recordings_dir = str(rec_dir)
margin_db = args.margin _Handler.analyses_dir = str(analyses_dir)
min_gap = args.min_gap _Handler.margin_db = args.margin
min_duration = args.min_duration _Handler.min_gap = args.min_gap
_Handler.min_duration = args.min_duration
server = _Server((args.host, args.port), Handler) server = _Server((args.host, args.port), _Handler)
print(f"ISR Web running on http://{args.host}:{args.port}/") print(f"ISR Web running on http://{args.host}:{args.port}/")
print(f"Recordings dir: {rec_dir}") print(f"Recordings dir: {rec_dir}")
-1218
View File
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>ISR Archive</title>
<link rel="stylesheet" href="/ui/style.css">
<script type="module" src="/ui/js/main.js"></script>
</head>
<body>
<div id="sr-announce" aria-live="polite" aria-atomic="true" class="sr"></div>
<a href="#days" class="skip">Skip to recordings</a>
<header>
<h1 id="page-title" tabindex="-1">ISR Archive</h1>
<p id="subtitle">Loading…</p>
<p id="storage-info"></p>
<button id="refresh-btn">Refresh</button>
</header>
<main>
<details id="settings">
<summary>Settings</summary>
<div class="settings-grid">
<label for="margin-input">Loudness margin</label>
<input type="number" id="margin-input" min="1" max="60" step="1" aria-describedby="margin-hint">
<span id="margin-hint" class="hint">dB above the background noise for a section to count as loud</span>
<label for="min-gap-input">Grace period</label>
<input type="number" id="min-gap-input" min="0" max="300" step="0.5" aria-describedby="min-gap-hint">
<span id="min-gap-hint" class="hint">seconds; loud sections closer than this are merged</span>
<label for="min-duration-input">Minimum length</label>
<input type="number" id="min-duration-input" min="0" max="60" step="0.1" aria-describedby="min-duration-hint">
<span id="min-duration-hint" class="hint">seconds; shorter loud sections are ignored</span>
<label for="preroll-input">Pre-roll</label>
<input type="number" id="preroll-input" min="0" max="30" step="0.5" value="3" aria-describedby="preroll-hint">
<span id="preroll-hint" class="hint">seconds played before each section starts</span>
</div>
</details>
<div class="filter-bar" role="search" aria-label="Filter recordings">
<label for="filter-name">Search</label>
<input type="search" id="filter-name" placeholder="filename…" autocomplete="off">
<label for="filter-from">From</label>
<input type="date" id="filter-from">
<label for="filter-to">To</label>
<input type="date" id="filter-to">
<button id="filter-clear">Clear filters</button>
</div>
<div id="days" tabindex="-1"></div>
<p id="empty" class="empty" hidden>No recordings found.</p>
</main>
<section id="clip-bar" hidden aria-labelledby="clip-heading">
<h2 id="clip-heading" class="sr">Clip player</h2>
<div class="clip-row">
<button id="clip-prev" aria-label="Previous section (J)" aria-keyshortcuts="J">Prev</button>
<button id="clip-next" aria-label="Next section (K)" aria-keyshortcuts="K">Next</button>
<p id="clip-label"></p>
<button id="clip-context" aria-label="Open in file (O)" aria-keyshortcuts="O">Open in file</button>
<button id="clip-close" aria-label="Close clip player">&times;</button>
</div>
<div class="clip-row">
<audio id="clip-audio" controls preload="auto" aria-label="Section clip"></audio>
<fieldset id="clip-adv">
<legend>When a clip ends</legend>
<label><input type="radio" name="clip-adv" value="off"> Stop</label>
<label><input type="radio" name="clip-adv" value="all" checked> Next in time</label>
<label><input type="radio" name="clip-adv" value="hl"> Next loudest</label>
</fieldset>
</div>
</section>
</body>
</html>
+145
View File
@@ -0,0 +1,145 @@
// Per-file loudness analysis: fetching (with a session cache) and the result
// cell — a meta line, the section chips, a Re-analyse button.
import {fmtDur, fmtClock, fileStartEpoch, announce, chipToggle} from './util.js';
import * as api from './api.js';
import {fileByName, fileByIdx, fileSpan, sectionMap} from './store.js';
import {analysisParams} from './settings.js';
import {playFrom} from './clips.js';
// filename|margin|gap|minDur -> analysis result, so re-renders (filtering,
// refresh) never refetch what this session already has
const analysisCache = new Map();
export async function fetchAnalysis(filename, params, force = false) {
const {margin, minGap, minDur} = params;
const key = `${filename}|${margin}|${minGap}|${minDur}`;
if (!force && analysisCache.has(key)) return analysisCache.get(key);
const d = await api.analyze(filename, params);
if (!d.error) {
analysisCache.set(key, d);
// Keep the listing's cache info current so re-renders (filtering) still
// know the file is analysed without refetching /api/files
const f = fileByName(filename);
if (f) f.cached_analysis = {margin, min_gap: minGap, min_duration: minDur};
}
return d;
}
export function forgetAnalyses(filename) {
for (const k of [...analysisCache.keys()])
if (k.startsWith(filename + '|')) analysisCache.delete(k);
}
export function playFileSection(idx, si) {
const f = fileByIdx(idx);
if (!f) return;
const epoch = fileStartEpoch(f.date);
const queue = (sectionMap.get(idx) || []).map(s => ({
filename: f.name, start: s.start, end: s.end, score: s.score,
absStart: epoch != null ? epoch + s.start : null,
}));
playFrom(queue, si);
}
export function analyseButton(idx, cell, text) {
const f = fileByIdx(idx);
const b = document.createElement('button');
b.textContent = text;
b.setAttribute('aria-label', `${text} ${f ? fileSpan(f) : ''}`);
b.addEventListener('click', () =>
analyse(idx, cell, b, {force: text === 'Re-analyse', speak: true}));
return b;
}
const para = (cls, text) => {
const p = document.createElement('p');
p.className = cls;
p.textContent = text;
return p;
};
// btn is the button that started it (optional). It stays in place and
// focusable while busy — disabling or replacing a focused button drops the
// screen reader at the top of the page. speak: the user asked for this one, so
// say how it went; background autoloads stay quiet.
export async function analyse(idx, cell, btn, {force = false, speak = false} = {}) {
const f = fileByIdx(idx);
if (!f || cell.dataset.busy) return;
cell.dataset.busy = '1';
const span = fileSpan(f);
if (btn) {
btn.textContent = 'Analysing…';
btn.setAttribute('aria-label', `Analysing ${span}`);
btn.setAttribute('aria-disabled', 'true');
} else {
cell.replaceChildren(para('quiet', 'Analysing…'));
}
const params = analysisParams();
let d;
try {
d = await fetchAnalysis(f.name, params, force);
} catch (e) {
d = {error: e.message};
}
delete cell.dataset.busy;
// Focus inside the cell is about to be destroyed: move it to the new content
const hadFocus = cell.contains(document.activeElement);
if (d.error) {
const b = analyseButton(idx, cell, 'Analyse');
cell.replaceChildren(para('quiet', `Error: ${d.error}`), b);
if (hadFocus) b.focus();
if (speak) announce(`Analysis of ${span} failed: ${d.error}`);
return;
}
const secs = d.sections || [];
const n = secs.length;
const box = document.createElement('div');
box.className = 'wbox';
box.append(para('analysis-meta', `${n} loud section${n !== 1 ? 's' : ''}`
+ ` · margin ${params.margin} dB · gap ${params.minGap} s · min ${params.minDur} s`
+ (d.cached ? ' · cached' : '')));
if (n) {
sectionMap.set(idx, secs);
const epoch = fileStartEpoch(f.date);
const chips = document.createElement('div');
chips.className = 'chips';
chips.setAttribute('role', 'group');
chips.setAttribute('aria-label', 'Loud sections');
secs.forEach((s, si) => {
const c = document.createElement('button');
c.className = 'chip';
// Wall-clock time like the day highlights and the clip label; the
// in-file offset only matters for cutting and lives in the tooltip
c.textContent = (epoch != null ? fmtClock(epoch + s.start) : `${fmtDur(s.start)} – ${fmtDur(s.end)}`)
+ (s.score != null ? ` · +${Math.round(s.score)} dB` : '');
c.title = `${fmtDur(s.start)}–${fmtDur(s.end)} into ${f.name}`;
c.addEventListener('click', () => playFileSection(idx, si));
chips.appendChild(c);
});
const tog = chipToggle(chips, `${n} sections`);
if (tog) box.append(tog);
box.append(chips);
} else {
sectionMap.delete(idx);
}
box.append(analyseButton(idx, cell, 'Re-analyse'));
box.lastChild.classList.add('reanalyse-btn');
cell.replaceChildren(box);
// First control in reading order: the first chip, the chip toggle when the
// list is collapsed, or Re-analyse when there are no sections
if (hadFocus) box.querySelector('button').focus();
if (speak) announce(n ? `${n} loud section${n !== 1 ? 's' : ''} in ${span}` : `No loud sections in ${span}`);
}
// Run the deferred analyses of a freshly expanded day
export function autoloadDayAnalyses(dayEl) {
dayEl.querySelectorAll('td[data-autoload]').forEach(cell => {
cell.removeAttribute('data-autoload');
analyse(Number(cell.dataset.idx), cell, cell.querySelector('button'));
});
}
+30
View File
@@ -0,0 +1,30 @@
// Every URL the UI talks to, in one place. A leaf module.
const q = encodeURIComponent;
async function getJson(url) {
return (await fetch(url)).json();
}
export const getConfig = () => getJson('/api/config');
export const getFiles = () => getJson('/api/files');
export const getStatus = () => getJson('/api/status');
export const getStorage = () => getJson('/api/storage');
export const analyze = (file, {margin, minGap, minDur}) =>
getJson(`/api/analyze?file=${q(file)}&margin=${q(margin)}`
+ `&min_gap=${q(minGap)}&min_duration=${q(minDur)}`);
export async function deleteFile(file) {
const r = await fetch('/api/files/' + q(file), {method: 'DELETE'});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.error || r.statusText);
}
}
export const clipUrl = (file, start, end) =>
`/api/clip?file=${q(file)}&start=${start.toFixed(1)}&end=${end.toFixed(1)}`;
export const cutUrl = (file, start, end) => `/api/cut?file=${q(file)}&start=${start}&end=${end}`;
export const streamUrl = file => '/stream/' + q(file);
export const downloadUrl = file => '/download/' + q(file);
+193
View File
@@ -0,0 +1,193 @@
// The clip player: sections play as small server-rendered WAV clips
// (/api/clip) in the bottom bar instead of seeking the full recording, which
// is slow for big FLACs. The queue holds the active review list (one file's
// sections, or a whole day's); J/K and Prev/Next step through it in time
// order, U/I step through the loudest-first ranking. The auto-advance radio
// picks what plays when a clip ends: nothing, the next in time, or the next
// by loudness.
//
// Two cursors, deliberately independent. clipCursor is the time-order position
// and follows every play (chip click, J/K, U/I). scoreCursor is the loudness
// walk's own position in scoreOrder(): only U/I (and "next loudest" auto-
// advance) move it; J/K detours to review nearby clips leave it alone, so
// coming back to U/I resumes the ranking exactly where you left it. An explicit
// jump (chip click, new queue) resets it to -1 so the next U/I re-anchors there.
import {$, fmtDur, fmtClock, announce} from './util.js';
import {fileIdxByName} from './store.js';
import {advanceMode, getPreroll} from './settings.js';
import {clipUrl} from './api.js';
import {setCutFields, seekToSection} from './player.js';
let clipQueue = [];
let clipCursor = -1;
let scoreCursor = -1;
// Where focus was when the bar opened, so closing it doesn't drop the user at
// the top of the page
let returnFocus = null;
export const hasQueue = () => clipQueue.length > 0;
// byScore: this play is part of a loudness walk (U/I or "next loudest"
// auto-advance), so the position shown is scoreCursor — the rank in the
// loudest-first ranking, labelled "by loudness" — not the time-order index.
export function playClip(i, byScore) {
if (i < 0 || i >= clipQueue.length) return;
clipCursor = i;
const c = clipQueue[i];
const cs = Math.max(0, c.start - getPreroll());
const ce = c.end + 1.5; // small tail after the section
const a = $('clip-audio');
a.src = clipUrl(c.filename, cs, ce);
a.play().catch(() => {});
// Label = wall-clock time of occurrence (absStart from the filename clock);
// falls back to in-file offsets for non-standard filenames. The visible
// label, the screen-reader announcement and the lock-screen title use one
// identical string: "17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)" —
// the count is the time-order index "by time" (J/K) or the loudness rank
// "by loudness" (U/I).
const when = c.absStart != null
? `${fmtClock(c.absStart)} to ${fmtClock(c.absStart + (c.end - c.start))}`
: `${fmtDur(c.start)} to ${fmtDur(c.end)}`;
const score = c.score != null ? ` · +${Math.round(c.score)} dB` : '';
const pos = byScore ? scoreCursor : i;
const dim = byScore ? 'by loudness' : 'by time';
const text = `${when}${score} (${pos + 1} / ${clipQueue.length} ${dim})`;
const label = $('clip-label');
label.textContent = text;
label.title = `${c.filename} @ ${fmtDur(c.start)}–${fmtDur(c.end)}`;
const bar = $('clip-bar');
const ae = document.activeElement;
if (ae && ae !== document.body && !bar.contains(ae)) returnFocus = ae;
bar.hidden = false;
document.body.classList.add('clip-open');
const rowIdx = fileIdxByName(c.filename);
if (rowIdx != null) setCutFields(rowIdx, c.start, c.end);
if ('mediaSession' in navigator && 'MediaMetadata' in window)
navigator.mediaSession.metadata = new MediaMetadata({title: text, artist: 'ISR', album: c.filename});
announce(text);
}
export function hideClipBar() {
const a = $('clip-audio');
a.pause();
a.removeAttribute('src');
$('clip-bar').hidden = true;
document.body.classList.remove('clip-open');
if ('mediaSession' in navigator) navigator.mediaSession.metadata = null;
clipQueue = [];
clipCursor = -1;
scoreCursor = -1;
}
// Point the queue at a new section list without playing: nothing is selected
// yet, and the next U/I must re-anchor here rather than resume a walk through
// the previous queue.
export function armQueue(sections) {
clipQueue = sections;
clipCursor = -1;
scoreCursor = -1;
}
// Explicit jump (chip click): play section i of sections; the next U/I
// re-anchors on it
export function playFrom(sections, i) {
clipQueue = sections;
scoreCursor = -1;
playClip(i);
}
// Highlight order = section indices sorted loudest-first. U/I (and "next
// loudest" auto-advance) walk this ranking, so a review runs from the most to
// the least interesting event until the user stops — no top-N cutoff.
// Cached against the section array's identity: a day queue runs to thousands
// of entries and U/I would otherwise re-sort all of them on every keypress.
// Every arming path assigns a fresh array, so identity is a safe key.
let scoreOrderCache = {secs: null, order: null};
export function scoreOrder(secs) {
if (scoreOrderCache.secs === secs) return scoreOrderCache.order;
const order = secs.map((s, i) => ({i, score: s.score || 0}))
.sort((a, b) => b.score - a.score)
.map(r => r.i);
scoreOrderCache = {secs, order};
return order;
}
// Step a position within an ordering: pos + dir, or straight to the extreme in
// that direction when jump. Returns null when the step runs off either end.
// pos === -1 means "not anchored yet" — start at the first entry going forward,
// or off the front going backward so the caller announces the boundary.
// Shared by both U/I walks (clip queue and in-player); they differ only in what
// the anchor is and what "play" means, and drifting apart is exactly how the
// two would stop agreeing.
export function nextInOrder(order, pos, dir, jump) {
const next = jump ? (dir > 0 ? order.length - 1 : 0)
: pos === -1 ? (dir > 0 ? 0 : -1) : pos + dir;
return (next >= 0 && next < order.length) ? next : null;
}
// Step the clip queue by dir (±1): in time order, or with byScore down/up
// the loudest-first ranking (next = next quieter). With jump=true, go straight
// to the extreme in that direction (first/last section, or loudest/quietest).
export function stepClip(dir, byScore, jump) {
if (!clipQueue.length) return;
if (byScore) {
// Walk scoreCursor, not the shared clipCursor: a J/K detour never moved
// it, so we resume the ranking where we left off. scoreCursor === -1 means
// the walk hasn't started since the queue was armed or an explicit jump —
// anchor on the section currently selected (clipCursor), else the loudest.
const order = scoreOrder(clipQueue);
const pos = scoreCursor !== -1 ? scoreCursor
: clipCursor >= 0 ? order.indexOf(clipCursor)
: -1;
const next = nextInOrder(order, pos, dir, jump);
if (next === null) { announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights'); return; }
scoreCursor = next;
playClip(order[next], true);
return;
}
// J/K (time order) deliberately does NOT touch scoreCursor.
const i = jump ? (dir > 0 ? clipQueue.length - 1 : 0) : clipCursor + dir;
if (i >= 0 && i < clipQueue.length) playClip(i);
else announce(dir < 0 ? 'Beginning of sections' : 'End of sections');
}
// "Open in file": switch from the current clip to the full recording at the
// same position. Bound to the clip-bar button and the O key.
export function openClipInFile() {
const c = clipQueue[clipCursor];
if (!c) return;
const idx = fileIdxByName(c.filename);
if (idx == null) return;
$('clip-audio').pause();
const row = $('row-' + idx);
if (row) {
const wrap = row.closest('.table-wrap');
if (wrap?.hidden) // file's day is collapsed: expand it
$('daytgl-' + wrap.id.slice('daytbl-'.length))?.click();
row.scrollIntoView({block: 'center'});
}
seekToSection(idx, c.start, c.end);
}
function closeClipBar() {
const back = returnFocus;
returnFocus = null;
hideClipBar();
(back?.isConnected ? back : $('page-title')).focus();
}
export function initClipBar() {
$('clip-prev').addEventListener('click', () => stepClip(-1, false));
$('clip-next').addEventListener('click', () => stepClip(1, false));
$('clip-close').addEventListener('click', closeClipBar);
$('clip-context').addEventListener('click', openClipInFile);
$('clip-audio').addEventListener('ended', () => {
const mode = advanceMode();
if (mode !== 'off') stepClip(1, mode === 'hl');
});
$('clip-bar').addEventListener('keydown', e => {
if (e.key === 'Escape') { e.preventDefault(); closeClipBar(); }
});
}
+190
View File
@@ -0,0 +1,190 @@
// Day review: the chronological cross-file section list behind a day's
// Highlights button.
//
// The user is blind and uses a screen reader — there is deliberately no day
// timeline graphic. The panel is linear text and buttons: summary line ->
// key-hint note -> chips toggle -> chips.
import {$, fmtDur, fmtClock, fileStartEpoch, announce, chipToggle} from './util.js';
import {fetchAnalysis} from './analysis.js';
import {armQueue, playFrom, hideClipBar} from './clips.js';
import {analysisParams, hlParams} from './settings.js';
let dayActiveSections = [];
let dayActiveId = null;
// dayId -> section list of an already-built highlights panel, so collapsing
// and re-expanding it re-arms J/K without recomputing
const dayHlSections = new Map();
export function setHlExpanded(dayId, exp) {
const btn = $('dayhln-' + dayId);
if (!btn) return;
btn.setAttribute('aria-expanded', exp);
const arrow = btn.querySelector('.day-arrow');
if (arrow) arrow.textContent = exp ? '▾ ' : '▸ ';
}
// The status suffix inside the Highlights button (" · analysed", progress).
// The button has no aria-label, so this text is part of its accessible name.
const setHlStatus = (dayId, text) => {
const el = $('dayhls-' + dayId);
if (el) el.textContent = text;
};
// Point the clip queue at a day's cross-file section list, from either the
// fresh-build path or the re-open-a-kept-panel path. Coupled globals set in
// two places is how the stale-_idx and diverging-label bugs happened.
function armDayQueue(dayId, sections) {
dayActiveSections = sections;
dayActiveId = dayId;
armQueue(sections);
}
function jumpToDaySection(si) {
if (si < 0 || si >= dayActiveSections.length) return;
playFrom(dayActiveSections, si);
}
// The day was collapsed: close its panel and drop its queue
export function releaseDay(dayId) {
const panel = $('dayhl-' + dayId);
if (panel) panel.hidden = true;
setHlExpanded(dayId, false);
if (dayActiveId === dayId) {
dayActiveSections = [];
dayActiveId = null;
hideClipBar();
}
}
// Highlights button: expand/collapse the panel; only (re)compute when it has
// not been built yet this session or the analysis params changed
export function toggleHighlights(dayId, files) {
const panel = $('dayhl-' + dayId);
const btn = $('dayhln-' + dayId);
if (btn?.dataset.busy) return;
if (!panel.hidden) { // collapse, keep the panel
panel.hidden = true;
setHlExpanded(dayId, false);
return;
}
if (panel.dataset.loaded === hlParams()) { // re-open and re-arm J/K
panel.hidden = false;
setHlExpanded(dayId, true);
armDayQueue(dayId, dayHlSections.get(dayId) || []);
return;
}
dayHighlights(dayId, files);
}
async function dayHighlights(dayId, files) {
const panel = $('dayhl-' + dayId);
const content = $('dayhlc-' + dayId);
const btn = $('dayhln-' + dayId);
const n = files.length;
panel.hidden = false;
setHlExpanded(dayId, true);
// Busy, not disabled: disabling the focused button would throw the screen
// reader's focus to the top of the page
btn.dataset.busy = '1';
btn.setAttribute('aria-disabled', 'true');
const progBar = document.createElement('div'); progBar.className = 'prog-bar';
const progFill = document.createElement('div'); progFill.className = 'prog-fill';
progBar.setAttribute('aria-hidden', 'true');
progBar.appendChild(progFill);
const progFile = document.createElement('p'); progFile.className = 'prog-file';
content.replaceChildren(progBar, progFile);
const params = analysisParams();
const results = [];
let nCached = 0;
for (let i = 0; i < n; i++) {
const f = files[i];
progFile.textContent = `${i + 1} / ${n} — ${f.name}`;
progFill.style.width = `${(i / n) * 100}%`;
setHlStatus(dayId, ` · ${i + 1} of ${n}`);
try {
const d = await fetchAnalysis(f.name, params);
if (!d.error) { results.push({f, data: d}); if (d.cached) nCached++; }
} catch { /* counted as missing below */ }
}
delete btn.dataset.busy;
btn.removeAttribute('aria-disabled');
// Every file is now cached with the current params
setHlStatus(dayId, results.length === n ? ' · analysed' : '');
// Map files onto the day. The filename is the clock: f.date is the
// recording start parsed server-side; mtime (≈ file end) is only a fallback
// for non-standard names.
const sections = [];
results.forEach(({f, data}) => {
const fileDur = data.duration || f.duration || 0;
if (fileDur <= 0) return;
const startEpoch = fileStartEpoch(f.date);
const fileStart = startEpoch != null ? startEpoch : f.mtime - fileDur;
(data.sections || []).forEach(s => sections.push({
filename: f.name, start: s.start, end: s.end, score: s.score,
absStart: fileStart + s.start,
}));
});
sections.sort((a, b) => a.absStart - b.absStart);
// Arm the clip queue so J/K steps through the day immediately
armDayQueue(dayId, sections);
const box = document.createElement('div');
box.className = 'wbox';
const nRes = results.length, nSec = sections.length;
const missing = n - nRes;
// Totals first, then the key hint, then the chips: linear reading order
const summary = document.createElement('p');
summary.className = 'quiet';
summary.textContent = `${nRes} recording${nRes !== 1 ? 's' : ''} analysed`
+ (nCached ? ` (${nCached} from cache)` : '')
+ (missing ? `, ${missing} failed` : '')
+ ` · ${nSec} loud section${nSec !== 1 ? 's' : ''}`;
box.appendChild(summary);
if (nSec) {
const note = document.createElement('p');
note.className = 'quiet';
note.textContent = 'J / K plays through all sections in time order, U / I by loudness (loudest first). Hold Shift to jump to the first / last section (Shift + J / K) or the loudest / quietest (Shift + U / I)';
box.appendChild(note);
const MAX_DAY_CHIPS = 50;
// When there are too many sections to show them all, show the ones most
// worth reviewing: the top MAX_DAY_CHIPS by score, loudest first.
let chipList = sections.map((sec, si) => ({sec, si}));
const truncated = chipList.length > MAX_DAY_CHIPS;
if (truncated) {
chipList = chipList
.sort((a, b) => (b.sec.score || 0) - (a.sec.score || 0))
.slice(0, MAX_DAY_CHIPS);
}
const chips = document.createElement('div');
chips.className = 'chips';
chips.setAttribute('role', 'group');
chips.setAttribute('aria-label', 'Day loud sections');
chipList.forEach(({sec, si}) => {
const c = document.createElement('button');
c.className = 'chip';
c.title = sec.filename + ' @ ' + fmtDur(sec.start);
c.textContent = fmtClock(sec.absStart) + (sec.score != null ? ` · +${Math.round(sec.score)} dB` : '');
c.addEventListener('click', () => jumpToDaySection(si));
chips.appendChild(c);
});
const tog = chipToggle(chips, truncated
? `Top ${chipList.length} sections by loudness`
: `${chipList.length} sections`);
if (tog) box.appendChild(tog);
box.appendChild(chips);
}
content.replaceChildren(box);
panel.dataset.loaded = hlParams();
dayHlSections.set(dayId, sections);
announce(nSec
? `${nSec} loud section${nSec !== 1 ? 's' : ''} across ${nRes} recording${nRes !== 1 ? 's' : ''}. K plays the first, I the loudest.`
: `No loud sections in ${nRes} recording${nRes !== 1 ? 's' : ''}.`);
}
+323
View File
@@ -0,0 +1,323 @@
// The recordings listing: one collapsible section per day, a table of
// recordings inside, plus loading, filtering, deleting and status polling.
import {$, esc, fmtDur, fmtDurWords, fmtSize, pad, parseTime, dayLabel, announce} from './util.js';
import * as api from './api.js';
import {allFiles, setFiles, removeFile, sectionMap, fileSpan, isAnalysable} from './store.js';
import {cachedParamsMatch, hlParams} from './settings.js';
import {analyse, analyseButton, autoloadDayAnalyses, forgetAnalyses} from './analysis.js';
import {togglePlayer, closePlayer, forgetPlayer, toggleCut} from './player.js';
import {toggleHighlights, releaseDay} from './day.js';
// dayId -> boolean, persists expanded state across re-renders
const dayExpanded = new Map();
function groupByDay(files) {
const map = new Map();
files.forEach(f => {
const day = f.date.slice(0, 10);
if (!map.has(day)) map.set(day, []);
map.get(day).push(f);
});
return map;
}
// Local date, not toISOString() — f.date is the recording start in local
// time, so a UTC "today" leaves the current day collapsed for part of every
// day in any non-UTC timezone (00:00–02:00 in UTC+2, all evening in UTC−5).
const localToday = () => {
const now = new Date();
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
};
// Everything a day's markup is built from. A day whose signature is unchanged
// keeps its DOM across reloads — open players, analyses, the highlights panel
// and, above all, the screen reader's position in it.
const daySignature = dayFiles => hlParams() + '#'
+ dayFiles.map(f => `${f.name}:${f.recording ? 1 : 0}:${f.duration ?? ''}`).join('|');
function buildRow(f, tbody, expanded) {
const i = f._idx;
const isRec = !!f.recording;
const span = esc(fileSpan(f));
const off = isRec ? ' disabled' : '';
const tr = document.createElement('tr');
tr.className = 'data-row';
tr.id = 'row-' + i;
tr.dataset.idx = i;
// Time is the row header, so table navigation names the row by its span.
// The player opens inside its own cell; the options cell right after it is
// always there, and Cut expands its fields inside it. Format, size and
// filename come last: rarely needed, and the filename reads as a string of
// digits.
tr.innerHTML = `
<th scope="row" class="when">${span} <span id="rec-${i}" class="badge-rec"${isRec ? '' : ' hidden'}><span aria-hidden="true">● </span>Recording</span></th>
<td class="nowrap">${fmtDur(f.duration)}</td>
<td id="wave-${i}" class="loudness">${isAnalysable(f) ? '' : '<span class="muted">Not available for this format</span>'}</td>
<td class="player">
<button id="pbtn-${i}" aria-expanded="false" aria-controls="aud-${i}" aria-label="Play ${span}">Play</button>
<audio id="aud-${i}" controls preload="none" aria-label="Recording ${span}" hidden></audio>
</td>
<td class="options">
<div class="actions">
<a class="dl" href="${api.downloadUrl(f.name)}" aria-label="Download ${span}">Download</a>
<button id="cutbtn-${i}" class="cut" aria-expanded="false" aria-controls="cut-${i}" aria-label="Cut ${span}"${off}><span
class="day-arrow" aria-hidden="true">▸ </span>Cut</button>
<button id="delbtn-${i}" class="del" aria-label="Delete ${span}"${off}>Delete</button>
</div>
<div class="cut-panel" id="cut-${i}" role="group" aria-label="Cut" hidden>
<label class="cut-field">Start
<input type="text" id="cut-start-${i}" class="cut-time" placeholder="m:ss or h:mm:ss" autocomplete="off">
</label>
<label class="cut-field">End
<input type="text" id="cut-end-${i}" class="cut-time" placeholder="m:ss or h:mm:ss" autocomplete="off">
</label>
<button id="cut-dl-${i}" class="cut">Download cut</button>
</div>
</td>
<td class="file"><span class="badge badge-${esc(f.ext)}">${esc(f.ext.toUpperCase())}</span> ${fmtSize(f.size)} · <span class="fn">${esc(f.name)}</span></td>`;
tbody.append(tr);
if (isAnalysable(f)) {
const cell = tr.querySelector('#wave-' + i);
const abtn = analyseButton(i, cell, 'Analyse');
cell.appendChild(abtn);
if (isRec) {
abtn.disabled = true;
} else if (cachedParamsMatch(f.cached_analysis)) {
if (expanded) analyse(i, cell, abtn);
// Collapsed day: defer the fetch until the day is opened
else { cell.dataset.autoload = '1'; cell.dataset.idx = i; }
}
}
tr.querySelector('#pbtn-' + i).addEventListener('click', () => togglePlayer(i));
if (!isRec) {
tr.querySelector('#cutbtn-' + i).addEventListener('click', () => toggleCut(i));
tr.querySelector('#delbtn-' + i).addEventListener('click', () => deleteFile(f));
tr.querySelector('#cut-dl-' + i).addEventListener('click', () => {
const start = parseTime($('cut-start-' + i).value);
const end = parseTime($('cut-end-' + i).value);
if (start === null || end === null) { alert('Enter start and end times, e.g. 1:30 or 0:01:30'); return; }
if (start >= end) { alert('Start must be before end'); return; }
window.location.href = api.cutUrl(f.name, start, end);
});
}
}
function buildDay(day, dayFiles) {
const dayId = 'day-' + day.replace(/-/g, '');
if (!dayExpanded.has(dayId)) dayExpanded.set(dayId, day === localToday());
const expanded = dayExpanded.get(dayId);
const label = esc(dayLabel(day));
const totalSize = dayFiles.reduce((a, f) => a + f.size, 0);
const totalDur = dayFiles.reduce((a, f) => a + (f.duration || 0), 0);
const hlFiles = dayFiles.filter(f => isAnalysable(f) && !f.recording);
// Every analysable file already has a cached analysis for the current
// params -> the day's highlights are available without recomputing
const analysed = hlFiles.length > 0 && hlFiles.every(f => cachedParamsMatch(f.cached_analysis));
const meta = `${dayFiles.length} recording${dayFiles.length !== 1 ? 's' : ''}`
+ (totalDur > 0 ? ' · ' + fmtDurWords(totalDur) : '') + ' · ' + fmtSize(totalSize);
const el = document.createElement('div');
el.className = 'day-section';
el.id = 'daysec-' + dayId;
el.dataset.day = day;
// The Highlights button has no aria-label on purpose: its content is its
// name, so the " · analysed" / progress suffix is heard without mirroring.
el.innerHTML = `
<div class="day-heading-bar${expanded ? ' open' : ''}">
<h2 class="day-heading">
<button class="day-toggle" id="daytgl-${dayId}" aria-expanded="${expanded}" aria-controls="daytbl-${dayId}"><span
class="day-arrow" aria-hidden="true">${expanded ? '▾ ' : '▸ '}</span>${label}<span
class="day-meta"><span class="sr">, </span>${esc(meta)}</span></button>
</h2>
${hlFiles.length ? `<button class="day-hl" id="dayhln-${dayId}" aria-expanded="false" aria-controls="dayhl-${dayId}"><span
class="day-arrow" aria-hidden="true">▸ </span>Highlights<span class="sr"> for ${label}</span><span
class="day-hl-status" id="dayhls-${dayId}">${analysed ? ' · analysed' : ''}</span></button>` : ''}
</div>
<div class="day-hl-container" id="dayhl-${dayId}" hidden><div id="dayhlc-${dayId}"></div></div>
<div class="table-wrap" id="daytbl-${dayId}"${expanded ? '' : ' hidden'}>
<table class="day-table">
<caption class="sr">Recordings, ${label}</caption>
<thead><tr>
<th scope="col">Time</th>
<th scope="col">Length</th>
<th scope="col">Loudness</th>
<th scope="col">Player</th>
<th scope="col">Options</th>
<th scope="col">File</th>
</tr></thead>
<tbody></tbody>
</table>
</div>`;
const tbody = el.querySelector('tbody');
dayFiles.forEach(f => buildRow(f, tbody, expanded));
const headBar = el.querySelector('.day-heading-bar');
const wrap = el.querySelector('.table-wrap');
const tgl = el.querySelector('.day-toggle');
tgl.addEventListener('click', () => {
const nowExp = !dayExpanded.get(dayId);
dayExpanded.set(dayId, nowExp);
tgl.setAttribute('aria-expanded', nowExp);
tgl.querySelector('.day-arrow').textContent = nowExp ? '▾ ' : '▸ ';
headBar.classList.toggle('open', nowExp);
wrap.hidden = !nowExp;
if (nowExp) {
autoloadDayAnalyses(wrap);
} else {
dayFiles.forEach(f => closePlayer(f._idx));
releaseDay(dayId);
}
});
el.querySelector('.day-hl')?.addEventListener('click', () => toggleHighlights(dayId, hlFiles));
return el;
}
function discardDay(el) {
el.querySelectorAll('audio').forEach(a => a.pause());
el.querySelectorAll('tr.data-row').forEach(tr => {
const i = Number(tr.dataset.idx);
sectionMap.delete(i);
forgetPlayer(i);
});
el.remove();
}
// Re-render the listing, rebuilding only the days whose content changed. A
// rollover touches today alone, so the rest of the page — and whatever the
// screen reader is reading — stays put. Focus inside a rebuilt day is put back
// on the element with the same id (ids are stable per filename, see store.js).
function renderFiles(files) {
const container = $('days');
const focusId = document.activeElement?.id || null;
const total = allFiles.length, visible = files.length;
$('subtitle').textContent = total === visible
? `${total} recording${total !== 1 ? 's' : ''}`
: `${visible} of ${total} recording${total !== 1 ? 's' : ''} shown`;
$('empty').hidden = visible > 0;
const old = new Map([...container.children].map(el => [el.dataset.day, el]));
let prev = null;
groupByDay(files).forEach((dayFiles, day) => {
const sig = daySignature(dayFiles);
let el = old.get(day);
old.delete(day);
if (!el || el.dataset.sig !== sig) {
if (el) discardDay(el);
el = buildDay(day, dayFiles);
el.dataset.sig = sig;
}
// Only move nodes that are out of place: moving one blurs focus inside it
const ref = prev ? prev.nextSibling : container.firstChild;
if (el !== ref) container.insertBefore(el, ref);
prev = el;
});
old.forEach(discardDay);
if (focusId && document.activeElement?.id !== focusId) $(focusId)?.focus();
}
export function applyFilters({speak = false} = {}) {
const nameQ = $('filter-name').value.toLowerCase().trim();
const fromD = $('filter-from').value;
const toD = $('filter-to').value;
renderFiles(allFiles.filter(f => {
if (nameQ && !f.name.toLowerCase().includes(nameQ)) return false;
if (fromD && f.date < fromD + ' 00:00:00') return false;
if (toD && f.date > toD + ' 23:59:59') return false;
return true;
}));
if (speak) announce($('subtitle').textContent);
}
async function updateStorage() {
try {
const s = await api.getStorage();
const used = allFiles.reduce((a, f) => a + f.size, 0);
let txt = fmtSize(used) + ' used';
if (s.disk_free != null) txt += ' · ' + fmtSize(s.disk_free) + ' free';
if (s.disk_total != null) txt += ' of ' + fmtSize(s.disk_total);
$('storage-info').textContent = txt;
} catch { /* keep the last value */ }
}
let loading = false;
// speak: the user pressed Refresh, so say what came back. Poll-driven reloads
// stay silent — a rollover every hour must not interrupt whatever is being read.
export async function load({speak = false} = {}) {
if (loading) return;
loading = true;
const btn = $('refresh-btn');
btn.setAttribute('aria-disabled', 'true');
try {
setFiles(await api.getFiles());
updateStorage();
applyFilters();
if (speak) announce('Refreshed. ' + $('subtitle').textContent);
} catch {
$('subtitle').textContent = 'Error loading recordings';
if (speak) announce('Could not load recordings');
} finally {
loading = false;
btn.removeAttribute('aria-disabled');
}
}
async function deleteFile(f) {
const span = fileSpan(f);
if (!confirm(`Delete the recording ${span} (${f.name})?\nThis cannot be undone.`)) return;
const btn = $('delbtn-' + f._idx);
if (btn) btn.setAttribute('aria-disabled', 'true');
// Where focus goes once the row is gone: the next recording's Play button,
// else the previous one's, else the day's heading button
const row = $('row-' + f._idx);
const rows = [...row.parentNode.querySelectorAll('tr.data-row')];
const k = rows.indexOf(row);
const near = rows[k + 1] || rows[k - 1];
const dayToggle = row.closest('.day-section').querySelector('.day-toggle');
try {
await api.deleteFile(f.name);
} catch (e) {
if (btn) btn.removeAttribute('aria-disabled');
alert('Delete failed: ' + e.message);
return;
}
removeFile(f.name);
forgetAnalyses(f.name);
applyFilters();
updateStorage();
// The day was rebuilt without the row; look the targets up again by id
const target = (near && $('pbtn-' + near.dataset.idx)) || $(dayToggle.id) || $('page-title');
target.focus();
announce(`Deleted ${span}`);
}
// Poll recording status every 5 s to update REC badges
let lastActiveKey = null;
export async function pollStatus() {
try {
const s = await api.getStatus();
const active = new Set(s.active || []);
// Rows not currently rendered (filtered out) have no badge element, so
// getElementById skips them.
allFiles.forEach(f => {
const badge = $('rec-' + f._idx);
if (badge) badge.hidden = !active.has(f.name);
});
// Active set changed (recording started/stopped or rolled over to a new
// split file): refresh the list so new files appear — but never yank the
// DOM out from under an in-progress playback.
const key = [...active].sort().join('|');
if (lastActiveKey === null) { lastActiveKey = key; return; }
if (key !== lastActiveKey) {
const playing = [...document.querySelectorAll('audio')].some(a => !a.paused && !a.ended);
if (!playing) { lastActiveKey = key; load(); }
}
} catch { /* next poll */ }
}
+35
View File
@@ -0,0 +1,35 @@
// Entry point: wire the page up and start.
//
// Modules import only from modules earlier in this list:
// util, api, store, settings, player, clips, analysis, day, list, nav, main
// Keep it that way. A module that needs something from a later one reaches it
// through the DOM (openClipInFile clicks the day toggle) rather than an import.
import {$} from './util.js';
import {getConfig} from './api.js';
import {initSettings} from './settings.js';
import {initClipBar} from './clips.js';
import {initNav} from './nav.js';
import {load, applyFilters, pollStatus} from './list.js';
initClipBar();
initNav();
$('refresh-btn').addEventListener('click', () => load({speak: true}));
let filterDebounce;
$('filter-name').addEventListener('input', () => {
clearTimeout(filterDebounce);
filterDebounce = setTimeout(() => applyFilters({speak: true}), 400);
});
$('filter-from').addEventListener('change', () => applyFilters({speak: true}));
$('filter-to').addEventListener('change', () => applyFilters({speak: true}));
$('filter-clear').addEventListener('click', () => {
$('filter-name').value = '';
$('filter-from').value = '';
$('filter-to').value = '';
applyFilters({speak: true});
});
getConfig().catch(() => ({})).then(initSettings)
.finally(() => load().then(() => setInterval(pollStatus, 5000)));
+89
View File
@@ -0,0 +1,89 @@
// Section navigation from the keyboard (J/K/U/I/O) and from media keys —
// headset buttons, AirPods, the lock screen — via the Media Session API.
//
// J/K = previous/next section in time order, U/I = up/down the loudest-first
// ranking, O = open the current clip in the full file. Holding Shift jumps to
// the extreme in that direction: Shift+J/K = first/last section in time,
// Shift+U/I = loudest/quietest. The clip queue takes priority; with no queue
// armed, the keys step the playhead of the open full-file player instead.
import {fmtDur, fmtClock, fileStartEpoch, announce} from './util.js';
import {hasQueue, stepClip, openClipInFile, scoreOrder, nextInOrder} from './clips.js';
import {activePlayerIdx, setCutFields} from './player.js';
import {sectionMap, fileByIdx} from './store.js';
import {getPreroll} from './settings.js';
export function navigate(dir, byScore, jump) {
if (hasQueue()) { stepClip(dir, byScore, jump); return; }
// Per-file in-player navigation (full-file listening, no clip queue)
const idx = activePlayerIdx;
if (idx === null) return;
const all = sectionMap.get(idx) || [];
const audio = document.getElementById('aud-' + idx);
if (!all.length || !audio) return;
const preroll = getPreroll();
const cur = audio.currentTime;
const epoch = fileStartEpoch(fileByIdx(idx)?.date);
const jumpTo = (s, i, n, word) => {
audio.currentTime = Math.max(0, s.start - preroll);
setCutFields(idx, s.start, s.end);
const when = epoch != null
? `${fmtClock(epoch + s.start)} to ${fmtClock(epoch + s.end)}`
: `${fmtDur(s.start)} to ${fmtDur(s.end)}`;
announce(`${word} ${i + 1} of ${n}: ${when}`);
};
if (byScore) {
// U/I walk the ranking; the anchor is the section the playhead sits in
// (incl. its preroll lead-in). Anywhere else, I starts at the loudest.
// Shift jumps straight to the loudest (Shift+U) or quietest (Shift+I).
const order = scoreOrder(all);
const curIdx = all.findIndex(s => cur >= s.start - preroll - 0.5 && cur <= s.end + 0.5);
const pos = curIdx === -1 ? -1 : order.indexOf(curIdx);
const next = nextInOrder(order, pos, dir, jump);
if (next === null) { announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights'); return; }
jumpTo(all[order[next]], next, order.length, 'Highlight');
return;
}
// Shift+J/K jump to the first/last section regardless of the playhead.
if (jump) {
const i = dir < 0 ? 0 : all.length - 1;
jumpTo(all[i], i, all.length, 'Section');
return;
}
if (dir < 0) {
for (let i = all.length - 1; i >= 0; i--)
if (all[i].start < cur - 1) { jumpTo(all[i], i, all.length, 'Section'); return; }
announce('Beginning of sections');
} else {
for (let i = 0; i < all.length; i++)
if (all[i].start > cur + preroll) { jumpTo(all[i], i, all.length, 'Section'); return; }
announce('End of sections');
}
}
export function initNav() {
document.addEventListener('keydown', e => {
const t = e.target;
if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
const key = e.key.toLowerCase();
if (!['j', 'k', 'u', 'i', 'o'].includes(key)) return;
e.preventDefault();
if (key === 'o') { openClipInFile(); return; }
navigate(key === 'j' || key === 'u' ? -1 : 1, key === 'u' || key === 'i', e.shiftKey);
});
// Previous/next track = J/K. Works from AirPods, the iOS lock screen and
// Control Center, and the media keys on a Windows keyboard.
if ('mediaSession' in navigator) {
for (const [action, dir] of [['previoustrack', -1], ['nexttrack', 1]]) {
try { navigator.mediaSession.setActionHandler(action, () => navigate(dir, false, false)); }
catch { /* action not supported here */ }
}
}
}
+91
View File
@@ -0,0 +1,91 @@
// The per-row full-file player and its cut fields. Section review normally
// goes through clips.js; this is for listening around a section ("Open in
// file") or to a whole recording.
import {$, fmtDur} from './util.js';
import {fileByIdx, fileSpan} from './store.js';
import {streamUrl} from './api.js';
import {getPreroll} from './settings.js';
// Row of the full-file player opened last. The J/K/U/I fallback steps through
// its sections when no clip queue is armed. Live binding — read, don't assign.
export let activePlayerIdx = null;
export function forgetPlayer(idx) {
if (activePlayerIdx === idx) activePlayerIdx = null;
}
export function setCutFields(idx, startSec, endSec) {
const startEl = $('cut-start-' + idx);
const endEl = $('cut-end-' + idx);
if (startEl) startEl.value = fmtDur(startSec);
if (endEl && endSec != null) endEl.value = fmtDur(endSec);
}
const isOpen = idx => $('pbtn-' + idx)?.getAttribute('aria-expanded') === 'true';
function setPlayButton(idx, open) {
const btn = $('pbtn-' + idx);
const f = fileByIdx(idx);
if (!btn || !f) return;
btn.setAttribute('aria-expanded', open);
btn.textContent = open ? 'Hide' : 'Play';
btn.setAttribute('aria-label', (open ? 'Hide player for ' : 'Play ') + fileSpan(f));
}
// The player lives in its own table cell: the Play button stays, and the
// audio element appears right after it
export function closePlayer(idx) {
const audio = $('aud-' + idx);
if (!audio || audio.hidden) return;
audio.pause();
audio.hidden = true;
setPlayButton(idx, false);
}
export function togglePlayer(idx) {
if (isOpen(idx)) { closePlayer(idx); return; }
const f = fileByIdx(idx);
const audio = $('aud-' + idx);
if (!f || !audio) return;
if (!audio.dataset.srcSet) {
audio.preload = 'auto';
audio.src = streamUrl(f.name);
audio.load();
audio.dataset.srcSet = '1';
} else if (!$('rec-' + idx)?.hidden) {
// Still recording: re-fetch so duration and seek range cover the audio
// captured since the source was last loaded
audio.load();
}
activePlayerIdx = idx;
audio.hidden = false;
setPlayButton(idx, true);
audio.focus();
}
export function seekToSection(idx, startSec, endSec) {
if (!isOpen(idx)) togglePlayer(idx);
activePlayerIdx = idx;
const audio = $('aud-' + idx);
if (!audio) return;
audio.preload = 'auto';
const seekTo = Math.max(0, startSec - getPreroll());
const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); };
if (audio.readyState >= 1) doSeek();
else audio.addEventListener('loadedmetadata', doSeek, {once: true});
setCutFields(idx, startSec, endSec);
}
// Cut button: show/hide the start/end fields inside the options cell. Opening
// puts focus in Start. Clip playback pre-fills the fields either way.
export function toggleCut(idx) {
const btn = $('cutbtn-' + idx);
const panel = $('cut-' + idx);
if (!btn || !panel) return;
const open = panel.hidden;
panel.hidden = !open;
btn.setAttribute('aria-expanded', open);
btn.querySelector('.day-arrow').textContent = open ? '▾ ' : '▸ ';
if (open) $('cut-start-' + idx)?.focus();
}
+58
View File
@@ -0,0 +1,58 @@
// The settings panel: analysis params, pre-roll and the auto-advance mode.
import {$, remember} from './util.js';
// The three analysis controls, read straight from the DOM. Deliberately no
// hardcoded fallbacks: the inputs are seeded from /api/config at startup, so
// web.py's MARGIN_DB / MIN_GAP_SECONDS / MIN_DURATION_SECONDS stay the single
// source of truth. An empty input sends an empty param, which _qs_float()
// falls back to the server default for.
export const analysisParams = () => ({
margin: $('margin-input').value,
minGap: $('min-gap-input').value,
minDur: $('min-duration-input').value,
});
// Same values as one string; a highlights panel built with other values is
// stale and gets recomputed on the next expand
export const hlParams = () => Object.values(analysisParams()).join('|');
export const getPreroll = () => {
const v = parseFloat($('preroll-input').value);
return isNaN(v) || v < 0 ? 0 : v;
};
export const advanceMode = () =>
document.querySelector('input[name="clip-adv"]:checked')?.value || 'off';
// Does a server-side cached analysis match the current control values?
// Auto-loading on a mismatch would silently recompute every file.
export function cachedParamsMatch(ca) {
const p = analysisParams();
return ca != null
&& Number(ca.margin) === parseFloat(p.margin)
&& Number(ca.min_gap) === parseFloat(p.minGap)
&& Number(ca.min_duration) === parseFloat(p.minDur);
}
// Seed the analysis params from /api/config, and restore the per-browser
// conveniences. Margin/gap/min-duration are not remembered client-side: a
// stale browser value would quietly disagree with the server's defaults.
export function initSettings(cfg) {
if (cfg.margin != null) $('margin-input').value = cfg.margin;
if (cfg.min_gap != null) $('min-gap-input').value = cfg.min_gap;
if (cfg.min_duration != null) $('min-duration-input').value = cfg.min_duration;
const pre = remember.get('preroll');
if (pre != null) $('preroll-input').value = pre;
$('preroll-input').addEventListener('change', e => remember.set('preroll', e.target.value));
const adv = remember.get('advance');
const radio = adv && document.querySelector(`input[name="clip-adv"][value="${adv}"]`);
if (radio) radio.checked = true;
document.querySelectorAll('input[name="clip-adv"]').forEach(r =>
r.addEventListener('change', () => remember.set('advance', r.value)));
const panel = $('settings');
panel.open = remember.get('settingsOpen') === '1';
panel.addEventListener('toggle', () => remember.set('settingsOpen', panel.open ? '1' : '0'));
}
+49
View File
@@ -0,0 +1,49 @@
// The file listing and what the UI knows about each file. A leaf apart from
// util.js.
import {fileStartEpoch, fmtHM} from './util.js';
// Full listing from /api/files. Reassigned on every load; importers see the
// new array through the live binding.
export let allFiles = [];
// _idx is per filename and stable for the whole session: assigned the first
// time a name is seen and never reused. DOM ids (row-, pbtn-, aud-, …) are
// built from it, so they survive reloads — that is what lets a re-rendered day
// keep focus where it was. It used to be the position in the listing, which
// shifted on every rollover and left stored indexes pointing one row off.
const idxByName = new Map();
const stableIdx = name => {
if (!idxByName.has(name)) idxByName.set(name, idxByName.size);
return idxByName.get(name);
};
export function setFiles(list) {
allFiles = list.map(f => ({...f, _idx: stableIdx(f.name)}));
}
export function removeFile(name) {
allFiles = allFiles.filter(f => f.name !== name);
}
export const fileByName = name => allFiles.find(f => f.name === name) ?? null;
export const fileByIdx = idx => allFiles.find(f => f._idx === idx) ?? null;
// Queue entries carry the filename, never a row index; resolve at use time.
// Null when the file is gone from the current listing.
export const fileIdxByName = name => fileByName(name)?._idx ?? null;
export const isAnalysable = f => f.ext === 'wav' || f.ext === 'flac';
// idx -> [{start, end, score}], populated by analyse()
export const sectionMap = new Map();
// What a recording is called wherever it is spoken: its wall-clock span from
// the filename clock, "22:00 to 23:00". The filename itself ("20260523_220000")
// reads as a string of numbers. Non-standard names fall back to the name.
export function fileSpan(f) {
const start = fileStartEpoch(f.date);
if (start == null) return f.name;
if (f.recording) return `${fmtHM(start)} to now`;
if (f.duration == null) return fmtHM(start);
return `${fmtHM(start)} to ${fmtHM(Math.round((start + f.duration) / 60) * 60)}`;
}
+115
View File
@@ -0,0 +1,115 @@
// Formatting and screen-reader helpers shared by every module. A leaf: it
// imports nothing, so any module can use it without creating a cycle.
export const $ = id => document.getElementById(id);
export const esc = s => String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
export const pad = n => String(n).padStart(2, '0');
export const fmtDur = s => {
if (s == null) return '—';
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = Math.floor(s % 60);
return h ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
};
// "2 h 59 min" — for running text a screen reader speaks, where "2:59:59"
// reads like a clock time
export const fmtDurWords = s => {
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
if (h) return m ? `${h} h ${m} min` : `${h} h`;
return m ? `${m} min` : `${Math.round(s)} s`;
};
export const fmtSize = b => {
if (b < 1024) return b + ' B';
if (b < 1 << 20) return (b / 1024).toFixed(0) + ' KB';
if (b < 1 << 30) return (b / (1 << 20)).toFixed(1) + ' MB';
return (b / (1 << 30)).toFixed(2) + ' GB';
};
// Epoch seconds -> local wall-clock "HH:MM:SS"
export const fmtClock = ts => {
const d = new Date(ts * 1000);
return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
};
// Epoch seconds -> local "HH:MM"
export const fmtHM = ts => {
const d = new Date(ts * 1000);
return pad(d.getHours()) + ':' + pad(d.getMinutes());
};
// Listing `date` ("YYYY-MM-DD HH:MM:SS", the recording start parsed out of
// the filename server-side) -> epoch seconds, or null for unparseable values
export const fileStartEpoch = date => {
const t = Date.parse(String(date).replace(' ', 'T'));
return isNaN(t) ? null : t / 1000;
};
// "1:30", "0:01:30" or "90" -> seconds; null when unparseable
export function parseTime(s) {
if (!s || !s.trim()) return null;
const parts = s.trim().split(':').map(v => parseFloat(v));
if (parts.some(isNaN) || parts.length > 3) return null;
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
if (parts.length === 2) return parts[0] * 60 + parts[1];
return parts[0];
}
// "2026-10-17" -> "Today, Saturday 17 October". Spoken far better than the
// ISO date, which screen readers read digit group by digit group. en-GB to
// match the page's lang="en", so the speech language never switches mid-page.
export function dayLabel(day) {
const [y, m, d] = day.split('-').map(Number);
const date = new Date(y, m - 1, d);
if (isNaN(date)) return day;
const now = new Date();
const opts = {weekday: 'long', day: 'numeric', month: 'long'};
if (y !== now.getFullYear()) opts.year = 'numeric';
const text = date.toLocaleDateString('en-GB', opts);
// Math.round absorbs the 23/25 h days around DST switches
const ago = Math.round((new Date(now.getFullYear(), now.getMonth(), now.getDate()) - date) / 86400000);
return ago === 0 ? 'Today, ' + text : ago === 1 ? 'Yesterday, ' + text : text;
}
export function announce(msg) {
const el = $('sr-announce');
if (!el) return;
el.textContent = ''; // clear first so same text re-triggers
setTimeout(() => { el.textContent = msg; }, 50);
}
// A long chip list is a wall of buttons: hide it behind an aria-expanded
// toggle. Returns the toggle to insert before the chips, or null when the list
// is short enough to show as is. The separator space lives inside the
// aria-hidden arrow — a leading space in the text node reads as an indent.
export function chipToggle(chips, text) {
if (chips.children.length <= 12) return null;
chips.hidden = true;
const tog = document.createElement('button');
tog.className = 'chip-toggle';
tog.setAttribute('aria-expanded', 'false');
const arrow = document.createElement('span');
arrow.className = 'day-arrow';
arrow.setAttribute('aria-hidden', 'true');
arrow.textContent = '▸ ';
tog.append(arrow, text);
tog.addEventListener('click', () => {
const exp = chips.hidden;
chips.hidden = !exp;
tog.setAttribute('aria-expanded', exp);
arrow.textContent = exp ? '▾ ' : '▸ ';
});
return tog;
}
// localStorage for per-browser conveniences (pre-roll, auto-advance, whether
// the settings are open). It can be missing or throw (private mode, blocked
// storage), and nothing here may depend on it working.
export const remember = {
get(key) { try { return localStorage.getItem('isr.' + key); } catch { return null; } },
set(key, value) { try { localStorage.setItem('isr.' + key, value); } catch { /* not kept */ } },
};
+135
View File
@@ -0,0 +1,135 @@
:root{--bg:#0f1117;--surf:#1a1d27;--brd:#2c3040;--txt:#e2e8f0;--muted:#8b93ad;
--accent:#4f9cf9;--orange:#f97316;--green:#22c55e;--red:#ef4444;
--gutter:28px}
@media (max-width:640px){:root{--gutter:16px}}
*{box-sizing:border-box;margin:0;padding:0}
/* One rule for every [hidden] element. Author display rules (flex chips, the
inline-flex REC badge, the flex clip bar) beat the UA's [hidden] otherwise —
the REC badge used to show on every row that way. */
[hidden]{display:none !important}
body{background:var(--bg);color:var(--txt);font:15px/1.5 system-ui,sans-serif}
.sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;
overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.skip{position:absolute;left:-999px;top:0;padding:8px 14px;background:var(--accent);
color:#000;border-radius:0 0 4px 4px;text-decoration:none;font-weight:600;z-index:30}
.skip:focus{left:0}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
#page-title:focus,#days:focus{outline:none}
.muted{color:var(--muted)}
.nowrap{white-space:nowrap}
header{padding:16px var(--gutter);border-bottom:1px solid var(--brd);
display:flex;align-items:baseline;gap:6px 14px;flex-wrap:wrap}
header h1{font-size:20px;font-weight:600}
#subtitle{color:var(--muted)}
#storage-info{color:var(--muted);font-size:13px;margin-right:auto}
button{cursor:pointer;border:1px solid var(--brd);background:var(--surf);color:var(--txt);
padding:6px 12px;border-radius:6px;font:inherit;font-size:13px;white-space:nowrap;min-height:32px}
button:hover{background:var(--brd)}
button:disabled,button[aria-disabled=true]{opacity:.55;cursor:default}
input{font:inherit;font-size:14px;background:var(--bg);border:1px solid var(--brd);
color:var(--txt);padding:5px 8px;border-radius:5px;min-height:32px}
input[type=date]{color-scheme:dark}
input[type=radio]{min-height:0;accent-color:var(--accent)}
/* settings */
#settings{border-bottom:1px solid var(--brd);background:var(--surf)}
#settings summary{padding:10px var(--gutter);cursor:pointer;color:var(--muted);font-size:14px}
#settings summary:hover{color:var(--txt)}
.settings-grid{display:grid;grid-template-columns:max-content 90px 1fr;gap:8px 12px;
align-items:center;padding:4px var(--gutter) 14px}
.settings-grid label{font-size:14px}
.hint{font-size:12px;color:var(--muted)}
@media (max-width:640px){
.settings-grid{grid-template-columns:1fr 90px}
.settings-grid .hint{grid-column:1 / -1;margin-top:-6px}
}
/* filters */
.filter-bar{display:flex;align-items:center;gap:8px 10px;padding:10px var(--gutter);
border-bottom:1px solid var(--brd);flex-wrap:wrap}
.filter-bar label{font-size:14px;color:var(--muted)}
#filter-name{width:200px}
/* days */
#days{padding:20px var(--gutter)}
.day-section{margin-bottom:18px}
.day-heading-bar{background:var(--surf);padding:6px 10px;border:1px solid var(--brd);
border-radius:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.day-heading-bar.open{border-radius:8px 8px 0 0}
h2.day-heading{font-size:inherit;font-weight:inherit;flex:1 1 auto}
.day-toggle{background:none;border:none;color:var(--txt);font-size:15px;font-weight:600;
padding:4px 0;display:inline-flex;align-items:baseline;gap:4px 10px;flex-wrap:wrap;text-align:left;white-space:normal}
.day-toggle:hover{background:none;color:var(--accent)}
.day-meta{color:var(--muted);font-size:13px;font-weight:400}
.day-arrow{font-size:10px}
button.day-hl{color:var(--green);border-color:#166534;background:#052e16}
button.day-hl:hover{background:#0a3d1f}
.day-hl-status{opacity:.8}
.day-hl-container{border:1px solid var(--brd);border-top:none;padding:10px 12px 12px}
/* recordings table; scrolls sideways on narrow screens instead of the page */
.table-wrap{overflow-x:auto;border:1px solid var(--brd);border-top:none}
table{width:100%;border-collapse:collapse}
th,td{padding:9px 10px;border-bottom:1px solid var(--brd);vertical-align:middle;text-align:left}
thead th{color:var(--muted);font-weight:500;font-size:13px;white-space:nowrap}
th.when{font-weight:600;white-space:nowrap}
tr.data-row:hover>*{background:var(--surf)}
td.file{color:var(--muted);font-size:12px;white-space:nowrap}
.fn{font-family:ui-monospace,monospace}
.badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;
font-weight:700;border:1px solid}
.badge-wav{color:var(--green);border-color:#166534;background:#052e16}
.badge-mp3{color:var(--accent);border-color:#1e40af;background:#0c1a40}
.badge-ogg{color:#c084fc;border-color:#6b21a8;background:#2d1157}
.badge-flac{color:#fb923c;border-color:#7c2d12;background:#2c0e04}
.badge-aac,.badge-opus{color:var(--muted);border-color:var(--brd);background:var(--surf)}
.badge-rec{display:inline-flex;align-items:center;padding:1px 6px;border-radius:3px;
font-size:11px;font-weight:700;color:var(--red);border:1px solid #7f1d1d;background:#2d0808;
animation:pulse 1.5s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
@media (prefers-reduced-motion:reduce){.badge-rec{animation:none}}
.actions{display:flex;gap:6px;align-items:center}
a.dl{color:var(--accent);text-decoration:none;font-size:13px;padding:6px 4px}
a.dl:hover{text-decoration:underline}
button.del{color:var(--red);border-color:#7f1d1d}
button.del:hover:not(:disabled){background:#2d0808}
button.cut{color:var(--accent);border-color:#1e40af;background:#0c1a40}
button.cut:hover:not(:disabled){background:#1e3a8a}
/* analysis */
td.loudness{min-width:200px}
.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:8px 10px}
.analysis-meta{font-size:12px;color:var(--muted)}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:5px;
padding:4px 9px;font-size:12px;font-family:ui-monospace,monospace;min-height:30px}
.chip:hover{background:#6c1f08;border-color:#9a3412}
.chip-toggle{margin-top:8px}
button.reanalyse-btn{margin-top:8px;font-size:12px;color:var(--muted);background:transparent}
.quiet{color:var(--muted);font-size:13px}
.quiet+.quiet{margin-top:6px}
.prog-bar{height:4px;background:var(--brd);border-radius:2px;margin:6px 0 4px;overflow:hidden}
.prog-fill{height:100%;width:0;background:var(--accent);transition:width .15s ease}
@media (prefers-reduced-motion:reduce){.prog-fill{transition:none}}
.prog-file{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.empty{text-align:center;padding:60px 16px;color:var(--muted)}
/* full-file player cell, options cell */
audio{width:100%;height:40px;display:block;color-scheme:dark;accent-color:var(--accent)}
td.player audio{width:300px;margin-top:6px}
.cut-panel{display:flex;align-items:center;gap:8px 12px;margin-top:8px;flex-wrap:wrap}
.cut-field{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--muted)}
.cut-time{width:110px;font-family:ui-monospace,monospace}
/* clip player bar */
#clip-bar{position:fixed;bottom:0;left:0;right:0;z-index:20;background:var(--surf);
border-top:1px solid var(--brd);padding:8px var(--gutter);display:flex;flex-direction:column;gap:6px}
.clip-row{display:flex;align-items:center;gap:8px 10px;flex-wrap:wrap}
#clip-label{flex:1 1 240px;font-family:ui-monospace,monospace;font-size:13px;min-width:0}
#clip-audio{flex:1 1 260px;min-width:200px;width:auto}
#clip-adv{border:none;display:flex;align-items:center;gap:4px 12px;flex-wrap:wrap}
#clip-adv legend{float:left;font-size:12px;color:var(--muted);margin-right:4px}
#clip-adv label{font-size:13px;display:flex;align-items:center;gap:5px;cursor:pointer}
body.clip-open{padding-bottom:130px}